diff --git a/.dockerignore b/.dockerignore index 05a6ad80c0c5..7077eba7722e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -51,32 +51,8 @@ coverage/ .env.* !.env.example -# Only the reviewed first-party module enters the build context. Local plugin -# packages, generated binaries and publisher keys remain excluded. -/plugins/* -!/plugins/bundle.source.json -!/plugins/codex-runtime/ -!/plugins/codex-runtime/** -!/plugins/model-policy/ -!/plugins/model-policy/** -!/plugins/prompt-skills/ -!/plugins/prompt-skills/** -!/plugins/account-tools/ -!/plugins/account-tools/** -!/plugins/cindy-provider/ -!/plugins/cindy-provider/** -!/plugins/image-tools/ -!/plugins/image-tools/** -!/plugins/admin-observability/ -!/plugins/admin-observability/** -/plugins/codex-runtime/dist/ -/plugins/model-policy/dist/ -/plugins/*/dist/ -/plugins/*/node_modules/ -/plugins/*/ui/compiled/ -/plugins/**/*.key -/plugins/**/*.s2plugin -/plugins/**/*.exe +# Local third-party packages and credentials do not enter the host image. +/plugins/ # Local config config.yaml diff --git a/.downstream/native-domains.md b/.downstream/native-domains.md new file mode 100644 index 000000000000..95bef62ee1b9 --- /dev/null +++ b/.downstream/native-domains.md @@ -0,0 +1,81 @@ +# Native domains + +Seven first-party domains run inside the host: Codex runtime, model policy, +prompt/skills, account tools, Cindy, image tools and observability. Native pages +retain the flat theme, MiSans, account fields and persisted task interactions. +The official third-party plugin framework and its own configuration UI remain. + +## Settings and operations + +All paths below are relative to `/api/v1`. Settings writes retain administrator +authentication and the existing step-up policy. + +| Interface | Contract | +| --- | --- | +| `GET/PUT /admin/settings/codex-runtime` | Original Codex configuration JSON, without an additional response envelope; `Cache-Control: no-store`. The source is encrypted at rest. | +| `GET/PUT /admin/settings/cindy-provider` | Balance, catalog and search switches; the catalog is available at the `/catalog` suffix. | +| `GET/PUT /admin/settings/image-tools` | Image Studio and Responses image bridge switches. | +| `GET/PUT /admin/settings/observability` | Telemetry and native theme switches. | +| `POST /admin/accounts/:id/codex-tickets/stop-job` | Persisted single-account stop operation; the batch endpoint is `/admin/accounts/codex-tickets/batch-stop`. Both return HTTP 202 and retain per-account/model retry. | + +The older synchronous ticket-stop endpoint remains compatible. 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 + +Before loading native settings or starting either runtime, one transaction: + +1. Validates the saved first-party capability scopes and decrypts configuration. +2. Imports the effective image, observability and Cindy switches only when their + native settings keys are absent. Existing keys are never overwritten. +3. Saves original installation/binding/bootstrap intent under + `deplugin_retired_plugins`, disables the seven installations and their bindings, + and marks their bootstrap records removed. +4. Advances the existing Codex `runtime_generation` once. A fresh database instead + receives a disabled state anchor with generation 1 and no executable/artifact. + +Missing settings may use deployment defaults. Database errors, malformed saved +settings and unknown/non-equivalent capability scopes stop startup; they never +silently enable features. A disabled whole Codex installation is not equivalent +to disabling only route acquisition and therefore requires an explicit migration +decision rather than automatic activation. + +The original installation rows, encrypted configuration, artifacts, state and +lease tables remain. The upstream plugin manager cannot list, modify, delete or +replace the retired first-party keys. Native state access uses the original +`codexrip.codex-runtime` owner, namespaces, state keys, CAS and lease generations. +Closed quality-run records and their budgets are not rewritten. + +An unchanged normalized configuration keeps its epoch and generation. A changed +Codex configuration is validated, the old epoch is drained, and encrypted source, +configuration hash/version and generation are committed together. Old qualified +connections cannot be reused after an epoch or connection-lease mismatch. + +Migration `254_plugin_job_action_digest.sql` preserves full 64-character historical +task action digests. History and retries continue to use saved targets; unsupported +legacy operations fail without choosing other accounts or models. + +## Release and rollback boundary + +The host is delivered as one immutable OCI image with image provenance. Normal +deployment uses the existing fixed-image `deploy-preserve` flow. It does not add +backups, canaries, model calls or automatic rollback. + +`rollback-preserve` still requires a verified compatible data contract. The +deployer recognizes the old and native prompt/skill directory locations by the +same content hashes; missing or ambiguous trees and changed schema remain errors. + +Returning to a plugin-based host additionally needs an explicit restoration of +legacy activation/configuration intent before starting that host. The retained +receipt and artifacts supply the original data; **never restore the receipt's old +runtime generation or delete its native anchor**. A plain image change does not +perform this reverse conversion. Such a legacy-host rollback has not been run as +part of the native migration and is not an automatic failure path. + +## Verification + +Normal required PR checks validate the native domain packages, account scope, +configuration/lease/ledger compatibility and frontend behavior. The release +reuses that source validation. Health endpoints establish availability only; +actual model-quality diagnostics remain separately budgeted and coordinated. diff --git a/.gitattributes b/.gitattributes index c31b8b16c4e1..125d260cd1c0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,15 +6,15 @@ backend/migrations/*.sql text eol=lf # Embedded system prompt seed must retain the exact LF byte sequence used for # its published SHA-256 and UTF-8 byte-length invariants. -plugins/prompt-skills/registry/prompts/*.txt text eol=lf -plugins/prompt-skills/source/prompts/*.md text eol=lf +backend/internal/promptskills/registry/prompts/*.txt text eol=lf +backend/internal/promptskills/source/prompts/*.md text eol=lf backend/internal/service/testdata/codexrip_reverse_skill_system_prompt_legacy_dual_marker.txt text eol=lf # The approved ModelGang seed is UTF-8 text, including extensionless and # Unicode-named files. Keep every embedded byte sequence stable cross-platform. -plugins/prompt-skills/registry/seed/manifest.json text eol=lf -whitespace -plugins/prompt-skills/registry/seed/tree/** text eol=lf -whitespace -plugins/prompt-skills/registry/seed/pinned/** text eol=lf -whitespace +backend/internal/promptskills/registry/seed/manifest.json text eol=lf -whitespace +backend/internal/promptskills/registry/seed/tree/** text eol=lf -whitespace +backend/internal/promptskills/registry/seed/pinned/** text eol=lf -whitespace # 前端 源代码文件 *.ts text eol=lf diff --git a/.github/scripts/initialize_plugin_publisher.go b/.github/scripts/initialize_plugin_publisher.go deleted file mode 100644 index 12288316e1f4..000000000000 --- a/.github/scripts/initialize_plugin_publisher.go +++ /dev/null @@ -1,137 +0,0 @@ -// Explicit one-time publisher setup. Private material is piped directly to the -// existing authenticated GitHub CLI; only public metadata is printed. Existing -// observed keys are never overwritten; incomplete metadata checks fail closed. -// Authorized first-time setup must not race another publisher initializer. -package main - -import ( - "bytes" - "context" - "crypto/ed25519" - "crypto/rand" - "encoding/base64" - "encoding/json" - "errors" - "flag" - "fmt" - "os" - "os/exec" - "strings" - "time" -) - -const publisherRepository = "HTExplicit/sub2api" -const publisherSecret = "SUB2API_PLUGIN_SIGNING_KEY" -const publisherMetadata = "backend/pkg/extensionapi/v1/publisher.json" - -type publisher struct { - KeyID string `json:"key_id"` - PublicKey string `json:"public_key"` -} - -func github(ctx context.Context, input []byte, args ...string) ([]byte, error) { - command := exec.CommandContext(ctx, "gh", args...) - command.Stdin = bytes.NewReader(input) - var output bytes.Buffer - command.Stdout = &output - // The command's raw error output is not a safe diagnostic for key setup. - if err := command.Run(); err != nil { - return nil, errors.New("GitHub publisher setup command failed; no secret value was logged") - } - return output.Bytes(), nil -} - -func publisherSecretExists(ctx context.Context, query func(context.Context, []byte, ...string) ([]byte, error)) (bool, error) { - exists, total := false, -1 - seen := map[string]bool{} - for page := 1; page <= 100; page++ { - endpoint := fmt.Sprintf("repos/%s/actions/secrets?per_page=100&page=%d", publisherRepository, page) - raw, err := query(ctx, nil, "api", endpoint) - if err != nil { - return false, err - } - var inventory struct { - TotalCount *int `json:"total_count"` - Secrets []struct { - Name string `json:"name"` - } `json:"secrets"` - } - if json.Unmarshal(raw, &inventory) != nil || inventory.TotalCount == nil || - *inventory.TotalCount < 0 || inventory.Secrets == nil || len(inventory.Secrets) > 100 { - return false, errors.New("invalid GitHub secret metadata response") - } - if total < 0 { - total = *inventory.TotalCount - } else if total != *inventory.TotalCount { - return false, errors.New("GitHub secret metadata changed during pagination") - } - for _, secret := range inventory.Secrets { - name := strings.ToUpper(secret.Name) - if name == "" || name != strings.TrimSpace(name) || seen[name] { - return false, errors.New("invalid or repeated GitHub secret metadata") - } - seen[name] = true - exists = exists || name == publisherSecret - } - if len(seen) == total { - return exists, nil - } - if len(seen) > total || len(inventory.Secrets) < 100 { - return false, errors.New("GitHub secret metadata inventory is incomplete") - } - } - return false, errors.New("GitHub secret metadata pagination limit exceeded") -} - -func run() error { - initialize := flag.Bool("initialize", false, "initialize the publisher only when neither metadata nor secret exists") - flag.Parse() - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - exists, err := publisherSecretExists(ctx, github) - if err != nil { - return err - } - metadata, readErr := os.ReadFile(publisherMetadata) - if readErr == nil { - var saved publisher - if json.Unmarshal(metadata, &saved) != nil { - return errors.New("invalid saved publisher metadata") - } - key, err := base64.StdEncoding.DecodeString(saved.PublicKey) - if err != nil || len(key) != ed25519.PublicKeySize || saved.KeyID != "codexrip-plugins-v1" { - return errors.New("invalid saved publisher identity") - } - if !exists { - return errors.New("saved public identity exists but its GitHub signing secret is missing") - } - return json.NewEncoder(os.Stdout).Encode(saved) - } - if !errors.Is(readErr, os.ErrNotExist) { - return readErr - } - if exists { - return errors.New("signing secret already exists; recover its public metadata instead of replacing the key") - } - if !*initialize { - return errors.New("publisher is not initialized; use -initialize for the authorized one-time setup") - } - public, private, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - return err - } - defer clear(private) - encoded := []byte(base64.StdEncoding.EncodeToString(private)) - defer clear(encoded) - if _, err = github(ctx, encoded, "secret", "set", publisherSecret, "--repo", publisherRepository); err != nil { - return err - } - return json.NewEncoder(os.Stdout).Encode(publisher{KeyID: "codexrip-plugins-v1", PublicKey: base64.StdEncoding.EncodeToString(public)}) -} - -func main() { - if err := run(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} diff --git a/.github/scripts/initialize_plugin_publisher_test.go b/.github/scripts/initialize_plugin_publisher_test.go deleted file mode 100644 index 80b7a58fcde4..000000000000 --- a/.github/scripts/initialize_plugin_publisher_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - "testing" -) - -func publisherMetadataPage(total int, names ...string) []byte { - secrets := make([]map[string]string, 0, len(names)) - for _, name := range names { - secrets = append(secrets, map[string]string{"name": name}) - } - raw, _ := json.Marshal(map[string]any{"total_count": total, "secrets": secrets}) - return raw -} - -func publisherFirstPage() []string { - names := make([]string, 100) - for index := range names { - names[index] = fmt.Sprintf("UNRELATED_%03d", index) - } - return names -} - -func TestPublisherSecretInventoryFindsLaterPageWithoutInitializing(t *testing.T) { - calls := 0 - exists, err := publisherSecretExists(context.Background(), func(_ context.Context, input []byte, args ...string) ([]byte, error) { - calls++ - if input != nil || len(args) < 2 || args[0] != "api" { - t.Fatal("only read-only metadata is allowed in this fixture") - } - if strings.Contains(strings.Join(args, " "), "page=2") { - return publisherMetadataPage(101, publisherSecret), nil - } - return publisherMetadataPage(101, publisherFirstPage()...), nil - }) - if err != nil || !exists || calls != 2 { - t.Fatalf("later-page key must prevent initialization: exists=%v calls=%d err=%v", exists, calls, err) - } -} - -func TestPublisherSecretInventoryIncompleteOrFailedPageIsNotAbsence(t *testing.T) { - for _, mode := range []string{"api-error", "incomplete", "changed-total", "duplicate"} { - t.Run(mode, func(t *testing.T) { - calls := 0 - _, err := publisherSecretExists(context.Background(), func(_ context.Context, _ []byte, _ ...string) ([]byte, error) { - calls++ - if calls == 1 { - return publisherMetadataPage(101, publisherFirstPage()...), nil - } - switch mode { - case "api-error": - return nil, errors.New("synthetic metadata failure") - case "incomplete": - return publisherMetadataPage(101), nil - case "changed-total": - return publisherMetadataPage(100, publisherSecret), nil - default: - return publisherMetadataPage(101, "UNRELATED_000"), nil - } - }) - if err == nil { - t.Fatal("an incomplete metadata inventory must never authorize a new publisher key") - } - }) - } -} - -func TestPublisherSecretInventoryRequiresExplicitCompleteMetadata(t *testing.T) { - for _, raw := range []string{`{}`, `{"total_count":0}`, `{"total_count":1,"secrets":[]}`} { - _, err := publisherSecretExists(context.Background(), func(context.Context, []byte, ...string) ([]byte, error) { return []byte(raw), nil }) - if err == nil { - t.Fatalf("invalid inventory accepted: %s", raw) - } - } - exists, err := publisherSecretExists(context.Background(), func(context.Context, []byte, ...string) ([]byte, error) { return publisherMetadataPage(0), nil }) - if err != nil || exists { - t.Fatalf("complete empty inventory rejected: exists=%v err=%v", exists, err) - } -} diff --git a/.github/scripts/release_preflight.py b/.github/scripts/release_preflight.py index 4be143c9bc6f..848dc4f23a1f 100644 --- a/.github/scripts/release_preflight.py +++ b/.github/scripts/release_preflight.py @@ -21,7 +21,6 @@ REPOSITORY = "HTExplicit/sub2api" IMAGE = "ghcr.io/htexplicit/sub2api" HOST_TAG = re.compile(r"v[0-9]+\.[0-9]+\.[0-9]+-codexrip\.[1-9][0-9]*") -PLUGIN_TAG = re.compile(r"plugins/([a-z][a-z0-9-]*)/v([0-9]+\.[0-9]+\.[0-9]+)") SHA = re.compile(r"[a-f0-9]{40}") DIGEST = re.compile(r"sha256:[a-f0-9]{64}") MAX_PAGES = 100 @@ -142,55 +141,17 @@ def registry_manifest(version: str, request=http_get, token=None, actor=None): def local_assets(args, root: Path) -> dict[str, tuple[int, str]]: - directory = root / "deploy/plugin-bundle/current" - lock_path = directory / "lock.json" - require(lock_path.is_file() and not lock_path.is_symlink(), "bundle_lock_missing") - raw_lock = lock_path.read_bytes() - lock = decode_json(raw_lock) - source = decode_json((root / "plugins/bundle.source.json").read_bytes()) - require(isinstance(source, dict) and isinstance(source.get("plugins"), list), "bundle_source_invalid") - domains = [item.get("directory") for item in source["plugins"] if isinstance(item, dict)] - require(len(domains) == len(source["plugins"]) and bool(domains) - and all(isinstance(domain, str) and re.fullmatch(r"[a-z][a-z0-9-]*", domain) for domain in domains) - and len(domains) == len(set(domains)), "bundle_source_invalid") - if args.kind == "plugin": - domain = PLUGIN_TAG.fullmatch(args.tag)[1] - require(domain in domains, "bundle_domain_invalid") - domains = [domain] - host_version = (args.tag if args.kind == "host" else args.host_tag)[1:] - require(isinstance(lock, dict) and lock.get("schema_version") == 1 and lock.get("host_version") == host_version - and lock.get("publisher_key_id") == "codexrip-plugins-v1" - and isinstance(lock.get("plugins"), list), "bundle_lock_invalid") - expected = {"codexrip." + domain: domain + ".s2plugin" for domain in domains} - require(len(lock["plugins"]) == len(expected), "bundle_inventory_mismatch") - assets = {"lock.json": (len(raw_lock), "sha256:" + hashlib.sha256(raw_lock).hexdigest())} - seen = set() - for entry in lock["plugins"]: - require(isinstance(entry, dict) and entry.get("id") in expected - and expected[entry["id"]] == entry.get("file") and entry["id"] not in seen, "bundle_inventory_mismatch") - seen.add(entry["id"]) - path = directory / entry["file"] - require(path.is_file() and not path.is_symlink(), "bundle_asset_invalid") - raw = path.read_bytes() - sha = hashlib.sha256(raw).hexdigest() - require(sha == entry.get("sha256"), "bundle_asset_changed") - assets[entry["file"]] = (len(raw), "sha256:" + sha) - return assets + # Native domains ship in the immutable OCI image; there are no separate + # first-party package assets attached to a host release. + return {} def check(args, github=github_json, registry=registry_manifest, root=ROOT): require(SHA.fullmatch(args.source_sha) is not None, "source_sha_invalid") - if args.kind == "host": - require(HOST_TAG.fullmatch(args.tag) is not None, "release_tag_invalid") - else: - require(PLUGIN_TAG.fullmatch(args.tag) is not None and HOST_TAG.fullmatch(args.host_tag) is not None - and SHA.fullmatch(args.host_source_sha) is not None, "plugin_source_invalid") - require(args.stage != "before-push", "plugin_stage_invalid") + require(args.kind == "host", "release_kind_invalid") + require(HOST_TAG.fullmatch(args.tag) is not None, "release_tag_invalid") releases = list_releases(github) matches = [release for release in releases if release["tag_name"] == args.tag] - if args.kind == "plugin": - host = [release for release in releases if release["tag_name"] == args.host_tag] - require(len(host) == 1 and host[0].get("draft") is False, "compatible_host_release_unavailable") if args.stage == "before-publish": require(len(matches) == 1 and matches[0].get("draft") is True, "unique_draft_required") release = matches[0] @@ -214,14 +175,12 @@ def check(args, github=github_json, registry=registry_manifest, root=ROOT): require(not matches, "release_already_exists") if args.kind == "host": digest = registry(args.tag[1:]) - if args.stage in ("before-sign", "before-push"): + if args.stage in ("before-build", "before-push"): require(digest is None, "version_image_already_exists") else: require(DIGEST.fullmatch(args.image_digest) is not None and digest == args.image_digest, "published_image_digest_mismatch") # These are deliberately the final remote reads before the workflow's next # write. They reduce the gap, but cannot replace server-side immutable tags. - if args.kind == "plugin": - require(tag_commit(github, args.host_tag) == args.host_source_sha, "compatible_host_tag_changed") require(tag_commit(github, args.tag) == args.source_sha, "release_tag_changed") result = {"state": "verified", "stage": args.stage, "tag": args.tag, "source_sha": args.source_sha} if args.stage == "before-publish": @@ -231,12 +190,10 @@ def check(args, github=github_json, registry=registry_manifest, root=ROOT): def main(argv=None) -> int: parser = argparse.ArgumentParser() - parser.add_argument("--kind", choices=("host", "plugin"), required=True) - parser.add_argument("--stage", choices=("before-sign", "before-push", "before-create", "before-publish"), required=True) + parser.add_argument("--kind", choices=("host",), required=True) + parser.add_argument("--stage", choices=("before-build", "before-push", "before-create", "before-publish"), required=True) parser.add_argument("--tag", required=True) parser.add_argument("--source-sha", required=True) - parser.add_argument("--host-tag", default="") - parser.add_argument("--host-source-sha", default="") parser.add_argument("--image-digest", default="") args = parser.parse_args(argv) try: diff --git a/.github/scripts/test_first_party_plugins.sh b/.github/scripts/test_first_party_plugins.sh deleted file mode 100644 index 1e8ba8b1934a..000000000000 --- a/.github/scripts/test_first_party_plugins.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root=$(git rev-parse --show-toplevel) -cd "$repo_root" -plugin_build_dir=$(mktemp -d) -node backend/pkg/extensionapi/ui/build.mjs - -for module in plugins/*; do - [[ -f "$module/go.mod" ]] || continue - ( - cd "$module" - go test ./... -count=1 - ) -done - -cd backend -go build -trimpath -o "$plugin_build_dir/package-plugin" ./cmd/package-plugin -"$plugin_build_dir/package-plugin" -build-binaries -bundle-source ../plugins/bundle.source.json -binary-dir "$plugin_build_dir/binaries" -platform linux-amd64 -"$plugin_build_dir/package-plugin" -generate-key "$plugin_build_dir/publisher.key" > "$plugin_build_dir/publisher.json" -"$plugin_build_dir/package-plugin" \ - -bundle-source ../plugins/bundle.source.json \ - -binary-dir "$plugin_build_dir/binaries" \ - -platform linux-amd64 \ - -tested-host-version 0.2.8-codexrip.2 \ - -output "$plugin_build_dir/bundle" \ - -signing-key-file "$plugin_build_dir/publisher.key" \ - -key-id codexrip-plugins-test - -export SUB2API_EXTENSION_TEST_BINARY="$plugin_build_dir/binaries/codex-runtime" -export SUB2API_MODEL_POLICY_TEST_BINARY="$plugin_build_dir/binaries/model-policy" -export SUB2API_EXTENSION_TEST_BUNDLE="$plugin_build_dir/bundle/lock.json" -export SUB2API_PROMPT_SKILLS_TEST_BINARY="$plugin_build_dir/binaries/prompt-skills" -export SUB2API_ACCOUNT_TOOLS_TEST_BINARY="$plugin_build_dir/binaries/account-tools" -export SUB2API_CINDY_PROVIDER_TEST_BINARY="$plugin_build_dir/binaries/cindy-provider" -export SUB2API_IMAGE_TOOLS_TEST_BINARY="$plugin_build_dir/binaries/image-tools" -export SUB2API_ADMIN_OBSERVABILITY_TEST_BINARY="$plugin_build_dir/binaries/admin-observability" -export SUB2API_EXTENSION_TEST_PUBLIC_KEY -SUB2API_EXTENSION_TEST_PUBLIC_KEY=$(jq -r .public_key "$plugin_build_dir/publisher.json") -go test ./internal/service -run '^(TestExtensionRuntimeUsesOfficialProcessAndHostBroker|TestCatalogExtensionRuntimeUsesIndependentProcess|TestPromptExtensionRuntimeUsesIndependentProcess|TestAccountToolsExtensionRuntimeUsesIndependentProcess|TestCindyProviderExtensionRuntimeUsesIndependentProcess|TestImageToolsExtensionRuntimeUsesIndependentProcess|TestObservabilityExtensionRuntimeUsesScopedMetricsBroker|TestFirstPartyBundleContainsMatchingSignedDomainPackages)$' -count=1 diff --git a/.github/scripts/test_model_context_capacity.sh b/.github/scripts/test_model_context_capacity.sh index a736666514e8..3c875075fe03 100644 --- a/.github/scripts/test_model_context_capacity.sh +++ b/.github/scripts/test_model_context_capacity.sh @@ -7,5 +7,5 @@ cd "$repo_root/backend" # Offline policy, persistence, and wire-contract fixtures only; no model calls. go test -p=1 -tags unit \ ./internal/service ./internal/repository ./internal/handler ./internal/handler/admin ./cmd/server \ - -run 'Test.*(ModelContext|ContextCapacit|GroupModelCapacity|ConvertOpenAIModelListLiveCapacity|CodexCapacityProjection|SyncUpstreamModelCatalog.*Capacity|RunCodexContextContractVerification)' \ + -run 'Test.*(ModelContext|OfficialModelCatalog|ContextCapacit|GroupModelCapacity|ConvertOpenAIModelListLiveCapacity|CodexCapacityProjection|SyncUpstreamModelCatalog.*Capacity|RunCodexContextContractVerification)' \ -count=1 diff --git a/.github/scripts/test_native_domains.sh b/.github/scripts/test_native_domains.sh new file mode 100644 index 000000000000..1fac920cb5d8 --- /dev/null +++ b/.github/scripts/test_native_domains.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) +cd "$repo_root" + +# The same domain policies now compile into the host. Lifecycle concurrency is +# selected once by the workflow's dedicated race step; no package/signing process. +go -C backend test -p=1 -tags unit \ + ./internal/accounttools/... ./internal/cindyprovider/... \ + ./internal/codexruntime/... ./internal/promptskills/... \ + -skip '^TestNativeModuleLifecycleStartsOnceAndDrains$' -count=1 + +# Preserve configuration/ledger/storage boundaries without starting real IO. +go -C backend test -p=1 -tags unit ./internal/service ./internal/repository \ + -run '^(TestNativeCodex(ConfigGenerationIsAtomicAndIdempotent|StoredConfigDistinguishesMissingAndReadError|ClosedLedgerReadableWithoutRewritingGeneration|HostKeepsPrivateStateAndCredentialScope|ErrorDiagnosticsUseStoredAccountsAndRemovePrivateFields)|TestNativeRuntimeLease.*|TestNativePluginBoundaryProtectsRetiredRecordsAndAllowsThirdParties|TestNativeFeatureBootstrapPreservesEffectiveConfiguration|TestNativeSettingLoadFailureNeverReenablesSavedSwitches|TestImageTools.*|TestSwitchingImageStudio(OnStartsTheRuntime|OffStopsDetachedUpstreamIO)|TestTrafficObservation.*|TestAccountTrafficOutcomeRulesKeepCancellationAboveErrorsAndRequireWSTerminals|TestDecodeSwitchSettingsRejectsUnknownAndNonBooleanValues)$' \ + -count=1 diff --git a/.github/scripts/test_reasoning_replay.sh b/.github/scripts/test_reasoning_replay.sh index 6f4ce2e1eefc..91b993e371ed 100644 --- a/.github/scripts/test_reasoning_replay.sh +++ b/.github/scripts/test_reasoning_replay.sh @@ -2,7 +2,7 @@ set -euo pipefail cd "$(dirname "$0")/../.." -# Offline contracts only. The broker-controlled live entry point is excluded +# Offline contracts only. The live diagnostic entry point is excluded # explicitly; this gate cannot consume account credentials or model requests. go -C backend test -p=1 -tags unit ./internal/pkg/apicompat ./internal/repository ./internal/service ./internal/handler/admin \ -run 'Test(OpenAIChatReasoning|OpenAIReasoning|OpenAIHTTPTerminal|GatewayCacheReasoningState|SchedulerCacheReasoningPolicy|ResponsesReasoningConfiguration)' -count=1 diff --git a/.github/scripts/test_release_preflight.py b/.github/scripts/test_release_preflight.py index ee45513e077d..5aaf2f638a08 100644 --- a/.github/scripts/test_release_preflight.py +++ b/.github/scripts/test_release_preflight.py @@ -19,16 +19,12 @@ SOURCE = "a" * 40 -HOST_SOURCE = "b" * 40 TAG = "v0.2.7-codexrip.1" -PLUGIN_TAG = "plugins/codex-runtime/v0.2.7" IMAGE_DIGEST = "sha256:" + "c" * 64 def options(stage="before-push", kind="host"): - return argparse.Namespace(kind=kind, stage=stage, tag=TAG if kind == "host" else PLUGIN_TAG, - source_sha=SOURCE, host_tag=TAG if kind == "plugin" else "", - host_source_sha=HOST_SOURCE if kind == "plugin" else "", + return argparse.Namespace(kind=kind, stage=stage, tag=TAG, source_sha=SOURCE, image_digest=IMAGE_DIGEST if stage in ("before-create", "before-publish") else "") @@ -38,8 +34,6 @@ def __init__(self, args, pages=None): self.calls = [] self.source = SOURCE self.pages = pages or {1: []} - if args.kind == "plugin" and pages is None: - self.pages = {1: [{"id": 80, "tag_name": TAG, "draft": False}]} def __call__(self, path): self.calls.append(path) @@ -47,35 +41,11 @@ def __call__(self, path): return copy.deepcopy(self.pages.get(int(path.rsplit("=", 1)[1]), [])) if path.startswith("git/ref/tags/"): tag = path.removeprefix("git/ref/tags/") - sha = HOST_SOURCE if self.args.kind == "plugin" and tag == TAG else self.source + sha = self.source return {"ref": "refs/tags/" + tag, "object": {"type": "commit", "sha": sha}} raise AssertionError("unexpected metadata request: " + path) -def bundle_fixture(root, kind="host"): - domains = ["codex-runtime", "model-policy"] - source = root / "plugins/bundle.source.json" - source.parent.mkdir(parents=True) - source.write_text(json.dumps({"schema_version": 1, "plugins": [{"directory": item} for item in domains]})) - if kind == "plugin": - domains = domains[:1] - directory = root / "deploy/plugin-bundle/current" - directory.mkdir(parents=True) - lock = {"schema_version": 1, "host_version": TAG[1:], "publisher_key_id": "codexrip-plugins-v1", "plugins": []} - assets = [] - for domain in domains: - raw = ("synthetic package bytes: " + domain).encode() - name = domain + ".s2plugin" - sha = hashlib.sha256(raw).hexdigest() - (directory / name).write_bytes(raw) - lock["plugins"].append({"id": "codexrip." + domain, "file": name, "sha256": sha}) - assets.append({"name": name, "size": len(raw), "state": "uploaded", "digest": "sha256:" + sha}) - raw = json.dumps(lock).encode() - (directory / "lock.json").write_bytes(raw) - assets.append({"name": "lock.json", "size": len(raw), "state": "uploaded", "digest": "sha256:" + hashlib.sha256(raw).hexdigest()}) - return assets - - class ReleaseMetadataTests(unittest.TestCase): def test_existing_draft_on_a_later_page_blocks_every_write(self): args = options() @@ -154,52 +124,30 @@ def test_existing_image_and_later_digest_drift_are_rejected(self): guard.check(args, github=Repository(args), registry=lambda _: observed) def test_complete_draft_assets_and_source_allow_publication_by_id(self): - for kind in ("host", "plugin"): - for target in (SOURCE, "main"): - with self.subTest(kind=kind, target=target), tempfile.TemporaryDirectory() as tmp: - args = options("before-publish", kind) - root = Path(tmp) - release = {"id": 101, "tag_name": args.tag, "draft": True, "target_commitish": target, - "assets": bundle_fixture(root, kind)} - records = [release] - if kind == "plugin": - records.append({"id": 80, "tag_name": TAG, "draft": False}) - repo = Repository(args, {1: records}) - result = guard.check(args, github=repo, registry=lambda _: IMAGE_DIGEST, root=root) - self.assertEqual(101, result["release_id"]) - self.assertEqual("git/ref/tags/" + args.tag, repo.calls[-1]) + for target in (SOURCE, "main"): + with self.subTest(target=target), tempfile.TemporaryDirectory() as tmp: + args = options("before-publish") + release = {"id": 101, "tag_name": args.tag, "draft": True, + "target_commitish": target, "assets": []} + repo = Repository(args, {1: [release]}) + result = guard.check(args, github=repo, registry=lambda _: IMAGE_DIGEST, root=Path(tmp)) + self.assertEqual(101, result["release_id"]) + self.assertEqual("git/ref/tags/" + args.tag, repo.calls[-1]) def test_missing_or_changed_draft_asset_metadata_prevents_publication(self): - for mutation in ("missing-digest", "wrong-digest", "wrong-size", "partial-upload", "extra-asset", "missing-asset", "wrong-target"): + for mutation in ("unexpected-package", "invalid-assets", "wrong-target"): with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as tmp: args = options("before-publish") - root = Path(tmp) - assets = bundle_fixture(root) - release = {"id": 101, "tag_name": TAG, "draft": True, "target_commitish": SOURCE, "assets": assets} - if mutation == "missing-digest": assets[0].pop("digest") - if mutation == "wrong-digest": assets[0]["digest"] = IMAGE_DIGEST - if mutation == "wrong-size": assets[0]["size"] += 1 - if mutation == "partial-upload": assets[0]["state"] = "starter" - if mutation == "extra-asset": assets.append(dict(assets[0], name="unexpected.s2plugin")) - if mutation == "missing-asset": assets.pop() - if mutation == "wrong-target": release["target_commitish"] = "d" * 40 + release = {"id": 101, "tag_name": TAG, "draft": True, + "target_commitish": SOURCE, "assets": []} + if mutation == "unexpected-package": + release["assets"] = [{"name": "old.s2plugin", "state": "uploaded"}] + if mutation == "invalid-assets": + release["assets"] = None + if mutation == "wrong-target": + release["target_commitish"] = "d" * 40 with self.assertRaises(guard.PreflightError): - guard.check(args, github=Repository(args, {1: [release]}), registry=lambda _: IMAGE_DIGEST, root=root) - - def test_compatible_host_must_be_published_and_keep_its_commit(self): - args = options("before-sign", "plugin") - repo = Repository(args, {1: [{"id": 80, "tag_name": TAG, "draft": True}]}) - with self.assertRaisesRegex(guard.PreflightError, "compatible_host_release_unavailable"): - guard.check(args, github=repo) - repo = Repository(args) - normal = repo.__call__ - def changed_host(path): - value = normal(path) - if path == "git/ref/tags/" + TAG: - value["object"]["sha"] = "d" * 40 - return value - with self.assertRaisesRegex(guard.PreflightError, "compatible_host_tag_changed"): - guard.check(args, github=changed_host) + guard.check(args, github=Repository(args, {1: [release]}), registry=lambda _: IMAGE_DIGEST, root=Path(tmp)) class ReleaseWorkflowContracts(unittest.TestCase): @@ -212,7 +160,7 @@ def test_git_authentication_is_temporary_and_only_fetches_main(self): bash = shutil.which("bash") if bash is None: self.skipTest("Bash is required for the environment-only fixture") - for name in ("downstream-release.yml", "plugin-release.yml"): + for name in ("downstream-release.yml",): source = (guard.ROOT / ".github/workflows" / name).read_text(encoding="utf-8") snippet = re.search(r"(?ms)^ authorization=\$\(.*?^ unset authorization$", source) self.assertIsNotNone(snippet) @@ -240,7 +188,7 @@ def test_git_authentication_is_temporary_and_only_fetches_main(self): self.assertNotIn("fixture-job-token", result.stdout + result.stderr) def test_write_credentials_are_not_in_build_environments(self): - for name in ("downstream-release.yml", "plugin-release.yml"): + for name in ("downstream-release.yml",): source = (guard.ROOT / ".github/workflows" / name).read_text(encoding="utf-8") self.assertEqual([], re.findall(r"(?m)^ GH_TOKEN:.*$", source), "a job-wide token reaches dependency/build subprocesses") self.assertEqual(source.count("uses: actions/checkout@"), source.count("persist-credentials: false")) @@ -269,29 +217,14 @@ def test_host_uses_admitted_sha_and_builds_before_guarded_push(self): self.assertNotIn("--clobber", source) self.assertNotIn("gh release delete", source) - def test_plugin_uses_exact_tag_and_stages_assets_before_publish(self): - source = (guard.ROOT / ".github/workflows/plugin-release.yml").read_text(encoding="utf-8") - self.assertIn("ref: refs/tags/${{ inputs.plugin_tag }}", source) - self.assertIn("host_source_sha=", source) - self.assertIn("--stage before-sign", source) - self.assertIn("--stage before-create", source) - self.assertIn("--stage before-publish", source) - self.assertIn("--draft", source) - self.assertIn('--target "$SOURCE_SHA"', source) - self.assertIn('releases/$RELEASE_ID', source) - self.assertNotIn("--clobber", source) - self.assertNotIn("gh release delete", source) - - def test_publisher_secret_is_only_on_original_signing_steps(self): - for name in ("downstream-release.yml", "plugin-release.yml"): - source = (guard.ROOT / ".github/workflows" / name).read_text(encoding="utf-8") - self.assertEqual(1, source.count("secrets.SUB2API_PLUGIN_SIGNING_KEY")) - step = next(part for part in source.split(" - ") if "secrets.SUB2API_PLUGIN_SIGNING_KEY" in part) - self.assertIn("name: Sign", step) - self.assertNotIn("release_preflight.py", step) - self.assertNotIn("go build", step) - helper = Path(guard.__file__).read_text(encoding="utf-8") - self.assertNotIn('os.environ.get("SUB2API_PLUGIN_SIGNING_KEY"', helper) + def test_native_release_has_no_first_party_signing_or_upload(self): + source = (guard.ROOT / ".github/workflows/downstream-release.yml").read_text(encoding="utf-8") + for retired in ("SUB2API_PLUGIN_SIGNING_KEY", "package-plugin", "plugin-bundle", ".s2plugin", "extensionapi/ui"): + self.assertNotIn(retired, source) + self.assertIn("Attest image provenance", source) + self.assertIn("Image:", source) + self.assertIn("Source:", source) + self.assertFalse((guard.ROOT / ".github/workflows/plugin-release.yml").exists()) if __name__ == "__main__": diff --git a/.github/scripts/test_upstream_automation_contract.py b/.github/scripts/test_upstream_automation_contract.py index 6b69981dae22..1220ae6a94d6 100644 --- a/.github/scripts/test_upstream_automation_contract.py +++ b/.github/scripts/test_upstream_automation_contract.py @@ -2,6 +2,7 @@ import json import re +import shlex import unittest from pathlib import Path from types import SimpleNamespace @@ -81,13 +82,54 @@ def test_image_build_skips_validation_without_changing_daily_build(self): dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") vite = (ROOT / "frontend/vite.config.ts").read_text(encoding="utf-8") package = json.loads((ROOT / "frontend/package.json").read_text(encoding="utf-8")) - self.assertIn("RUN node /app/backend/pkg/extensionapi/ui/build.mjs && SUB2API_ARTIFACT_BUILD=1 pnpm exec vite build", dockerfile) + self.assertIn("RUN SUB2API_ARTIFACT_BUILD=1 pnpm exec vite build", dockerfile) + self.assertNotIn("backend/pkg/extensionapi/ui/build.mjs", dockerfile) self.assertNotIn("RUN pnpm run build", dockerfile) self.assertNotIn("ENV SUB2API_ARTIFACT_BUILD", dockerfile) self.assertIn("process.env.SUB2API_ARTIFACT_BUILD === '1'", vite) self.assertIn("enableBuild: !artifactBuild", vite) self.assertEqual(package["scripts"]["build"], "pnpm run check:i18n && vue-tsc -b && vite build") + def test_native_ci_preserves_required_checks_and_selects_existing_contracts(self): + verify = (ROOT / ".github/workflows/downstream-verify.yml").read_text(encoding="utf-8") + for name in ("Downstream backend", "Downstream frontend", "Candidate OCI image"): + self.assertRegex(verify, rf"(?m)^ name: {re.escape(name)}$") + self.assertIn("run: bash .github/scripts/test_native_domains.sh", verify) + for retired in ("test_first_party_plugins.sh", "initialize_plugin_publisher", + "backend/pkg/extensionapi/ui/tsconfig.json", "../plugins/"): + self.assertNotIn(retired, verify) + specs = re.findall(r"(?m)^\s+(src/\S+\.spec\.ts)(?:\s+\\)?$", verify) + self.assertTrue(specs, "the frontend gate must select real spec files") + self.assertEqual(len(specs), len(set(specs)), "do not repeat identical frontend specs") + for spec in specs: + self.assertTrue((ROOT / "frontend" / spec).is_file(), spec) + + script = (ROOT / ".github/scripts/test_native_domains.sh").read_text(encoding="utf-8") + self.assertNotRegex(script, r"package-plugin|generate-key|publisher\.key|plugins/\*") + commands = [shlex.split(line) for line in script.replace("\\\n", " ").splitlines() + if line.startswith("go -C backend test ")] + self.assertEqual(len(commands), 2) + for command in commands: + selector = command[command.index("-run") + 1] if "-run" in command else r"^Test" + skipped = command[command.index("-skip") + 1] if "-skip" in command else r"(?!)" + packages = [arg for arg in command if arg.startswith("./")] + self.assertTrue(packages) + for package in packages: + path = ROOT / "backend" / package.removeprefix("./").removesuffix("/...") + self.assertTrue(path.is_dir(), package) + files = path.rglob("*_test.go") if package.endswith("/...") else path.glob("*_test.go") + names = [] + for file in files: + source = file.read_text(encoding="utf-8") + # These commands select unit fixtures on the Linux PR runner. + build = re.search(r"(?m)^//go:build (.+)$", source) + if build and re.search(r"\b(integration|windows)\b", build.group(1)): + continue + names.extend(re.findall(r"(?m)^func (Test\w+)\(", source)) + selected = [name for name in names if re.search(selector, name) and not re.search(skipped, name)] + self.assertTrue(selected, f"{package}: {selector} selected no unit tests") + self.assertIn("-run '^TestNativeModuleLifecycleStartsOnceAndDrains$'", verify) + def test_bot_chain_is_explicit_and_does_not_overwrite_manual_resolutions(self): self.assertIn("upstream_tag:", self.sync) self.assertIn("preserve manual resolutions", self.sync) diff --git a/.github/workflows/downstream-release.yml b/.github/workflows/downstream-release.yml index b7ff720fe890..e81292690a6a 100644 --- a/.github/workflows/downstream-release.yml +++ b/.github/workflows/downstream-release.yml @@ -92,7 +92,7 @@ jobs: run: | set -euo pipefail [[ "$(git rev-parse HEAD)" == "$SOURCE_SHA" ]] - python3 .github/scripts/release_preflight.py --kind host --stage before-sign \ + python3 .github/scripts/release_preflight.py --kind host --stage before-build \ --tag "$RELEASE_TAG" --source-sha "$SOURCE_SHA" - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 with: @@ -102,50 +102,15 @@ jobs: node-version: '20' cache: pnpm cache-dependency-path: frontend/pnpm-lock.yaml - - name: Build independent plugin interfaces - run: | - pnpm --dir frontend install --frozen-lockfile - node backend/pkg/extensionapi/ui/build.mjs - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version-file: backend/go.mod cache-dependency-path: backend/go.sum - - name: Build independent plugin programs - working-directory: backend - shell: bash - run: | - set -euo pipefail - go build -trimpath -o "$RUNNER_TEMP/package-plugin" ./cmd/package-plugin - "$RUNNER_TEMP/package-plugin" -build-binaries -bundle-source ../plugins/bundle.source.json \ - -binary-dir "$RUNNER_TEMP/plugin-binaries" -platform linux-amd64 - - name: Recheck source before exposing the signing key - env: - GH_TOKEN: ${{ github.token }} - shell: bash - run: | - set -euo pipefail - [[ "$(git rev-parse HEAD)" == "$SOURCE_SHA" ]] - python3 .github/scripts/release_preflight.py --kind host --stage before-sign \ - --tag "$RELEASE_TAG" --source-sha "$SOURCE_SHA" - - name: Sign and verify the immutable plugin bundle - working-directory: backend - env: - SUB2API_PLUGIN_SIGNING_KEY: ${{ secrets.SUB2API_PLUGIN_SIGNING_KEY }} - shell: bash - run: | - set -euo pipefail - test -n "$SUB2API_PLUGIN_SIGNING_KEY" - "$RUNNER_TEMP/package-plugin" -bundle-source ../plugins/bundle.source.json \ - -binary-dir "$RUNNER_TEMP/plugin-binaries" -platform linux-amd64 \ - -tested-host-version "$VERSION" -source-revision "$SOURCE_SHA" \ - -output ../deploy/plugin-bundle/current -key-id codexrip-plugins-v1 - "$RUNNER_TEMP/package-plugin" -verify-bundle ../deploy/plugin-bundle/current/lock.json \ - -tested-host-version "$VERSION" - name: Verify fixed System Prompt capture shell: bash run: | set -euo pipefail - actual="$(sha256sum plugins/prompt-skills/registry/prompts/codexrip_reverse_skill_system_prompt.txt | awk '{print $1}')" + actual="$(sha256sum backend/internal/promptskills/registry/prompts/codexrip_reverse_skill_system_prompt.txt | awk '{print $1}')" [[ "$actual" == "$PROMPT_SHA" ]] - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Build release image without publication @@ -168,7 +133,6 @@ jobs: io.github.htexplicit.patch=codexrip io.github.htexplicit.cindy-platform-v1=true build-args: | - PLUGIN_BUNDLE_STAGE=plugin-bundle-release VERSION=${{ env.VERSION }} COMMIT=${{ needs.verify.outputs.source_sha }} DATE=${{ env.BUILD_DATE }} @@ -203,11 +167,7 @@ jobs: subject-name: ${{ env.IMAGE }} subject-digest: ${{ steps.push.outputs.digest }} push-to-registry: true - - name: Attest signed plugin packages - uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 - with: - subject-path: deploy/plugin-bundle/current/* - - name: Create a draft with the exact signed assets + - name: Create the immutable image release draft env: GH_TOKEN: ${{ github.token }} IMAGE_DIGEST: ${{ steps.push.outputs.digest }} @@ -225,11 +185,10 @@ jobs: python3 .github/scripts/release_preflight.py --kind host --stage before-create \ --tag "$RELEASE_TAG" --source-sha "$SOURCE_SHA" --image-digest "$IMAGE_DIGEST" gh release create "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ - deploy/plugin-bundle/current/*.s2plugin deploy/plugin-bundle/current/lock.json \ --verify-tag --draft --target "$SOURCE_SHA" \ --title "Sub2API ${VERSION}" \ --notes-file /tmp/release-notes.md - - name: Verify complete assets and publish the draft + - name: Verify image metadata and publish the draft env: GH_TOKEN: ${{ github.token }} IMAGE_DIGEST: ${{ steps.push.outputs.digest }} diff --git a/.github/workflows/downstream-verify.yml b/.github/workflows/downstream-verify.yml index e9fd4b3c5dcb..b9d62e440045 100644 --- a/.github/workflows/downstream-verify.yml +++ b/.github/workflows/downstream-verify.yml @@ -23,32 +23,19 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 - with: - version: 9.15.9 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - with: - node-version: '20' - cache: pnpm - cache-dependency-path: frontend/pnpm-lock.yaml - - name: Install public UI build dependencies - working-directory: frontend - run: pnpm install --frozen-lockfile - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version-file: backend/go.mod check-latest: false cache: true cache-dependency-path: backend/go.sum - - name: Release metadata and publisher inventory contracts + - name: Release metadata contracts run: | python3 .github/scripts/test_release_preflight.py - go -C backend test ../.github/scripts/initialize_plugin_publisher.go ../.github/scripts/initialize_plugin_publisher_test.go -run '^TestPublisherSecretInventory' -count=1 - name: Agent protocol contracts run: bash .github/scripts/test_agent_protocol.sh - - name: First-party plugin contracts and signed process package - # Includes codex-runtime routing outcomes and legacy-state migration. - run: bash .github/scripts/test_first_party_plugins.sh + - name: Native domain and retirement contracts + run: bash .github/scripts/test_native_domains.sh - name: Reasoning replay and recovery contracts run: bash .github/scripts/test_reasoning_replay.sh - name: Model context capacity contracts @@ -75,13 +62,15 @@ jobs: - name: Gateway management isolation race test working-directory: backend run: go test -race ./internal/handler -run '^TestOpenAIBlockedUpstreamsDoNotSerializeManagementPlane$' -count=1 - - name: Plugin policy cancellation race tests + - name: Native policy cancellation race tests working-directory: backend - run: go test -race -tags unit ./internal/service -run '^(TestPluginPolicyCancellationSurvivesUpstreamDetachWithoutCancelingBilling|TestPluginPolicyContextsStopOnConfigurationChangeAndDisable|TestSavingIdenticalPluginConfigurationDoesNotCancelHostWork|TestPluginOwnedHostJobHoldsUpdateLeaseUntilCleanup|TestAccountJobRetryPreservesOwnerAndAdmitsCurrentGeneration|TestLegacyImportJobUsesCurrentPluginLifetimeWithoutRewritingHistory|TestPluginJobCompletionDoesNotCancelButPolicyStopPersistsCancellation)$' -count=1 + run: | + go test -p=1 -race -tags unit ./internal/service -run '^(TestNativePolicyCancellationSurvivesDetachedClientContext|TestNativeCodexSameConfigSaveKeepsActiveEpoch)$' -count=1 + go test -p=1 -race ./internal/codexruntime/tickets -run '^TestNativeModuleLifecycleStartsOnceAndDrains$' -count=1 - name: Account identity, routing leases, balance, image, and turn-state tests working-directory: backend run: | - go test -p=1 -tags unit ./internal/repository ./internal/service ./internal/handler ./internal/handler/admin -run 'Test.*(AccountCredentialIdentity|AccountJobCindyMutation|ProviderIdentityMismatch|CindyBalanceProbe|CindyGroupSplit|CindyCatalog|CindyCodexModels|ImageStudioEligibleKeys|CindyCompatibility|LegacyCindyRuntimeCompatibility|CodexImageGenerationBridgeOverridePrecedence|TurnStateCommits|LegacyCodexRoutingImport|CodexConnectionLease|SchedulerCache|SchedulerRefresh|KeepsCindyRoutingIdentity|StrictCindyGroup|MaterializesCindyIdentity|DisabledOrdinaryMember|SkipsCindyAggregate|GatewayModelCapabilities|GatewayModels|WriteOpenAIModelsList|AccountHandlerGetAvailableModels|ForwardResponsesOllamaClampPreservesContinuationPolicy|AccountAvailableModels|FetchOpenAIAccountModels|SetAnthropicAPIKeyAuthHeader|OllamaCloud.*(MaxTokens|MaxOutputTokens|Bearer)|Clamps.*OllamaBase)' -count=1 + go test -p=1 -tags unit ./internal/repository ./internal/service ./internal/handler ./internal/handler/admin -run 'Test.*(AccountCredentialIdentity|AccountJobCindyMutation|ProviderIdentityMismatch|CindyBalanceProbe|CindyGroupSplit|CindyCatalog|CindyCodexModels|ImageStudioEligibleKeys|CindyCompatibility|LegacyCindyRuntimeCompatibility|CodexImageGenerationBridgeOverridePrecedence|TurnStateCommits|CodexConnectionLease|SchedulerCache|SchedulerRefresh|KeepsCindyRoutingIdentity|StrictCindyGroup|MaterializesCindyIdentity|DisabledOrdinaryMember|SkipsCindyAggregate|GatewayModelCapabilities|GatewayModels|WriteOpenAIModelsList|AccountHandlerGetAvailableModels|ForwardResponsesOllamaClampPreservesContinuationPolicy|AccountAvailableModels|FetchOpenAIAccountModels|SetAnthropicAPIKeyAuthHeader|OllamaCloud.*(MaxTokens|MaxOutputTokens|Bearer)|Clamps.*OllamaBase)' -count=1 go test -p=1 -tags unit ./migrations -run 'Test.*(Migration222|Migration223|Migration224|Migration230|Migration231|Migration232|Migration235|Migration236|GroupModelAllowlistRepairMigration|ReleaseAcceptance)' -count=1 - name: Request body recovery tests working-directory: backend @@ -92,10 +81,10 @@ jobs: go test -p=1 -tags unit ./internal/handler ./internal/handler/admin -run 'Test.*(Refusal|Cyber|Continuation|AlphaSearch|RemoteSkill)' -count=1 go test -p=1 ./internal/handler/admin -run 'TestUpdateSettings(PartialPayload|RejectsTwoCaptchaProviders|RequiresFourTencentCaptchaCredentials|RetainsStoredTencentCaptchaCredentials|ValidatesTencentCaptchaAppID|ValidatesTurnstileSiteKey)' -count=1 go test -p=1 ./internal/handler/admin ./internal/service -run 'Test(ExportData|PreviewDataImport|ImportData|DataAccountIdentityKeys|DataIdentityIndex|AccountJobDataImport|OpsMetricsCollectorRecordsDatabasePoolWaitDelta|BulkUpdateAccepts|AccountHandler)' -count=1 - go test -p=1 ./internal/repository -run '^$' + go build ./internal/repository - name: Account view, create, and edit scope contracts working-directory: backend - run: go test -p=1 -tags unit ./internal/service ./internal/handler/admin ./internal/handler/dto ./internal/repository -run '^(TestAccountView.*|TestAccountCreate.*|TestAccountEdit.*|TestPluginManifest(SchemaDeclaresContributionFields|AcceptsRegisteredHyphenatedResources|ResourcePatternParityAndBoundaries)|TestCindyRecoveryResourceProjection)$' -count=1 + run: go test -p=1 -tags unit ./internal/service ./internal/handler/admin ./internal/handler/dto ./internal/repository -run '^(TestAccountEdit.*|TestAccountViewProbe.*|TestAccountJobBulk.*|TestNativeCindyRecoveryReturnsAccountDTO|TestLegacyCodexAccountJobRequiresCompleteActionAndFrozenTarget|TestAccountJobLegacy(RetryPreservesActorCipherExpiryAndAction64|ViewComparesSavedDigestWithoutQueryingTargets|DuplicateTargetsKeepLosers))$' -count=1 - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.12' @@ -104,9 +93,9 @@ jobs: python -m pip install 'PyYAML==6.0.2' candidate=$(mktemp -d)/security-research mkdir -p "$candidate" - cp -a plugins/prompt-skills/registry/seed/tree/. "$candidate/" + cp -a backend/internal/promptskills/registry/seed/tree/. "$candidate/" install -D -m 0644 \ - plugins/prompt-skills/registry/seed/pinned/waf-bypass.md \ + backend/internal/promptskills/registry/seed/pinned/waf-bypass.md \ "$candidate/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/waf-bypass.md" python "$candidate/scripts/validate_skill.py" "$candidate" --require-evals --json - name: Account taxonomy migration integration test @@ -114,7 +103,7 @@ jobs: run: go test -p=1 -tags integration ./internal/repository -run '^(Test(AccountTaxonomy.*|DeleteAccountFolder.*)Integration|TestAPIKeyRepoSuite/TestReleaseAcceptanceKeyIsHiddenButAuthenticates)$' -count=1 - name: Cindy balance, identity, and split integration tests working-directory: backend - run: go test -p=1 -tags integration ./internal/repository -run '^Test(CindyBalanceProbe.*|CindyGroupSplit(PreviewDriftAndAtomicCommit|RollsBackAllWritesWhenOutboxInsertFails|KeepsCanonicalSourceAndTreatsLegacyLaxaAsOrdinary)|ClassifyStrictCindyGroupUsesAllNonDeletedMembersAndOpenAIGroup|PGXBatchInsertAuditAndSystemLogs|Migration236.*|AccountView(DualOwnerFence|ScopedProbeTerminalAtomic|ProbeResumeJSONBCAS)Integration|AccountCreateEntFenceIntegration|AccountEditEntFenceNullDeletionAndStateCASIntegration)$' -count=1 + run: go test -p=1 -tags integration ./internal/repository -run '^Test(CindyBalanceProbe.*|CindyGroupSplit(PreviewDriftAndAtomicCommit|RollsBackAllWritesWhenOutboxInsertFails|KeepsCanonicalSourceAndTreatsLegacyLaxaAsOrdinary)|ClassifyStrictCindyGroupUsesAllNonDeletedMembersAndOpenAIGroup|PGXBatchInsertAuditAndSystemLogs|Migration236.*|NativeFeatureRetirementIsAtomicAndPreservesLedger|AccountJobPluginActionDigestRoundTrip|AccountViewScopedProbeTerminalAtomicIntegration|AccountViewProbeResumeJSONBCASIntegration)$' -count=1 - name: Cindy defaults migration tests working-directory: backend run: | @@ -142,7 +131,6 @@ jobs: run: | pnpm run lint:check pnpm run typecheck - pnpm exec vue-tsc --noEmit -p ../backend/pkg/extensionapi/ui/tsconfig.json - name: Focused downstream tests working-directory: frontend run: | @@ -154,34 +142,25 @@ jobs: src/components/keys/__tests__/UseKeyModal.spec.ts \ src/views/admin/__tests__/SettingsView.spec.ts \ src/components/common/__tests__/VersionBadge.downstream.spec.ts \ + src/components/common/__tests__/BaseDialog.spec.ts \ + src/components/common/__tests__/Select.disabled.spec.ts \ + src/utils/__tests__/imageStudioLocalResources.spec.ts \ + src/views/admin/__tests__/PluginsView.spec.ts \ + src/components/admin/codex/__tests__/CodexRuntime.native.spec.ts \ src/api/__tests__/downstreamStatus.spec.ts \ src/i18n/__tests__/accountConsoleLocales.spec.ts \ - ../plugins/account-tools/ui/src/__tests__/data-import.spec.ts \ - ../plugins/account-tools/ui/src/__tests__/ImportDataModal.spec.ts \ + src/__tests__/integration/data-import.spec.ts \ + src/components/admin/account/__tests__/ImportDataModal.spec.ts \ src/components/admin/account-jobs/__tests__/AccountTaskDrawer.spec.ts \ src/stores/__tests__/accountJobs.spec.ts \ src/composables/__tests__/useAccountSelectionMetadata.spec.ts \ src/api/__tests__/accountOperationIdempotency.spec.ts \ - src/components/plugins/__tests__/ExtensionSlot.spec.ts \ - src/components/plugins/__tests__/ExtensionFields.spec.ts \ - src/components/plugins/__tests__/AccountViewPage.spec.ts \ - src/components/plugins/__tests__/CindyAccountControls.spec.ts \ - src/components/plugins/__tests__/accountView.spec.ts \ - src/components/plugins/__tests__/accountViewNative.spec.ts \ - src/components/plugins/__tests__/accountViewStepUp.spec.ts \ - src/components/plugins/__tests__/pluginViewDispatch.spec.ts \ - src/components/plugins/__tests__/resourceAvailability.spec.ts \ - src/components/plugins/__tests__/PluginFrame.precondition.spec.ts \ - src/components/plugins/__tests__/theme.spec.ts \ - src/components/plugins/__tests__/presentationStability.spec.ts \ - src/components/plugins/__tests__/resourceClient.spec.ts \ - src/components/plugins/__tests__/localResources.spec.ts \ src/features/image-studio/history.legacy.spec.ts \ - ../plugins/account-tools/ui/src/__tests__/BatchTestAccountModal.spec.ts \ - ../plugins/account-tools/ui/src/__tests__/BatchTestAccountModal.plan.spec.ts \ - ../plugins/account-tools/ui/src/__tests__/AccountFolderBar.spec.ts \ - ../plugins/account-tools/ui/src/__tests__/AccountTaxonomyManager.spec.ts \ - ../plugins/account-tools/ui/src/__tests__/AccountBulkTaxonomyModal.spec.ts \ + src/components/admin/account/__tests__/BatchTestAccountModal.spec.ts \ + src/components/admin/account/__tests__/BatchTestAccountModal.plan.spec.ts \ + src/components/admin/account/__tests__/AccountFolderBar.spec.ts \ + src/components/admin/account/__tests__/AccountTaxonomyManager.spec.ts \ + src/components/admin/account/__tests__/AccountBulkTaxonomyModal.spec.ts \ src/components/admin/account/__tests__/AccountBulkActionsBar.spec.ts \ src/components/admin/account/__tests__/AccountActionMenu.spark_shadow.spec.ts \ src/components/admin/account/__tests__/AccountConsoleFilters.spec.ts \ @@ -200,7 +179,6 @@ jobs: src/utils/__tests__/openaiReasoningPolicy.spec.ts \ src/components/account/__tests__/EditAccountModal.spec.ts \ src/utils/__tests__/accountEditCodec.spec.ts \ - src/composables/__tests__/useAccountEditProfile.spec.ts \ src/api/__tests__/admin.accounts.editContext.spec.ts \ src/components/account/__tests__/ModelWhitelistSelector.spec.ts \ src/components/account/__tests__/ModelContextCapacityField.spec.ts \ @@ -209,29 +187,37 @@ jobs: src/api/__tests__/admin.accounts.contextCapacity.spec.ts \ src/composables/__tests__/useModelWhitelist.spec.ts \ src/components/account/__tests__/BulkEditAccountModal.spec.ts \ - ../plugins/prompt-skills/ui/src/__tests__/App.spec.ts \ - ../plugins/prompt-skills/ui/src/__tests__/PromptRules.spec.ts \ - ../plugins/prompt-skills/ui/src/__tests__/locales.spec.ts \ + src/views/admin/__tests__/SystemPromptsView.spec.ts \ + src/views/admin/__tests__/SystemPromptsView.locale.spec.ts \ + src/components/admin/systemPrompt/__tests__/PromptRules.spec.ts \ src/views/admin/__tests__/AccountsView.console.spec.ts \ src/views/admin/__tests__/AccountsView.priorityColumn.spec.ts \ - ../plugins/image-tools/ui/src/__tests__/api.spec.ts \ + src/features/image-studio/__tests__/api.spec.ts \ src/api/__tests__/cindyBalanceProbe.spec.ts \ src/api/__tests__/admin.groups.cindy-split.spec.ts \ - ../plugins/cindy-provider/ui/src/__tests__/CindyGroupAuditDialog.spec.ts \ - ../plugins/cindy-provider/ui/src/__tests__/CindyBalanceProbePanel.spec.ts \ - ../plugins/cindy-provider/ui/src/__tests__/CindyDuplicateInventoryPanel.spec.ts \ - ../plugins/cindy-provider/ui/src/__tests__/CindyUISecondReview.spec.ts \ - ../plugins/cindy-provider/ui/src/__tests__/ProviderSettings.spec.ts \ + src/features/cindy-group-split/__tests__/CindyGroupAuditDialog.spec.ts \ + src/features/cindy-balance-probe/__tests__/CindyBalanceProbePanel.spec.ts \ + src/features/cindy-duplicate-inventory/__tests__/CindyDuplicateInventoryPanel.spec.ts \ + src/features/cindy/__tests__/nativeAccountForms.spec.ts \ + src/router/__tests__/cindy-legacy-redirect.spec.ts \ + src/features/cindy/__tests__/providerSettings.spec.ts \ src/features/cindy-balance-probe/__tests__/CindyBalanceProbeSummary.spec.ts \ src/i18n/__tests__/cindyProbeLocales.spec.ts \ src/components/layout/__tests__/AppSidebar.spec.ts \ src/components/layout/__tests__/AppSidebar.simple-mode.spec.ts \ src/views/admin/__tests__/GroupsView.columnSettings.spec.ts \ - ../plugins/image-tools/ui/src/__tests__/App.spec.ts \ + src/features/image-studio/__tests__/ImageStudioPage.spec.ts \ src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts \ + src/views/admin/__tests__/AccountsView.selectionScope.spec.ts \ src/views/admin/__tests__/AccountsView.schedulerScore.spec.ts \ src/views/admin/__tests__/AccountsView.sparkShadow.spec.ts \ src/views/admin/__tests__/AccountsView.usageWindowsHint.spec.ts + - name: Native Cindy lite account scope + working-directory: frontend + run: | + pnpm exec vitest run \ + src/views/admin/__tests__/AccountsView.lite.spec.ts \ + -t 'keeps Cindy scope filters' - name: MiniMax quota and Grok save compatibility working-directory: frontend run: | @@ -255,8 +241,7 @@ jobs: - name: Resolve candidate compatibility version id: version shell: bash - # A codexrip prerelease that sorts above every numbered codexrip.N of this - # base, so bundled plugins requiring the newest downstream host verify. + # Keep candidate versions distinct from every numbered codexrip release. run: printf 'value=%s-codexrip.candidate\n' "$(tr -d '\r\n' < backend/cmd/server/VERSION)" >> "$GITHUB_OUTPUT" - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Build candidate diff --git a/.github/workflows/plugin-release.yml b/.github/workflows/plugin-release.yml deleted file mode 100644 index aec66f2912f3..000000000000 --- a/.github/workflows/plugin-release.yml +++ /dev/null @@ -1,168 +0,0 @@ -name: Independent Plugin Release -run-name: Plugin release ${{ inputs.plugin_tag }} - -on: - workflow_dispatch: - inputs: - plugin_tag: - description: Existing immutable plugins//v tag on main - required: true - type: string - host_tag: - description: Existing compatible Sub2API release tag - required: true - type: string - -permissions: - contents: write - id-token: write - attestations: write - -concurrency: - group: plugin-release-${{ inputs.plugin_tag }} - cancel-in-progress: false - -jobs: - publish: - runs-on: ubuntu-latest - env: - PLUGIN_TAG: ${{ inputs.plugin_tag }} - HOST_TAG: ${{ inputs.host_tag }} - steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - ref: refs/tags/${{ inputs.plugin_tag }} - fetch-depth: 0 - persist-credentials: false - - name: Validate immutable source and selected domain - id: source - env: - GH_TOKEN: ${{ github.token }} - shell: bash - run: | - set -euo pipefail - [[ "$PLUGIN_TAG" =~ ^plugins/([a-z][a-z0-9-]*)/v([0-9]+\.[0-9]+\.[0-9]+)$ ]] - domain=${BASH_REMATCH[1]} - version=${BASH_REMATCH[2]} - [[ "$HOST_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-codexrip\.[1-9][0-9]*$ ]] - authorization=$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 -w0) - printf '::add-mask::%s\n' "$authorization" - GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.https://github.com/.extraheader \ - GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $authorization" git fetch origin main - unset authorization - source_sha=$(git rev-parse --verify "refs/tags/$PLUGIN_TAG^{commit}") - host_source_sha=$(git rev-parse --verify "refs/tags/$HOST_TAG^{commit}") - [[ "$source_sha" =~ ^[a-f0-9]{40}$ && "$host_source_sha" =~ ^[a-f0-9]{40}$ ]] - [[ "$(git rev-parse HEAD)" == "$source_sha" ]] - git merge-base --is-ancestor "$source_sha" origin/main - git checkout --detach "$source_sha" - # Reuse main's required checks only when they exercised the same host - # implementation as the selected release. New host APIs require a - # host release before independently publishing a plugin that uses them. - git diff --quiet "$host_source_sha" "$source_sha" -- backend frontend ':(exclude)**/*_test.go' ':(exclude)**/*.spec.ts' ':(exclude)**/*.test.ts' - jq -e --arg domain "$domain" '.plugins | any(.directory == $domain)' plugins/bundle.source.json >/dev/null - jq -e --arg version "$version" --arg id "codexrip.$domain" '.version == $version and .id == $id' "plugins/$domain/manifest.source.json" >/dev/null - echo "domain=$domain" >> "$GITHUB_OUTPUT" - echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT" - echo "host_source_sha=$host_source_sha" >> "$GITHUB_OUTPUT" - echo "host_version=${HOST_TAG#v}" >> "$GITHUB_OUTPUT" - - name: Reject existing or uncertain publication slots - env: - GH_TOKEN: ${{ github.token }} - SOURCE_SHA: ${{ steps.source.outputs.source_sha }} - HOST_SOURCE_SHA: ${{ steps.source.outputs.host_source_sha }} - shell: bash - run: | - set -euo pipefail - python3 .github/scripts/release_preflight.py --kind plugin --stage before-sign \ - --tag "$PLUGIN_TAG" --source-sha "$SOURCE_SHA" --host-tag "$HOST_TAG" --host-source-sha "$HOST_SOURCE_SHA" - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 - with: - version: 9.15.9 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - with: - node-version: '20' - cache: pnpm - cache-dependency-path: frontend/pnpm-lock.yaml - - name: Build the selected independent interface - env: - PLUGIN_DOMAIN: ${{ steps.source.outputs.domain }} - run: | - pnpm --dir frontend install --frozen-lockfile - node backend/pkg/extensionapi/ui/build.mjs "$PLUGIN_DOMAIN" - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 - with: - go-version-file: backend/go.mod - cache-dependency-path: backend/go.sum - # Main already passed required PR checks. Build the selected artifact; - # do not repeat the host/frontend/business test suites when publishing. - - name: Build the selected independent program - working-directory: backend - env: - PLUGIN_DOMAIN: ${{ steps.source.outputs.domain }} - shell: bash - run: | - set -euo pipefail - go build -trimpath -o "$RUNNER_TEMP/package-plugin" ./cmd/package-plugin - "$RUNNER_TEMP/package-plugin" -build-binaries -bundle-source ../plugins/bundle.source.json \ - -plugin-domain "$PLUGIN_DOMAIN" -binary-dir "$RUNNER_TEMP/plugin-binaries" -platform linux-amd64 - - name: Recheck source before exposing the signing key - env: - GH_TOKEN: ${{ github.token }} - SOURCE_SHA: ${{ steps.source.outputs.source_sha }} - HOST_SOURCE_SHA: ${{ steps.source.outputs.host_source_sha }} - shell: bash - run: | - set -euo pipefail - [[ "$(git rev-parse HEAD)" == "$SOURCE_SHA" ]] - python3 .github/scripts/release_preflight.py --kind plugin --stage before-sign \ - --tag "$PLUGIN_TAG" --source-sha "$SOURCE_SHA" --host-tag "$HOST_TAG" --host-source-sha "$HOST_SOURCE_SHA" - - name: Sign the selected package - working-directory: backend - env: - SUB2API_PLUGIN_SIGNING_KEY: ${{ secrets.SUB2API_PLUGIN_SIGNING_KEY }} - PLUGIN_DOMAIN: ${{ steps.source.outputs.domain }} - SOURCE_SHA: ${{ steps.source.outputs.source_sha }} - HOST_VERSION: ${{ steps.source.outputs.host_version }} - shell: bash - run: | - set -euo pipefail - test -n "$SUB2API_PLUGIN_SIGNING_KEY" - "$RUNNER_TEMP/package-plugin" -bundle-source ../plugins/bundle.source.json -plugin-domain "$PLUGIN_DOMAIN" \ - -binary-dir "$RUNNER_TEMP/plugin-binaries" -platform linux-amd64 \ - -tested-host-version "$HOST_VERSION" -source-revision "$SOURCE_SHA" \ - -output ../deploy/plugin-bundle/current -key-id codexrip-plugins-v1 - "$RUNNER_TEMP/package-plugin" -verify-bundle ../deploy/plugin-bundle/current/lock.json -tested-host-version "$HOST_VERSION" - - name: Attest plugin provenance - uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 - with: - subject-path: deploy/plugin-bundle/current/* - - name: Create a draft without replacing any release - env: - GH_TOKEN: ${{ github.token }} - SOURCE_SHA: ${{ steps.source.outputs.source_sha }} - HOST_VERSION: ${{ steps.source.outputs.host_version }} - HOST_SOURCE_SHA: ${{ steps.source.outputs.host_source_sha }} - shell: bash - run: | - set -euo pipefail - printf '%s\n' "Independent signed plugin package." "Source: $SOURCE_SHA" "Compatible host checked during packaging: $HOST_VERSION" \ - "Upload the .s2plugin using Update package. This keeps its configuration and pins the selected version." > "$RUNNER_TEMP/plugin-notes.md" - python3 .github/scripts/release_preflight.py --kind plugin --stage before-create \ - --tag "$PLUGIN_TAG" --source-sha "$SOURCE_SHA" --host-tag "$HOST_TAG" --host-source-sha "$HOST_SOURCE_SHA" - gh release create "$PLUGIN_TAG" --repo "$GITHUB_REPOSITORY" --verify-tag --draft --target "$SOURCE_SHA" --title "$PLUGIN_TAG" \ - --notes-file "$RUNNER_TEMP/plugin-notes.md" deploy/plugin-bundle/current/*.s2plugin deploy/plugin-bundle/current/lock.json - - name: Verify complete assets and publish the draft - env: - GH_TOKEN: ${{ github.token }} - SOURCE_SHA: ${{ steps.source.outputs.source_sha }} - HOST_SOURCE_SHA: ${{ steps.source.outputs.host_source_sha }} - shell: bash - run: | - set -euo pipefail - [[ "$(git rev-parse HEAD)" == "$SOURCE_SHA" ]] - result=$(python3 .github/scripts/release_preflight.py --kind plugin --stage before-publish \ - --tag "$PLUGIN_TAG" --source-sha "$SOURCE_SHA" --host-tag "$HOST_TAG" --host-source-sha "$HOST_SOURCE_SHA") - RELEASE_ID=$(jq -er '.release_id | select(type == "number" and . > 0)' <<< "$result") - [[ "$RELEASE_ID" =~ ^[1-9][0-9]*$ ]] - gh api --hostname github.com --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" -F draft=false --silent diff --git a/.github/workflows/production-deploy.yml b/.github/workflows/production-deploy.yml index 0fa0110a0f94..82f632bdd566 100644 --- a/.github/workflows/production-deploy.yml +++ b/.github/workflows/production-deploy.yml @@ -1,6 +1,6 @@ name: Production Deploy run-name: >- - ${{ inputs.operation == 'rollback' && format('Rollback to {0}', inputs.release_tag) || inputs.operation == 'reconcile-runtime' && format('Reconcile runtime for {0}', inputs.release_tag) || inputs.operation == 'deploy-preserve' && format('Deploy {0} (preserve)', inputs.release_tag) || format('Deploy {0} (explicit)', inputs.release_tag) }} + ${{ inputs.operation == 'rollback' && format('Rollback to {0}', inputs.release_tag) || inputs.operation == 'rollback-preserve' && format('Rollback to {0} (preserve)', inputs.release_tag) || inputs.operation == 'reconcile-runtime' && format('Reconcile runtime for {0}', inputs.release_tag) || inputs.operation == 'deploy-preserve' && format('Deploy {0} (preserve)', inputs.release_tag) || format('Deploy {0} (explicit)', inputs.release_tag) }} on: workflow_dispatch: @@ -15,6 +15,7 @@ on: - deploy-preserve - reconcile-runtime - rollback + - rollback-preserve release_tag: description: 'Target release tag; for reconcile, the exact release currently running' required: true @@ -120,6 +121,10 @@ jobs: if [[ "$OPERATION" == deploy-preserve ]]; then OPERATION=deploy runtime_mode=preserve + elif [[ "$OPERATION" == rollback-preserve ]]; then + # Keeps the running runtime flags; the host refuses unless the data contract is unchanged. + OPERATION=rollback + runtime_mode=preserve fi RESOURCE_PROFILE="${RESOURCE_PROFILE:-preserve}" log() { printf 'RESOLVE|%s\n' "$1" >&2; } @@ -219,7 +224,7 @@ jobs: [[ "$RESOURCE_PROFILE" == preserve || "$RESOURCE_PROFILE" == auto || "$RESOURCE_PROFILE" == high ]] || exit 1 if [[ "$runtime_mode" == preserve ]]; then [[ "$INTERRUPT_BUSINESS" == false ]] || exit 1 - [[ "$OPERATION" == deploy ]] || exit 1 + [[ "$OPERATION" == deploy || "$OPERATION" == rollback ]] || exit 1 [[ "$RESOURCE_PROFILE" == preserve ]] || exit 1 else for flag in "$CINDY_HEALTH" "$CINDY_CAPABILITY_CATALOG" "$CINDY_SEARCH" "$IMAGE_STUDIO" "$CINDY_RESPONSES_IMAGE_BRIDGE"; do @@ -305,7 +310,8 @@ jobs: [[ -z "$MAINTENANCE_SPEC" || "$MAINTENANCE_SPEC" == maintenance=interrupt ]] || exit 1 [[ "$OPERATION" == deploy || "$OPERATION" == reconcile-runtime || -z "$MAINTENANCE_SPEC" ]] || exit 1 else - [[ -z "$CINDY_ROLLOUT" && -z "$MAINTENANCE_SPEC" && "$RESOURCE_SPEC" == resource=preserve && "$OPERATION" == deploy ]] || exit 1 + [[ -z "$CINDY_ROLLOUT" && -z "$MAINTENANCE_SPEC" && "$RESOURCE_SPEC" == resource=preserve ]] || exit 1 + [[ "$OPERATION" == deploy || "$OPERATION" == rollback ]] || exit 1 fi case "$OPERATION" in deploy) @@ -334,7 +340,11 @@ jobs: [[ "$EXPECTED_CURRENT_IMAGE_REF" =~ ^ghcr\.io/htexplicit/sub2api:[0-9]+\.[0-9]+\.[0-9]+-codexrip\.[1-9][0-9]*@sha256:[0-9a-f]{64}$ ]] || exit 1 [[ "$EXPECTED_CURRENT_IMAGE_REF" != "$IMAGE_REF" ]] || exit 1 [[ "$RESOURCE_SPEC" == resource=preserve ]] || exit 1 - remote_command="rollback ${IMAGE_REF} from=${EXPECTED_CURRENT_IMAGE_REF} ${CINDY_ROLLOUT}" + if [[ "$RUNTIME_SPEC" == runtime=preserve ]]; then + remote_command="rollback ${IMAGE_REF} from=${EXPECTED_CURRENT_IMAGE_REF} runtime=preserve" + else + remote_command="rollback ${IMAGE_REF} from=${EXPECTED_CURRENT_IMAGE_REF} ${CINDY_ROLLOUT}" + fi ;; *) echo 'Operation must be deploy, reconcile-runtime, or rollback' >&2 @@ -353,8 +363,10 @@ jobs: -p "$VPS_PORT" \ "${VPS_USER}@${VPS_HOST}" \ "$remote_command" + # The recreated container needs time for startup and migrations; the + # edge answers 502/503 until then, so keep retrying for up to three minutes. - name: Verify public health env: PUBLIC_HEALTH_URL: ${{ secrets.PUBLIC_HEALTH_URL }} shell: bash - run: curl --fail --silent --show-error --retry 3 --retry-delay 2 --max-time 30 "$PUBLIC_HEALTH_URL" >/dev/null + run: curl --fail --silent --show-error --retry 36 --retry-delay 5 --retry-max-time 180 --retry-connrefused --max-time 30 "$PUBLIC_HEALTH_URL" >/dev/null diff --git a/.gitignore b/.gitignore index d5b81c5de002..9dac6630cf03 100644 --- a/.gitignore +++ b/.gitignore @@ -126,6 +126,8 @@ scripts !.github/scripts/*.py !backend/scripts/ !backend/scripts/resolve-version.sh +!/backend/internal/promptskills/registry/seed/tree/scripts/ +!/backend/internal/promptskills/registry/seed/tree/scripts/** .code-review-state #openspec/ code-reviews/ @@ -163,35 +165,5 @@ issues/ # Vitest / Vite cache at repo root .vite/ -# First-party extension sources are versioned; installed packages and local -# third-party projects remain outside the source tree. -/plugins/* -!/plugins/bundle.source.json -!/plugins/codex-runtime/ -!/plugins/codex-runtime/scripts/ -!/plugins/codex-runtime/scripts/check-ui.py -!/plugins/cindy-provider/ -!/plugins/cindy-provider/scripts/ -!/plugins/cindy-provider/scripts/check-ui.py -!/plugins/account-tools/ -!/plugins/account-tools/scripts/ -!/plugins/account-tools/scripts/check-ui.py -!/plugins/prompt-skills/ -!/plugins/prompt-skills/scripts/ -!/plugins/prompt-skills/scripts/check-ui.py -!/plugins/prompt-skills/registry/seed/tree/scripts/ -!/plugins/prompt-skills/registry/seed/tree/scripts/** -!/plugins/model-catalog/ -!/plugins/model-policy/ -!/plugins/image-tools/ -!/plugins/image-tools/scripts/ -!/plugins/image-tools/scripts/check-ui.py -!/plugins/admin-observability/ -!/plugins/admin-observability/scripts/ -!/plugins/admin-observability/scripts/check-theme.py -/plugins/*/dist/ -/plugins/*/node_modules/ -/plugins/*/ui/compiled/ -/plugins/*/*.s2plugin -/plugins/**/*.key -/deploy/plugin-bundle/current/ +# Installed packages and local third-party projects are not versioned. +/plugins/ diff --git a/DOWNSTREAM.md b/DOWNSTREAM.md index d24f6b9bd88c..240cc06efe4b 100644 --- a/DOWNSTREAM.md +++ b/DOWNSTREAM.md @@ -4,22 +4,34 @@ This fork maintains the `codexrip` patch set over official Sub2API releases. ## Source baseline -- Integrated upstream: recovered `v0.2.6`, exact commit `49a39b6dc1abed30fd227611e8af1108bc427610`. The public tag was withdrawn when recovered; source is pinned to the commit, not a recreated official tag. -- Integration base: production `v0.2.5-codexrip.14`, commit `a2f13d34a41bb89225e5d6864d466b4a35d6448f`; embedded version is `0.2.6`. -- Conflict decisions and validation: [v0.2.6 review](.downstream/upstream-review-v0.2.6.md); exact-SHA evidence: [risk manifest](.downstream/upstream-risk.json). -- The operator workspace `docs/sub2api.md` owns the current production pointer. A source merge or Release alone does not establish deployment completion. -- The full recovered tree includes the official changes after v0.2.5 and all six Codex ticket patches. Deployment preserves the existing live ticket switch and proxy; source defaults remain disabled. -- Tickets apply only to OpenAI OAuth/Setup Token accounts, excluding shadows. Cindy/API-key paths retain their existing identity, health, quota and sticky-session behavior. - -## Account tests and ticket lifecycle - -- Single and batch text tests accept an optional user prompt, up to 8192 Unicode characters; blank uses `hi`. The browser remembers text separately from media, per site and administrator. Batch prompts are held in the existing encrypted task payload, while scheduled tests retain their defaults. -- Initial 292 harvesting is manual, per account and model, with one model request per item, up to 100 accounts and five concurrent requests. Valid tickets are skipped unless explicitly refreshed. The existing task drawer provides progress, cancellation and failed-item retry. -- Successful account/model pairs renew once 60 seconds before expiry, then once 60 seconds after expiry if needed. Two failures stop renewal until another manual success. Startup imports only still-valid legacy tickets and never revives stopped enrollments. -- Migration `244_codex_ticket_lifecycle.sql` stores durable stages and execution leases. Ticket material remains solely in account Extra; ticket and lifecycle writes commit together. Account disablement, deletion or principal changes invalidate in-flight claims; token refresh preserves the principal. -- The ticket proxy editor accepts URLs, colon-separated fields and labeled fields in any order. Only authenticated administrator settings responses reveal the full proxy credentials; those responses prohibit caching. Explicit clearing also disables tickets. -- Draft connection tests send no OAuth credentials or model requests. Private certificates can be learned only during an unauthenticated ChatGPT preflight, remain bound to that proxy configuration generation and hostname, and must pass hostname/validity checks. Automatic renewal does not learn certificate changes; manual harvesting or testing the configured proxy can update its trust. Business transports and system roots are unchanged. -- OpenAI OAuth text tests apply the same account/model ticket policy as forwarding. Diagnostic events expose presence, length and a SHA-256 fingerprint, never the ticket itself. A successful connection or a 292 header does not establish model quality. +- Integrated official baseline: `v0.2.8`, commit `fd80b08c90b55edcad5b00171b53f08721d30da1`. +- Retained compatibility decisions: [upstream review](.downstream/upstream-review-v0.2.8.md). +- The operator workspace `docs/sub2api.md` owns the production version and image digest. A source merge or Release does not establish deployment completion. + +## Native domains and account operations + +The seven first-party domains now 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). + +Account bulk operations remain HTTP 202 jobs with progress, cancellation and +failed-item retry. Both edit entries use the frozen selected IDs whenever there +is a selection; without a selection, filter-based updates retain their existing +semantics. A late select-all response cannot replace a newer manual selection. +Invalid nonempty ID lists cannot fall back to all filter results. + +Cindy, folder/tag filters, explicit field clearing and untouched-field preservation +remain supported. Old jobs retain actor, encrypted payload, expiry and frozen +item targets. An old filter-only job without a saved target fails rather than +selecting a fresh set of accounts. + +Codex routing applies only to OpenAI OAuth/setup-token accounts, excluding shadows. +Acquisition and stopping renewal use the existing persisted task experience. +Routing qualification, connection leases and the closed quality-run ledger keep +their existing contracts; a 292 header or a healthy deployment is not a quality +result. Proxy settings preserve their saved values and accept the existing input +formats. Draft proxy tests do not send OAuth credentials or model requests. ## Official behavior and downstream contracts @@ -46,7 +58,8 @@ 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 an image digest and provenance. It reuses PR validation. + publishes the native host image digest and provenance. It reuses PR validation; + first-party package signing and separate plugin release assets are retired. - 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/Dockerfile b/Dockerfile index 4ac3d7210b9f..86e36941468e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,6 @@ ARG POSTGRES_IMAGE=postgres:18-alpine ARG GOPROXY=https://goproxy.cn,direct ARG GOSUMDB=sum.golang.google.cn ARG NPM_CONFIG_REGISTRY= -ARG PLUGIN_BUNDLE_STAGE=plugin-bundle-development # ----------------------------------------------------------------------------- # Stage 1: Frontend Builder @@ -42,11 +41,9 @@ RUN --mount=type=cache,id=sub2api-pnpm-store,target=/root/.local/share/pnpm/stor # Copy only that subtree to keep the build dependency minimal. COPY frontend/ ./ COPY docs/legal/ /app/docs/legal/ -COPY backend/pkg/extensionapi/ui/ /app/backend/pkg/extensionapi/ui/ -COPY plugins/ /app/plugins/ # Required PR checks own validation; the image stage only compiles the artifact. # Scope this flag to the build command, not the runtime image or daily builds. -RUN node /app/backend/pkg/extensionapi/ui/build.mjs && SUB2API_ARTIFACT_BUILD=1 pnpm exec vite build +RUN SUB2API_ARTIFACT_BUILD=1 pnpm exec vite build # ----------------------------------------------------------------------------- # Stage 2: Backend Builder @@ -77,24 +74,18 @@ WORKDIR /app/backend # Copy go mod files first (better caching) COPY backend/go.mod backend/go.sum ./ -COPY plugins/ /app/plugins/ # Cache mount keeps the module cache across builds so a transient CDN blip on # retry resumes instead of re-fetching every zip from scratch. RUN --mount=type=cache,id=sub2api-gomod,target=/go/pkg/mod \ - go mod download && \ - for module_dir in /app/plugins/*; do \ - if [ -f "$module_dir/go.mod" ]; then (cd "$module_dir" && go mod download) || exit 1; fi; \ - done + go mod download # Copy backend source first COPY backend/ ./ -COPY --from=frontend-builder /app/plugins/ /app/plugins/ # Copy frontend dist from previous stage (must be after backend copy to avoid being overwritten) COPY --from=frontend-builder /app/backend/internal/web/dist ./internal/web/dist -# Build the host. Production plugin packages are independently built and signed -# before the image build, then copied and verified without being regenerated. +# Build the host with its native domain modules. # Version precedence: build arg VERSION > exact git tag > cmd/server/VERSION RUN --mount=type=cache,id=sub2api-gomod,target=/go/pkg/mod \ --mount=type=cache,id=sub2api-gobuild,target=/root/.cache/go-build \ @@ -109,21 +100,6 @@ RUN --mount=type=cache,id=sub2api-gomod,target=/go/pkg/mod \ -o /app/sub2api \ ./cmd/server -FROM backend-builder AS plugin-bundle-development -RUN --mount=type=cache,id=sub2api-gomod,target=/go/pkg/mod \ - --mount=type=cache,id=sub2api-gobuild,target=/root/.cache/go-build \ - VERSION_VALUE="${VERSION}" && \ - if [ -z "${VERSION_VALUE}" ]; then VERSION_VALUE="$(./scripts/resolve-version.sh)"; fi && \ - bash ./scripts/build-development-plugins.sh /bundle/current "${VERSION_VALUE}" "${TARGETOS:-linux}-${TARGETARCH:-amd64}" "${COMMIT}" - -FROM backend-builder AS plugin-bundle-release -COPY deploy/plugin-bundle/ /bundle/ -RUN --mount=type=cache,id=sub2api-gomod,target=/go/pkg/mod \ - --mount=type=cache,id=sub2api-gobuild,target=/root/.cache/go-build \ - go run ./cmd/package-plugin -verify-bundle /bundle/current/lock.json -tested-host-version "${VERSION}" -platform "${TARGETOS:-linux}-${TARGETARCH:-amd64}" - -FROM ${PLUGIN_BUNDLE_STAGE} AS plugin-bundle - # ----------------------------------------------------------------------------- # Stage 3: PostgreSQL Client (version-matched with docker-compose) # ----------------------------------------------------------------------------- @@ -171,7 +147,6 @@ WORKDIR /app # Copy binary/resources with ownership to avoid extra full-layer chown copy COPY --from=backend-builder --chown=sub2api:sub2api /app/sub2api /app/sub2api -COPY --from=plugin-bundle --chown=sub2api:sub2api /bundle/current /app/bundled-plugins COPY --from=backend-builder --chown=sub2api:sub2api /app/backend/resources /app/resources COPY --chown=sub2api:sub2api THIRD_PARTY_NOTICES.md /app/THIRD_PARTY_NOTICES.md diff --git a/Makefile b/Makefile index dcd56275aefd..a91b6a9dc304 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ FRONTEND_CRITICAL_VITEST := \ src/components/account/__tests__/ModelWhitelistSelector.spec.ts \ src/composables/__tests__/useModelWhitelist.spec.ts \ src/components/layout/__tests__/AppSidebar.simple-mode.spec.ts \ - ../plugins/cindy-provider/ui/src/__tests__/CindyGroupAuditDialog.spec.ts \ + src/features/cindy-group-split/__tests__/CindyGroupAuditDialog.spec.ts \ src/features/channel-monitor-v2/__tests__/designSystem.structure.spec.ts \ src/features/channel-monitor-v2/__tests__/monitorFormat.spec.ts \ src/features/channel-monitor-v2/__tests__/monitorZoom.spec.ts diff --git a/backend/cmd/package-plugin/bundle.go b/backend/cmd/package-plugin/bundle.go deleted file mode 100644 index a9051c7fc745..000000000000 --- a/backend/cmd/package-plugin/bundle.go +++ /dev/null @@ -1,150 +0,0 @@ -package main - -import ( - "encoding/json" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "regexp" - "strings" - - "github.com/Wei-Shaw/sub2api/internal/service" - "golang.org/x/mod/semver" -) - -type bundleSource struct { - SchemaVersion int `json:"schema_version"` - Plugins []struct { - Directory string `json:"directory"` - DefaultEnabled bool `json:"default_enabled"` - Migration string `json:"migration"` - } `json:"plugins"` -} - -// The source inventory is the only place that chooses bundled domains and -// initial activation. It accepts prebuilt binaries and never executes a plugin. -func packageBundle(source, binaryDirectory string, options packageOptions) error { - if binaryDirectory == "" || options.output == "" || options.hostVersion == "" { - return errors.New("bundle requires binary-dir, output directory and tested-host-version") - } - raw, err := os.ReadFile(source) - if err != nil { - return err - } - var inventory bundleSource - if json.Unmarshal(raw, &inventory) != nil || inventory.SchemaVersion != 1 || len(inventory.Plugins) == 0 { - return errors.New("invalid bundle source") - } - if existing, err := os.ReadDir(options.output); err == nil && len(existing) > 0 { - return errors.New("bundle output directory must be empty") - } else if err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - if err := os.MkdirAll(options.output, 0700); err != nil { - return err - } - seen := map[string]bool{} - matched := false - valid := regexp.MustCompile(`^[a-z][a-z0-9-]*$`) - for _, entry := range inventory.Plugins { - if !valid.MatchString(entry.Directory) || seen[entry.Directory] { - return errors.New("invalid or duplicate bundled module directory") - } - seen[entry.Directory] = true - if options.domain != "" && options.domain != entry.Directory { - continue - } - matched = true - suffix := "" - if strings.HasPrefix(options.platform, "windows-") { - suffix = ".exe" - } - module := options - module.source = filepath.Join(filepath.Dir(source), entry.Directory) - module.binary = filepath.Join(binaryDirectory, entry.Directory+suffix) - module.output = filepath.Join(options.output, entry.Directory+".s2plugin") - module.bundleLock = filepath.Join(options.output, "lock.json") - module.defaultEnabled, module.migration = entry.DefaultEnabled, entry.Migration - if err := packagePlugin(module); err != nil { - return err - } - } - if !matched { - return errors.New("plugin domain is not in the bundle source") - } - return nil -} - -func buildBundleBinaries(source, destination string, options packageOptions) error { - if source == "" || destination == "" { - return errors.New("bundle-source and binary-dir are required") - } - raw, err := os.ReadFile(source) - if err != nil { - return err - } - var inventory bundleSource - if json.Unmarshal(raw, &inventory) != nil || inventory.SchemaVersion != 1 { - return errors.New("invalid bundle source") - } - platform := strings.Split(options.platform, "-") - if len(platform) != 2 || !regexp.MustCompile(`^[a-z0-9]+$`).MatchString(platform[0]) || !regexp.MustCompile(`^[a-z0-9]+$`).MatchString(platform[1]) { - return errors.New("invalid runtime platform") - } - if err = os.MkdirAll(destination, 0700); err != nil { - return err - } - destination, err = filepath.Abs(destination) - if err != nil { - return err - } - matched := false - seen := map[string]bool{} - for _, entry := range inventory.Plugins { - if !regexp.MustCompile(`^[a-z][a-z0-9-]*$`).MatchString(entry.Directory) || seen[entry.Directory] { - return errors.New("invalid domain directory") - } - seen[entry.Directory] = true - if options.domain != "" && options.domain != entry.Directory { - continue - } - matched = true - module := filepath.Join(filepath.Dir(source), entry.Directory) - raw, err := os.ReadFile(filepath.Join(module, "manifest.source.json")) - if err != nil { - return err - } - var manifest service.PluginManifest - if json.Unmarshal(raw, &manifest) != nil || manifest.ID != "codexrip."+entry.Directory || !semver.IsValid("v"+manifest.Version) { - return errors.New("invalid domain version or identity") - } - suffix := "" - if platform[0] == "windows" { - suffix = ".exe" - } - output := filepath.Join(destination, entry.Directory+suffix) - if _, err = os.Stat(output); !errors.Is(err, os.ErrNotExist) { - return errors.New("binary output already exists or cannot be inspected") - } - command := exec.Command("go", "build", "-trimpath", "-ldflags", "-s -w -X main.version="+manifest.Version, "-o", output, "./cmd/plugin") - command.Dir = module - for _, value := range os.Environ() { - key, _, _ := strings.Cut(value, "=") - if strings.EqualFold(key, "SUB2API_PLUGIN_SIGNING_KEY") || strings.EqualFold(key, "GOOS") || strings.EqualFold(key, "GOARCH") || strings.EqualFold(key, "CGO_ENABLED") { - continue - } - command.Env = append(command.Env, value) - } - command.Env = append(command.Env, "GOOS="+platform[0], "GOARCH="+platform[1], "CGO_ENABLED=0") - command.Stdout, command.Stderr = os.Stdout, os.Stderr - if err = command.Run(); err != nil { - return fmt.Errorf("build domain %s: %w", entry.Directory, err) - } - } - if !matched { - return errors.New("plugin domain is not in the bundle source") - } - return nil -} diff --git a/backend/cmd/package-plugin/main.go b/backend/cmd/package-plugin/main.go deleted file mode 100644 index 6e23c1458f85..000000000000 --- a/backend/cmd/package-plugin/main.go +++ /dev/null @@ -1,289 +0,0 @@ -// package-plugin creates an immutable signed package from a first-party module. -// Publisher private keys are read only by this build command, never embedded. -package main - -import ( - "archive/zip" - "crypto/ed25519" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "flag" - "fmt" - "io/fs" - "os" - "path/filepath" - "sort" - "strings" - "time" - - "github.com/Wei-Shaw/sub2api/internal/service" -) - -type packageOptions struct { - domain, sourceRevision string - defaultEnabled bool - source, binary, platform, hostVersion, output, keyFile, keyID, sdkUI string - bundleLock, migration string -} - -func main() { - var options packageOptions - flag.StringVar(&options.source, "source", "", "plugin module directory") - flag.StringVar(&options.domain, "plugin-domain", "", "select one domain from the bundle source") - flag.StringVar(&options.sourceRevision, "source-revision", "", "immutable source commit included in the signed manifest") - flag.StringVar(&options.binary, "binary", "", "compiled plugin program") - flag.StringVar(&options.platform, "platform", "linux-amd64", "runtime platform") - flag.StringVar(&options.hostVersion, "tested-host-version", "", "exact host version whose contract tests passed") - flag.StringVar(&options.output, "output", "", "new .s2plugin archive path") - flag.StringVar(&options.keyFile, "signing-key-file", "", "base64 Ed25519 private key file (or use SUB2API_PLUGIN_SIGNING_KEY)") - flag.StringVar(&options.keyID, "key-id", "codexrip-plugins-v1", "publisher key identifier") - flag.StringVar(&options.sdkUI, "ui-sdk", "pkg/extensionapi/ui", "public UI SDK directory") - flag.StringVar(&options.bundleLock, "bundle-lock", "", "write an immutable first-party bundle lock beside the package") - flag.StringVar(&options.migration, "migration", "", "one-time migration profile recorded in the bundle lock") - flag.BoolVar(&options.defaultEnabled, "default-enabled", false, "initial activation when no saved plugin state exists") - generate := flag.String("generate-key", "", "create a new private key file; prints only its public key") - bundleSourcePath := flag.String("bundle-source", "", "first-party source inventory to package") - binaryDirectory := flag.String("binary-dir", "", "prebuilt module binaries for the bundle") - buildBinaries := flag.Bool("build-binaries", false, "build selected binaries without loading any signing key") - verifyBundle := flag.String("verify-bundle", "", "verify packages against the fixed publisher without executing them") - development := flag.Bool("development", false, "allow a synthetic publisher only for development bundle verification") - flag.Parse() - var err error - if *buildBinaries { - err = buildBundleBinaries(*bundleSourcePath, *binaryDirectory, options) - } else if *verifyBundle != "" { - err = verifyPluginBundle(*verifyBundle, options.hostVersion, options.platform, *development) - } else if *generate != "" { - err = generateKey(*generate) - } else if *bundleSourcePath != "" { - err = packageBundle(*bundleSourcePath, *binaryDirectory, options) - } else { - err = packagePlugin(options) - } - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -func generateKey(path string) error { - public, private, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - return errors.New("could not generate publisher key") - } - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { - return err - } - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) - if err != nil { - return err - } - _, writeErr := file.WriteString(base64.StdEncoding.EncodeToString(private) + "\n") - closeErr := file.Close() - if writeErr != nil { - return writeErr - } - if closeErr != nil { - return closeErr - } - return json.NewEncoder(os.Stdout).Encode(map[string]string{"public_key": base64.StdEncoding.EncodeToString(public), "private_key_file": path}) -} - -func packagePlugin(options packageOptions) error { - if options.source == "" || options.binary == "" || options.output == "" || options.keyID == "" { - return errors.New("source, binary, output and key-id are required") - } - raw, err := os.ReadFile(filepath.Join(options.source, "manifest.source.json")) - if err != nil { - return err - } - var manifest service.PluginManifest - if err := json.Unmarshal(raw, &manifest); err != nil { - return err - } - if options.sourceRevision != "" { - decoded, err := hex.DecodeString(options.sourceRevision) - if err != nil || len(decoded) != 20 { - return errors.New("source-revision must be a complete commit SHA") - } - manifest.SourceRevision = options.sourceRevision - } - assets := make(map[string][]byte) - if err := filepath.WalkDir(filepath.Join(options.source, "ui"), func(path string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if entry.Type()&os.ModeSymlink != 0 { - return errors.New("plugin assets cannot be symbolic links") - } - if entry.IsDir() { - if entry.Name() == "src" || entry.Name() == "node_modules" || entry.Name() == "__tests__" { - return filepath.SkipDir - } - return nil - } - if !entry.Type().IsRegular() { - return errors.New("plugin assets must be regular files") - } - relative, err := filepath.Rel(options.source, path) - if err != nil { - return err - } - content, err := os.ReadFile(path) - if err != nil { - return err - } - assets[filepath.ToSlash(relative)] = content - return nil - }); err != nil { - return err - } - bridge, err := os.ReadFile(filepath.Join(options.sdkUI, "bridge.js")) - if err != nil { - return err - } - assets["ui/assets/bridge.js"] = bridge - binary, err := os.ReadFile(options.binary) - if err != nil { - return err - } - binaryName := "plugin" - if strings.HasPrefix(options.platform, "windows-") { - binaryName += ".exe" - } - binaryPath := "runtimes/" + options.platform + "/" + binaryName - assets[binaryPath] = binary - manifest.Runtimes = map[string]service.PluginRuntime{options.platform: {Path: binaryPath}} - manifest.Files = make(map[string]string, len(assets)) - for name, content := range assets { - digest := sha256.Sum256(content) - manifest.Files[name] = hex.EncodeToString(digest[:]) - } - if options.hostVersion != "" { - manifest.Requires.TestedSub2APIVersions = []string{options.hostVersion} - manifest.Requires.RecommendedSub2APIVersion = options.hostVersion - } - if err := manifest.ValidateForRuntime(options.platform); err != nil { - return err - } - manifestRaw, err := json.MarshalIndent(manifest, "", " ") - if err != nil { - return err - } - encodedKey := strings.TrimSpace(os.Getenv("SUB2API_PLUGIN_SIGNING_KEY")) - if options.keyFile != "" { - if encodedKey != "" { - return errors.New("choose a signing-key file or environment value, not both") - } - value, err := os.ReadFile(options.keyFile) - if err != nil { - return err - } - encodedKey = strings.TrimSpace(string(value)) - } - key, err := base64.StdEncoding.DecodeString(encodedKey) - if err != nil || len(key) != ed25519.PrivateKeySize { - return errors.New("a valid Ed25519 private signing key is required") - } - private := ed25519.PrivateKey(key) - publisherPublic, ok := private.Public().(ed25519.PublicKey) - if !ok { - return errors.New("invalid Ed25519 public signing key") - } - signature := service.PluginSignature{Algorithm: "ed25519", KeyID: options.keyID, Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(private, manifestRaw))} - signatureRaw, err := json.Marshal(signature) - if err != nil { - return err - } - assets["manifest.json"] = manifestRaw - assets["signature.json"] = signatureRaw - if err := os.MkdirAll(filepath.Dir(options.output), 0755); err != nil { - return err - } - file, err := os.OpenFile(options.output, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) - if err != nil { - return err - } - archive := zip.NewWriter(file) - names := make([]string, 0, len(assets)) - for name := range assets { - names = append(names, name) - } - sort.Strings(names) - for _, name := range names { - header := &zip.FileHeader{Name: name, Method: zip.Deflate} - header.Modified = time.Date(1980, 1, 1, 0, 0, 0, 0, time.UTC) - header.SetMode(0644) - if name == binaryPath { - header.SetMode(0755) - } - writer, writeErr := archive.CreateHeader(header) - if writeErr == nil { - _, writeErr = writer.Write(assets[name]) - } - if writeErr != nil { - _ = archive.Close() - _ = file.Close() - return writeErr - } - } - if err := archive.Close(); err != nil { - _ = file.Close() - return err - } - if err := file.Close(); err != nil { - return err - } - content, err := os.ReadFile(options.output) - if err != nil { - return err - } - digest := sha256.Sum256(content) - packageDigest := hex.EncodeToString(digest[:]) - publicKey := base64.StdEncoding.EncodeToString(publisherPublic) - if options.bundleLock != "" { - if strings.TrimSpace(options.hostVersion) == "" { - return errors.New("tested-host-version is required when writing a bundle lock") - } - if filepath.Dir(options.bundleLock) != filepath.Dir(options.output) { - return errors.New("bundle lock and plugin package must share a directory") - } - bundle := service.PluginBundle{ - SchemaVersion: 1, - HostVersion: options.hostVersion, - PublisherKeyID: options.keyID, - PublisherPublicKey: publicKey, - Plugins: []service.BundledPlugin{{ - ID: manifest.ID, Version: manifest.Version, File: filepath.Base(options.output), - SHA256: packageDigest, Migration: options.migration, DefaultEnabled: options.defaultEnabled, - }}, - } - if previous, readErr := os.ReadFile(options.bundleLock); readErr == nil { - var existing service.PluginBundle - if json.Unmarshal(previous, &existing) != nil || existing.SchemaVersion != bundle.SchemaVersion || existing.HostVersion != bundle.HostVersion || existing.PublisherKeyID != bundle.PublisherKeyID || existing.PublisherPublicKey != bundle.PublisherPublicKey { - return errors.New("existing bundle identity does not match this build") - } - for _, entry := range existing.Plugins { - if entry.ID == manifest.ID { - return errors.New("plugin already exists in bundle lock") - } - } - bundle.Plugins = append(existing.Plugins, bundle.Plugins...) - } else if !errors.Is(readErr, os.ErrNotExist) { - return readErr - } - lock, err := json.MarshalIndent(bundle, "", " ") - if err != nil { - return err - } - lock = append(lock, '\n') - if err := os.WriteFile(options.bundleLock, lock, 0600); err != nil { - return err - } - } - return json.NewEncoder(os.Stdout).Encode(map[string]string{"plugin_id": manifest.ID, "version": manifest.Version, "sha256": packageDigest, "public_key": publicKey, "path": options.output}) -} diff --git a/backend/cmd/package-plugin/verify.go b/backend/cmd/package-plugin/verify.go deleted file mode 100644 index d8eb6d0ab1d0..000000000000 --- a/backend/cmd/package-plugin/verify.go +++ /dev/null @@ -1,76 +0,0 @@ -package main - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - - "github.com/Wei-Shaw/sub2api/internal/config" - "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" -) - -// Verification uses the production package inspector, including path, file, -// signature and compatibility checks. It never starts a plugin executable. -func verifyPluginBundle(path, hostVersion, runtimeKey string, development bool) error { - raw, err := os.ReadFile(path) - if err != nil { - return err - } - var bundle service.PluginBundle - if json.Unmarshal(raw, &bundle) != nil || bundle.SchemaVersion != 1 || len(bundle.Plugins) == 0 || hostVersion == "" || bundle.HostVersion != hostVersion { - return errors.New("bundle does not identify this host version") - } - publisher := extensionv1.FirstPartyPublisher() - if !development && (bundle.PublisherKeyID != publisher.KeyID || bundle.PublisherPublicKey != publisher.PublicKey) { - return errors.New("bundle is not signed by the fixed release publisher") - } - directory, err := os.MkdirTemp("", "sub2api-package-verification-") - if err != nil { - return err - } - defer func() { _ = os.RemoveAll(directory) }() - cfg := &config.Config{} - cfg.Plugins.DataDir = directory - cfg.Plugins.MaxUploadBytes = 128 << 20 - cfg.Plugins.MaxUncompressedBytes = 512 << 20 - cfg.Plugins.TrustedPublishers = map[string]string{bundle.PublisherKeyID: bundle.PublisherPublicKey} - installer := service.NewPluginPackageInstaller(cfg, service.PluginHostInfo{Version: hostVersion, BuildType: "artifact"}) - seen := map[string]bool{} - for _, entry := range bundle.Plugins { - if seen[entry.ID] || entry.File == "" || filepath.Base(entry.File) != entry.File || filepath.IsAbs(entry.File) { - return errors.New("invalid or repeated bundle entry") - } - seen[entry.ID] = true - file := filepath.Join(filepath.Dir(path), entry.File) - info, err := os.Lstat(file) - if err != nil { - return err - } - if !info.Mode().IsRegular() || info.Size() > cfg.Plugins.MaxUploadBytes { - return errors.New("invalid bundled artifact") - } - artifact, err := os.ReadFile(file) - if err != nil { - return err - } - sum := sha256.Sum256(artifact) - if hex.EncodeToString(sum[:]) != entry.SHA256 { - return errors.New("bundled package checksum differs") - } - manifest, err := installer.VerifyPackage(context.Background(), artifact, runtimeKey) - if err != nil { - return err - } - if manifest.ID != entry.ID || manifest.Version != entry.Version || !service.EvaluatePluginCompatibility(manifest, service.PluginHostInfo{Version: hostVersion}).Compatible { - return errors.New("bundled identity or compatibility differs") - } - } - fmt.Printf("verified_plugin_packages=%d\n", len(bundle.Plugins)) - return nil -} diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 84b0339112c6..ca928fb388d6 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -208,6 +208,13 @@ func runMainServer() { log.Fatalf("Failed to initialize application: %v", err) } defer app.Cleanup() + if app.CodexRuntime != nil { + if err := app.CodexRuntime.Start(context.Background()); err != nil { + log.Printf("Native Codex runtime could not start: %v", err) + app.Cleanup() + os.Exit(1) + } + } if app.PluginManager != nil { if err := app.PluginManager.Start(context.Background()); err != nil { log.Printf("Plugin manager started in degraded state: %v", err) @@ -235,6 +242,14 @@ func runMainServer() { os.Exit(1) } } + if app.ImageStudio != nil { + studio := app.ImageStudio + service.SetImageStudioStarter(func() { + if err := studio.Start(context.Background()); err != nil { + log.Printf("Image Studio could not start: %v", err) + } + }) + } if app.ImageStudio != nil && service.ImageStudioFeatureEnabled() { if err := app.ImageStudio.Start(context.Background()); err != nil { log.Printf("Image Studio could not start: %v", err) diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index e51ca7e0debd..11a499ddf6c3 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -33,6 +33,7 @@ type Application struct { ImageStudio *service.ImageStudioRuntime PromptDomain *service.PromptDomainRuntime CodexIdentity *service.CodexClientIdentityBackfillService + CodexRuntime *service.NativeCodexRuntime Cleanup func() } @@ -65,7 +66,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { provideCleanup, // Application struct - wire.Struct(new(Application), "Server", "PromptAudit", "PluginManager", "AccountJobs", "ImageStudio", "PromptDomain", "CodexIdentity", "Cleanup"), + wire.Struct(new(Application), "Server", "PromptAudit", "PluginManager", "AccountJobs", "ImageStudio", "PromptDomain", "CodexIdentity", "CodexRuntime", "Cleanup"), ) return nil, nil } @@ -151,6 +152,7 @@ func provideCleanup( cindyBalanceProbe *service.CindyBalanceProbeService, imageStudioRuntime *service.ImageStudioRuntime, pluginManager *service.PluginManager, + codexRuntime *service.NativeCodexRuntime, ) func() { return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -495,6 +497,11 @@ func provideCleanup( } } + if codexRuntime != nil { + if err := codexRuntime.Stop(ctx); err != nil { + log.Printf("[Cleanup] native Codex runtime did not stop cleanly") + } + } runParallel(parallelSteps) if pluginManager != nil { pluginManager.Stop() diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 76c8bdb02c81..876e46de1eb8 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -52,7 +52,19 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { settingRepository := repository.NewSettingRepository(client) groupRepository := repository.NewGroupRepository(client, db) proxyRepository := repository.NewProxyRepository(client, db) - settingService := service.ProvideSettingService(settingRepository, groupRepository, proxyRepository, configConfig) + 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) + if err != nil { + return nil, err + } emailCache := repository.NewEmailCache(redisClient) emailService := service.NewEmailService(settingRepository, emailCache) turnstileVerifier := repository.NewTurnstileVerifier() @@ -86,10 +98,6 @@ 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) @@ -223,7 +231,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, accountRepository) + pluginRepository := repository.ProvidePluginRepository(db, nativeFeatureBootstrap) pluginHostInfo := providePluginHostInfo(buildInfo) pluginKVStore := repository.NewPluginKVStore(redisClient) accountTrafficObserveCache := repository.ProvideAccountTrafficObserveCache(redisClient, configConfig) @@ -263,7 +271,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { registry := payment.ProvideRegistry() defaultLoadBalancer := payment.ProvideDefaultLoadBalancer(client, encryptionKey) paymentService := service.ProvidePaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository, affiliateService, notificationEmailService) - settingHandler := handler.ProvideAdminSettingHandler(settingService, emailService, turnstileService, aliyunCaptchaService, opsService, paymentConfigService, paymentService, userAttributeService, notificationEmailService, totpService, userService) + settingHandler := handler.ProvideAdminSettingHandler(settingService, emailService, turnstileService, aliyunCaptchaService, opsService, paymentConfigService, paymentService, userAttributeService, notificationEmailService, totpService, userService, secretEncryptor) opsHandler := admin.NewOpsHandler(opsService) updateCache := repository.NewUpdateCache(redisClient) gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig) @@ -282,7 +290,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, accountJobService) + pluginHandler := handler.ProvidePluginHandler(pluginManager) adminAPIKeyHandler := admin.NewAdminAPIKeyHandler(adminService) scheduledTestPlanRepository := repository.NewScheduledTestPlanRepository(db) scheduledTestResultRepository := repository.NewScheduledTestResultRepository(db) @@ -313,7 +321,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { systemPromptHandler := admin.NewSystemPromptHandler(businessSystemPromptService, remoteSkillRegistryService) cindyBalanceProbeService := service.ProvideCindyBalanceProbeService(cindyBalanceProbeRepository, accountRepository, openAIGatewayService, rateLimitService) cindyBalanceProbeHandler := admin.NewCindyBalanceProbeHandler(cindyBalanceProbeService) - accountJobRuntime, err := handler.ProvideAccountJobRuntime(accountJobService, accountHandler, pluginManager) + accountJobRuntime, err := handler.ProvideAccountJobRuntime(accountJobService, accountHandler) if err != nil { return nil, err } @@ -328,7 +336,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { coordinator := securityaudit.NewCoordinator(legacyEngine, promptService) gatewayHandler := handler.ProvideGatewayHandler(gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService, coordinator) openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, gatewayService, pluginManager, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, accountTrafficObserver, configConfig, coordinator) - handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService, pluginManager) + handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService) totpHandler := handler.NewTotpHandler(totpService) passkeyRepository := repository.NewPasskeyRepository(db) passkeySessionStore := repository.NewPasskeySessionStore(redisClient) @@ -376,6 +384,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { } promptDomainRuntime := service.NewPromptDomainRuntime(remoteSkillRegistryService, businessSystemPromptService) codexClientIdentityBackfillService := service.ProvideCodexClientIdentityBackfillService(accountRepository) + nativeCodexRepository := repository.NewNativeCodexRepository(db, accountRepository) + nativeCodexRuntime := service.ProvideNativeCodexRuntime(openAIGatewayService, nativeCodexRepository, settingRepository, secretEncryptor, configConfig, nativeFeatureBootstrap, quotaActivityService) opsMetricsCollector := service.ProvideOpsMetricsCollector(opsRepository, settingRepository, accountRepository, concurrencyService, db, redisClient, configConfig) opsAggregationService := service.ProvideOpsAggregationService(opsRepository, settingRepository, db, redisClient, configConfig) opsAlertEvaluatorService := service.ProvideOpsAlertEvaluatorService(opsService, opsRepository, emailService, redisClient, configConfig, proxyRepository) @@ -398,7 +408,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { openAIQuotaAutoResetService := service.ProvideOpenAIQuotaAutoResetService(accountRepository, openAIQuotaService, rateLimitService, idempotencyCoordinator, auditLogService, settingService, leaderLockCache) cindyHealthRepository := repository.NewCindyHealthRepository(db) cindyHealthService := service.ProvideCindyHealthService(accountRepository, accountCredentialIdentityRepository, cindyHealthRepository, gatewayCache, openAIGatewayService, gatewayService) - v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, codexClientIdentityBackfillService, claudeCodeVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, auditLogService, openAIQuotaAutoResetService, promptService, promptDomainRuntime, businessSystemPromptService, remoteSkillRegistryService, accountJobRuntime, cindyHealthService, cindyBalanceProbeService, imageStudioRuntime, pluginManager) + v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, codexClientIdentityBackfillService, claudeCodeVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, auditLogService, openAIQuotaAutoResetService, promptService, promptDomainRuntime, businessSystemPromptService, remoteSkillRegistryService, accountJobRuntime, cindyHealthService, cindyBalanceProbeService, imageStudioRuntime, pluginManager, nativeCodexRuntime) application := &Application{ Server: httpServer, PromptAudit: promptService, @@ -407,6 +417,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { ImageStudio: imageStudioRuntime, PromptDomain: promptDomainRuntime, CodexIdentity: codexClientIdentityBackfillService, + CodexRuntime: nativeCodexRuntime, Cleanup: v, } return application, nil @@ -422,6 +433,7 @@ type Application struct { ImageStudio *service.ImageStudioRuntime PromptDomain *service.PromptDomainRuntime CodexIdentity *service.CodexClientIdentityBackfillService + CodexRuntime *service.NativeCodexRuntime Cleanup func() } @@ -506,6 +518,7 @@ func provideCleanup( cindyBalanceProbe *service.CindyBalanceProbeService, imageStudioRuntime *service.ImageStudioRuntime, pluginManager *service.PluginManager, + codexRuntime *service.NativeCodexRuntime, ) func() { return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -849,6 +862,11 @@ func provideCleanup( } } + if codexRuntime != nil { + if err := codexRuntime.Stop(ctx); err != nil { + log.Printf("[Cleanup] native Codex runtime did not stop cleanly") + } + } runParallel(parallelSteps) if pluginManager != nil { pluginManager.Stop() diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go index 159af0eaf5ec..2ab7a8988d89 100644 --- a/backend/cmd/server/wire_gen_test.go +++ b/backend/cmd/server/wire_gen_test.go @@ -116,6 +116,7 @@ func minimalDependencyCleanup(autoReset *service.OpenAIQuotaAutoResetService) fu nil, // cindyBalanceProbe nil, // imageStudioRuntime nil, // pluginManager + nil, // nativeCodexRuntime ) } diff --git a/backend/go.mod b/backend/go.mod index d21098adf666..1f6067256ce3 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,28 +2,7 @@ module github.com/Wei-Shaw/sub2api go 1.27.0 -replace github.com/HTExplicit/sub2api-plugins/modelpolicy => ../plugins/model-policy - -replace github.com/HTExplicit/sub2api-plugins/codexruntime => ../plugins/codex-runtime - -replace github.com/HTExplicit/sub2api-plugins/promptskills => ../plugins/prompt-skills - -replace github.com/HTExplicit/sub2api-plugins/accounttools => ../plugins/account-tools - -replace github.com/HTExplicit/sub2api-plugins/cindyprovider => ../plugins/cindy-provider - -replace github.com/HTExplicit/sub2api-plugins/imagetools => ../plugins/image-tools - -replace github.com/HTExplicit/sub2api-plugins/adminobservability => ../plugins/admin-observability - require ( - github.com/HTExplicit/sub2api-plugins/modelpolicy v0.0.0 - github.com/HTExplicit/sub2api-plugins/codexruntime v0.0.0 - github.com/HTExplicit/sub2api-plugins/promptskills v0.0.0 - github.com/HTExplicit/sub2api-plugins/accounttools v0.0.0 - github.com/HTExplicit/sub2api-plugins/cindyprovider v0.0.0 - github.com/HTExplicit/sub2api-plugins/imagetools v0.0.0 - github.com/HTExplicit/sub2api-plugins/adminobservability v0.0.0 entgo.io/ent v0.14.5 github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/alibabacloud-go/captcha-20230305 v1.1.3 diff --git a/plugins/account-tools/policy/batch.go b/backend/internal/accounttools/policy/batch.go similarity index 96% rename from plugins/account-tools/policy/batch.go rename to backend/internal/accounttools/policy/batch.go index 9b438e76576e..2bf6fdd22083 100644 --- a/plugins/account-tools/policy/batch.go +++ b/backend/internal/accounttools/policy/batch.go @@ -5,7 +5,7 @@ import ( "slices" "strings" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func planBatch(request extensionv1.BatchTestPlanningRequest) (extensionv1.BatchTestPlan, error) { diff --git a/plugins/account-tools/policy/batch_test.go b/backend/internal/accounttools/policy/batch_test.go similarity index 95% rename from plugins/account-tools/policy/batch_test.go rename to backend/internal/accounttools/policy/batch_test.go index 6323c7816706..2cbcbbea1212 100644 --- a/plugins/account-tools/policy/batch_test.go +++ b/backend/internal/accounttools/policy/batch_test.go @@ -6,7 +6,7 @@ import ( "reflect" "testing" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func TestPlanBatchRejectsNonPositiveLegacyIDsWithoutShrinkingSelection(t *testing.T) { diff --git a/plugins/account-tools/policy/import.go b/backend/internal/accounttools/policy/import.go similarity index 98% rename from plugins/account-tools/policy/import.go rename to backend/internal/accounttools/policy/import.go index d060fb264445..ff5fe98a1c00 100644 --- a/plugins/account-tools/policy/import.go +++ b/backend/internal/accounttools/policy/import.go @@ -1,6 +1,6 @@ package policy -import extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" +import extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" func planImport(in extensionv1.AccountImportPlanningRequest) []extensionv1.AccountImportItemPlan { if in.Phase == "finalize" { diff --git a/plugins/account-tools/policy/import_test.go b/backend/internal/accounttools/policy/import_test.go similarity index 96% rename from plugins/account-tools/policy/import_test.go rename to backend/internal/accounttools/policy/import_test.go index 052db56a3b52..91a645532f4d 100644 --- a/plugins/account-tools/policy/import_test.go +++ b/backend/internal/accounttools/policy/import_test.go @@ -5,7 +5,7 @@ import ( "encoding/json" "testing" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func TestImportPolicyPreservesRejectionPriorityAndExplicitGroup(t *testing.T) { diff --git a/plugins/account-tools/policy/module.go b/backend/internal/accounttools/policy/module.go similarity index 99% rename from plugins/account-tools/policy/module.go rename to backend/internal/accounttools/policy/module.go index 2eca23898b00..3f4bc3e8e048 100644 --- a/plugins/account-tools/policy/module.go +++ b/backend/internal/accounttools/policy/module.go @@ -8,7 +8,7 @@ import ( "strings" "unicode/utf8" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) type Module struct{} diff --git a/plugins/account-tools/policy/module_test.go b/backend/internal/accounttools/policy/module_test.go similarity index 97% rename from plugins/account-tools/policy/module_test.go rename to backend/internal/accounttools/policy/module_test.go index 19e0cbd50b36..101e14fd845c 100644 --- a/plugins/account-tools/policy/module_test.go +++ b/backend/internal/accounttools/policy/module_test.go @@ -5,7 +5,7 @@ import ( "encoding/json" "testing" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func TestTaxonomyPoliciesRejectAmbiguityAndInvalidAssignments(t *testing.T) { diff --git a/plugins/cindy-provider/catalog/account_test_projection.go b/backend/internal/cindyprovider/catalog/account_test_projection.go similarity index 96% rename from plugins/cindy-provider/catalog/account_test_projection.go rename to backend/internal/cindyprovider/catalog/account_test_projection.go index 1e7fa20ca5c8..f590f8cb030d 100644 --- a/plugins/cindy-provider/catalog/account_test_projection.go +++ b/backend/internal/cindyprovider/catalog/account_test_projection.go @@ -4,7 +4,7 @@ import ( "slices" "strings" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func (r Registry) AccountTestPlanV1() extensionv1.CindyAccountTestPlanV1 { diff --git a/plugins/cindy-provider/catalog/account_test_projection_test.go b/backend/internal/cindyprovider/catalog/account_test_projection_test.go similarity index 98% rename from plugins/cindy-provider/catalog/account_test_projection_test.go rename to backend/internal/cindyprovider/catalog/account_test_projection_test.go index f3efc1f45562..6134050ab1ee 100644 --- a/plugins/cindy-provider/catalog/account_test_projection_test.go +++ b/backend/internal/cindyprovider/catalog/account_test_projection_test.go @@ -6,7 +6,7 @@ import ( "reflect" "testing" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func TestCindyAccountTestPlanOwnsProjectionAndKeepsV1(t *testing.T) { diff --git a/plugins/cindy-provider/catalog/catalog.go b/backend/internal/cindyprovider/catalog/catalog.go similarity index 99% rename from plugins/cindy-provider/catalog/catalog.go rename to backend/internal/cindyprovider/catalog/catalog.go index 34df563ea8cc..55e3592e1008 100644 --- a/plugins/cindy-provider/catalog/catalog.go +++ b/backend/internal/cindyprovider/catalog/catalog.go @@ -1,7 +1,7 @@ package catalog import ( - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "sort" "strings" ) diff --git a/plugins/cindy-provider/catalog/catalog_data.go b/backend/internal/cindyprovider/catalog/catalog_data.go similarity index 100% rename from plugins/cindy-provider/catalog/catalog_data.go rename to backend/internal/cindyprovider/catalog/catalog_data.go diff --git a/plugins/cindy-provider/catalog/channel.go b/backend/internal/cindyprovider/catalog/channel.go similarity index 100% rename from plugins/cindy-provider/catalog/channel.go rename to backend/internal/cindyprovider/catalog/channel.go diff --git a/plugins/cindy-provider/catalog/channel_test.go b/backend/internal/cindyprovider/catalog/channel_test.go similarity index 91% rename from plugins/cindy-provider/catalog/channel_test.go rename to backend/internal/cindyprovider/catalog/channel_test.go index cbf39cc02b51..5004b617003e 100644 --- a/plugins/cindy-provider/catalog/channel_test.go +++ b/backend/internal/cindyprovider/catalog/channel_test.go @@ -3,7 +3,7 @@ package catalog import ( "testing" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func TestManagedChannelProjectionPreservesAliasesAndRejectsUnverifiedModels(t *testing.T) { diff --git a/plugins/cindy-provider/catalog/codex_manifest.go b/backend/internal/cindyprovider/catalog/codex_manifest.go similarity index 98% rename from plugins/cindy-provider/catalog/codex_manifest.go rename to backend/internal/cindyprovider/catalog/codex_manifest.go index ccd0540605e3..cc90e7ad7b24 100644 --- a/plugins/cindy-provider/catalog/codex_manifest.go +++ b/backend/internal/cindyprovider/catalog/codex_manifest.go @@ -1,7 +1,7 @@ package catalog import ( - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "strings" ) diff --git a/plugins/cindy-provider/catalog/codex_manifest_test.go b/backend/internal/cindyprovider/catalog/codex_manifest_test.go similarity index 100% rename from plugins/cindy-provider/catalog/codex_manifest_test.go rename to backend/internal/cindyprovider/catalog/codex_manifest_test.go diff --git a/plugins/cindy-provider/catalog/health.go b/backend/internal/cindyprovider/catalog/health.go similarity index 94% rename from plugins/cindy-provider/catalog/health.go rename to backend/internal/cindyprovider/catalog/health.go index 1dac79a43f3e..cfde3eb75eb0 100644 --- a/plugins/cindy-provider/catalog/health.go +++ b/backend/internal/cindyprovider/catalog/health.go @@ -1,7 +1,7 @@ package catalog import ( - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "net/http" ) diff --git a/plugins/cindy-provider/catalog/management.go b/backend/internal/cindyprovider/catalog/management.go similarity index 98% rename from plugins/cindy-provider/catalog/management.go rename to backend/internal/cindyprovider/catalog/management.go index 37efd855ff55..695661284112 100644 --- a/plugins/cindy-provider/catalog/management.go +++ b/backend/internal/cindyprovider/catalog/management.go @@ -6,7 +6,7 @@ import ( "strings" "unicode/utf8" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func duplicateInventory(accounts []extensionv1.CindyDuplicateCandidate) []extensionv1.CindyDuplicateIdentityGroup { diff --git a/plugins/cindy-provider/catalog/management_test.go b/backend/internal/cindyprovider/catalog/management_test.go similarity index 96% rename from plugins/cindy-provider/catalog/management_test.go rename to backend/internal/cindyprovider/catalog/management_test.go index 4bbbbc0c0dc6..e78226861988 100644 --- a/plugins/cindy-provider/catalog/management_test.go +++ b/backend/internal/cindyprovider/catalog/management_test.go @@ -4,7 +4,7 @@ import ( "testing" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func TestManagementDuplicateOwnerExcludesAllTerminalStates(t *testing.T) { diff --git a/plugins/cindy-provider/catalog/metadata.go b/backend/internal/cindyprovider/catalog/metadata.go similarity index 97% rename from plugins/cindy-provider/catalog/metadata.go rename to backend/internal/cindyprovider/catalog/metadata.go index f8a2ed663755..a92f8fe4b484 100644 --- a/plugins/cindy-provider/catalog/metadata.go +++ b/backend/internal/cindyprovider/catalog/metadata.go @@ -1,6 +1,6 @@ package catalog -import extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" +import extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" // These are provider release data, intentionally independent of deprecated // SDK constants. Updating this package updates its complete runtime snapshot. diff --git a/plugins/cindy-provider/catalog/module.go b/backend/internal/cindyprovider/catalog/module.go similarity index 99% rename from plugins/cindy-provider/catalog/module.go rename to backend/internal/cindyprovider/catalog/module.go index 37e7c5ff7fde..2828b9f66fff 100644 --- a/plugins/cindy-provider/catalog/module.go +++ b/backend/internal/cindyprovider/catalog/module.go @@ -5,7 +5,7 @@ import ( "context" "encoding/json" "errors" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "sync" ) diff --git a/plugins/cindy-provider/catalog/module_test.go b/backend/internal/cindyprovider/catalog/module_test.go similarity index 98% rename from plugins/cindy-provider/catalog/module_test.go rename to backend/internal/cindyprovider/catalog/module_test.go index 87217591e0e9..7961a61513ca 100644 --- a/plugins/cindy-provider/catalog/module_test.go +++ b/backend/internal/cindyprovider/catalog/module_test.go @@ -5,7 +5,7 @@ import ( "encoding/json" "testing" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func text(value string) *string { return &value } diff --git a/plugins/cindy-provider/catalog/probe.go b/backend/internal/cindyprovider/catalog/probe.go similarity index 95% rename from plugins/cindy-provider/catalog/probe.go rename to backend/internal/cindyprovider/catalog/probe.go index 2c15d9c0287e..44e65712fa35 100644 --- a/plugins/cindy-provider/catalog/probe.go +++ b/backend/internal/cindyprovider/catalog/probe.go @@ -3,7 +3,7 @@ package catalog import ( "errors" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func balanceProbePlan() extensionv1.CindyProbePlan { diff --git a/plugins/cindy-provider/catalog/probe_test.go b/backend/internal/cindyprovider/catalog/probe_test.go similarity index 96% rename from plugins/cindy-provider/catalog/probe_test.go rename to backend/internal/cindyprovider/catalog/probe_test.go index 427bec815573..4c554c7705ed 100644 --- a/plugins/cindy-provider/catalog/probe_test.go +++ b/backend/internal/cindyprovider/catalog/probe_test.go @@ -5,7 +5,7 @@ import ( "encoding/json" "testing" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func TestProbePolicyOwnsControlsAndHealthDecisions(t *testing.T) { diff --git a/plugins/cindy-provider/catalog/search.go b/backend/internal/cindyprovider/catalog/search.go similarity index 97% rename from plugins/cindy-provider/catalog/search.go rename to backend/internal/cindyprovider/catalog/search.go index c3946413fc3a..38978f686526 100644 --- a/plugins/cindy-provider/catalog/search.go +++ b/backend/internal/cindyprovider/catalog/search.go @@ -3,7 +3,7 @@ package catalog import ( "strings" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) // AlphaSearchPlan is a bounded provider decision. It contains no credentials, diff --git a/plugins/cindy-provider/catalog/search_test.go b/backend/internal/cindyprovider/catalog/search_test.go similarity index 98% rename from plugins/cindy-provider/catalog/search_test.go rename to backend/internal/cindyprovider/catalog/search_test.go index 28a9151cb5b9..a229395034a4 100644 --- a/plugins/cindy-provider/catalog/search_test.go +++ b/backend/internal/cindyprovider/catalog/search_test.go @@ -5,7 +5,7 @@ import ( "encoding/json" "testing" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func TestAlphaSearchPlanRequiresSearchEnabled(t *testing.T) { diff --git a/plugins/codex-runtime/profile/identity.go b/backend/internal/codexruntime/profile/identity.go similarity index 99% rename from plugins/codex-runtime/profile/identity.go rename to backend/internal/codexruntime/profile/identity.go index d384e4b7fc49..8877b741b358 100644 --- a/plugins/codex-runtime/profile/identity.go +++ b/backend/internal/codexruntime/profile/identity.go @@ -9,7 +9,7 @@ import ( "regexp" "strings" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func Invoke(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { diff --git a/plugins/codex-runtime/profile/identity_test.go b/backend/internal/codexruntime/profile/identity_test.go similarity index 97% rename from plugins/codex-runtime/profile/identity_test.go rename to backend/internal/codexruntime/profile/identity_test.go index 3178856bcd1f..35e825354f83 100644 --- a/plugins/codex-runtime/profile/identity_test.go +++ b/backend/internal/codexruntime/profile/identity_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func TestIdentityPolicyPreservesDeterminismAndProtocolPairing(t *testing.T) { diff --git a/plugins/codex-runtime/profile/transport.go b/backend/internal/codexruntime/profile/transport.go similarity index 89% rename from plugins/codex-runtime/profile/transport.go rename to backend/internal/codexruntime/profile/transport.go index 9f336c092a07..972d32464863 100644 --- a/plugins/codex-runtime/profile/transport.go +++ b/backend/internal/codexruntime/profile/transport.go @@ -4,7 +4,7 @@ import ( "net/http" "strings" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func TransportPlan(query extensionv1.CodexTransportQuery, enabled bool) extensionv1.CodexTransportPlan { diff --git a/plugins/codex-runtime/profile/transport_test.go b/backend/internal/codexruntime/profile/transport_test.go similarity index 94% rename from plugins/codex-runtime/profile/transport_test.go rename to backend/internal/codexruntime/profile/transport_test.go index 0843c92b228c..8866eb0aec36 100644 --- a/plugins/codex-runtime/profile/transport_test.go +++ b/backend/internal/codexruntime/profile/transport_test.go @@ -3,7 +3,7 @@ package profile import ( "testing" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func TestTransportPolicyControlsOnlyUnencodedResponsesBodies(t *testing.T) { diff --git a/plugins/codex-runtime/recovery/policy.go b/backend/internal/codexruntime/recovery/policy.go similarity index 98% rename from plugins/codex-runtime/recovery/policy.go rename to backend/internal/codexruntime/recovery/policy.go index eda808be0f37..f474c1786485 100644 --- a/plugins/codex-runtime/recovery/policy.go +++ b/backend/internal/codexruntime/recovery/policy.go @@ -7,7 +7,7 @@ import ( "regexp" "strconv" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) var inputParam = regexp.MustCompile(`^input(?:\[(\d+)\]|\.(\d+))(?:\.encrypted_content)?$`) diff --git a/plugins/codex-runtime/recovery/policy_test.go b/backend/internal/codexruntime/recovery/policy_test.go similarity index 95% rename from plugins/codex-runtime/recovery/policy_test.go rename to backend/internal/codexruntime/recovery/policy_test.go index 33ac26fcadb5..552b4c64ffed 100644 --- a/plugins/codex-runtime/recovery/policy_test.go +++ b/backend/internal/codexruntime/recovery/policy_test.go @@ -3,7 +3,7 @@ package recovery import ( "testing" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func TestRecoveryPolicyRequiresExactErrorAndUnambiguousLocalCipherScope(t *testing.T) { diff --git a/plugins/codex-runtime/internal/tickets/cutover_test.go b/backend/internal/codexruntime/tickets/cutover_test.go similarity index 96% rename from plugins/codex-runtime/internal/tickets/cutover_test.go rename to backend/internal/codexruntime/tickets/cutover_test.go index e1b4a4e06f4a..4f3283b5496b 100644 --- a/plugins/codex-runtime/internal/tickets/cutover_test.go +++ b/backend/internal/codexruntime/tickets/cutover_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func TestCanceledHarvestCannotPublishLateTicket(t *testing.T) { @@ -27,7 +27,7 @@ func TestCanceledHarvestCannotPublishLateTicket(t *testing.T) { } ctx, cancel := context.WithCancel(context.Background()) defer cancel() - module.prepareProxy = func(context.Context, *extensionv1.Client, string, bool) (*http.Client, *ProxyResult, error) { + module.prepareProxy = func(context.Context, HostCaller, string, bool) (*http.Client, *ProxyResult, error) { return &http.Client{}, &ProxyResult{}, nil } host.probe = func(query extensionv1.CodexRoutingQuery) extensionv1.CodexRoutingProbeResult { @@ -143,7 +143,7 @@ func TestRestartRecoversAbandonedFirstAttemptWithoutSendingAnotherRequest(t *tes client := testHostClient(t, host) module := NewModule() t.Cleanup(module.cancel) - module.prepareProxy = func(context.Context, *extensionv1.Client, string, bool) (*http.Client, *ProxyResult, error) { + module.prepareProxy = func(context.Context, HostCaller, string, bool) (*http.Client, *ProxyResult, error) { t.Error("an abandoned attempt must never reconnect") return nil, nil, nil } diff --git a/plugins/codex-runtime/internal/tickets/identity.go b/backend/internal/codexruntime/tickets/identity.go similarity index 100% rename from plugins/codex-runtime/internal/tickets/identity.go rename to backend/internal/codexruntime/tickets/identity.go diff --git a/plugins/codex-runtime/internal/tickets/identity_test.go b/backend/internal/codexruntime/tickets/identity_test.go similarity index 100% rename from plugins/codex-runtime/internal/tickets/identity_test.go rename to backend/internal/codexruntime/tickets/identity_test.go diff --git a/backend/internal/codexruntime/tickets/lifecycle_test.go b/backend/internal/codexruntime/tickets/lifecycle_test.go new file mode 100644 index 000000000000..d6deeae6a09a --- /dev/null +++ b/backend/internal/codexruntime/tickets/lifecycle_test.go @@ -0,0 +1,105 @@ +package tickets + +import ( + "context" + "encoding/json" + "sync/atomic" + "testing" + "time" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" +) + +type lifecycleHost struct { + entered chan context.Context + finished chan struct{} + calls atomic.Int32 +} + +func (h *lifecycleHost) Call(ctx context.Context, in extensionv1.HostInvocation) (extensionv1.Result, error) { + if in.Operation == extensionv1.HostAccountList { + h.calls.Add(1) + h.entered <- ctx + <-ctx.Done() + h.finished <- struct{}{} + return extensionv1.Result{}, ctx.Err() + } + return extensionv1.Result{Payload: json.RawMessage(`{}`)}, nil +} + +func TestNativeModuleLifecycleStartsOnceAndDrains(t *testing.T) { + host := &lifecycleHost{entered: make(chan context.Context, 2), finished: make(chan struct{}, 2)} + module := NewModule() + module.SetHost(host) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + defer func() { _ = module.Stop(ctx) }() + if err := module.ApplyConfig(ctx, json.RawMessage(`{"enabled":false}`)); err != nil { + t.Fatal(err) + } + if host.calls.Load() != 0 { + t.Fatal("configuration started background work before Start") + } + if err := module.Start(ctx); err != nil { + t.Fatal(err) + } + var first context.Context + select { + case first = <-host.entered: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + if err := module.Start(ctx); err != nil { + t.Fatal(err) + } + if host.calls.Load() != 1 { + t.Fatal("duplicate Start launched another migration") + } + if err := module.ApplyConfig(ctx, json.RawMessage(`{"enabled":null}`)); err == nil { + t.Fatal("invalid config accepted") + } + if first.Err() != nil { + t.Fatal("invalid config canceled the active epoch") + } + if err := module.Stop(ctx); err != nil { + t.Fatal(err) + } + select { + case <-host.finished: + default: + t.Fatal("Stop returned before background work finished") + } + if first.Err() == nil { + t.Fatal("Stop retained the old epoch") + } + if err := module.Stop(ctx); err != nil { + t.Fatal(err) + } + if err := module.Start(ctx); err != nil { + t.Fatal(err) + } + select { + case second := <-host.entered: + if second == first || second.Err() != nil { + t.Fatal("restart reused canceled epoch") + } + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } +} + +func TestNativeHostCallerPreservesStateCAS(t *testing.T) { + host := &memoryHost{state: map[string]extensionv1.StateResult{}, lease: map[string]int64{}} + state := State{Schema: 2, Identity: "owner", Phase: "stopped", Failures: 2} + revision, err := writeState(context.Background(), host, 7, "gpt-6-astra", state, 0) + if err != nil || revision != 1 { + t.Fatalf("initial CAS: revision=%d error=%v", revision, err) + } + loaded, got, err := readState(context.Background(), host, 7, "gpt-6-astra") + if err != nil || got != revision || loaded.Phase != "stopped" || loaded.Failures != 2 { + t.Fatal("direct host changed the persisted state") + } + if _, err = writeState(context.Background(), host, 7, "gpt-6-astra", State{Schema: 2, Phase: "ready"}, 0); err == nil { + t.Fatal("stale revision overwrote state") + } +} diff --git a/plugins/codex-runtime/internal/tickets/module.go b/backend/internal/codexruntime/tickets/module.go similarity index 83% rename from plugins/codex-runtime/internal/tickets/module.go rename to backend/internal/codexruntime/tickets/module.go index 287e447fd3ca..5e59e9a09f3a 100644 --- a/plugins/codex-runtime/internal/tickets/module.go +++ b/backend/internal/codexruntime/tickets/module.go @@ -17,10 +17,10 @@ import ( "sync" "time" - "github.com/HTExplicit/sub2api-plugins/codexruntime/profile" - "github.com/HTExplicit/sub2api-plugins/codexruntime/recovery" - proxytransport "github.com/Wei-Shaw/sub2api/pkg/extensionapi/proxy" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + "github.com/Wei-Shaw/sub2api/internal/codexruntime/profile" + "github.com/Wei-Shaw/sub2api/internal/codexruntime/recovery" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + proxytransport "github.com/Wei-Shaw/sub2api/internal/proxytransport" ) type Config struct { @@ -33,22 +33,31 @@ type Config struct { RequestZstd bool `json:"request_zstd"` } +type HostCaller interface { + Call(context.Context, extensionv1.HostInvocation) (extensionv1.Result, error) +} + type Module struct { + lifecycleMu sync.Mutex + root context.Context + started bool + accepting bool + work *sync.WaitGroup mu sync.RWMutex config Config - host *extensionv1.Client + host HostCaller epoch context.Context cancel context.CancelFunc slots chan struct{} - prepareProxy func(context.Context, *extensionv1.Client, string, bool) (*http.Client, *ProxyResult, error) + prepareProxy func(context.Context, HostCaller, string, bool) (*http.Client, *ProxyResult, error) } func NewModule() *Module { ctx, cancel := context.WithCancel(context.Background()) - return &Module{config: Config{RoutingSchema: 2, FailClosed: true, Models: []string{"gpt-6-astra", "gpt-6-sol", "gpt-6-luna"}, RequestZstd: true}, epoch: ctx, cancel: cancel, slots: make(chan struct{}, 5), prepareProxy: prepareProxy} + return &Module{accepting: true, work: &sync.WaitGroup{}, config: Config{RoutingSchema: 2, FailClosed: true, Models: []string{"gpt-6-astra", "gpt-6-sol", "gpt-6-luna"}, RequestZstd: true}, epoch: ctx, cancel: cancel, slots: make(chan struct{}, 5), prepareProxy: prepareProxy} } -func (m *Module) SetHost(host *extensionv1.Client) { m.mu.Lock(); defer m.mu.Unlock(); m.host = host } +func (m *Module) SetHost(host HostCaller) { m.mu.Lock(); defer m.mu.Unlock(); m.host = host } func (m *Module) ValidateConfig(_ context.Context, raw json.RawMessage) (json.RawMessage, error) { cfg := Config{RoutingSchema: 2, FailClosed: true, Models: []string{"gpt-6-astra", "gpt-6-sol", "gpt-6-luna"}, RequestZstd: true} @@ -98,6 +107,66 @@ func (m *Module) ValidateConfig(_ context.Context, raw json.RawMessage) (json.Ra return json.Marshal(cfg) } +// Start owns the renewal loop; construction and validation never start network work. +func (m *Module) Start(ctx context.Context) error { + m.lifecycleMu.Lock() + defer m.lifecycleMu.Unlock() + m.mu.RLock() + started := m.started + m.mu.RUnlock() + if started { + return nil + } + if err := m.stopEpoch(ctx); err != nil { + return err + } + m.mu.Lock() + m.root, m.started = ctx, true + m.startEpochLocked() + m.mu.Unlock() + return nil +} + +func (m *Module) Stop(ctx context.Context) error { + m.lifecycleMu.Lock() + defer m.lifecycleMu.Unlock() + m.mu.Lock() + m.started = false + m.mu.Unlock() + return m.stopEpoch(ctx) +} + +func (m *Module) stopEpoch(ctx context.Context) error { + m.mu.Lock() + m.accepting = false + m.cancel() + work := m.work + m.mu.Unlock() + done := make(chan struct{}) + go func() { work.Wait(); close(done) }() + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (m *Module) startEpochLocked() { + root := m.root + if root == nil { + root = context.Background() + } + m.epoch, m.cancel = context.WithCancel(root) + m.accepting = true + if m.started && m.host != nil { + epoch, host, cfg, work := m.epoch, m.host, m.config, m.work + work.Add(2) + go func() { defer work.Done(); m.migrateRoutingStates(epoch, host, cfg) }() + go func() { defer work.Done(); m.renew(epoch, host, cfg) }() + } +} + func (m *Module) ApplyConfig(ctx context.Context, raw json.RawMessage) error { normalized, err := m.ValidateConfig(ctx, raw) if err != nil { @@ -107,19 +176,17 @@ func (m *Module) ApplyConfig(ctx context.Context, raw json.RawMessage) error { if err = json.Unmarshal(normalized, &cfg); err != nil { return err } + m.lifecycleMu.Lock() + defer m.lifecycleMu.Unlock() m.mu.Lock() - defer m.mu.Unlock() m.cancel() - m.epoch, m.cancel = context.WithCancel(context.Background()) m.config = cfg - if m.host != nil { - go m.migrateRoutingStates(m.epoch, m.host, cfg) - go m.renew(m.epoch, m.host, cfg) - } + m.startEpochLocked() + m.mu.Unlock() return nil } -func (m *Module) renew(ctx context.Context, host *extensionv1.Client, cfg Config) { +func (m *Module) renew(ctx context.Context, host HostCaller, cfg Config) { ticker := time.NewTicker(6 * time.Second) defer ticker.Stop() for { @@ -132,7 +199,7 @@ func (m *Module) renew(ctx context.Context, host *extensionv1.Client, cfg Config } } -func (m *Module) renewOnce(ctx context.Context, host *extensionv1.Client, cfg Config) { +func (m *Module) renewOnce(ctx context.Context, host HostCaller, cfg Config) { var redacted map[string]int _ = hostCall(ctx, host, extensionv1.HostCodexRoutingCleanup, struct{}{}, &redacted) if !cfg.Enabled { @@ -220,9 +287,15 @@ func (m *Module) Invoke(ctx context.Context, in extensionv1.Invocation) (extensi if in.Capability == extensionv1.CapabilityRequest && strings.HasPrefix(in.Operation, "codex.identity.") { return profile.Invoke(ctx, in) } - m.mu.RLock() - host, cfg, epoch := m.host, m.config, m.epoch - m.mu.RUnlock() + m.mu.Lock() + if !m.accepting { + m.mu.Unlock() + return extensionv1.Result{}, context.Canceled + } + host, cfg, epoch, work := m.host, m.config, m.epoch, m.work + work.Add(1) + m.mu.Unlock() + defer work.Done() if in.Capability == extensionv1.CapabilityRequest && in.Operation == "codex.transport.plan" { if err := ctx.Err(); err != nil { return extensionv1.Result{}, err @@ -382,7 +455,7 @@ func stateKey(accountID int64, model string) string { return strconv.FormatInt(accountID, 10) + "." + hex.EncodeToString(sum[:]) } -func hostCall(ctx context.Context, host *extensionv1.Client, operation extensionv1.HostOperation, value, into any) error { +func hostCall(ctx context.Context, host HostCaller, operation extensionv1.HostOperation, value, into any) error { raw, err := json.Marshal(value) if err != nil { return err @@ -397,7 +470,7 @@ func hostCall(ctx context.Context, host *extensionv1.Client, operation extension return json.Unmarshal(result.Payload, into) } -func readState(ctx context.Context, host *extensionv1.Client, id int64, model string) (State, int64, error) { +func readState(ctx context.Context, host HostCaller, id int64, model string) (State, int64, error) { var result extensionv1.StateResult err := hostCall(ctx, host, extensionv1.HostStateRead, extensionv1.StateRequest{Namespace: "tickets", Key: stateKey(id, model)}, &result) var state State @@ -408,7 +481,7 @@ func readState(ctx context.Context, host *extensionv1.Client, id int64, model st return state, result.Revision, err } -func writeState(ctx context.Context, host *extensionv1.Client, id int64, model string, state State, revision int64) (int64, error) { +func writeState(ctx context.Context, host HostCaller, id int64, model string, state State, revision int64) (int64, error) { raw, err := json.Marshal(state) if err != nil { return 0, err diff --git a/plugins/codex-runtime/internal/tickets/module_test.go b/backend/internal/codexruntime/tickets/module_test.go similarity index 91% rename from plugins/codex-runtime/internal/tickets/module_test.go rename to backend/internal/codexruntime/tickets/module_test.go index 0a3f40e14379..27b1d67b5580 100644 --- a/plugins/codex-runtime/internal/tickets/module_test.go +++ b/backend/internal/codexruntime/tickets/module_test.go @@ -4,17 +4,13 @@ import ( "context" "encoding/json" "errors" - "net" "strings" "sync" "sync/atomic" "testing" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - "google.golang.org/grpc/test/bufconn" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) type memoryHost struct { @@ -127,19 +123,9 @@ func (h *memoryHost) Call(_ context.Context, in extensionv1.HostInvocation) (ext return extensionv1.Result{Payload: raw}, err } -func testHostClient(t *testing.T, host *memoryHost) *extensionv1.Client { +func testHostClient(t *testing.T, host *memoryHost) HostCaller { t.Helper() - listener := bufconn.Listen(1024 * 1024) - server := grpc.NewServer() - extensionv1.RegisterHost(server, host) - go func() { _ = server.Serve(listener) }() - t.Cleanup(server.Stop) - connection, err := grpc.NewClient("passthrough:///host", grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() })) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = connection.Close() }) - return extensionv1.NewClient(connection) + return host } func TestModulePersistsManualLifecycleAndNeverReturnsTicketToAdmin(t *testing.T) { diff --git a/plugins/codex-runtime/internal/tickets/outcome.go b/backend/internal/codexruntime/tickets/outcome.go similarity index 98% rename from plugins/codex-runtime/internal/tickets/outcome.go rename to backend/internal/codexruntime/tickets/outcome.go index 9f0743ca7d3e..c6af64f8abb6 100644 --- a/plugins/codex-runtime/internal/tickets/outcome.go +++ b/backend/internal/codexruntime/tickets/outcome.go @@ -6,7 +6,7 @@ import ( "strings" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func (o Outcome) MarshalJSON() ([]byte, error) { diff --git a/plugins/codex-runtime/internal/tickets/outcome_test.go b/backend/internal/codexruntime/tickets/outcome_test.go similarity index 96% rename from plugins/codex-runtime/internal/tickets/outcome_test.go rename to backend/internal/codexruntime/tickets/outcome_test.go index 4b892772c178..33c116b7cdf1 100644 --- a/plugins/codex-runtime/internal/tickets/outcome_test.go +++ b/backend/internal/codexruntime/tickets/outcome_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func TestRoutingOutcomeSeparatesModelAndQualityEvidence(t *testing.T) { diff --git a/plugins/codex-runtime/internal/tickets/proxy.go b/backend/internal/codexruntime/tickets/proxy.go similarity index 95% rename from plugins/codex-runtime/internal/tickets/proxy.go rename to backend/internal/codexruntime/tickets/proxy.go index 4007df61b564..e4ed297fac28 100644 --- a/plugins/codex-runtime/internal/tickets/proxy.go +++ b/backend/internal/codexruntime/tickets/proxy.go @@ -16,8 +16,8 @@ import ( "sync" "time" - proxytransport "github.com/Wei-Shaw/sub2api/pkg/extensionapi/proxy" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + proxytransport "github.com/Wei-Shaw/sub2api/internal/proxytransport" ) type ProxyTrust struct { @@ -116,7 +116,7 @@ func proxyStateKey(raw string) string { return hex.EncodeToString(sum[:]) } -func loadProxyTrust(ctx context.Context, host *extensionv1.Client, raw string) (*ProxyTrust, int64, error) { +func loadProxyTrust(ctx context.Context, host HostCaller, raw string) (*ProxyTrust, int64, error) { var state extensionv1.StateResult if err := hostCall(ctx, host, extensionv1.HostStateRead, extensionv1.StateRequest{Namespace: "proxy-trust", Key: proxyStateKey(raw)}, &state); err != nil { return nil, 0, err @@ -131,7 +131,7 @@ func loadProxyTrust(ctx context.Context, host *extensionv1.Client, raw string) ( return &trust, state.Revision, nil } -func prepareProxy(ctx context.Context, host *extensionv1.Client, raw string, explicitTest bool) (*http.Client, *ProxyResult, error) { +func prepareProxy(ctx context.Context, host HostCaller, raw string, explicitTest bool) (*http.Client, *ProxyResult, error) { normal, err := proxytransport.Normalize(raw) result := &ProxyResult{Stages: []ProxyStage{}, Code: "invalid_proxy"} if err != nil || normal == "" { diff --git a/plugins/codex-runtime/internal/tickets/proxy_test.go b/backend/internal/codexruntime/tickets/proxy_test.go similarity index 94% rename from plugins/codex-runtime/internal/tickets/proxy_test.go rename to backend/internal/codexruntime/tickets/proxy_test.go index da1290d58500..ca918109c16f 100644 --- a/plugins/codex-runtime/internal/tickets/proxy_test.go +++ b/backend/internal/codexruntime/tickets/proxy_test.go @@ -19,7 +19,7 @@ import ( "testing" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func syntheticCertificate(t *testing.T, hostname string, expired bool) (tls.Certificate, *x509.Certificate) { @@ -112,15 +112,21 @@ func TestProxyPreflightAndHarvestUseSamePinnedTransport(t *testing.T) { w.WriteHeader(502) return } - client, _, err := w.(http.Hijacker).Hijack() + hijacker, ok := w.(http.Hijacker) + if !ok { + _ = upstream.Close() + t.Error("proxy response writer does not support hijacking") + return + } + client, _, err := hijacker.Hijack() if err != nil { _ = upstream.Close() return } _, _ = io.WriteString(client, "HTTP/1.1 200 Connection Established\r\n\r\n") go func() { - defer client.Close() - defer upstream.Close() + defer func() { _ = client.Close() }() + defer func() { _ = upstream.Close() }() go func() { _, _ = io.Copy(upstream, client) }() _, _ = io.Copy(client, upstream) }() diff --git a/plugins/codex-runtime/internal/tickets/routing.go b/backend/internal/codexruntime/tickets/routing.go similarity index 94% rename from plugins/codex-runtime/internal/tickets/routing.go rename to backend/internal/codexruntime/tickets/routing.go index 37fb5ed7c79b..9e3f8b3a4612 100644 --- a/plugins/codex-runtime/internal/tickets/routing.go +++ b/backend/internal/codexruntime/tickets/routing.go @@ -9,10 +9,10 @@ import ( "strconv" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) -func (m *Module) harvest(ctx, epoch context.Context, host *extensionv1.Client, cfg Config, req Operation, manual bool) (Outcome, error) { +func (m *Module) harvest(ctx, epoch context.Context, host HostCaller, cfg Config, req Operation, manual bool) (Outcome, error) { if !cfg.Enabled { return Outcome{Code: "ticket_disabled"}, nil } @@ -144,7 +144,7 @@ func (m *Module) harvest(ctx, epoch context.Context, host *extensionv1.Client, c return Outcome{Success: qualified != nil, Code: observation.Code, HTTPStatus: observation.HTTPStatus, ObservedLength: observation.StateLength, ExpiresAt: state.ExpiresAt, Observation: &observation}, nil } -func (m *Module) migrateRoutingStates(ctx context.Context, host *extensionv1.Client, cfg Config) { +func (m *Module) migrateRoutingStates(ctx context.Context, host HostCaller, cfg Config) { var accounts []extensionv1.Account if hostCall(ctx, host, extensionv1.HostAccountList, extensionv1.AccountQuery{Platform: "openai", IncludeInactive: true}, &accounts) != nil { return @@ -176,13 +176,13 @@ type routingDemand struct { At time.Time `json:"at"` } -func hasRoutingDemand(ctx context.Context, host *extensionv1.Client, id int64, model string) bool { +func hasRoutingDemand(ctx context.Context, host HostCaller, id int64, model string) bool { var record extensionv1.StateResult var demand routingDemand return hostCall(ctx, host, extensionv1.HostStateRead, extensionv1.StateRequest{Namespace: "routing-demand", Key: stateKey(id, model)}, &record) == nil && record.Found && json.Unmarshal(record.Value, &demand) == nil && time.Since(demand.At) >= 0 && time.Since(demand.At) <= extensionv1.CodexRoutingMaxAge } -func (m *Module) recordRoutingDemand(ctx context.Context, host *extensionv1.Client, cfg Config, request extensionv1.CodexRoutingDemand) (any, error) { +func (m *Module) recordRoutingDemand(ctx context.Context, host HostCaller, cfg Config, request extensionv1.CodexRoutingDemand) (any, error) { state, revision, err := readState(ctx, host, request.AccountID, request.Model) if err != nil || !cfg.Enabled || !state.Enrolled || state.Phase == "stopped" { return map[string]bool{"enqueued": false}, err @@ -213,7 +213,7 @@ func (m *Module) recordRoutingDemand(ctx context.Context, host *extensionv1.Clie return map[string]bool{"enqueued": err == nil && saved.Applied}, err } -func observeRoutingResponse(ctx context.Context, host *extensionv1.Client, req extensionv1.CodexRoutingResponse) (any, error) { +func observeRoutingResponse(ctx context.Context, host HostCaller, req extensionv1.CodexRoutingResponse) (any, error) { if req.AccountID <= 0 || req.Identity == "" || req.Model == "" { return nil, errors.New("invalid routing response") } diff --git a/plugins/codex-runtime/internal/tickets/routing_test.go b/backend/internal/codexruntime/tickets/routing_test.go similarity index 98% rename from plugins/codex-runtime/internal/tickets/routing_test.go rename to backend/internal/codexruntime/tickets/routing_test.go index a3a36594fd46..1e8df6d1a1f9 100644 --- a/plugins/codex-runtime/internal/tickets/routing_test.go +++ b/backend/internal/codexruntime/tickets/routing_test.go @@ -3,7 +3,7 @@ package tickets import ( "context" "encoding/json" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "testing" "time" ) diff --git a/plugins/codex-runtime/internal/tickets/state.go b/backend/internal/codexruntime/tickets/state.go similarity index 98% rename from plugins/codex-runtime/internal/tickets/state.go rename to backend/internal/codexruntime/tickets/state.go index e3a967b7b20e..c8a1adf3166b 100644 --- a/plugins/codex-runtime/internal/tickets/state.go +++ b/backend/internal/codexruntime/tickets/state.go @@ -5,7 +5,7 @@ import ( "errors" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) var ErrNotDue = errors.New("ticket_not_due") diff --git a/plugins/codex-runtime/internal/tickets/state_test.go b/backend/internal/codexruntime/tickets/state_test.go similarity index 97% rename from plugins/codex-runtime/internal/tickets/state_test.go rename to backend/internal/codexruntime/tickets/state_test.go index 116c1e2a4c57..020db888d6e2 100644 --- a/plugins/codex-runtime/internal/tickets/state_test.go +++ b/backend/internal/codexruntime/tickets/state_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func TestRenewalStagesPersistAndStop(t *testing.T) { diff --git a/backend/internal/handler/admin/account_batch_testing.go b/backend/internal/handler/admin/account_batch_testing.go index c519f3951d4e..9f02c9bdda78 100644 --- a/backend/internal/handler/admin/account_batch_testing.go +++ b/backend/internal/handler/admin/account_batch_testing.go @@ -5,7 +5,7 @@ import ( "encoding/json" "sync" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/service" @@ -15,13 +15,12 @@ import ( type batchTestJobItem = extensionv1.BatchTestSelection type batchTestJobPayload struct { - Prompt string `json:"prompt,omitempty"` - AccountIDs []int64 `json:"account_ids,omitempty"` - ModelID string `json:"model_id,omitempty"` - Items []batchTestJobItem `json:"items,omitempty"` - hasItems bool - hasLegacy bool - ownerPluginID int64 + Prompt string `json:"prompt,omitempty"` + AccountIDs []int64 `json:"account_ids,omitempty"` + ModelID string `json:"model_id,omitempty"` + Items []batchTestJobItem `json:"items,omitempty"` + hasItems bool + hasLegacy bool } func (p *batchTestJobPayload) UnmarshalJSON(raw []byte) error { @@ -52,7 +51,7 @@ func (p *batchTestJobPayload) normalizeContext(ctx context.Context) ([]int64, ma return nil, nil, err } explicit := p.hasItems || p.Items != nil - plan, owner, err := service.PlanBatchAccountTests(ctx, extensionv1.BatchTestPlanningRequest{HasItems: explicit, HasLegacy: p.hasLegacy, AccountIDs: p.AccountIDs, ModelID: p.ModelID, Items: p.Items}) + plan, err := service.PlanBatchAccountTests(ctx, extensionv1.BatchTestPlanningRequest{HasItems: explicit, HasLegacy: p.hasLegacy, AccountIDs: p.AccountIDs, ModelID: p.ModelID, Items: p.Items}) if err != nil { return nil, nil, err } @@ -61,7 +60,6 @@ func (p *batchTestJobPayload) normalizeContext(ctx context.Context) ([]int64, ma } else { p.AccountIDs, p.ModelID = plan.AccountIDs, plan.ModelID } - p.ownerPluginID = owner return plan.AccountIDs, plan.Models, nil } @@ -90,7 +88,7 @@ func (h *AccountHandler) BatchTest(c *gin.Context) { } seeds[i].Metadata, _ = json.Marshal(metadata) } - h.submitAccountJob(c, service.AccountJobKindBatchTest, req, seeds, req.ownerPluginID) + h.submitAccountJob(c, service.AccountJobKindBatchTest, req, seeds) } // Shared across requests, including catalog loads by multiple administrators. @@ -125,7 +123,7 @@ func (h *AccountHandler) BatchTestModels(c *gin.Context) { response.BadRequest(c, err.Error()) return } - if _, _, err := service.PlanBatchAccountTests(c.Request.Context(), extensionv1.BatchTestPlanningRequest{HasLegacy: true, AccountIDs: ids}); err != nil { + if _, err := service.PlanBatchAccountTests(c.Request.Context(), extensionv1.BatchTestPlanningRequest{HasLegacy: true, AccountIDs: ids}); err != nil { response.ErrorFrom(c, err) return } diff --git a/backend/internal/handler/admin/account_bulk_job_scope_test.go b/backend/internal/handler/admin/account_bulk_job_scope_test.go index 447aa500124e..20c838ed0b04 100644 --- a/backend/internal/handler/admin/account_bulk_job_scope_test.go +++ b/backend/internal/handler/admin/account_bulk_job_scope_test.go @@ -162,10 +162,8 @@ func TestAccountJobBulkReplayUsesSubmissionIdentityWithoutWrites(t *testing.T) { repo := &bulkJobScopeRepository{accountJobSubmitRepository: &accountJobSubmitRepository{}} jobs := service.NewAccountJobService(repo, accountJobTestEncryptor{}) payload := json.RawMessage(`{"filters":{"status":"active"},"name":"updated"}`) - ownerContext := func(id int64) context.Context { - return service.WithPluginExecution(context.Background(), &service.PluginInstallation{ID: id, RuntimeGeneration: 2}) - } - job, replayed, err := jobs.Submit(ownerContext(7), 77, service.AccountJobKindBulkUpdate, "owner-bound", payload, nil, accountJobSeeds([]int64{11})) + + job, replayed, err := jobs.Submit(context.Background(), 77, service.AccountJobKindBulkUpdate, "owner-bound", payload, nil, accountJobSeeds([]int64{11})) require.NoError(t, err) require.False(t, replayed) require.Equal(t, 1, len(repo.created)) @@ -180,13 +178,11 @@ func TestAccountJobBulkReplayUsesSubmissionIdentityWithoutWrites(t *testing.T) { wantReplay bool wantErr error }{ - {name: "same_submission", ctx: ownerContext(7), actor: 77, kind: service.AccountJobKindBulkUpdate, key: "owner-bound", payload: payload, wantReplay: true}, - {name: "other_plugin", ctx: ownerContext(8), actor: 77, kind: service.AccountJobKindBulkUpdate, key: "owner-bound", payload: payload, wantErr: service.ErrAccountJobIdempotencyConflict}, - {name: "unbound_owner", ctx: context.Background(), actor: 77, kind: service.AccountJobKindBulkUpdate, key: "owner-bound", payload: payload, wantErr: service.ErrAccountJobIdempotencyConflict}, - {name: "other_actor", ctx: ownerContext(7), actor: 78, kind: service.AccountJobKindBulkUpdate, key: "owner-bound", payload: payload}, - {name: "other_kind", ctx: ownerContext(7), actor: 77, kind: service.AccountJobKindBatchDelete, key: "owner-bound", payload: payload}, - {name: "changed_payload", ctx: ownerContext(7), actor: 77, kind: service.AccountJobKindBulkUpdate, key: "owner-bound", payload: json.RawMessage(`{"name":"other"}`), wantErr: service.ErrAccountJobIdempotencyConflict}, - {name: "missing_key", ctx: ownerContext(7), actor: 77, kind: service.AccountJobKindBulkUpdate, payload: payload, wantErr: service.ErrAccountJobIdempotencyRequired}, + {name: "same_submission", ctx: context.Background(), actor: 77, kind: service.AccountJobKindBulkUpdate, key: "owner-bound", payload: payload, wantReplay: true}, + {name: "other_actor", ctx: context.Background(), actor: 78, kind: service.AccountJobKindBulkUpdate, key: "owner-bound", payload: payload}, + {name: "other_kind", ctx: context.Background(), actor: 77, kind: service.AccountJobKindBatchDelete, key: "owner-bound", payload: payload}, + {name: "changed_payload", ctx: context.Background(), actor: 77, kind: service.AccountJobKindBulkUpdate, key: "owner-bound", payload: json.RawMessage(`{"name":"other"}`), wantErr: service.ErrAccountJobIdempotencyConflict}, + {name: "missing_key", ctx: context.Background(), actor: 77, kind: service.AccountJobKindBulkUpdate, payload: payload, wantErr: service.ErrAccountJobIdempotencyRequired}, } { t.Run(test.name, func(t *testing.T) { got, replayed, err := jobs.ReplaySubmission(test.ctx, test.actor, test.kind, test.key, test.payload) @@ -206,9 +202,20 @@ func TestAccountJobBulkReplayUsesSubmissionIdentityWithoutWrites(t *testing.T) { require.Equal(t, original, repo.created[0], "lookup must not create, reseed or extend expiry") }) } - _, _, err = jobs.Submit(ownerContext(8), 77, service.AccountJobKindBulkUpdate, "owner-bound", payload, nil, accountJobSeeds([]int64{13})) - require.ErrorIs(t, err, service.ErrAccountJobIdempotencyConflict, "ordinary Submit must retain the same shared owner guard") + replayedJob, replayed, err := jobs.Submit(context.Background(), 77, service.AccountJobKindBulkUpdate, "owner-bound", payload, nil, accountJobSeeds([]int64{13})) + require.NoError(t, err) + require.True(t, replayed) + require.Equal(t, job.ID, replayedJob.ID) + require.Equal(t, original, repo.created[0], "resubmission must retain the originally frozen target, not the newly supplied seeds") require.Equal(t, 1, len(repo.created)) + legacy := original + legacy.Metadata = json.RawMessage(`{"plugin_id":7,"plugin_generation":2,"target_count":1}`) + legacyRepo := &bulkJobScopeRepository{accountJobSubmitRepository: &accountJobSubmitRepository{created: []service.CreateAccountJobParams{legacy}}} + legacyJobs := service.NewAccountJobService(legacyRepo, accountJobTestEncryptor{}) + _, _, err = legacyJobs.ReplaySubmission(context.Background(), 77, service.AccountJobKindBulkUpdate, "owner-bound", payload) + require.ErrorIs(t, err, service.ErrAccountJobIdempotencyConflict, "a native request cannot silently claim a historical plugin-owned submission") + require.Equal(t, []service.CreateAccountJobParams{legacy}, legacyRepo.created) + } type bulkJobScopeAdmin struct { @@ -218,6 +225,7 @@ type bulkJobScopeAdmin struct { filterCalls int filterErr error updatedIDs []int64 + bulkInputs []*service.BulkUpdateAccountsInput } func newBulkJobScopeAdmin(ids ...int64) *bulkJobScopeAdmin { @@ -257,7 +265,18 @@ func (s *bulkJobScopeAdmin) GetAccount(_ context.Context, id int64) (*service.Ac return nil, service.ErrAccountNotFound } +func (s *bulkJobScopeAdmin) GetAccountsByIDs(_ context.Context, ids []int64) ([]*service.Account, error) { + accounts := make([]*service.Account, 0, len(ids)) + for _, id := range ids { + if account := s.accountsByID[id]; account != nil { + accounts = append(accounts, account) + } + } + return accounts, nil +} + func (s *bulkJobScopeAdmin) BulkUpdateAccounts(_ context.Context, input *service.BulkUpdateAccountsInput) (*service.BulkUpdateAccountsResult, error) { + s.bulkInputs = append(s.bulkInputs, input) if len(input.AccountIDs) != 1 || input.Filters != nil { return nil, errors.New("fixture requires a single explicit account") } diff --git a/backend/internal/handler/admin/account_bulk_selection_guard_test.go b/backend/internal/handler/admin/account_bulk_selection_guard_test.go new file mode 100644 index 000000000000..5d955b4e9857 --- /dev/null +++ b/backend/internal/handler/admin/account_bulk_selection_guard_test.go @@ -0,0 +1,70 @@ +package admin + +import ( + "context" + "net/http" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestAccountJobBulkUpdateInvalidExplicitIDsDoNotBroadenScope(t *testing.T) { + for _, test := range []struct { + name string + ids []int64 + filters *BulkUpdateAccountFilters + wantStatus int + wantIDs []int64 + }{ + {name: "invalid_ids_without_filters", ids: []int64{0, -1}, wantStatus: http.StatusBadRequest}, + {name: "invalid_ids_with_all_filters", ids: []int64{0, -1}, filters: &BulkUpdateAccountFilters{}, wantStatus: http.StatusBadRequest}, + {name: "invalid_ids_before_filter_validation", ids: []int64{0, -1}, filters: &BulkUpdateAccountFilters{Group: "not-an-id"}, wantStatus: http.StatusBadRequest}, + {name: "valid_ids_still_ignore_filters", ids: []int64{0, 7, -1, 3, 7}, filters: &BulkUpdateAccountFilters{Group: "not-an-id"}, wantStatus: http.StatusAccepted, wantIDs: []int64{3, 7}}, + {name: "filter_only_preserved", filters: &BulkUpdateAccountFilters{}, wantStatus: http.StatusAccepted, wantIDs: []int64{3, 7, 99}}, + {name: "empty_ids_with_filters_preserved", ids: []int64{}, filters: &BulkUpdateAccountFilters{}, wantStatus: http.StatusAccepted, wantIDs: []int64{3, 7, 99}}, + } { + t.Run(test.name, func(t *testing.T) { + admin := newBulkJobScopeAdmin(3, 7, 99) + admin.matches = []int64{3, 7, 99} + router, handler, scopeRepo := newBulkJobScopeRouter(admin) + repo := &bulkSelectionGuardRepository{bulkJobScopeRepository: scopeRepo} + handler.SetAccountJobService(service.NewAccountJobService(repo, accountJobTestEncryptor{})) + + recorder := submitBulkJobScopeRequest(t, router, BulkUpdateAccountsRequest{ + AccountIDs: test.ids, Filters: test.filters, Name: "updated", + }, "selection-scope") + + require.Equal(t, test.wantStatus, recorder.Code, recorder.Body.String()) + require.Empty(t, admin.updatedIDs, "acceptance must not execute the job synchronously") + if test.wantStatus == http.StatusBadRequest { + require.Contains(t, recorder.Body.String(), "account_ids must include at least one positive ID") + require.Zero(t, repo.replayLookups, "invalid explicit IDs must be rejected before idempotency replay") + require.Zero(t, admin.filterCalls, "invalid explicit IDs must not broaden to filter targets") + require.Empty(t, repo.created, "invalid explicit IDs must not enqueue a job") + return + } + + require.Len(t, repo.created, 1) + var targets []int64 + for _, seed := range repo.created[0].Items { + require.NotNil(t, seed.TargetAccountID) + targets = append(targets, *seed.TargetAccountID) + } + require.Equal(t, test.wantIDs, targets) + if len(test.ids) > 0 { + require.Zero(t, admin.filterCalls, "valid explicit IDs must retain priority over filters") + } + }) + } +} + +type bulkSelectionGuardRepository struct { + *bulkJobScopeRepository + replayLookups int +} + +func (r *bulkSelectionGuardRepository) FindIdempotent(ctx context.Context, actor int64, kind, key string) (*service.AccountJob, error) { + r.replayLookups++ + return r.bulkJobScopeRepository.FindIdempotent(ctx, actor, kind, key) +} diff --git a/backend/internal/handler/admin/account_cindy_balance.go b/backend/internal/handler/admin/account_cindy_balance.go index 87acff4e2961..825a548df755 100644 --- a/backend/internal/handler/admin/account_cindy_balance.go +++ b/backend/internal/handler/admin/account_cindy_balance.go @@ -6,7 +6,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/gin-gonic/gin" ) @@ -21,12 +20,7 @@ func (h *AccountHandler) ClearCindyBalanceInsufficient(c *gin.Context) { response.ErrorFrom(c, err) return } - // Native callers keep the existing Account response. Only an authenticated - // plugin resource caller gets the explicit credential-free acknowledgement. - if service.PluginResourceBound(c.Request.Context()) && c.GetHeader("X-Sub2API-Plugin") != "" { - response.Success(c, extensionv1.AccountViewRecoveryAck{AccountID: account.ID, Recovered: true}) - return - } + response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account)) } diff --git a/backend/internal/handler/admin/account_codex_stop_job.go b/backend/internal/handler/admin/account_codex_stop_job.go new file mode 100644 index 000000000000..b8f8d61f6497 --- /dev/null +++ b/backend/internal/handler/admin/account_codex_stop_job.go @@ -0,0 +1,98 @@ +package admin + +import ( + "context" + "encoding/json" + "strconv" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +// Stop remains a persisted account operation after the first-party iframe and +// extension executor retire. The older synchronous endpoint stays available. +func (h *AccountHandler) StopCodexTicketRenewalJob(c *gin.Context) { + h.submitCodexTicketStop(c, true) +} + +func (h *AccountHandler) BatchStopCodexTicketRenewal(c *gin.Context) { + h.submitCodexTicketStop(c, false) +} + +func (h *AccountHandler) submitCodexTicketStop(c *gin.Context, single bool) { + var req codexTicketHarvestRequest + if c.ShouldBindJSON(&req) != nil { + response.BadRequest(c, "请求格式不正确") + return + } + if single { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || id <= 0 { + response.BadRequest(c, "账号ID不正确") + return + } + req.AccountIDs = []int64{id} + } + req.AccountIDs = normalizeInt64IDList(req.AccountIDs) + if len(req.AccountIDs) == 0 || len(req.AccountIDs) > 100 { + response.BadRequest(c, "每次请选择1至100个账号") + return + } + models, err := h.ticketModels(req.Models) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + req.Models, req.Force = models, false + accounts, err := h.adminService.GetAccountsByIDs(c.Request.Context(), req.AccountIDs) + if err != nil { + response.ErrorFrom(c, err) + return + } + if len(accounts) != len(req.AccountIDs) { + response.BadRequest(c, "所选账号已发生变化") + return + } + for _, account := range accounts { + if account.Platform != service.PlatformOpenAI || (account.Type != service.AccountTypeOAuth && account.Type != service.AccountTypeSetupToken) || account.IsShadow() { + response.BadRequest(c, "全部所选账号必须支持 Codex 路由续期") + return + } + } + seeds := make([]service.AccountJobItemSeed, 0, len(req.AccountIDs)*len(models)) + for _, id := range req.AccountIDs { + for _, model := range models { + target := id + metadata, _ := json.Marshal(map[string]any{"account_id": id, "model_id": model}) + seeds = append(seeds, service.AccountJobItemSeed{Ordinal: len(seeds) + 1, TargetAccountID: &target, Metadata: metadata}) + } + } + h.submitAccountJob(c, service.AccountJobKindCodexTicketStop, req, seeds) +} + +func (h *AccountHandler) executeCodexTicketStop(ctx context.Context, raw json.RawMessage, item service.AccountJobItem) service.AccountJobExecutionResult { + var req codexTicketHarvestRequest + var metadata struct { + Model string `json:"model_id"` + } + if json.Unmarshal(raw, &req) != nil || json.Unmarshal(item.Metadata, &metadata) != nil || metadata.Model == "" { + return accountJobFailed(item.ID, "payload_invalid") + } + id, ok := accountJobTarget(item) + if !ok || h.codexTicketGateway == nil { + return accountJobFailed(item.ID, "target_missing") + } + account, err := h.adminService.GetAccount(ctx, id) + if err != nil || account == nil || account.Platform != service.PlatformOpenAI || (account.Type != service.AccountTypeOAuth && account.Type != service.AccountTypeSetupToken) || account.IsShadow() { + return accountJobFailed(item.ID, "account_ineligible") + } + if err := h.codexTicketGateway.StopCodexTicketRenewal(ctx, id, []string{metadata.Model}); err != nil { + failure := accountJobFailed(item.ID, "stop_failed") + if ctx.Err() != nil { + failure.Status = service.AccountJobItemStatusCanceled + } + return failure + } + return accountJobSucceeded(item.ID, map[string]any{"account_id": id, "model_id": metadata.Model}) +} diff --git a/backend/internal/handler/admin/account_create_profile_test.go b/backend/internal/handler/admin/account_create_profile_test.go index 647d9afd7636..07e8785d8c6b 100644 --- a/backend/internal/handler/admin/account_create_profile_test.go +++ b/backend/internal/handler/admin/account_create_profile_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/stretchr/testify/require" ) diff --git a/backend/internal/handler/admin/account_data.go b/backend/internal/handler/admin/account_data.go index 6f267ec441e5..e6d3d952414c 100644 --- a/backend/internal/handler/admin/account_data.go +++ b/backend/internal/handler/admin/account_data.go @@ -382,9 +382,6 @@ func (h *AccountHandler) listAccountsFiltered(ctx context.Context, platform, acc func (h *AccountHandler) resolveExportAccounts(ctx context.Context, ids []int64, c *gin.Context) ([]service.Account, error) { if len(ids) > 0 { - if err := service.ValidateAccountViewSelection(ctx, ids); err != nil { - return nil, err - } accounts, err := h.adminService.GetAccountsByIDs(ctx, ids) if err != nil { return nil, err @@ -422,8 +419,7 @@ func (h *AccountHandler) resolveExportAccounts(ctx context.Context, ids []int64, groupID = parsedGroupID } } - _, accountViewBound := service.AccountViewFromContext(ctx) - if hasAccountConsoleFilters(c) || accountViewBound { + if hasAccountConsoleFilters(c) { filters, filterErr := parseAccountConsoleFilters(c, groupID) if filterErr != nil { return nil, filterErr diff --git a/backend/internal/handler/admin/account_data_import_decision.go b/backend/internal/handler/admin/account_data_import_decision.go index 5a61088c4787..2c4aa228f250 100644 --- a/backend/internal/handler/admin/account_data_import_decision.go +++ b/backend/internal/handler/admin/account_data_import_decision.go @@ -8,9 +8,9 @@ import ( "sort" "strings" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" ) const ( diff --git a/backend/internal/handler/admin/account_data_import_job.go b/backend/internal/handler/admin/account_data_import_job.go index cbe1ceb45557..8e36e3fb2a17 100644 --- a/backend/internal/handler/admin/account_data_import_job.go +++ b/backend/internal/handler/admin/account_data_import_job.go @@ -63,6 +63,9 @@ func (h *AccountHandler) PrepareAccountJob( job *service.AccountJob, raw json.RawMessage, ) (context.Context, func(), error) { + if err := service.ValidateRecordedAccountJob(job, raw); err != nil { + return ctx, nil, err + } if job != nil && job.Kind == service.AccountJobKindBatchTest { var req batchTestJobPayload if err := json.Unmarshal(raw, &req); err != nil { diff --git a/backend/internal/handler/admin/account_edit_test.go b/backend/internal/handler/admin/account_edit_test.go index ba2a085bfdf9..ec5b1bcd0802 100644 --- a/backend/internal/handler/admin/account_edit_test.go +++ b/backend/internal/handler/admin/account_edit_test.go @@ -1,26 +1,22 @@ package admin import ( - "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) -func TestAccountEditContextKeepsCoreIndependentAndViewBound(t *testing.T) { +func TestAccountEditContextKeepsCoreIndependentAndStoredIdentityBound(t *testing.T) { gin.SetMode(gin.TestMode) svc := &availableModelsAdminService{stubAdminService: newStubAdminService(), account: service.Account{ID: 44, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, Credentials: map[string]any{"api_key": "must-not-leak", "base_url": "https://api.laxarouter.ai"}, Extra: map[string]any{"is_cindy": true}}} handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - plugin := &PluginHandler{} router := gin.New() - router.Use(plugin.AccountViewRequest()) router.GET("/api/v1/admin/accounts/:id/edit-context", handler.GetEditContext) request := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/44/edit-context", nil) response := httptest.NewRecorder() @@ -32,11 +28,34 @@ func TestAccountEditContextKeepsCoreIndependentAndViewBound(t *testing.T) { } require.NoError(t, json.Unmarshal(response.Body.Bytes(), &result)) require.Equal(t, map[string]any{"schema_version": float64(1), "kind": "core", "account_id": float64(44)}, result.Data) + require.NotContains(t, response.Body.String(), "must-not-leak") + // Only the stored canonical identity selects native Cindy editing. A legacy + // OpenAI base URL/extra marker above was not enough to reclassify the account. + svc.account.Platform = service.PlatformCindy + svc.account.WirePlatform = service.WirePlatformOpenAI + svc.account.ProviderProfile = service.ProviderProfileCindyLaxaV1 request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/44/edit-context", nil) - request.Header.Set(extensionv1.AccountViewHeader, base64.RawURLEncoding.EncodeToString([]byte(`{"version":1,"plugin_id":999}`))) response = httptest.NewRecorder() router.ServeHTTP(response, request) - require.GreaterOrEqual(t, response.Code, 400, "a bound origin must not fall back to core") + require.Equal(t, http.StatusOK, response.Code, response.Body.String()) + require.Equal(t, "no-store", response.Header().Get("Cache-Control")) + var native struct { + Data service.AccountEditContext `json:"data"` + } + require.NoError(t, json.Unmarshal(response.Body.Bytes(), &native)) + require.EqualValues(t, 44, native.Data.AccountID) + require.Equal(t, "provider", native.Data.Kind) + require.NotNil(t, native.Data.Profile) + require.True(t, native.Data.Profile.Native) + require.True(t, native.Data.Profile.Available) + require.Len(t, native.Data.EditStateSHA256, 64) + require.NotContains(t, response.Body.String(), "must-not-leak") + require.NotContains(t, response.Body.String(), "plugin_id") + request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/0/edit-context", nil) + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + require.Equal(t, http.StatusBadRequest, response.Code, "invalid native targets cannot receive an edit context") + } func TestAccountEditRequestRejectsUnknownAndNullIntent(t *testing.T) { diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 7a921dc53173..79a4a131b2bc 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -20,6 +20,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/domain" "github.com/Wei-Shaw/sub2api/internal/handler/dto" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" @@ -29,7 +30,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/gin-gonic/gin" "golang.org/x/sync/errgroup" @@ -795,8 +795,7 @@ func (h *AccountHandler) List(c *gin.Context) { consoleService accountConsoleAdminService err error ) - _, accountViewBound := service.AccountViewFromContext(c.Request.Context()) - if hasAccountConsoleFilters(c) || accountViewBound { + if hasAccountConsoleFilters(c) { filters, filterErr := parseAccountConsoleFilters(c, groupID) if filterErr != nil { response.ErrorFrom(c, filterErr) @@ -2115,10 +2114,13 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) { } ids := normalizeInt64IDList(req.AccountIDs) + if len(req.AccountIDs) > 0 && len(ids) == 0 { + response.BadRequest(c, "account_ids must include at least one positive ID") + return + } req.AccountIDs = ids submission := req - _, viewScopedSubmission := service.AccountViewFromContext(c.Request.Context()) - if h.replayScopedAccountJob(c, service.AccountJobKindBulkUpdate, submission) { + if h.replayAccountJob(c, service.AccountJobKindBulkUpdate, submission) { return } if service.HasOpenAIReasoningPolicyUpdates(req.Extra) { @@ -2142,11 +2144,9 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) { response.ErrorFrom(c, err) return } - // Freeze selection; workers must not re-evaluate a filter and silently - // include newly matching accounts after the complete-set preflight. + // Freeze the validated selection in item seeds. The original request + // remains the replay source; workers execute only each seed's target. ids = resolvedIDs - req.Filters = nil - req.AccountIDs = ids } else if len(ids) == 0 { if h.accountJobs == nil { response.ErrorFrom(c, infraerrors.New(503, "ACCOUNT_JOBS_UNAVAILABLE", "account jobs are unavailable")) @@ -2183,10 +2183,8 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) { return } } - if viewScopedSubmission { - req = submission - } - if service.HasAccountEditOwnedInput(req.Credentials, req.Extra) { + // Keep the submitted operation identity; only item seeds freeze resolved IDs. + if service.HasAccountEditOwnedInput(submission.Credentials, submission.Extra) { accounts, err := h.adminService.GetAccountsByIDs(c.Request.Context(), ids) if err != nil { response.ErrorFrom(c, err) @@ -2194,7 +2192,7 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) { } c.Request = c.Request.WithContext(service.WithAccountJobEditAccounts(c.Request.Context(), accounts)) } - h.submitAccountJob(c, service.AccountJobKindBulkUpdate, req, accountJobSeeds(ids)) + h.submitAccountJob(c, service.AccountJobKindBulkUpdate, submission, accountJobSeeds(ids)) } func splitBulkAccountFilterValues(values ...string) []string { @@ -2249,7 +2247,7 @@ func toServiceBulkUpdateAccountFilters(filters *BulkUpdateAccountFilters) (*serv GroupID: 0, PrivacyMode: strings.TrimSpace(filters.PrivacyMode), SortBy: "id", SortOrder: "asc", } if (filters.CindyBalanceStatus != "" && filters.CindyBalanceStatus != "insufficient") || (filters.CindyHealthStatus != "" && filters.CindyHealthStatus != "banned") { - return nil, service.ErrAccountViewInvalid + return nil, infraerrors.BadRequest("INVALID_ACCOUNT_FILTER", "invalid account filter") } switch strings.TrimSpace(filters.Group) { case "": @@ -2745,7 +2743,11 @@ func (h *AccountHandler) GetEditContext(c *gin.Context) { response.ErrorFrom(c, err) return } - result, err := service.LoadAccountEditContext(c.Request.Context(), account) + wsDefault := "ctx_pool" + if h.cfg != nil { + wsDefault = h.cfg.Gateway.OpenAIWS.IngressModeDefault + } + result, err := service.LoadAccountEditContext(c.Request.Context(), account, wsDefault) if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/handler/admin/account_handler_available_models_test.go b/backend/internal/handler/admin/account_handler_available_models_test.go index 0ef1dc9300e9..a0e690fece85 100644 --- a/backend/internal/handler/admin/account_handler_available_models_test.go +++ b/backend/internal/handler/admin/account_handler_available_models_test.go @@ -312,7 +312,7 @@ func TestAccountHandlerGetAvailableModels_OpenAIAPIKeyReportsUnavailableDiscover func TestAccountHandlerGetAvailableModels_CindyUsesManagedCatalogInsteadOfStoredMapping(t *testing.T) { fixture := &testPlanOperations{} - service.ConfigureProcessExtensionServices(nil, fixture) + service.ConfigureNativePolicyOperations(fixture) t.Cleanup(testextensions.Install) svc := &availableModelsAdminService{ stubAdminService: newStubAdminService(), diff --git a/backend/internal/handler/admin/account_handler_traffic_telemetry_test.go b/backend/internal/handler/admin/account_handler_traffic_telemetry_test.go index 4c5c88b99437..e4a3d07e2f01 100644 --- a/backend/internal/handler/admin/account_handler_traffic_telemetry_test.go +++ b/backend/internal/handler/admin/account_handler_traffic_telemetry_test.go @@ -8,9 +8,9 @@ import ( "strconv" "testing" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/testextensions" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -43,34 +43,12 @@ func (s *scopedTrafficAdminService) GetAccount(ctx context.Context, id int64) (* return s.stubAdminService.GetAccount(ctx, id) } -type scopedTrafficPolicy struct { - t *testing.T - allowed bool - calls int -} - -func (p *scopedTrafficPolicy) InvokeOperation(_ context.Context, platform, kind string, in extensionv1.Invocation) (extensionv1.Result, error) { - p.calls++ - require.Equal(p.t, service.PlatformOpenAI, platform) - require.Equal(p.t, service.AccountTypeAPIKey, kind) - require.EqualValues(p.t, 18, in.AccountID) - require.Equal(p.t, extensionv1.CapabilityObservability, in.Capability) - require.Equal(p.t, "observability.policy", in.Operation) - require.JSONEq(p.t, `{}`, string(in.Payload)) - if !p.allowed { - return extensionv1.Result{}, service.ErrExtensionOperationDisabled - } - raw, err := json.Marshal(extensionv1.TrafficObservationPolicy{Enabled: true, Classification: extensionv1.DecisionTable{Default: string(service.AccountTrafficOutcomeFailedOther)}}) - return extensionv1.Result{Payload: raw}, err -} - -func TestAccountHandlerTrafficTelemetryUsesLoadedAccountScope(t *testing.T) { +func TestAccountHandlerTrafficTelemetryFollowsTelemetrySwitch(t *testing.T) { gin.SetMode(gin.TestMode) - for _, allowed := range []bool{false, true} { - t.Run(strconv.FormatBool(allowed), func(t *testing.T) { + for _, enabled := range []bool{false, true} { + t.Run(strconv.FormatBool(enabled), func(t *testing.T) { t.Cleanup(testextensions.Install) - policy := &scopedTrafficPolicy{t: t, allowed: allowed} - service.ConfigureProcessExtensionServices(nil, policy) + service.ConfigureAdminObservability(&extensionv1.AdminObservabilityConfig{TelemetryEnabled: enabled}) stub := &scopedTrafficAdminService{stubAdminService: newStubAdminService()} stub.getAccountResult = &service.Account{ID: 18, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, Concurrency: 3, Credentials: map[string]any{"api_key": "never-return-secret"}} cache := &stubAccountTrafficObserveCache{snapshot: map[service.AccountTrafficProtocol]service.AccountTrafficObserveState{service.AccountTrafficProtocolHTTP: {Started: 3, Completed2xx: 2}}} @@ -86,18 +64,17 @@ func TestAccountHandlerTrafficTelemetryUsesLoadedAccountScope(t *testing.T) { Data map[string]any `json:"data"` } require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) - require.Equal(t, allowed, response.Data["state_available"]) + require.Equal(t, enabled, response.Data["state_available"]) require.EqualValues(t, 18, response.Data["account_id"]) - require.Equal(t, 1, stub.reads, "policy selection must reuse the account already loaded by the handler") - require.Equal(t, 1, policy.calls, "an unscoped Enabled preflight must not precede account-specific admission") + require.Equal(t, 1, stub.reads) require.NotContains(t, recorder.Body.String(), "never-return-secret") - if allowed { + if enabled { require.Equal(t, 1, cache.snapshotCalls) require.Contains(t, response.Data, "protocols") } else { require.Zero(t, cache.snapshotCalls) require.NotContains(t, response.Data, "protocols") - require.NotContains(t, response.Data, "error", "out-of-scope telemetry keeps the existing disabled no-data response") + require.NotContains(t, response.Data, "error", "disabled telemetry keeps the no-data response") } }) } diff --git a/backend/internal/handler/admin/account_job_execution.go b/backend/internal/handler/admin/account_job_execution.go index 1a3f4acee951..ef18a4cc1783 100644 --- a/backend/internal/handler/admin/account_job_execution.go +++ b/backend/internal/handler/admin/account_job_execution.go @@ -8,8 +8,8 @@ import ( "strings" "time" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" ) func (h *AccountHandler) ExecuteAccountJob( @@ -21,10 +21,19 @@ func (h *AccountHandler) ExecuteAccountJob( if h == nil || job == nil || len(items) != 1 { return nil, errors.New("invalid account job execution") } + if err := service.ValidateRecordedAccountJobTargets(job, payload, items); err != nil { + return nil, err + } item := items[0] if job.Kind == service.AccountJobKindCodexTicketHarvest { return []service.AccountJobExecutionResult{h.executeCodexTicketHarvest(ctx, job, payload, item)}, nil } + if job.Kind == service.AccountJobKindCodexTicketStop { + return []service.AccountJobExecutionResult{h.executeCodexTicketStop(ctx, payload, item)}, nil + } + if job.Kind == service.AccountJobKindExtensionOperation { + return []service.AccountJobExecutionResult{h.executeLegacyCodexAccountJob(ctx, job, payload, item)}, nil + } result := h.executeAccountJobItem(ctx, job.Kind, payload, item) return []service.AccountJobExecutionResult{result}, nil } @@ -61,7 +70,7 @@ func (h *AccountHandler) executeAccountJobItem(ctx context.Context, kind string, } testCtx, cancel := context.WithTimeout(ctx, 90*time.Second) defer cancel() - if _, _, err := service.PlanBatchAccountTests(testCtx, extensionv1.BatchTestPlanningRequest{HasLegacy: true, AccountIDs: []int64{id}, ModelID: model}); err != nil { + if _, err := service.PlanBatchAccountTests(testCtx, extensionv1.BatchTestPlanningRequest{HasLegacy: true, AccountIDs: []int64{id}, ModelID: model}); err != nil { return accountJobFailed(item.ID, "test_unavailable") } effort := "" @@ -407,32 +416,11 @@ func (h *AccountHandler) resolveAccountJobTargetIDs( requested []int64, requestFilters *BulkUpdateAccountFilters, ) ([]int64, error) { - if _, bound := service.AccountViewFromContext(ctx); bound && len(requested) > 0 { - if err := service.ValidateAccountViewSelection(ctx, requested); err != nil { - return nil, err - } - } + if ids := normalizeInt64IDList(requested); len(ids) > 0 { return ids, nil } - if view, bound := service.AccountViewFromContext(ctx); bound { - console, err := h.accountConsoleService() - if err != nil { - return nil, err - } - accounts, err := h.listAccountsConsoleFiltered(ctx, console, service.AccountViewQueryFilters(view.Request.Query)) - if err != nil { - return nil, err - } - ids := make([]int64, 0, len(accounts)) - for _, account := range accounts { - ids = append(ids, account.ID) - } - if err := service.ValidateAccountViewSelection(ctx, ids); err != nil { - return nil, err - } - return normalizeInt64IDList(ids), nil - } + filters, err := toServiceBulkUpdateAccountFilters(requestFilters) if err != nil { return nil, err @@ -476,25 +464,12 @@ func (h *AccountHandler) executeBulkTaxonomyJob(ctx context.Context, raw json.Ra if json.Unmarshal(raw, &req) != nil { return accountJobFailed(item.ID, "payload_invalid") } - var ids []int64 - if _, bound := service.AccountViewFromContext(ctx); bound { - id, exists := accountJobTarget(item) - if !exists { - return accountJobFailed(item.ID, "target_missing") - } - ids = []int64{id} - } else { - var err error - ids, err = h.resolveAccountJobTargetIDs(ctx, req.AccountIDs, req.Filters) - if err != nil { - return accountJobFailed(item.ID, "filters_invalid") - } - } - if id, ok := accountJobTarget(item); ok { - ids = []int64{id} - } else if req.Filters != nil && (req.ExpectedMatchCount == nil || *req.ExpectedMatchCount != len(ids)) { - return accountJobFailed(item.ID, "taxonomy_target_changed") + id, ok := accountJobTarget(item) + if !ok { + return accountJobFailed(item.ID, "target_missing") } + ids := []int64{id} + console, err := h.accountTaxonomyMutationService() if err != nil { return accountJobFailed(item.ID, "taxonomy_unavailable") diff --git a/backend/internal/handler/admin/account_job_handler.go b/backend/internal/handler/admin/account_job_handler.go index 8c181b0b3a8c..1c5304e84586 100644 --- a/backend/internal/handler/admin/account_job_handler.go +++ b/backend/internal/handler/admin/account_job_handler.go @@ -169,12 +169,9 @@ func (h *AccountHandler) SetAccountJobService(jobs *service.AccountJobService) { h.accountJobs = jobs } -func (h *AccountHandler) replayScopedAccountJob(c *gin.Context, kind string, payload any) bool { - if _, bound := service.AccountViewFromContext(c.Request.Context()); !bound { - return false - } +func (h *AccountHandler) replayAccountJob(c *gin.Context, kind string, payload any) bool { if h.accountJobs == nil { - accountViewRequestError(c, infraerrors.New(503, "ACCOUNT_JOBS_UNAVAILABLE", "account jobs are unavailable")) + response.ErrorFrom(c, infraerrors.New(503, "ACCOUNT_JOBS_UNAVAILABLE", "account jobs are unavailable")) return true } actorID, ok := accountJobActorID(c) @@ -183,7 +180,7 @@ func (h *AccountHandler) replayScopedAccountJob(c *gin.Context, kind string, pay } raw, err := json.Marshal(payload) if err != nil { - accountViewRequestError(c, service.ErrAccountViewInvalid) + response.ErrorFrom(c, service.ErrAccountViewInvalid) return true } job, replayed, err := h.accountJobs.ReplaySubmission(c.Request.Context(), actorID, kind, c.GetHeader("Idempotency-Key"), raw) @@ -199,7 +196,7 @@ func (h *AccountHandler) replayScopedAccountJob(c *gin.Context, kind string, pay return true } -func (h *AccountHandler) submitAccountJob(c *gin.Context, kind string, payload any, seeds []service.AccountJobItemSeed, owner ...int64) { +func (h *AccountHandler) submitAccountJob(c *gin.Context, kind string, payload any, seeds []service.AccountJobItemSeed) { if h == nil || h.accountJobs == nil { response.ErrorFrom(c, infraerrors.New(503, "ACCOUNT_JOBS_UNAVAILABLE", "account jobs are unavailable")) return @@ -214,16 +211,7 @@ func (h *AccountHandler) submitAccountJob(c *gin.Context, kind string, payload a return } meta := map[string]any{"target_count": len(seeds)} - if execution, bound := service.PluginExecutionFromContext(c.Request.Context()); bound { - if len(owner) > 0 && owner[0] > 0 && owner[0] != execution.ID { - response.Forbidden(c, "Plugin job ownership changed") - return - } - meta["plugin_id"] = execution.ID - } - if len(owner) > 0 && owner[0] > 0 { - meta["plugin_id"] = owner[0] - } + metadata, _ := json.Marshal(meta) job, replayed, err := h.accountJobs.Submit(c.Request.Context(), actorID, kind, c.GetHeader("Idempotency-Key"), raw, metadata, seeds) if err != nil { diff --git a/backend/internal/handler/admin/account_job_legacy_codex.go b/backend/internal/handler/admin/account_job_legacy_codex.go new file mode 100644 index 000000000000..b9351bc5ff6c --- /dev/null +++ b/backend/internal/handler/admin/account_job_legacy_codex.go @@ -0,0 +1,35 @@ +package admin + +import ( + "context" + "encoding/json" + "errors" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +func (h *AccountHandler) executeLegacyCodexAccountJob(ctx context.Context, job *service.AccountJob, raw json.RawMessage, item service.AccountJobItem) service.AccountJobExecutionResult { + intent, err := service.DecodeLegacyCodexAccountJob(job.Metadata, raw, item) + if errors.Is(err, service.ErrLegacyAccountJobUnsupported) { + return service.AccountJobExecutionResult{ItemID: item.ID, Status: service.AccountJobItemStatusFailed, + Metadata: json.RawMessage(`{}`), ErrorCode: "legacy_operation_unsupported", ErrorMessage: "The stored operation has no native executor"} + } + if err != nil { + return accountJobFailed(item.ID, "payload_invalid") + } + if h.codexTicketGateway == nil || h.codexTicketGateway.ValidateLegacyCodexJobSource(ctx, intent.PluginID) != nil { + return accountJobFailed(item.ID, "legacy_source_unavailable") + } + models, err := h.ticketModels([]string{intent.Model}) + if err != nil || len(models) != 1 { + return accountJobFailed(item.ID, "payload_invalid") + } + payload, _ := json.Marshal(codexTicketHarvestRequest{AccountIDs: []int64{intent.AccountID}, Models: models, Force: intent.Force}) + // Only the original frozen target and single saved model reach the native + // entrypoint; operation_id remains the durable job's own identity. + item.Metadata, _ = json.Marshal(map[string]any{"account_id": intent.AccountID, "model_id": models[0]}) + if intent.Operation == "stop" { + return h.executeCodexTicketStop(ctx, payload, item) + } + return h.executeCodexTicketHarvest(ctx, job, payload, item) +} diff --git a/backend/internal/handler/admin/account_job_submit_test.go b/backend/internal/handler/admin/account_job_submit_test.go index 19fa3f10b1c4..01b530bbc920 100644 --- a/backend/internal/handler/admin/account_job_submit_test.go +++ b/backend/internal/handler/admin/account_job_submit_test.go @@ -15,21 +15,15 @@ import ( const accountJobTestActorID int64 = 77 -func TestAccountJobSubmissionRetainsResourceOwnerAndRejectsSubstitution(t *testing.T) { +func TestAccountJobSubmissionRetainsActorAndNativeMetadata(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() handler := &AccountHandler{} repo := attachAccountJobSubmitter(router, handler) - router.Use(func(c *gin.Context) { - c.Request = c.Request.WithContext(service.WithPluginExecution(c.Request.Context(), &service.PluginInstallation{ID: 7, RuntimeGeneration: 2})) - c.Next() - }) + router.POST("/jobs", func(c *gin.Context) { handler.submitAccountJob(c, service.AccountJobKindImportData, map[string]any{"data": "synthetic"}, ordinalAccountJobSeeds(1)) }) - router.POST("/wrong-owner", func(c *gin.Context) { - handler.submitAccountJob(c, service.AccountJobKindImportData, map[string]any{"data": "synthetic"}, ordinalAccountJobSeeds(1), 8) - }) recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/jobs", nil) request.Header.Set("Idempotency-Key", "fixture-import") @@ -38,11 +32,20 @@ func TestAccountJobSubmissionRetainsResourceOwnerAndRejectsSubstitution(t *testi require.Len(t, repo.created, 1) var metadata map[string]any require.NoError(t, json.Unmarshal(repo.created[0].Metadata, &metadata)) - require.Equal(t, float64(7), metadata["plugin_id"]) - recorder = httptest.NewRecorder() - router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/wrong-owner", nil)) - require.Equal(t, http.StatusForbidden, recorder.Code) - require.Len(t, repo.created, 1) + require.Equal(t, map[string]any{"target_count": float64(1)}, metadata) + require.NotContains(t, metadata, "plugin_id") + require.NotContains(t, metadata, "plugin_generation") + require.Equal(t, accountJobTestActorID, repo.created[0].CreatedBy) + require.Equal(t, service.AccountJobKindImportData, repo.created[0].Kind) + require.Equal(t, "fixture-import", repo.created[0].IdempotencyKey) + require.JSONEq(t, `{"data":"synthetic"}`, repo.created[0].PayloadCipher) + var response struct { + Data service.AccountJob `json:"data"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.Equal(t, accountJobTestActorID, response.Data.CreatedBy) + require.Equal(t, service.AccountJobStatusPending, response.Data.Status) + require.Equal(t, 1, response.Data.TargetCount) } type accountJobSubmitRepository struct { diff --git a/backend/internal/handler/admin/account_native_filters_test.go b/backend/internal/handler/admin/account_native_filters_test.go new file mode 100644 index 000000000000..c3832f837b36 --- /dev/null +++ b/backend/internal/handler/admin/account_native_filters_test.go @@ -0,0 +1,51 @@ +package admin + +import ( + "context" + "encoding/json" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "net/http" + "net/http/httptest" + "testing" +) + +func TestNativeBulkCindyFilterPredicates(t *testing.T) { + filters, err := toServiceBulkUpdateAccountFilters(&BulkUpdateAccountFilters{CindyOnly: true, CindyHealthStatus: "banned", CindyBalanceStatus: "insufficient"}) + require.NoError(t, err) + require.NotNil(t, filters.Console) + require.True(t, filters.Console.CindyOnly) + require.Equal(t, "banned", filters.Console.CindyHealthStatus) + require.Equal(t, "insufficient", filters.Console.CindyBalanceStatus) + _, err = toServiceBulkUpdateAccountFilters(&BulkUpdateAccountFilters{CindyBalanceStatus: "invented"}) + require.Error(t, err) +} + +type nativeRecoveryAdmin struct { + *stubAdminService + recoveredID int64 +} + +func (s *nativeRecoveryAdmin) ClearCindyBalanceInsufficient(_ context.Context, id int64) (*service.Account, error) { + s.recoveredID = id + return &service.Account{ID: id, Name: "Cindy fixture", Credentials: map[string]any{"api_key": "synthetic-secret"}}, nil +} + +func TestNativeCindyRecoveryReturnsAccountDTO(t *testing.T) { + stub := &nativeRecoveryAdmin{stubAdminService: newStubAdminService()} + handler := NewAccountHandler(stub, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router := gin.New() + router.POST("/accounts/:id/recover", handler.ClearCindyBalanceInsufficient) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/accounts/7/recover", nil)) + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, int64(7), stub.recoveredID) + var envelope struct { + Data map[string]any `json:"data"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &envelope)) + require.Equal(t, float64(7), envelope.Data["id"]) + require.Contains(t, envelope.Data, "credentials") + require.NotContains(t, recorder.Body.String(), "synthetic-secret") +} diff --git a/backend/internal/handler/admin/account_reasoning_policy_test.go b/backend/internal/handler/admin/account_reasoning_policy_test.go index 98ce9c208479..8e40d00cfa75 100644 --- a/backend/internal/handler/admin/account_reasoning_policy_test.go +++ b/backend/internal/handler/admin/account_reasoning_policy_test.go @@ -185,28 +185,70 @@ func TestOpenAIReasoningPolicyBulkAdminPreflightsAllTargetsBeforeSubmittingJobs( } func TestOpenAIReasoningPolicyBulkAdminFreezesFilteredSelection(t *testing.T) { - stub := &reasoningPolicyBulkAdminStub{stubAdminService: newStubAdminService()} - stub.accounts = make([]service.Account, 101) - ids := make([]int64, len(stub.accounts)) - for index := range stub.accounts { + ids := make([]int64, 101) + for index := range ids { ids[index] = int64(index + 1) - stub.accounts[index] = service.Account{ID: ids[index], Platform: service.PlatformOpenAI, Type: service.AccountTypeSetupToken} } - stub.accounts[100] = service.Account{ID: 101, Platform: service.PlatformCindy, Type: service.AccountTypeAPIKey} - recorder, jobs := submitReasoningPolicyBulkTestRequest(t, stub, BulkUpdateAccountsRequest{ + stub := newBulkJobScopeAdmin(ids...) + for _, account := range stub.accountsByID { + account.Type = service.AccountTypeSetupToken + } + stub.accountsByID[101] = canonicalCindyJobAccount(101) + stub.matches = ids + router, handler, jobs := newBulkJobScopeRouter(stub) + handler.cindyJobMutations = &recordingCindyJobMutationRunner{} + req := BulkUpdateAccountsRequest{ Filters: &BulkUpdateAccountFilters{Search: "batch"}, Extra: map[string]any{service.OpenAIChatReasoningReplayEnabledExtraKey: true}, - }) + } + recorder := submitBulkJobScopeRequest(t, router, req, "reasoning-frozen-scope") require.Equal(t, http.StatusAccepted, recorder.Code) - var frozen BulkUpdateAccountsRequest - params := requireSubmittedAccountJob(t, jobs, service.AccountJobKindBulkUpdate, &frozen) - require.Nil(t, frozen.Filters, "workers must not re-resolve the live filter") - require.Equal(t, ids, frozen.AccountIDs) + var submitted BulkUpdateAccountsRequest + params := requireSubmittedAccountJob(t, jobs.accountJobSubmitRepository, service.AccountJobKindBulkUpdate, &submitted) + require.Equal(t, req.Filters, submitted.Filters, "the original filter is retained only as the replay source") + require.Empty(t, submitted.AccountIDs, "resolved IDs must not change the original request hash") require.Len(t, params.Items, len(ids)) for index, seed := range params.Items { require.NotNil(t, seed.TargetAccountID) require.Equal(t, ids[index], *seed.TargetAccountID) } + queriesAfterSubmission := stub.filterCalls + require.Empty(t, stub.bulkInputs, "submission remains a background job") + + // Every original account leaves the live filter, and a new invalid target + // enters it. Execution must still use only the already validated item IDs. + stub.accountsByID[102] = &service.Account{ID: 102, Platform: service.PlatformGrok} + stub.matches = []int64{102} + results := executeBulkJobScopeItems(t, handler, params) + require.Len(t, results, len(ids)) + for _, result := range results { + require.Equal(t, service.AccountJobItemStatusSucceeded, result.Status) + } + require.Equal(t, ids, stub.updatedIDs) + require.Len(t, stub.bulkInputs, len(ids)) + for index, input := range stub.bulkInputs { + require.Nil(t, input.Filters, "workers must not re-resolve the live filter") + require.Equal(t, []int64{ids[index]}, input.AccountIDs) + require.Equal(t, req.Extra, input.Extra) + } + require.Equal(t, queriesAfterSubmission, stub.filterCalls) + + for _, matches := range [][]int64{{102}, nil} { + stub.matches = matches + replayed := submitBulkJobScopeRequest(t, router, req, "reasoning-frozen-scope") + require.Equal(t, http.StatusAccepted, replayed.Code) + require.Equal(t, "true", replayed.Header().Get("Idempotency-Replayed")) + require.Len(t, jobs.created, 1) + require.Equal(t, params, jobs.created[0], "replay must preserve the original payload, expiry and frozen items") + require.Equal(t, queriesAfterSubmission, stub.filterCalls) + require.Len(t, stub.bulkInputs, len(ids), "replay cannot execute an item again") + } + changed := req + changed.Extra = map[string]any{service.OpenAIChatReasoningReplayEnabledExtraKey: false} + conflict := submitBulkJobScopeRequest(t, router, changed, "reasoning-frozen-scope") + require.Equal(t, http.StatusConflict, conflict.Code) + require.Len(t, jobs.created, 1) + require.Equal(t, queriesAfterSubmission, stub.filterCalls) } func TestOpenAIReasoningPolicyBulkAdminRejectsEmptyAndMissingTargets(t *testing.T) { diff --git a/backend/internal/handler/admin/account_taxonomy.go b/backend/internal/handler/admin/account_taxonomy.go index 7a4e660c0bf3..cf456613722f 100644 --- a/backend/internal/handler/admin/account_taxonomy.go +++ b/backend/internal/handler/admin/account_taxonomy.go @@ -6,10 +6,10 @@ import ( "strings" "github.com/Wei-Shaw/sub2api/internal/handler/dto" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/gin-gonic/gin" ) @@ -347,10 +347,6 @@ func (h *AccountHandler) SetAccountTaxonomy(c *gin.Context) { response.ErrorFrom(c, err) return } - if c.GetHeader("X-Sub2API-Plugin") != "" { - response.Success(c, gin.H{"account_id": account.ID}) - return - } response.Success(c, h.accountResponseFromService(account)) } @@ -360,35 +356,41 @@ func (h *AccountHandler) BulkUpdateAccountTaxonomy(c *gin.Context) { response.BadRequest(c, "Invalid request: "+err.Error()) return } + rawIDCount := len(req.AccountIDs) req.AccountIDs = normalizeInt64IDList(req.AccountIDs) - targetIDs := req.AccountIDs - hasFilters := req.Filters != nil - if _, bound := service.AccountViewFromContext(c.Request.Context()); bound { - if h.replayScopedAccountJob(c, service.AccountJobKindBulkTaxonomy, req) { - return - } - var err error - targetIDs, err = h.resolveAccountJobTargetIDs(c.Request.Context(), req.AccountIDs, req.Filters) - if err != nil { - accountViewRequestError(c, err) + if rawIDCount > 0 && len(req.AccountIDs) == 0 { + response.BadRequest(c, "account_ids must contain a positive account ID") + return + } + if h.replayAccountJob(c, service.AccountJobKindBulkTaxonomy, req) { + return + } + targetIDs, err := h.resolveAccountJobTargetIDs(c.Request.Context(), req.AccountIDs, req.Filters) + if err != nil { + response.ErrorFrom(c, err) + return + } + if len(req.AccountIDs) == 0 && req.Filters != nil { + if req.ExpectedMatchCount == nil || *req.ExpectedMatchCount < 0 { + response.BadRequest(c, "expected_match_count is required for filter targets") return } - if len(targetIDs) == 0 || (req.ExpectedMatchCount != nil && *req.ExpectedMatchCount != len(targetIDs)) { - accountViewRequestError(c, service.ErrAccountViewScope) + if len(targetIDs) != *req.ExpectedMatchCount { + response.ErrorFrom(c, infraerrors.Conflict("ACCOUNT_TAXONOMY_TARGET_CHANGED", "matching accounts changed; reload and confirm again")) return } - hasFilters = false // Only seeds change; keep the original request/hash. } - owner, err := service.ValidateAccountTaxonomyPlan(c.Request.Context(), extensionv1.TaxonomyBulkPlan{AccountIDs: targetIDs, HasFilters: hasFilters, ExpectedMatchCount: req.ExpectedMatchCount, FolderAction: req.FolderAction, FolderID: req.FolderID, TagAddIDs: req.TagAddIDs, TagRemoveIDs: req.TagRemoveIDs}) - if err != nil { + if len(targetIDs) == 0 { + response.BadRequest(c, "no matching accounts for taxonomy update") + return + } + + if err := service.ValidateAccountTaxonomyPlan(c.Request.Context(), extensionv1.TaxonomyBulkPlan{AccountIDs: targetIDs, HasFilters: false, ExpectedMatchCount: req.ExpectedMatchCount, FolderAction: req.FolderAction, FolderID: req.FolderID, TagAddIDs: req.TagAddIDs, TagRemoveIDs: req.TagRemoveIDs}); err != nil { response.ErrorFrom(c, err) return } seeds := accountJobSeeds(targetIDs) - if len(seeds) == 0 { - seeds = ordinalAccountJobSeeds(1) - } - h.submitAccountJob(c, service.AccountJobKindBulkTaxonomy, req, seeds, owner) + h.submitAccountJob(c, service.AccountJobKindBulkTaxonomy, req, seeds) } func splitQueryValues(c *gin.Context, keys ...string) []string { @@ -503,12 +505,6 @@ func (h *AccountHandler) GetAccountFacets(c *gin.Context) { response.ErrorFrom(c, err) return } - if _, bound := service.AccountViewFromContext(c.Request.Context()); bound { - response.Success(c, struct { - dto.AccountConsoleFacets - ViewPresetCounts map[string]int `json:"view_preset_counts"` - }{accountFacetsDTO(facets), facets.ViewPresetCounts}) - return - } + response.Success(c, accountFacetsDTO(facets)) } diff --git a/backend/internal/handler/admin/account_taxonomy_handler_test.go b/backend/internal/handler/admin/account_taxonomy_handler_test.go index 874b445a76e1..be886cc0c3c5 100644 --- a/backend/internal/handler/admin/account_taxonomy_handler_test.go +++ b/backend/internal/handler/admin/account_taxonomy_handler_test.go @@ -16,15 +16,16 @@ import ( type accountTaxonomyHandlerStub struct { *stubAdminService - facets *service.AccountConsoleFacets - lastBulk service.BulkAccountTaxonomyInput - bulkResult *service.BulkAccountTaxonomyResult - folders []service.AccountManagementFolder - tags []service.AccountManagementTag - lastFolderIDs []int64 - lastTagIDs []int64 - lastFacets service.AccountConsoleFilters - assignedAccount *service.Account + facets *service.AccountConsoleFacets + lastBulk service.BulkAccountTaxonomyInput + bulkResult *service.BulkAccountTaxonomyResult + folders []service.AccountManagementFolder + tags []service.AccountManagementTag + lastFolderIDs []int64 + lastTagIDs []int64 + lastFacets service.AccountConsoleFilters + assignedAccount *service.Account + matchingAccounts []service.Account } func newAccountTaxonomyHandlerStub() *accountTaxonomyHandlerStub { @@ -68,7 +69,7 @@ func (s *accountTaxonomyHandlerStub) SetAccountTaxonomy(context.Context, int64, return &service.Account{}, nil } -func TestTaxonomyPluginResponseDoesNotExposeAccountCredentials(t *testing.T) { +func TestTaxonomyNativeResponsePreservesRedactedAccount(t *testing.T) { stub := newAccountTaxonomyHandlerStub() stub.assignedAccount = &service.Account{ID: 7, Credentials: map[string]any{"api_key": "synthetic-secret"}} h := NewAccountHandler(stub, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) @@ -76,17 +77,16 @@ func TestTaxonomyPluginResponseDoesNotExposeAccountCredentials(t *testing.T) { router.PUT("/accounts/:id/taxonomy", h.SetAccountTaxonomy) request := httptest.NewRequest(http.MethodPut, "/accounts/7/taxonomy", bytes.NewBufferString(`{"folder_id":null,"tag_ids":[]}`)) request.Header.Set("Content-Type", "application/json") - request.Header.Set("X-Sub2API-Plugin", "3") response := httptest.NewRecorder() router.ServeHTTP(response, request) require.Equal(t, http.StatusOK, response.Code) require.NotContains(t, response.Body.String(), "synthetic-secret") - require.NotContains(t, response.Body.String(), "credentials") - require.Contains(t, response.Body.String(), `"account_id":7`) + require.Contains(t, response.Body.String(), "credentials") + require.Contains(t, response.Body.String(), `"id":7`) } func (s *accountTaxonomyHandlerStub) ListAccountsConsole(context.Context, int, int, service.AccountConsoleFilters) ([]service.Account, int64, error) { - return nil, 0, nil + return s.matchingAccounts, int64(len(s.matchingAccounts)), nil } func (s *accountTaxonomyHandlerStub) GetAccountConsoleFacets(_ context.Context, filters service.AccountConsoleFilters) (*service.AccountConsoleFacets, error) { @@ -168,6 +168,7 @@ func TestAccountFacetsUsesStableSnakeCaseTaxonomyDTO(t *testing.T) { func TestBulkAccountTaxonomyMapsFilteredTarget(t *testing.T) { adminSvc := newAccountTaxonomyHandlerStub() + adminSvc.matchingAccounts = []service.Account{{ID: 7}, {ID: 9}} adminSvc.bulkResult = &service.BulkAccountTaxonomyResult{MatchedCount: 2, UpdatedCount: 2} body, err := json.Marshal(map[string]any{ "filters": map[string]any{"platforms": "openai", "folder": "uncategorized", "tags": "3"}, @@ -186,7 +187,9 @@ func TestBulkAccountTaxonomyMapsFilteredTarget(t *testing.T) { require.Equal(t, http.StatusAccepted, recorder.Code, recorder.Body.String()) var payload bulkAccountTaxonomyRequest params := requireSubmittedAccountJob(t, jobs, service.AccountJobKindBulkTaxonomy, &payload) - require.Len(t, params.Items, 1, "filter-targeted jobs resolve concrete accounts in the worker") + require.Len(t, params.Items, 2, "filter-targeted jobs freeze concrete accounts before submission") + require.Equal(t, int64(7), *params.Items[0].TargetAccountID) + require.Equal(t, int64(9), *params.Items[1].TargetAccountID) filters, err := toServiceBulkUpdateAccountFilters(payload.Filters) require.NoError(t, err) require.NotNil(t, filters) diff --git a/backend/internal/handler/admin/account_test_plan_test.go b/backend/internal/handler/admin/account_test_plan_test.go index 9a2b21f9042d..d955b923bc09 100644 --- a/backend/internal/handler/admin/account_test_plan_test.go +++ b/backend/internal/handler/admin/account_test_plan_test.go @@ -8,11 +8,11 @@ import ( "strings" "testing" - accounttools "github.com/HTExplicit/sub2api-plugins/accounttools/policy" - cindy "github.com/HTExplicit/sub2api-plugins/cindyprovider/catalog" + accounttools "github.com/Wei-Shaw/sub2api/internal/accounttools/policy" + cindy "github.com/Wei-Shaw/sub2api/internal/cindyprovider/catalog" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/testextensions" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -70,7 +70,7 @@ func TestAccountTestPlanOrdinaryAndLegacyKeepCoreWithoutPlugins(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { fixture := &testPlanOperations{rejectAll: true} - service.ConfigureProcessExtensionServices(nil, fixture) + service.ConfigureNativePolicyOperations(fixture) t.Cleanup(testextensions.Install) account := &service.Account{ID: 42, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, Credentials: map[string]any{"base_url": tc.baseURL, "model_mapping": map[string]any{"plain-id": "plain-wire"}}} @@ -139,7 +139,7 @@ func TestAccountTestPlanOrdinaryAndLegacyKeepCoreWithoutPlugins(t *testing.T) { func TestAccountTestPlanSingleAndBatchUseSameScopedCindyView(t *testing.T) { fixture := &testPlanOperations{} - service.ConfigureProcessExtensionServices(nil, fixture) + service.ConfigureNativePolicyOperations(fixture) t.Cleanup(testextensions.Install) account := &service.Account{ID: 88, Platform: service.PlatformCindy, Type: service.AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://api.laxarouter.ai"}} diff --git a/backend/internal/handler/admin/plugin_error_diagnostics.go b/backend/internal/handler/admin/codex_error_diagnostics.go similarity index 59% rename from backend/internal/handler/admin/plugin_error_diagnostics.go rename to backend/internal/handler/admin/codex_error_diagnostics.go index aede21bdee0c..7094bae67ff5 100644 --- a/backend/internal/handler/admin/plugin_error_diagnostics.go +++ b/backend/internal/handler/admin/codex_error_diagnostics.go @@ -5,14 +5,16 @@ import ( "strconv" "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" ) // ErrorDiagnostics is installed behind the existing administrator middleware -// and a named resource grant. It never exposes the underlying log document. -func (h *PluginHandler) ErrorDiagnostics(ops *OpsHandler) gin.HandlerFunc { +// and never exposes the underlying log document. +func (h *AccountHandler) CodexErrorDiagnostics(ops *OpsHandler) gin.HandlerFunc { return func(c *gin.Context) { - if h == nil || h.manager == nil || ops == nil || ops.opsService == nil { + c.Header("Cache-Control", "private, no-store") + if h == nil || h.adminService == nil || ops == nil || ops.opsService == nil { response.Error(c, http.StatusServiceUnavailable, "Diagnostics unavailable") return } @@ -26,6 +28,6 @@ func (h *PluginHandler) ErrorDiagnostics(ops *OpsHandler) gin.HandlerFunc { response.ErrorFrom(c, err) return } - response.Success(c, h.manager.ProjectErrorDiagnostics(c.Request.Context(), detail)) + response.Success(c, service.ProjectCodexErrorDiagnostics(c.Request.Context(), detail, h.adminService.GetAccount)) } } diff --git a/backend/internal/handler/admin/group_plugin_options.go b/backend/internal/handler/admin/group_plugin_options.go deleted file mode 100644 index ab1d4b1785d2..000000000000 --- a/backend/internal/handler/admin/group_plugin_options.go +++ /dev/null @@ -1,32 +0,0 @@ -package admin - -import ( - "github.com/Wei-Shaw/sub2api/internal/pkg/response" - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/gin-gonic/gin" -) - -// PluginOptions supplies only the labels and identifiers needed by a group -// picker. It preserves the host's simple-mode restrictions and omits settings. -func (h *GroupHandler) PluginOptions(c *gin.Context) { - if h == nil || h.adminService == nil { - response.Error(c, 503, "Group options unavailable") - return - } - if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupGetAll) { - return - } - groups, err := h.adminService.GetAllGroupsIncludingInactive(c.Request.Context()) - if err != nil { - response.ErrorFrom(c, err) - return - } - options := make([]gin.H, 0, len(groups)) - for _, group := range groups { - if h.isSimpleMode() && !service.IsGroupBindableInSimpleMode(&group) { - continue - } - options = append(options, gin.H{"id": group.ID, "name": group.Name, "platform": group.Platform, "status": group.Status}) - } - response.Success(c, options) -} diff --git a/backend/internal/handler/admin/plugin_contract_fixture_test.go b/backend/internal/handler/admin/native_policy_fixture_test.go similarity index 100% rename from backend/internal/handler/admin/plugin_contract_fixture_test.go rename to backend/internal/handler/admin/native_policy_fixture_test.go diff --git a/backend/internal/handler/admin/plugin_account_views.go b/backend/internal/handler/admin/plugin_account_views.go deleted file mode 100644 index ae0160c16054..000000000000 --- a/backend/internal/handler/admin/plugin_account_views.go +++ /dev/null @@ -1,339 +0,0 @@ -package admin - -import ( - "bytes" - "encoding/base64" - "encoding/json" - "io" - "net/http" - "slices" - "strconv" - "strings" - - "github.com/Wei-Shaw/sub2api/internal/pkg/response" - "github.com/Wei-Shaw/sub2api/internal/server/middleware" - "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/gin-gonic/gin" -) - -const accountViewEnvelopeLimit = 4 * 1024 * 1024 - -func decodeAccountViewEnvelope(raw []byte, target any) error { - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(target); err != nil { - return service.ErrAccountViewInvalid - } - if err := decoder.Decode(new(any)); err != io.EOF { - return service.ErrAccountViewInvalid - } - return nil -} - -// AccountViewRequest is request-local and is a no-op for native core requests. -// It never changes X-Sub2API-Plugin: that is the independent action owner. -func (h *PluginHandler) AccountViewRequest() gin.HandlerFunc { - return func(c *gin.Context) { - encoded := c.GetHeader(extensionv1.AccountViewHeader) - binding := c.GetHeader("X-Sub2API-Plugin-UI-Session") - var header *extensionv1.AccountViewIdentityV1 - if encoded != "" { - if len(encoded) > 2048 { - accountViewRequestError(c, service.ErrAccountViewInvalid) - return - } - raw, err := base64.RawURLEncoding.DecodeString(encoded) - var identity extensionv1.AccountViewIdentityV1 - if err != nil || decodeAccountViewEnvelope(raw, &identity) != nil { - accountViewRequestError(c, service.ErrAccountViewInvalid) - return - } - header = &identity - } - var request *extensionv1.AccountViewContextV1 - var envelope struct { - ViewContext json.RawMessage `json:"view_context"` - AccountID int64 `json:"account_id"` - AccountIDs []int64 `json:"account_ids"` - SurvivorAccountID int64 `json:"survivor_account_id"` - LoserAccountIDs []int64 `json:"loser_account_ids"` - ReviewJobID int64 `json:"review_job_id"` - Items []struct { - AccountID int64 `json:"account_id"` - } `json:"items"` - } - // Large native import/create bodies retain the original path and limits. - // View-sourced existing-target requests are explicitly bounded JSON only. - if c.Request.Body != nil && c.Request.ContentLength != 0 && strings.HasPrefix(c.GetHeader("Content-Type"), "application/json") && (header != nil || binding != "" || (c.Request.ContentLength > 0 && c.Request.ContentLength <= accountViewEnvelopeLimit)) { - raw, err := io.ReadAll(io.LimitReader(c.Request.Body, accountViewEnvelopeLimit+1)) - c.Request.Body = io.NopCloser(bytes.NewReader(raw)) - if err != nil || len(raw) > accountViewEnvelopeLimit { - accountViewRequestError(c, service.ErrAccountViewInvalid) - return - } - // Native bodies may be arrays or have unrelated heterogeneous items. - // Only reserved origin evidence opts into this typed envelope; leave - // malformed/native body validation and response contracts to its handler. - if header == nil && binding == "" { - var reserved map[string]json.RawMessage - if json.Unmarshal(raw, &reserved) != nil || reserved == nil { - c.Next() - return - } - viewRaw := bytes.TrimSpace(reserved["view_context"]) - inheritsReview := c.Request.Method == http.MethodPost && c.FullPath() == "/api/v1/admin/accounts/duplicates/merge" && len(reserved["review_job_id"]) > 0 && string(reserved["review_job_id"]) != "0" && string(reserved["review_job_id"]) != "null" - if (len(viewRaw) == 0 || string(viewRaw) == "null") && !inheritsReview { - c.Next() - return - } - } - if json.Unmarshal(raw, &envelope) != nil { - accountViewRequestError(c, service.ErrAccountViewInvalid) - return - } - if len(envelope.ViewContext) > 0 && string(envelope.ViewContext) != "null" { - var captured extensionv1.AccountViewContextV1 - if decodeAccountViewEnvelope(envelope.ViewContext, &captured) != nil { - accountViewRequestError(c, service.ErrAccountViewInvalid) - return - } - request = &captured - } else if header != nil && string(envelope.ViewContext) == "null" { - accountViewRequestError(c, service.ErrAccountViewInvalid) - return - } - } - if header != nil && strings.HasPrefix(c.GetHeader("Content-Type"), "multipart/") { - accountViewRequestError(c, service.ErrAccountViewInvalid) - return - } - if header != nil && c.Request.Body != nil && c.Request.ContentLength != 0 && !strings.HasPrefix(c.GetHeader("Content-Type"), "application/json") { - accountViewRequestError(c, service.ErrAccountViewInvalid) - return - } - if header != nil && request != nil && *header != request.AccountViewIdentityV1 { - accountViewRequestError(c, service.ErrAccountViewInvalid) - return - } - if envelope.ReviewJobID > 0 && c.Request.Method == http.MethodPost && c.FullPath() == "/api/v1/admin/accounts/duplicates/merge" { - actor, ok := middleware.GetAuthSubjectFromContext(c) - if !ok || h == nil || h.jobs == nil { - accountViewRequestError(c, service.ErrAccountViewUnavailable) - return - } - captured, reviewIDs, err := h.jobs.AccountViewFromReviewJob(c.Request.Context(), envelope.ReviewJobID, actor.UserID) - if err != nil { - accountViewRequestError(c, err) - return - } - if (captured == nil && (request != nil || header != nil)) || (captured != nil && header != nil && *header != captured.AccountViewIdentityV1) { - accountViewRequestError(c, service.ErrAccountViewInvalid) - return - } - if request != nil && captured != nil { - query, err := service.NormalizeAccountViewQuery(request.Query) - if err != nil { - accountViewRequestError(c, err) - return - } - request.Query = query - left, _ := json.Marshal(request) - right, _ := json.Marshal(captured) - if !bytes.Equal(left, right) { - accountViewRequestError(c, service.ErrAccountViewInvalid) - return - } - } - selected := append([]int64{envelope.SurvivorAccountID}, envelope.LoserAccountIDs...) - slices.Sort(selected) - slices.Sort(reviewIDs) - if !slices.Equal(selected, reviewIDs) { - accountViewRequestError(c, service.ErrAccountViewScope) - return - } - request = captured - } - if request == nil && header != nil { - request = &extensionv1.AccountViewContextV1{AccountViewIdentityV1: *header} - } - if binding != "" { - primary, _ := strconv.ParseInt(c.GetHeader("X-Sub2API-Plugin"), 10, 64) - if strings.HasPrefix(c.FullPath(), "/api/v1/admin/plugins/:id") { - pathID, _ := strconv.ParseInt(c.Param("id"), 10, 64) - if primary != 0 && primary != pathID { - accountViewRequestError(c, service.ErrAccountViewInvalid) - return - } - primary = pathID - } - actor, ok := middleware.GetAuthSubjectFromContext(c) - if !ok || h == nil || h.manager == nil || h.manager.ValidateUIRequestBinding(c.Request.Context(), binding, primary, actor.UserID, header) != nil { - accountViewRequestError(c, service.ErrAccountViewUnavailable) - return - } - } - if request == nil { - c.Next() - return - } - // Generic plugin RPC can call back into Host.Call without an origin-view - // proof. View UI uses named resources/jobs, whose host handlers retain it. - if c.Request.Method == http.MethodPost && (c.FullPath() == "/api/v1/admin/plugins/:id/actions" || c.FullPath() == "/api/v1/admin/plugins/:id/jobs") { - accountViewRequestError(c, service.ErrAccountViewUnsupportedOperation) - return - } - if h == nil || h.manager == nil { - accountViewRequestError(c, service.ErrAccountViewUnavailable) - return - } - retainedRequest := c.Request.Method == http.MethodGet && c.FullPath() == "/api/v1/admin/plugins/:id/resources" - if (c.Request.Method == http.MethodGet && (c.FullPath() == "/api/v1/admin/account-jobs/:id" || c.FullPath() == "/api/v1/admin/account-jobs/:id/items" || c.FullPath() == "/api/v1/admin/account-jobs/:id/result-account-ids")) || (c.Request.Method == http.MethodPost && c.FullPath() == "/api/v1/admin/account-jobs/:id/cancel") { - jobID, err := strconv.ParseInt(c.Param("id"), 10, 64) - if err != nil || jobID <= 0 || h.jobs == nil { - accountViewRequestError(c, service.ErrAccountViewInvalid) - return - } - job, err := h.jobs.Get(c.Request.Context(), jobID) - if err != nil { - accountViewRequestError(c, err) - return - } - if binding != "" || c.GetHeader("X-Sub2API-Plugin") != "" { - primaryID, parseErr := strconv.ParseInt(c.GetHeader("X-Sub2API-Plugin"), 10, 64) - owner, ownerErr := service.AccountJobPluginExecution(job.Metadata) - if parseErr != nil || primaryID <= 0 || ownerErr != nil || owner.ID != primaryID { - accountViewRequestError(c, service.ErrAccountViewScope) - return - } - } - stored, err := service.AccountJobViewExecution(job.Metadata) - if err != nil || (stored != nil && stored.AccountViewIdentityV1 != request.AccountViewIdentityV1) { - accountViewRequestError(c, service.ErrAccountViewInvalid) - return - } - retainedRequest = true - } - for _, descriptor := range h.resources { - if descriptor.Retained && descriptor.Method == c.Request.Method && descriptor.Path == c.FullPath() { - retainedRequest = true - } - } - if retainedRequest { - ctx, err := h.manager.BindRetainedAccountView(c.Request.Context(), *request) - if err != nil { - accountViewRequestError(c, err) - return - } - c.Request = c.Request.WithContext(ctx) - c.Next() - return - } - listRequest := c.Request.Method == http.MethodGet && (c.FullPath() == "/api/v1/admin/accounts" || c.FullPath() == "/api/v1/admin/accounts/facets" || c.FullPath() == "/api/v1/admin/accounts/data") - if listRequest { - query, err := accountViewHTTPQuery(c) - if err != nil { - accountViewRequestError(c, err) - return - } - request.Query = query - } - ctx, release, err := h.manager.BindAccountViewRequest(c.Request.Context(), *request) - if err != nil { - accountViewRequestError(c, err) - return - } - defer release() - c.Request = c.Request.WithContext(ctx) - reserved := map[string]string{} - for _, key := range []string{"cindy_only", "cindy_balance_status", "cindy_health_status"} { - if value, present := c.GetQuery(key); present { - reserved[key] = value - } - } - if err := service.ValidateAccountViewPredicateQuery(ctx, reserved); err != nil { - accountViewRequestError(c, err) - return - } - var ids []int64 - accountIDPath := false - for _, prefix := range []string{"/api/v1/admin/accounts/:id", "/api/v1/admin/openai/accounts/:id", "/api/v1/admin/grok/accounts/:id", "/api/v1/admin/cn-providers/accounts/:id"} { - accountIDPath = accountIDPath || strings.HasPrefix(c.FullPath(), prefix) - } - if accountIDPath { - id, err := strconv.ParseInt(c.Param("id"), 10, 64) - if err != nil || id <= 0 { - accountViewRequestError(c, service.ErrAccountViewScope) - return - } - ids = append(ids, id) - } - if envelope.AccountID != 0 { - ids = append(ids, envelope.AccountID) - } - if envelope.SurvivorAccountID != 0 { - ids = append(ids, envelope.SurvivorAccountID) - } - ids = append(ids, envelope.LoserAccountIDs...) - ids = append(ids, envelope.AccountIDs...) - for _, item := range envelope.Items { - ids = append(ids, item.AccountID) - } - if listRequest { - ids = append(ids, request.Query.AccountIDs...) - } - if len(ids) > 3200 { - accountViewRequestError(c, service.ErrAccountViewInvalid) - return - } - if len(ids) > 0 { - if err := service.ValidateAccountViewTargets(ctx, ids); err != nil { - accountViewRequestError(c, err) - return - } - } - c.Next() - } -} - -func accountViewRequestError(c *gin.Context, err error) { response.ErrorFrom(c, err); c.Abort() } - -func accountViewHTTPQuery(c *gin.Context) (extensionv1.AccountViewQueryV1, error) { - allowed := map[string]bool{} - for _, key := range []string{"platform", "platforms", "type", "types", "status", "statuses", "plans", "proxies", "folder", "folders", "tags", "account_ids", "ids", "group_id", "privacy_mode", "search", "sort_by", "sort_order", "page", "page_size", "lite", "include_scheduler_score", "cindy_only", "cindy_balance_status", "cindy_health_status"} { - allowed[key] = true - } - for key := range c.Request.URL.Query() { - if !allowed[key] { - return extensionv1.AccountViewQueryV1{}, service.ErrAccountViewInvalid - } - } - var groupID int64 - if group := c.Query("group_id"); group == "ungrouped" { - groupID = service.AccountListGroupUngrouped - } else if group != "" { - id, err := strconv.ParseInt(group, 10, 64) - if err != nil || id < 0 { - return extensionv1.AccountViewQueryV1{}, service.ErrAccountViewInvalid - } - groupID = id - } - filters, err := parseAccountConsoleFilters(c, groupID) - if err != nil { - return extensionv1.AccountViewQueryV1{}, err - } - query := extensionv1.AccountViewQueryV1{Platforms: filters.Platforms, Types: filters.Types, Statuses: filters.Statuses, Plans: filters.Plans, Tags: filters.TagIDs, AccountIDs: filters.AccountIDs, GroupID: groupID, PrivacyMode: filters.PrivacyMode, Search: filters.Search, SortBy: filters.SortBy, SortOrder: filters.SortOrder} - for _, id := range filters.ProxyIDs { - query.Proxies = append(query.Proxies, strconv.FormatInt(id, 10)) - } - if filters.IncludeDirect { - query.Proxies = append(query.Proxies, "direct") - } - for _, id := range filters.FolderIDs { - query.Folders = append(query.Folders, strconv.FormatInt(id, 10)) - } - if filters.IncludeUncategorized { - query.Folders = append(query.Folders, "uncategorized") - } - return service.NormalizeAccountViewQuery(query) -} diff --git a/backend/internal/handler/admin/plugin_account_views_test.go b/backend/internal/handler/admin/plugin_account_views_test.go deleted file mode 100644 index 762ba000db9c..000000000000 --- a/backend/internal/handler/admin/plugin_account_views_test.go +++ /dev/null @@ -1,139 +0,0 @@ -package admin - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" -) - -func TestAccountViewCoreJSONNoop(t *testing.T) { - gin.SetMode(gin.TestMode) - for _, body := range []string{`[1,"two",{"native":true}]`, `{"items":[1,"two",null],"account_ids":"native-not-accounts"}`, `{"unfinished":`, `null`} { - router := gin.New() - var plugin *PluginHandler // No registry or plugin runtime exists. - router.Use(plugin.AccountViewRequest()) - router.POST("/api/v1/admin/native-fixture", func(c *gin.Context) { - raw, err := io.ReadAll(c.Request.Body) - require.NoError(t, err) - c.Data(http.StatusAccepted, "application/octet-stream", raw) - }) - request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/native-fixture", bytes.NewBufferString(body)) - request.Header.Set("Content-Type", "application/json") - response := httptest.NewRecorder() - router.ServeHTTP(response, request) - require.Equal(t, http.StatusAccepted, response.Code) - require.Equal(t, body, response.Body.String(), "plain core body bytes and handler response must be untouched") - } -} - -func TestAccountViewInvalidOrUnboundIdentityCannotFallBackToCore(t *testing.T) { - gin.SetMode(gin.TestMode) - router := gin.New() - called := 0 - plugin := &PluginHandler{} - router.Use(plugin.AccountViewRequest()) - router.GET("/api/v1/admin/accounts", func(c *gin.Context) { called++; c.Status(200) }) - for _, header := range []string{"not/base64?", base64.RawURLEncoding.EncodeToString([]byte(`{"version":1,"url":"/admin/accounts"}`)), base64.RawURLEncoding.EncodeToString([]byte(`{"version":1,"plugin_id":1,"plugin_key":"codexrip.cindy-provider","package_sha256":"invalid","view_id":"cindy-accounts","preset_id":"cindy","view_definition_digest":"invalid"}`))} { - request := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts", nil) - request.Header.Set(extensionv1.AccountViewHeader, header) - response := httptest.NewRecorder() - router.ServeHTTP(response, request) - require.GreaterOrEqual(t, response.Code, 400) - } - require.Zero(t, called) -} - -func TestAccountViewRejectsGenericRPCChannelOnlyForViewOrigin(t *testing.T) { - gin.SetMode(gin.TestMode) - for _, route := range []string{"/api/v1/admin/plugins/:id/actions", "/api/v1/admin/plugins/:id/jobs"} { - router := gin.New() - var plugin *PluginHandler - router.Use(plugin.AccountViewRequest()) - called := 0 - router.POST(route, func(c *gin.Context) { called++; c.Status(http.StatusAccepted) }) - path := strings.ReplaceAll(route, ":id", "2") - for _, withView := range []bool{true, false} { - request := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(`{"operation":"fixture","items":[]}`)) - request.Header.Set("Content-Type", "application/json") - if withView { - request.Header.Set(extensionv1.AccountViewHeader, base64.RawURLEncoding.EncodeToString([]byte(`{"version":1}`))) - } - response := httptest.NewRecorder() - router.ServeHTTP(response, request) - if withView { - require.Equal(t, http.StatusBadRequest, response.Code) - require.Contains(t, response.Body.String(), "ACCOUNT_VIEW_UNSUPPORTED_OPERATION") - } else { - require.Equal(t, http.StatusAccepted, response.Code) - } - } - require.Equal(t, 1, called, "only the original no-view channel may reach generic RPC") - } -} - -func TestAccountViewTypedQueryAndBulkCindyPredicates(t *testing.T) { - var context extensionv1.AccountViewContextV1 - require.Error(t, decodeAccountViewEnvelope([]byte(`{"query":{"extra":{"api_key":"synthetic"}}}`), &context)) - require.Error(t, decodeAccountViewEnvelope([]byte(`{"query":null}`), &context)) - filters, err := toServiceBulkUpdateAccountFilters(&BulkUpdateAccountFilters{CindyOnly: true, CindyHealthStatus: "banned", CindyBalanceStatus: "insufficient"}) - require.NoError(t, err) - require.NotNil(t, filters.Console) - require.True(t, filters.Console.CindyOnly) - require.Equal(t, "banned", filters.Console.CindyHealthStatus) - require.Equal(t, "insufficient", filters.Console.CindyBalanceStatus) - _, err = toServiceBulkUpdateAccountFilters(&BulkUpdateAccountFilters{CindyBalanceStatus: "invented"}) - require.Error(t, err) -} - -type viewRecoveryAdmin struct { - *stubAdminService - account *service.Account -} - -func (s *viewRecoveryAdmin) ClearCindyBalanceInsufficient(context.Context, int64) (*service.Account, error) { - return s.account, nil -} - -func TestCindyRecoveryResourceProjection(t *testing.T) { - stub := &viewRecoveryAdmin{stubAdminService: newStubAdminService(), account: &service.Account{ID: 7, Credentials: map[string]any{"api_key": "synthetic-key"}, Extra: map[string]any{"private_secret": "synthetic-extra"}}} - handler := NewAccountHandler(stub, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - for _, pluginRequest := range []bool{true, false} { - router := gin.New() - router.POST("/accounts/:id/recover", func(c *gin.Context) { - if pluginRequest { - manager := service.NewPluginManager(nil, nil, nil, service.PluginHostInfo{}, nil) - c.Request = c.Request.WithContext(manager.WithResourcePolicy(c.Request.Context(), extensionv1.ResourceDescriptor{})) - } - handler.ClearCindyBalanceInsufficient(c) - }) - request := httptest.NewRequest(http.MethodPost, "/accounts/7/recover", nil) - if pluginRequest { - request.Header.Set("X-Sub2API-Plugin", "3") - } - response := httptest.NewRecorder() - router.ServeHTTP(response, request) - require.Equal(t, http.StatusOK, response.Code) - if pluginRequest { - var envelope struct { - Data map[string]any `json:"data"` - } - require.NoError(t, json.Unmarshal(response.Body.Bytes(), &envelope)) - require.Equal(t, map[string]any{"account_id": float64(7), "recovered": true}, envelope.Data) - require.NotContains(t, response.Body.String(), "credentials") - require.NotContains(t, response.Body.String(), "synthetic-extra") - } else { - require.Contains(t, response.Body.String(), `"credentials"`, "native response shape stays compatible") - } - } -} diff --git a/backend/internal/handler/admin/plugin_handler.go b/backend/internal/handler/admin/plugin_handler.go index 47a029061682..b622b4fc6d48 100644 --- a/backend/internal/handler/admin/plugin_handler.go +++ b/backend/internal/handler/admin/plugin_handler.go @@ -2,7 +2,6 @@ package admin import ( "crypto/rand" - "crypto/sha256" "encoding/hex" "encoding/json" "errors" @@ -19,7 +18,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/gin-gonic/gin" ) @@ -27,81 +25,7 @@ const pluginUISessionTTL = 30 * time.Minute // PluginHandler 提供插件安装、生命周期、配置和隔离 UI 资源接口。 type PluginHandler struct { - resources map[string]extensionv1.ResourceDescriptor - manager *service.PluginManager - jobs *service.AccountJobService -} - -func (h *PluginHandler) SetAccountJobs(jobs *service.AccountJobService) { h.jobs = jobs } - -func (h *PluginHandler) SubmitJob(c *gin.Context) { - id, ok := pluginIDParam(c) - if !ok { - return - } - if !bindPluginHTTPPrecondition(c, false, true) { - return - } - actor, ok := accountJobActorID(c) - if !ok { - return - } - var req struct { - Operation string `json:"operation"` - Items []extensionv1.JobTarget `json:"items"` - } - if c.ShouldBindJSON(&req) != nil || len(req.Items) == 0 || len(req.Items) > 3200 || req.Operation == "" || h.jobs == nil { - response.BadRequest(c, "invalid plugin job") - return - } - accounts := make(map[int64]bool) - payloads := make(map[string]json.RawMessage) - var seeds []service.AccountJobItemSeed - for _, target := range req.Items { - accountID, payload := target.AccountID, target.Payload - var fields map[string]json.RawMessage - if accountID <= 0 || json.Unmarshal(payload, &fields) != nil || fields == nil || len([]rune(target.Label)) > 256 { - response.BadRequest(c, "invalid plugin job target") - return - } - if !accounts[accountID] { - if err := h.manager.ValidateAdminExtension(c.Request.Context(), id, accountID, req.Operation); err != nil { - if pluginPreconditionError(c, err) { - return - } - response.Error(c, http.StatusConflict, "Plugin operation is unavailable") - return - } - accounts[accountID] = true - if len(accounts) > 100 { - response.BadRequest(c, "plugin jobs support at most 100 accounts") - return - } - } - payload, _ = json.Marshal(fields) - fingerprint := sha256.Sum256(append([]byte(strconv.FormatInt(accountID, 10)+"\x00"), payload...)) - action := hex.EncodeToString(fingerprint[:]) - if _, exists := payloads[action]; exists { - continue - } - payloads[action] = payload - itemMeta, _ := json.Marshal(map[string]any{"label": target.Label}) - seeds = append(seeds, service.AccountJobItemSeed{Ordinal: len(seeds) + 1, Action: action, TargetAccountID: &accountID, Metadata: itemMeta}) - } - raw, _ := json.Marshal(service.PluginJobPayload{PluginID: id, Operation: req.Operation, Items: payloads}) - metadata, _ := json.Marshal(map[string]any{"plugin_id": id, "operation": req.Operation, "target_count": len(seeds), "account_count": len(accounts)}) - job, replayed, err := h.jobs.Submit(c.Request.Context(), actor, service.AccountJobKindExtensionOperation, c.GetHeader("Idempotency-Key"), raw, metadata, seeds) - if err != nil { - if pluginPreconditionError(c, err) { - return - } - response.ErrorFrom(c, accountJobHTTPError(err)) - return - } - if replayed { - c.Header("Idempotency-Replayed", "true") - } - response.Accepted(c, job) + manager *service.PluginManager } func NewPluginHandler(manager *service.PluginManager) *PluginHandler { @@ -117,40 +41,6 @@ func (h *PluginHandler) List(c *gin.Context) { response.Success(c, plugins) } -func (h *PluginHandler) Contributions(c *gin.Context) { - response.Success(c, h.manager.Contributions()) -} - -// InvokeAdmin accepts only the admin capability. Credential-bearing request -// hooks and host storage operations are never callable through a browser route. -func (h *PluginHandler) InvokeAdmin(c *gin.Context) { - id, ok := pluginIDParam(c) - if !ok { - return - } - if !bindPluginHTTPPrecondition(c, false, true) { - return - } - var req struct { - Operation string `json:"operation"` - AccountID int64 `json:"account_id"` - Payload json.RawMessage `json:"payload"` - } - if c.ShouldBindJSON(&req) != nil || req.Operation == "" || len(req.Payload) > extensionv1.MaxPayloadBytes { - response.BadRequest(c, "invalid plugin admin operation") - return - } - result, err := h.manager.InvokeAdminExtension(c.Request.Context(), id, req.AccountID, req.Operation, req.Payload) - if err != nil { - if pluginPreconditionError(c, err) { - return - } - response.Error(c, http.StatusConflict, "Plugin operation is unavailable") - return - } - response.Success(c, result) -} - func (h *PluginHandler) Get(c *gin.Context) { id, ok := pluginIDParam(c) if !ok { @@ -184,10 +74,6 @@ func (h *PluginHandler) Upload(c *gin.Context) { } plugin, err := h.manager.Install(c.Request.Context(), file, installedBy) if err != nil { - if errors.Is(err, service.ErrPluginAlreadyInstalled) { - response.ErrorFrom(c, err) - return - } response.BadRequest(c, err.Error()) return } @@ -204,9 +90,6 @@ func (h *PluginHandler) Enable(c *gin.Context) { if !ok { return } - if !bindPluginHTTPPrecondition(c, true, true) { - return - } request := pluginEnableRequest{RolloutPercent: 100} if err := c.ShouldBindJSON(&request); err != nil { response.BadRequest(c, "启用参数无效") @@ -214,9 +97,6 @@ func (h *PluginHandler) Enable(c *gin.Context) { } plugin, err := h.manager.Enable(c.Request.Context(), id, request.AcceptUntested, request.RolloutPercent) if err != nil { - if pluginPreconditionError(c, err) { - return - } response.BadRequest(c, err.Error()) return } @@ -228,14 +108,8 @@ func (h *PluginHandler) Disable(c *gin.Context) { if !ok { return } - if !bindPluginHTTPPrecondition(c, true, true) { - return - } plugin, err := h.manager.Disable(c.Request.Context(), id) if err != nil { - if pluginPreconditionError(c, err) { - return - } response.ErrorFrom(c, err) return } @@ -247,13 +121,7 @@ func (h *PluginHandler) Delete(c *gin.Context) { if !ok { return } - if !bindPluginHTTPPrecondition(c, true, true) { - return - } if err := h.manager.Delete(c.Request.Context(), id); err != nil { - if pluginPreconditionError(c, err) { - return - } response.ErrorFrom(c, err) return } @@ -266,18 +134,12 @@ func (h *PluginHandler) GetConfig(c *gin.Context) { if !ok { return } - if !bindPluginHTTPPrecondition(c, false, false) { - return - } - snapshot, err := h.manager.GetConfigSnapshot(c.Request.Context(), id) + configJSON, err := h.manager.GetConfig(c.Request.Context(), id) if err != nil { - if pluginPreconditionError(c, err) { - return - } response.ErrorFrom(c, err) return } - writePluginConfigSnapshot(c, snapshot) + c.Data(http.StatusOK, "application/json; charset=utf-8", configJSON) } func (h *PluginHandler) SaveConfig(c *gin.Context) { @@ -286,9 +148,6 @@ func (h *PluginHandler) SaveConfig(c *gin.Context) { if !ok { return } - if !bindPluginHTTPPrecondition(c, true, true) { - return - } decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 4*1024*1024)) decoder.UseNumber() var value any @@ -305,19 +164,12 @@ func (h *PluginHandler) SaveConfig(c *gin.Context) { response.BadRequest(c, "插件配置无法序列化") return } - saved, err := h.manager.SaveConfigSnapshot(c.Request.Context(), id, raw) + saved, err := h.manager.SaveConfig(c.Request.Context(), id, raw) if err != nil { - if pluginPreconditionError(c, err) { - return - } - if errors.Is(err, service.ErrPluginConfigReceiptUnavailable) { - response.ErrorFrom(c, err) - return - } response.BadRequest(c, err.Error()) return } - writePluginConfigSnapshot(c, saved) + c.Data(http.StatusOK, "application/json; charset=utf-8", saved) } func (h *PluginHandler) Test(c *gin.Context) { @@ -325,14 +177,8 @@ func (h *PluginHandler) Test(c *gin.Context) { if !ok { return } - if !bindPluginHTTPPrecondition(c, true, true) { - return - } result, err := h.manager.Test(c.Request.Context(), id) if err != nil { - if pluginPreconditionError(c, err) { - return - } response.BadRequest(c, err.Error()) return } @@ -355,32 +201,12 @@ func (h *PluginHandler) Status(c *gin.Context) { response.Success(c, result) } -func (h *PluginHandler) CreateUISession(c *gin.Context) { h.createUISession(c, "admin") } -func (h *PluginHandler) CreateUserUISession(c *gin.Context) { h.createUISession(c, "user") } -func (h *PluginHandler) createUISession(c *gin.Context, permission string) { +func (h *PluginHandler) CreateUISession(c *gin.Context) { id, ok := pluginIDParam(c) if !ok { return } - var input struct { - Contribution string `json:"contribution_id"` - ViewContext *extensionv1.AccountViewContextV1 `json:"view_context,omitempty"` - } - if c.Request.ContentLength > 0 && c.ShouldBindJSON(&input) != nil { - response.BadRequest(c, "Invalid plugin UI context") - return - } - ctx := c.Request.Context() - if actor, ok := middleware.GetAuthSubjectFromContext(c); ok { - ctx = service.WithPluginUIActor(ctx, actor.UserID) - } - if input.ViewContext != nil { - if view, bound := service.AccountViewFromContext(ctx); !bound || view.Request.AccountViewIdentityV1 != input.ViewContext.AccountViewIdentityV1 { - accountViewRequestError(c, service.ErrAccountViewInvalid) - return - } - } - session, err := h.manager.CreateUIAssetSession(ctx, id, input.Contribution, permission, pluginUISessionTTL) + assetToken, expires, err := h.manager.CreateUIAssetToken(c.Request.Context(), id, pluginUISessionTTL) if err != nil { response.ErrorFrom(c, err) return @@ -391,28 +217,24 @@ func (h *PluginHandler) createUISession(c *gin.Context, permission string) { return } response.Success(c, gin.H{ - "url": fmt.Sprintf("/api/v1/plugin-ui/%s/index.html#bridge_token=%s", session.Token, bridgeToken), + "url": fmt.Sprintf("/api/v1/plugin-ui/%s/index.html#bridge_token=%s", assetToken, bridgeToken), "bridge_token": bridgeToken, "ui_bridge_version": 1, - "expires_at": session.Expires, - "package_sha256": session.PackageSHA256, - "plugin_key": session.PluginKey, - "permission": session.Permission, - "view_context": session.ViewContext, - "request_binding": session.Token, + "expires_at": expires, }) } // ServeUIAsset 使用短时随机能力 URL 提供插件静态资源,不向 iframe 暴露管理员凭据。 func (h *PluginHandler) ServeUIAsset(c *gin.Context) { token := strings.TrimSpace(c.Param("token")) + pluginID, err := h.manager.ResolveUIAssetToken(token) + if err != nil { + c.Status(http.StatusGone) + return + } relative := strings.TrimPrefix(c.Param("path"), "/") - data, logicalPath, err := h.manager.ReadUIAssetForToken(c.Request.Context(), token, relative) + data, logicalPath, err := h.manager.ReadUIAsset(c.Request.Context(), pluginID, relative) if err != nil { - if errors.Is(err, service.ErrPluginUISessionChanged) { - c.Status(http.StatusGone) - return - } if errors.Is(err, os.ErrNotExist) { c.Status(http.StatusNotFound) return @@ -431,8 +253,6 @@ 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") - // Sandboxed module scripts and fonts have an opaque Origin. The URL is a - // short-lived capability for immutable UI assets, not an ambient session. 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.Data(http.StatusOK, contentType, data) diff --git a/backend/internal/handler/admin/plugin_precondition.go b/backend/internal/handler/admin/plugin_precondition.go deleted file mode 100644 index 71e3b23c47c1..000000000000 --- a/backend/internal/handler/admin/plugin_precondition.go +++ /dev/null @@ -1,61 +0,0 @@ -package admin - -import ( - "errors" - "net/http" - "regexp" - "strconv" - "strings" - - "github.com/Wei-Shaw/sub2api/internal/pkg/response" - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/gin-gonic/gin" -) - -const ( - pluginRevisionHeader = "X-Sub2API-Plugin-Revision" - pluginPackageHeader = "X-Sub2API-Plugin-Package" -) - -var pluginPackageDigestPattern = regexp.MustCompile(`^[a-f0-9]{64}$`) - -func bindPluginHTTPPrecondition(c *gin.Context, revisionRequired, packageRequired bool) bool { - revision, digest := strings.TrimSpace(c.GetHeader(pluginRevisionHeader)), strings.TrimSpace(c.GetHeader(pluginPackageHeader)) - if (revisionRequired && revision == "") || (packageRequired && digest == "") { - response.ErrorWithDetails(c, http.StatusPreconditionRequired, "Reload the plugin view before submitting this operation", "PLUGIN_PRECONDITION_REQUIRED", nil) - return false - } - ctx := c.Request.Context() - if digest != "" { - if len(c.Request.Header.Values(pluginPackageHeader)) != 1 || !pluginPackageDigestPattern.MatchString(digest) { - response.ErrorWithDetails(c, http.StatusBadRequest, "Invalid plugin package precondition", "PLUGIN_PRECONDITION_INVALID", nil) - return false - } - ctx = service.WithPluginExpectedPackage(ctx, digest) - } - if revision != "" { - value, err := strconv.ParseInt(revision, 10, 64) - if len(c.Request.Header.Values(pluginRevisionHeader)) != 1 || err != nil || value <= 0 || strconv.FormatInt(value, 10) != revision { - response.ErrorWithDetails(c, http.StatusBadRequest, "Invalid plugin revision precondition", "PLUGIN_PRECONDITION_INVALID", nil) - return false - } - ctx = service.WithPluginExpectedRevision(ctx, value) - } - c.Request = c.Request.WithContext(ctx) - return true -} - -func pluginPreconditionError(c *gin.Context, err error) bool { - if errors.Is(err, service.ErrPluginStateChanged) || errors.Is(err, service.ErrPluginUISessionChanged) { - response.ErrorWithDetails(c, http.StatusConflict, "Plugin changed; reload explicitly before retrying", "PLUGIN_STATE_CHANGED", nil) - return true - } - return false -} - -func writePluginConfigSnapshot(c *gin.Context, snapshot *service.PluginConfigSnapshot) { - c.Header("Cache-Control", "private, no-store") - c.Header(pluginRevisionHeader, strconv.FormatInt(snapshot.Revision, 10)) - c.Header(pluginPackageHeader, snapshot.PackageSHA256) - c.Data(http.StatusOK, "application/json; charset=utf-8", snapshot.Config) -} diff --git a/backend/internal/handler/admin/plugin_precondition_test.go b/backend/internal/handler/admin/plugin_precondition_test.go deleted file mode 100644 index ce54f0c744f2..000000000000 --- a/backend/internal/handler/admin/plugin_precondition_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package admin - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" -) - -type preconditionPluginRepository struct { - service.PluginRepository - installation *service.PluginInstallation - reads int -} - -func (r *preconditionPluginRepository) GetByID(context.Context, int64) (*service.PluginInstallation, error) { - r.reads++ - copy := *r.installation - return ©, nil -} -func (r *preconditionPluginRepository) List(context.Context) ([]*service.PluginInstallation, error) { - return []*service.PluginInstallation{r.installation}, nil -} -func (*preconditionPluginRepository) UpdateConfigReturningRevision(context.Context, int64, string, string) (int64, error) { - panic("stale HTTP precondition must not reach a configuration write") -} - -type preconditionCipher struct{} - -func (preconditionCipher) Encrypt(raw string) (string, error) { return raw, nil } -func (preconditionCipher) Decrypt(raw string) (string, error) { return raw, nil } - -func pluginPreconditionContext(method, body string) (*gin.Context, *httptest.ResponseRecorder) { - response := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(response) - ctx.Params = gin.Params{{Key: "id", Value: "7"}} - ctx.Request = httptest.NewRequest(method, "/admin/plugins/7", strings.NewReader(body)) - ctx.Request.Header.Set("Content-Type", "application/json") - return ctx, response -} - -func TestPluginMutationHTTPRequiresOriginalPrecondition(t *testing.T) { - handler := NewPluginHandler(nil) - for name, handle := range map[string]func(*gin.Context){"enable": handler.Enable, "disable": handler.Disable, "save": handler.SaveConfig, "test": handler.Test, "delete": handler.Delete, "action": handler.InvokeAdmin, "job": handler.SubmitJob} { - t.Run(name, func(t *testing.T) { - ctx, result := pluginPreconditionContext(http.MethodPost, `{}`) - handle(ctx) - require.Equal(t, http.StatusPreconditionRequired, result.Code) - require.Contains(t, result.Body.String(), "PLUGIN_PRECONDITION_REQUIRED") - }) - } -} - -func TestPluginMutationHTTPRejectsMalformedPrecondition(t *testing.T) { - for name, revision := range map[string]string{"zero": "0", "negative": "-1", "whitespace_inner": "1 2", "overflow": "9223372036854775808"} { - t.Run(name, func(t *testing.T) { - ctx, result := pluginPreconditionContext(http.MethodPost, `{}`) - ctx.Request.Header.Set(pluginRevisionHeader, revision) - ctx.Request.Header.Set(pluginPackageHeader, strings.Repeat("a", 64)) - NewPluginHandler(nil).Disable(ctx) - require.Equal(t, http.StatusBadRequest, result.Code) - }) - } - ctx, result := pluginPreconditionContext(http.MethodPost, `{}`) - ctx.Request.Header.Set(pluginRevisionHeader, "4") - ctx.Request.Header.Set(pluginPackageHeader, "not-a-digest") - NewPluginHandler(nil).Disable(ctx) - require.Equal(t, http.StatusBadRequest, result.Code) -} - -func TestPluginMutationHTTPRejectsStaleReadSnapshot(t *testing.T) { - for _, mismatch := range []string{"revision", "package"} { - t.Run(mismatch, func(t *testing.T) { - repo := &preconditionPluginRepository{installation: &service.PluginInstallation{ID: 7, Revision: 5, State: service.PluginStateDisabled, ConfigEncrypted: `{"enabled":false}`, PackageSHA256: strings.Repeat("a", 64)}} - manager := service.NewPluginManager(repo, preconditionCipher{}, nil, service.PluginHostInfo{}, nil) - handler := NewPluginHandler(manager) - for name, handle := range map[string]func(*gin.Context){"enable": handler.Enable, "disable": handler.Disable, "save": handler.SaveConfig, "test": handler.Test, "delete": handler.Delete} { - t.Run(name, func(t *testing.T) { - ctx, result := pluginPreconditionContext(http.MethodPost, `{}`) - ctx.Request.Header.Set(pluginRevisionHeader, "5") - ctx.Request.Header.Set(pluginPackageHeader, strings.Repeat("a", 64)) - if mismatch == "revision" { - ctx.Request.Header.Set(pluginRevisionHeader, "4") - } else { - ctx.Request.Header.Set(pluginPackageHeader, strings.Repeat("b", 64)) - } - handle(ctx) - require.Equal(t, http.StatusConflict, result.Code) - require.Contains(t, result.Body.String(), "PLUGIN_STATE_CHANGED") - }) - } - }) - } -} - -func TestPluginConfigHTTPReadReturnsOneRawSnapshotAndNoStore(t *testing.T) { - repo := &preconditionPluginRepository{installation: &service.PluginInstallation{ID: 7, Revision: 8, PackageSHA256: strings.Repeat("a", 64), ConfigEncrypted: `{"code":17,"enabled":false}`}} - handler := NewPluginHandler(service.NewPluginManager(repo, preconditionCipher{}, nil, service.PluginHostInfo{}, nil)) - ctx, result := pluginPreconditionContext(http.MethodGet, "") - ctx.Request.Header.Set(pluginPackageHeader, strings.Repeat("a", 64)) - handler.GetConfig(ctx) - require.Equal(t, 1, repo.reads) - require.Equal(t, http.StatusOK, result.Code) - require.Equal(t, "8", result.Header().Get(pluginRevisionHeader)) - require.Equal(t, repo.installation.PackageSHA256, result.Header().Get(pluginPackageHeader)) - require.Contains(t, result.Header().Get("Cache-Control"), "no-store") - var decoded map[string]any - require.NoError(t, json.Unmarshal(result.Body.Bytes(), &decoded)) - require.Equal(t, float64(17), decoded["code"]) - require.NotContains(t, decoded, "data") -} diff --git a/backend/internal/handler/admin/plugin_resource_scope_test.go b/backend/internal/handler/admin/plugin_resource_scope_test.go deleted file mode 100644 index 29d2a3be75fe..000000000000 --- a/backend/internal/handler/admin/plugin_resource_scope_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package admin - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" -) - -func TestPluginResourceNestedSelectionCannotBecomeUnrestricted(t *testing.T) { - for _, test := range []struct { - body string - ids []int64 - filtered, invalid bool - }{ - {`{"scope":{"mode":"selected","account_ids":[9],"filters":{"account_ids":[99]}}}`, []int64{9}, false, false}, - {`{"scope":{"mode":"selected","filters":{"account_ids":[99]}}}`, []int64{99}, false, false}, - {`{"scope":{"mode":"selected","account_ids":[]}}`, nil, false, true}, - {`{"scope":{"mode":"all"}}`, nil, true, false}, - } { - ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) - ctx.Request = httptest.NewRequest(http.MethodPost, "/fixture", strings.NewReader(test.body)) - ids, filtered, err := resourceAccountTargets(ctx, extensionv1.ResourceDescriptor{AccountScopeField: "scope"}) - if test.invalid { - require.Error(t, err) - continue - } - require.NoError(t, err) - require.Equal(t, test.ids, ids) - require.Equal(t, test.filtered, filtered) - } -} diff --git a/backend/internal/handler/admin/plugin_resources.go b/backend/internal/handler/admin/plugin_resources.go deleted file mode 100644 index da7bd1b1b999..000000000000 --- a/backend/internal/handler/admin/plugin_resources.go +++ /dev/null @@ -1,199 +0,0 @@ -package admin - -import ( - "bytes" - "encoding/json" - "errors" - "io" - "net/http" - "sort" - "strconv" - - "github.com/Wei-Shaw/sub2api/internal/pkg/response" - "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/gin-gonic/gin" -) - -// RegisterResource is called only during route assembly. The guard runs on the -// existing route, so its authentication, audit, compliance and step-up chain is -// identical for host UI, plugin UI and compatibility API callers. -func (h *PluginHandler) RegisterResource(descriptor extensionv1.ResourceDescriptor) gin.HandlerFunc { - if h == nil { - return func(c *gin.Context) { - response.Error(c, http.StatusServiceUnavailable, "Plugin resources are unavailable") - c.Abort() - } - } - if h.resources == nil { - h.resources = map[string]extensionv1.ResourceDescriptor{} - } - if _, exists := h.resources[descriptor.Name]; exists { - panic("duplicate plugin resource registration") - } - h.resources[descriptor.Name] = descriptor - return func(c *gin.Context) { - id := int64(0) - if raw := c.GetHeader("X-Sub2API-Plugin"); raw != "" { - parsed, err := strconv.ParseInt(raw, 10, 64) - if err != nil || parsed <= 0 { - response.BadRequest(c, "Invalid plugin context") - c.Abort() - return - } - id = parsed - } - if descriptor.Retained && id == 0 { - c.Next() - return - } - if id == 0 && descriptor.OwnerPluginKey != "" { - var err error - id, err = h.manager.PinnedPluginOwner(c.Request.Context(), descriptor.OwnerPluginKey) - if err != nil { - accountViewRequestError(c, service.ErrAccountViewUnavailable) - return - } - } - ctx, release, err := h.manager.BindResourceContext(c.Request.Context(), id, c.GetHeader("X-Sub2API-Plugin-Package"), descriptor.ResourceGrant, descriptor.Retained) - if err != nil { - status := http.StatusServiceUnavailable - if errors.Is(err, service.ErrExtensionOperationDisabled) { - status = http.StatusNotFound - } - if errors.Is(err, service.ErrPluginUISessionChanged) { - status = http.StatusConflict - } - response.Error(c, status, err.Error()) - c.Abort() - return - } - defer release() - ctx = h.manager.WithResourcePolicy(ctx, descriptor) - c.Request = c.Request.WithContext(ctx) - ids, filtered, err := resourceAccountTargets(c, descriptor) - if err != nil { - response.BadRequest(c, err.Error()) - c.Abort() - return - } - if !descriptor.Retained { - // A view's filtered bulk taxonomy request is resolved and frozen by - // the handler before mutation; validate its actual IDs there. Other - // filtered resources keep their existing full-domain requirement. - checkFiltered := filtered - if _, viewBound := service.AccountViewFromContext(ctx); viewBound && filtered && !descriptor.AllAccounts && descriptor.Name == "taxonomy.bulk.update" { - checkFiltered = false - } - if err = h.manager.ValidateResourcePolicy(ctx, descriptor, ids, checkFiltered); err != nil { - response.Error(c, http.StatusForbidden, err.Error()) - c.Abort() - return - } - } else if len(ids) > 0 || filtered { - if err = h.manager.ValidateResourceAccounts(ctx, descriptor.Capability, ids, filtered, descriptor.FilterPlatform, descriptor.FilterAccountType); err != nil { - accountViewRequestError(c, err) - return - } - } - if !descriptor.WholeAccountViewDomain && len(ids) > 0 { - if err = service.ValidateAccountViewTargets(ctx, ids); err != nil { - accountViewRequestError(c, err) - return - } - } - c.Next() - } -} - -func resourceAccountTargets(c *gin.Context, descriptor extensionv1.ResourceDescriptor) ([]int64, bool, error) { - if descriptor.AllAccounts { - return nil, true, nil - } - var ids []int64 - if descriptor.AccountParam != "" { - id, err := strconv.ParseInt(c.Param(descriptor.AccountParam), 10, 64) - if err != nil || id <= 0 { - return nil, false, errors.New("invalid resource account") - } - ids = append(ids, id) - } - if descriptor.AccountBodyField == "" && descriptor.AccountItemsField == "" && descriptor.FilterField == "" && descriptor.AccountScopeField == "" { - return ids, false, nil - } - raw, err := io.ReadAll(io.LimitReader(c.Request.Body, 4*1024*1024+1)) - if err != nil || len(raw) > 4*1024*1024 { - return nil, false, errors.New("resource account selection exceeds limit") - } - c.Request.Body = io.NopCloser(bytes.NewReader(raw)) - var body map[string]json.RawMessage - if json.Unmarshal(raw, &body) != nil { - return nil, false, errors.New("invalid resource selection") - } - if descriptor.AccountScopeField != "" { - var scope struct { - Mode string `json:"mode"` - AccountIDs []int64 `json:"account_ids"` - Filters struct { - AccountIDs []int64 `json:"account_ids"` - } `json:"filters"` - } - if json.Unmarshal(body[descriptor.AccountScopeField], &scope) != nil { - return nil, false, errors.New("invalid resource account scope") - } - if scope.Mode != "selected" { - return ids, true, nil - } - selected := scope.AccountIDs - if len(selected) == 0 { - selected = scope.Filters.AccountIDs - } - if len(selected) == 0 || len(selected) > 3200 { - return nil, false, errors.New("invalid resource account list") - } - return append(ids, selected...), false, nil - } - if value := body[descriptor.AccountBodyField]; descriptor.AccountBodyField != "" && len(value) > 0 { - var direct []int64 - if json.Unmarshal(value, &direct) != nil { - return nil, false, errors.New("invalid resource account list") - } - ids = append(ids, direct...) - } - if value := body[descriptor.AccountItemsField]; descriptor.AccountItemsField != "" && len(value) > 0 { - var items []struct { - AccountID int64 `json:"account_id"` - } - if json.Unmarshal(value, &items) != nil { - return nil, false, errors.New("invalid resource account items") - } - for _, item := range items { - ids = append(ids, item.AccountID) - } - } - if len(ids) > 3200 { - return nil, false, errors.New("too many resource accounts") - } - filter := body[descriptor.FilterField] - return ids, descriptor.FilterField != "" && len(filter) > 0 && string(filter) != "null", nil -} - -func (h *PluginHandler) Resources(c *gin.Context) { h.listResources(c, "admin") } -func (h *PluginHandler) UserResources(c *gin.Context) { h.listResources(c, "user") } -func (h *PluginHandler) listResources(c *gin.Context, permission string) { - id, ok := pluginIDParam(c) - if !ok { - return - } - registered := make([]extensionv1.ResourceDescriptor, 0, len(h.resources)) - for _, descriptor := range h.resources { - registered = append(registered, descriptor) - } - sort.Slice(registered, func(i, j int) bool { return registered[i].Name < registered[j].Name }) - descriptors, err := h.manager.ResourceDescriptors(c.Request.Context(), id, permission, registered) - if err != nil { - response.ErrorFrom(c, err) - return - } - response.Success(c, descriptors) -} diff --git a/backend/internal/handler/admin/plugin_update.go b/backend/internal/handler/admin/plugin_update.go deleted file mode 100644 index d955dec4634e..000000000000 --- a/backend/internal/handler/admin/plugin_update.go +++ /dev/null @@ -1,65 +0,0 @@ -package admin - -import ( - "net/http" - "strconv" - "strings" - - "github.com/Wei-Shaw/sub2api/internal/pkg/response" - "github.com/Wei-Shaw/sub2api/internal/server/middleware" - "github.com/gin-gonic/gin" -) - -func (h *PluginHandler) Update(c *gin.Context) { - id, ok := pluginIDParam(c) - if !ok { - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.manager.MaxUploadBytes()+(1<<20)) - file, header, err := c.Request.FormFile("plugin") - if err != nil { - response.BadRequest(c, "请选择有效的 .s2plugin 文件") - return - } - defer func() { _ = file.Close() }() - if !strings.HasSuffix(strings.ToLower(header.Filename), ".s2plugin") { - response.BadRequest(c, "插件包扩展名必须是 .s2plugin") - return - } - revision, err := strconv.ParseInt(c.PostForm("expected_revision"), 10, 64) - if err != nil || revision <= 0 { - response.BadRequest(c, "需要当前插件版本修订号") - return - } - var actor *int64 - if subject, ok := middleware.GetAuthSubjectFromContext(c); ok { - actor = &subject.UserID - } - plugin, err := h.manager.Update(c.Request.Context(), id, revision, c.PostForm("expected_package_sha256"), file, actor) - if err != nil { - response.Error(c, http.StatusConflict, err.Error()) - return - } - response.Success(c, plugin) -} - -func (h *PluginHandler) FollowBundledVersion(c *gin.Context) { - id, ok := pluginIDParam(c) - if !ok { - return - } - var input struct { - Revision int64 `json:"expected_revision"` - Digest string `json:"expected_package_sha256"` - } - if c.ShouldBindJSON(&input) != nil || input.Revision <= 0 || len(input.Digest) != 64 { - response.BadRequest(c, "需要当前插件版本与摘要") - return - } - plugin, err := h.manager.FollowBundledVersion(c.Request.Context(), id, input.Revision, input.Digest) - if err != nil { - response.Error(c, http.StatusConflict, err.Error()) - return - } - response.Success(c, plugin) -} diff --git a/backend/internal/handler/admin/plugin_upload_guard_test.go b/backend/internal/handler/admin/plugin_upload_guard_test.go deleted file mode 100644 index 4a64676e57dc..000000000000 --- a/backend/internal/handler/admin/plugin_upload_guard_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package admin - -import ( - "archive/zip" - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "mime/multipart" - "net/http" - "net/http/httptest" - "testing" - - "github.com/Wei-Shaw/sub2api/internal/config" - "github.com/Wei-Shaw/sub2api/internal/service" - pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" -) - -type installedPluginUploadRepository struct{ service.PluginRepository } - -func (installedPluginUploadRepository) GetByKey(context.Context, string) (*service.PluginInstallation, error) { - return &service.PluginInstallation{ID: 7, State: service.PluginStateDisabled}, nil -} - -func TestPluginUploadExistingIdentityReturnsConflict(t *testing.T) { - files := map[string][]byte{"bin/plugin": []byte("not a runnable program"), "ui/index.html": []byte("")} - hashes := make(map[string]string) - for name, content := range files { - sum := sha256.Sum256(content) - hashes[name] = hex.EncodeToString(sum[:]) - } - manifest := service.PluginManifest{ - SchemaVersion: 1, ID: "com.example.upload", Name: "upload fixture", Version: "1.0.0", - Requires: service.PluginRequirements{Sub2API: ">=0.1.170 <0.2.0", PluginProtocol: pluginv1.ProtocolVersion, TransportAPI: pluginv1.TransportAPIVersion, UIBridge: pluginv1.UIBridgeVersion}, - Capabilities: []service.PluginCapability{{ID: service.PluginCapabilityOpenAIOAuthOutbound, Platform: service.PlatformOpenAI, AccountType: service.AccountTypeOAuth}}, - Runtimes: map[string]service.PluginRuntime{service.PluginManifest{}.RuntimeKey(): {Path: "bin/plugin"}}, - UI: service.PluginUIManifest{Entrypoint: "ui/index.html"}, Files: hashes, - } - raw, err := json.Marshal(manifest) - require.NoError(t, err) - files["manifest.json"] = raw - var archive bytes.Buffer - writer := zip.NewWriter(&archive) - for name, content := range files { - entry, err := writer.Create(name) - require.NoError(t, err) - _, err = entry.Write(content) - require.NoError(t, err) - } - require.NoError(t, writer.Close()) - var body bytes.Buffer - form := multipart.NewWriter(&body) - part, err := form.CreateFormFile("plugin", "existing.s2plugin") - require.NoError(t, err) - _, err = part.Write(archive.Bytes()) - require.NoError(t, err) - require.NoError(t, form.Close()) - cfg := &config.Config{Plugins: config.PluginConfig{DataDir: t.TempDir(), AllowUnsigned: true, MaxUploadBytes: 1 << 20, MaxUncompressedBytes: 1 << 20}} - manager := service.NewPluginManager(installedPluginUploadRepository{}, nil, cfg, service.PluginHostInfo{Version: "0.1.179"}, nil) - handler := NewPluginHandler(manager) - response := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(response) - ctx.Request = httptest.NewRequest(http.MethodPost, "/plugins", &body) - ctx.Request.Header.Set("Content-Type", form.FormDataContentType()) - handler.Upload(ctx) - require.Equal(t, http.StatusConflict, response.Code) - var payload struct { - Code int `json:"code"` - Reason string `json:"reason"` - Message string `json:"message"` - } - require.NoError(t, json.Unmarshal(response.Body.Bytes(), &payload)) - require.Equal(t, http.StatusConflict, payload.Code) - require.Equal(t, "PLUGIN_ALREADY_INSTALLED", payload.Reason) - require.Contains(t, payload.Message, "独立更新") -} diff --git a/backend/internal/handler/admin/scheduled_test_handler.go b/backend/internal/handler/admin/scheduled_test_handler.go index 32dfc4eae7b5..8387df3e596d 100644 --- a/backend/internal/handler/admin/scheduled_test_handler.go +++ b/backend/internal/handler/admin/scheduled_test_handler.go @@ -99,10 +99,6 @@ func (h *ScheduledTestHandler) Update(c *gin.Context) { response.NotFound(c, "plan not found") return } - if err := service.ValidateAccountViewTargets(c.Request.Context(), []int64{existing.AccountID}); err != nil { - accountViewRequestError(c, err) - return - } var req updateScheduledTestPlanRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -142,17 +138,6 @@ func (h *ScheduledTestHandler) Delete(c *gin.Context) { return } - if _, bound := service.AccountViewFromContext(c.Request.Context()); bound { - plan, err := h.scheduledTestSvc.GetPlan(c.Request.Context(), planID) - if err != nil || plan == nil { - response.NotFound(c, "plan not found") - return - } - if err := service.ValidateAccountViewTargets(c.Request.Context(), []int64{plan.AccountID}); err != nil { - accountViewRequestError(c, err) - return - } - } if err := h.scheduledTestSvc.DeletePlan(c.Request.Context(), planID); err != nil { response.InternalError(c, err.Error()) return @@ -168,17 +153,6 @@ func (h *ScheduledTestHandler) ListResults(c *gin.Context) { return } - if _, bound := service.AccountViewFromContext(c.Request.Context()); bound { - plan, err := h.scheduledTestSvc.GetPlan(c.Request.Context(), planID) - if err != nil || plan == nil { - response.NotFound(c, "plan not found") - return - } - if err := service.ValidateAccountViewTargets(c.Request.Context(), []int64{plan.AccountID}); err != nil { - accountViewRequestError(c, err) - return - } - } limit := 50 if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 { limit = l diff --git a/backend/internal/handler/admin/setting_codex_runtime.go b/backend/internal/handler/admin/setting_codex_runtime.go new file mode 100644 index 000000000000..56acb4189d71 --- /dev/null +++ b/backend/internal/handler/admin/setting_codex_runtime.go @@ -0,0 +1,47 @@ +package admin + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +func (h *SettingHandler) SetNativeCodexConfigEncryptor(encryptor service.SecretEncryptor) { + h.nativeCodexConfigEncryptor = encryptor +} + +func (h *SettingHandler) GetNativeCodexConfiguration(c *gin.Context) { + c.Header("Cache-Control", "no-store") + raw, err := h.codexTicketGateway.NativeCodexConfiguration() + if err != nil { + response.Error(c, 503, "Codex runtime is unavailable") + return + } + c.Data(http.StatusOK, "application/json; charset=utf-8", raw) +} + +func (h *SettingHandler) UpdateNativeCodexConfiguration(c *gin.Context) { + c.Header("Cache-Control", "no-store") + var raw json.RawMessage + if c.ShouldBindJSON(&raw) != nil || len(raw) > 1<<20 { + response.BadRequest(c, "Invalid Codex routing configuration") + return + } + if _, err := service.NormalizeNativeCodexConfig(c.Request.Context(), raw); err != nil { + response.BadRequest(c, "Invalid Codex routing configuration") + return + } + if err := h.codexTicketGateway.UpdateNativeCodexConfiguration(c.Request.Context(), raw, h.nativeCodexConfigEncryptor); err != nil { + if errors.Is(err, service.ErrNativeCodexRuntimeChanged) { + response.Error(c, 409, "Codex runtime configuration changed; reload and retry") + } else { + response.Error(c, 503, "Codex runtime configuration could not be applied") + } + return + } + h.GetNativeCodexConfiguration(c) +} diff --git a/backend/internal/handler/admin/setting_codex_ticket_proxy.go b/backend/internal/handler/admin/setting_codex_ticket_proxy.go index 5cf5d8a8b3b4..44c17775a8ef 100644 --- a/backend/internal/handler/admin/setting_codex_ticket_proxy.go +++ b/backend/internal/handler/admin/setting_codex_ticket_proxy.go @@ -14,6 +14,7 @@ func (h *SettingHandler) TestCodexTicketProxy(c *gin.Context) { c.Header("Cache-Control", "no-store") var req struct { ProxyURL string `json:"proxy_url"` + Protocol string `json:"protocol"` } if c.ShouldBindJSON(&req) != nil { response.BadRequest(c, "请求格式不正确") @@ -23,7 +24,7 @@ func (h *SettingHandler) TestCodexTicketProxy(c *gin.Context) { response.Error(c, 503, "票据服务不可用") return } - result, err := h.codexTicketGateway.TestCodexTicketProxy(c.Request.Context(), req.ProxyURL) + result, err := h.codexTicketGateway.TestCodexTicketProxyWithProtocol(c.Request.Context(), req.ProxyURL, req.Protocol) if err != nil { response.InternalError(c, "无法完成代理测试") return diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 1b94c77fd0ec..d9461d9576e1 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/Wei-Shaw/sub2api/internal/handler/dto" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/service" @@ -51,18 +52,19 @@ func firstNonEmpty(values ...string) string { // SettingHandler 系统设置处理器 type SettingHandler struct { - codexTicketGateway *service.OpenAIGatewayService - settingService *service.SettingService - emailService *service.EmailService - turnstileService *service.TurnstileService - aliyunCaptchaService *service.AliyunCaptchaService - opsService *service.OpsService - paymentConfigService *service.PaymentConfigService - paymentService *service.PaymentService - userAttributeService *service.UserAttributeService - notificationEmailService *service.NotificationEmailService - totpService *service.TotpService - userService *service.UserService + codexTicketGateway *service.OpenAIGatewayService + nativeCodexConfigEncryptor service.SecretEncryptor + settingService *service.SettingService + emailService *service.EmailService + turnstileService *service.TurnstileService + aliyunCaptchaService *service.AliyunCaptchaService + opsService *service.OpsService + paymentConfigService *service.PaymentConfigService + paymentService *service.PaymentService + userAttributeService *service.UserAttributeService + notificationEmailService *service.NotificationEmailService + totpService *service.TotpService + userService *service.UserService } // NewSettingHandler 创建系统设置处理器 @@ -99,6 +101,94 @@ func (h *SettingHandler) SetStepUpDeps(totpService *service.TotpService, userSer h.userService = userService } +// GetOfficialModelContextCatalog returns the release-pinned official model +// capacity catalog for the read-only admin browser. +// GET /api/v1/admin/settings/model-context-catalog +func (h *SettingHandler) GetOfficialModelContextCatalog(c *gin.Context) { + response.Success(c, gin.H{ + "reference_release": service.GPTContextCapacityReferenceRelease, + "entries": service.OfficialModelCatalogSnapshot(), + }) +} + +// GetImageToolsSettings returns the Image Studio and Responses image bridge +// switches this process applies. +// GET /api/v1/admin/settings/image-tools +func (h *SettingHandler) GetImageToolsSettings(c *gin.Context) { + response.Success(c, service.EffectiveImageToolsConfig()) +} + +// UpdateImageToolsSettings saves the Image Studio and Responses image bridge +// switches. Omitted switches are off, as in the former plugin configuration. +// PUT /api/v1/admin/settings/image-tools +func (h *SettingHandler) UpdateImageToolsSettings(c *gin.Context) { + var req extensionv1.ImageToolsConfig + raw, err := c.GetRawData() + if err != nil || service.DecodeSwitchSettings(raw, &req, "studio_enabled", "responses_image_enabled") != nil { + response.BadRequest(c, "Invalid image tools settings") + return + } + if err := h.settingService.UpdateImageToolsConfig(c.Request.Context(), req); err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, req) +} + +// GetObservabilitySettings returns the account traffic telemetry and flat theme +// switches this process applies. +// GET /api/v1/admin/settings/observability +func (h *SettingHandler) GetObservabilitySettings(c *gin.Context) { + response.Success(c, service.EffectiveAdminObservabilityConfig()) +} + +// UpdateObservabilitySettings saves the account traffic telemetry and flat theme +// switches. Omitted switches stay on, as in the former plugin configuration. +// PUT /api/v1/admin/settings/observability +func (h *SettingHandler) UpdateObservabilitySettings(c *gin.Context) { + req := extensionv1.AdminObservabilityConfig{TelemetryEnabled: true, ThemeEnabled: true} + raw, err := c.GetRawData() + if err != nil || service.DecodeSwitchSettings(raw, &req, "telemetry_enabled", "theme_enabled") != nil { + response.BadRequest(c, "Invalid observability settings") + return + } + if err := h.settingService.UpdateAdminObservabilityConfig(c.Request.Context(), req); err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, req) +} + +// GetCindyProviderSettings returns the Cindy balance detection, catalog and +// search switches this process applies. +// GET /api/v1/admin/settings/cindy-provider +func (h *SettingHandler) GetCindyProviderSettings(c *gin.Context) { + response.Success(c, service.EffectiveCindyProviderConfig()) +} + +// UpdateCindyProviderSettings saves the Cindy switches. An omitted balance +// detection switch stays on, as in the former plugin configuration. +// PUT /api/v1/admin/settings/cindy-provider +func (h *SettingHandler) UpdateCindyProviderSettings(c *gin.Context) { + req := extensionv1.CindyProviderConfig{BalanceDetection: true} + raw, err := c.GetRawData() + if err != nil || service.DecodeSwitchSettings(raw, &req, "balance_detection", "catalog_enabled", "search_enabled") != nil { + response.BadRequest(c, "Invalid Cindy provider settings") + return + } + if err := h.settingService.UpdateCindyProviderConfig(c.Request.Context(), req); err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, req) +} + +// GetCindyProviderCatalog lists the Cindy model catalog for the settings page. +// GET /api/v1/admin/settings/cindy-provider/catalog +func (h *SettingHandler) GetCindyProviderCatalog(c *gin.Context) { + response.Success(c, service.CindyCatalogModels()) +} + // GetSettings 获取所有系统设置 // GET /api/v1/admin/settings func (h *SettingHandler) GetSettings(c *gin.Context) { diff --git a/backend/internal/handler/admin/system_prompt_rules_handler.go b/backend/internal/handler/admin/system_prompt_rules_handler.go index 651cda9a78a1..9aa3001979d6 100644 --- a/backend/internal/handler/admin/system_prompt_rules_handler.go +++ b/backend/internal/handler/admin/system_prompt_rules_handler.go @@ -3,10 +3,10 @@ package admin import ( "strconv" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/gin-gonic/gin" ) diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 3a0d7bfb94d0..d425c581b5c3 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -460,6 +460,7 @@ type PublicSettings struct { AvailableChannelsEnabled bool `json:"available_channels_enabled"` ImageStudioEnabled bool `json:"image_studio_enabled"` + FlatThemeEnabled bool `json:"flat_theme_enabled"` SubscriptionEnabled bool `json:"subscription_enabled"` diff --git a/backend/internal/handler/image_studio_executor.go b/backend/internal/handler/image_studio_executor.go index a441390c358c..1bfaab73fd43 100644 --- a/backend/internal/handler/image_studio_executor.go +++ b/backend/internal/handler/image_studio_executor.go @@ -14,9 +14,9 @@ import ( "strconv" "strings" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/gin-gonic/gin" ) diff --git a/backend/internal/handler/plugin_contract_fixture_test.go b/backend/internal/handler/plugin_contract_fixture_test.go index d9722ece3fba..eb45f04bdd75 100644 --- a/backend/internal/handler/plugin_contract_fixture_test.go +++ b/backend/internal/handler/plugin_contract_fixture_test.go @@ -3,8 +3,8 @@ package handler import ( "testing" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/testextensions" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" ) func init() { testextensions.Install() } diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 1189bba9eded..f79949080180 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -3,7 +3,6 @@ package handler import ( "html" "net/http" - "strconv" "strings" "github.com/Wei-Shaw/sub2api/internal/handler/dto" @@ -16,46 +15,11 @@ import ( // SettingHandler 公开设置处理器(无需认证) type SettingHandler struct { - pluginManager *service.PluginManager settingService *service.SettingService notificationEmailService *service.NotificationEmailService version string } -func (h *SettingHandler) GetPublicPluginContributions(c *gin.Context) { - c.Header("Cache-Control", "no-store") - if h.pluginManager == nil { - response.Success(c, []service.PluginContribution{}) - return - } - response.Success(c, h.pluginManager.PublicContributions()) -} - -func (h *SettingHandler) GetPublicPluginThemeAsset(c *gin.Context) { - id, err := strconv.ParseInt(c.Param("id"), 10, 64) - if err != nil || id <= 0 || h.pluginManager == nil { - c.Status(http.StatusNotFound) - return - } - data, path, err := h.pluginManager.ReadPublicThemeAsset(c.Request.Context(), id, c.Param("revision"), strings.TrimPrefix(c.Param("path"), "/")) - if err != nil { - c.Status(http.StatusNotFound) - return - } - contentType := "text/css; charset=utf-8" - if strings.HasSuffix(path, ".woff2") { - contentType = "font/woff2" - } - c.Header("Cache-Control", "no-store") - c.Header("X-Content-Type-Options", "nosniff") - // Only manifest-declared public appearance assets reach this handler. - // Opaque sandboxed plugin frames need CORS for stylesheets and font loads. - c.Header("Cross-Origin-Resource-Policy", "cross-origin") - c.Header("Access-Control-Allow-Origin", "*") - c.Data(http.StatusOK, contentType, data) -} - -// NewSettingHandler 创建公开设置处理器 func NewSettingHandler(settingService *service.SettingService, version string) *SettingHandler { return &SettingHandler{ settingService: settingService, @@ -148,6 +112,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { AvailableChannelsEnabled: settings.AvailableChannelsEnabled, ImageStudioEnabled: settings.ImageStudioEnabled, + FlatThemeEnabled: settings.FlatThemeEnabled, SubscriptionEnabled: settings.SubscriptionEnabled, ModelPlazaEnabled: settings.ModelPlazaEnabled, diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index b9cfb905a133..ba0b982ed6dc 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -175,19 +175,19 @@ func ProvideSystemHandler(updateService *service.UpdateService, lockService *ser } // ProvideSettingHandler creates SettingHandler with version from BuildInfo -func ProvideSettingHandler(settingService *service.SettingService, buildInfo BuildInfo, notificationEmailService *service.NotificationEmailService, pluginManager *service.PluginManager) *SettingHandler { +func ProvideSettingHandler(settingService *service.SettingService, buildInfo BuildInfo, notificationEmailService *service.NotificationEmailService) *SettingHandler { h := NewSettingHandler(settingService, buildInfo.Version) - h.pluginManager = pluginManager h.SetNotificationEmailService(notificationEmailService) return h } // ProvideAdminSettingHandler creates admin.SettingHandler with notification template APIs. -func ProvideAdminSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, aliyunCaptchaService *service.AliyunCaptchaService, opsService *service.OpsService, paymentConfigService *service.PaymentConfigService, paymentService *service.PaymentService, userAttributeService *service.UserAttributeService, notificationEmailService *service.NotificationEmailService, totpService *service.TotpService, userService *service.UserService) *admin.SettingHandler { +func ProvideAdminSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, aliyunCaptchaService *service.AliyunCaptchaService, opsService *service.OpsService, paymentConfigService *service.PaymentConfigService, paymentService *service.PaymentService, userAttributeService *service.UserAttributeService, notificationEmailService *service.NotificationEmailService, totpService *service.TotpService, userService *service.UserService, encryptor service.SecretEncryptor) *admin.SettingHandler { h := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService, userAttributeService) h.SetNotificationEmailService(notificationEmailService) h.SetAliyunCaptchaService(aliyunCaptchaService) h.SetStepUpDeps(totpService, userService) + h.SetNativeCodexConfigEncryptor(encryptor) return h } @@ -321,15 +321,14 @@ var ProviderSet = wire.NewSet( ProvideHandlers, ) -func ProvideAccountJobRuntime(jobs *service.AccountJobService, accountHandler *admin.AccountHandler, pluginManager *service.PluginManager) (*service.AccountJobRuntime, error) { +func ProvideAccountJobRuntime(jobs *service.AccountJobService, accountHandler *admin.AccountHandler) (*service.AccountJobRuntime, error) { accountHandler.SetAccountJobService(jobs) - runtime := service.NewAccountJobRuntime(jobs, service.NewPluginJobExecutor(pluginManager, accountHandler)) + runtime := service.NewAccountJobRuntime(jobs, accountHandler) return runtime, nil } -func ProvidePluginHandler(manager *service.PluginManager, jobs *service.AccountJobService) *admin.PluginHandler { +func ProvidePluginHandler(manager *service.PluginManager) *admin.PluginHandler { handler := admin.NewPluginHandler(manager) - handler.SetAccountJobs(jobs) return handler } diff --git a/backend/pkg/extensionapi/v1/account_create.go b/backend/internal/nativeapi/account_create.go similarity index 95% rename from backend/pkg/extensionapi/v1/account_create.go rename to backend/internal/nativeapi/account_create.go index 57c7a2f20853..6d11f5d38b62 100644 --- a/backend/pkg/extensionapi/v1/account_create.go +++ b/backend/internal/nativeapi/account_create.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi import ( "bytes" @@ -127,7 +127,7 @@ func (definition *AccountCreateDefinitionV1) UnmarshalJSON(raw []byte) error { func (request *ProviderCreateRequestV1) UnmarshalJSON(raw []byte) error { type plain ProviderCreateRequestV1 var decoded plain - if err := decodeAccountCreateJSON(raw, &decoded, false, "contribution_id", "expected_package_sha256", "expected_definition_sha256", "expected_runtime_generation", "values", "inherit_defaults"); err != nil { + if err := decodeAccountCreateJSON(raw, &decoded, false, "values", "inherit_defaults"); err != nil { return errors.New("invalid provider create input") } *request = ProviderCreateRequestV1(decoded) diff --git a/backend/pkg/extensionapi/v1/account_edit.go b/backend/internal/nativeapi/account_edit.go similarity index 94% rename from backend/pkg/extensionapi/v1/account_edit.go rename to backend/internal/nativeapi/account_edit.go index c97f1861e815..b07d1c93bdc1 100644 --- a/backend/pkg/extensionapi/v1/account_edit.go +++ b/backend/internal/nativeapi/account_edit.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi import ( "encoding/json" @@ -94,7 +94,7 @@ func (definition *AccountEditDefinitionV1) UnmarshalJSON(raw []byte) error { func (request *ProviderEditRequestV1) UnmarshalJSON(raw []byte) error { type plain ProviderEditRequestV1 var decoded plain - if err := decodeAccountCreateJSON(raw, &decoded, false, "contribution_id", "expected_package_sha256", "expected_definition_sha256", "expected_runtime_generation", "expected_state_sha256", "changes"); err != nil { + if err := decodeAccountCreateJSON(raw, &decoded, false, "expected_state_sha256", "changes"); err != nil { return errors.New("invalid provider edit input") } *request = ProviderEditRequestV1(decoded) diff --git a/backend/pkg/extensionapi/v1/account_import.go b/backend/internal/nativeapi/account_import.go similarity index 99% rename from backend/pkg/extensionapi/v1/account_import.go rename to backend/internal/nativeapi/account_import.go index 2ec5bae45a75..2d82776a464f 100644 --- a/backend/pkg/extensionapi/v1/account_import.go +++ b/backend/internal/nativeapi/account_import.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi const ( AccountImportCodeCreate = "account_import_create" diff --git a/backend/pkg/extensionapi/v1/account_tools.go b/backend/internal/nativeapi/account_tools.go similarity index 98% rename from backend/pkg/extensionapi/v1/account_tools.go rename to backend/internal/nativeapi/account_tools.go index 4472b351e403..2a4e03eb7995 100644 --- a/backend/pkg/extensionapi/v1/account_tools.go +++ b/backend/internal/nativeapi/account_tools.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi type TaxonomyName struct { Name string `json:"name"` diff --git a/backend/pkg/extensionapi/v1/account_view.go b/backend/internal/nativeapi/account_view.go similarity index 99% rename from backend/pkg/extensionapi/v1/account_view.go rename to backend/internal/nativeapi/account_view.go index a4062160a753..38126406cefd 100644 --- a/backend/pkg/extensionapi/v1/account_view.go +++ b/backend/internal/nativeapi/account_view.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi import ( "bytes" diff --git a/backend/pkg/extensionapi/v1/api.go b/backend/internal/nativeapi/api.go similarity index 97% rename from backend/pkg/extensionapi/v1/api.go rename to backend/internal/nativeapi/api.go index 92c99a5197ad..e96f927feb42 100644 --- a/backend/pkg/extensionapi/v1/api.go +++ b/backend/internal/nativeapi/api.go @@ -1,6 +1,6 @@ -// Package extensionv1 is the public, process-independent downstream extension -// contract. It deliberately contains no host service or database model types. -package extensionv1 +// Package nativeapi contains the shared payloads of the in-process domains. +// Stored JSON field names remain compatible with existing account data and ledgers. +package nativeapi import ( "context" @@ -10,6 +10,8 @@ import ( ) const ( + // Preserve the existing bound on internal policy plans and catalog payloads. + MaxPayloadBytes = 4 * 1024 * 1024 Version = 1 CapabilityProvider = "extensions.provider.v1" CapabilityCatalog = "extensions.catalog.v1" diff --git a/backend/pkg/extensionapi/v1/catalog.go b/backend/internal/nativeapi/catalog.go similarity index 99% rename from backend/pkg/extensionapi/v1/catalog.go rename to backend/internal/nativeapi/catalog.go index 36a86c49a65e..6e6dada3c058 100644 --- a/backend/pkg/extensionapi/v1/catalog.go +++ b/backend/internal/nativeapi/catalog.go @@ -1,5 +1,5 @@ // Versioned model-reference contracts shared by the host and domain plugins. -package extensionv1 +package nativeapi import "context" diff --git a/backend/pkg/extensionapi/v1/cindy.go b/backend/internal/nativeapi/cindy.go similarity index 99% rename from backend/pkg/extensionapi/v1/cindy.go rename to backend/internal/nativeapi/cindy.go index cbe02f11abb0..d4d87712b67d 100644 --- a/backend/pkg/extensionapi/v1/cindy.go +++ b/backend/internal/nativeapi/cindy.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi import "encoding/json" diff --git a/backend/pkg/extensionapi/v1/cindy_account_test_plan.go b/backend/internal/nativeapi/cindy_account_test_plan.go similarity index 97% rename from backend/pkg/extensionapi/v1/cindy_account_test_plan.go rename to backend/internal/nativeapi/cindy_account_test_plan.go index c9f7fba13119..82d8b07ceffc 100644 --- a/backend/pkg/extensionapi/v1/cindy_account_test_plan.go +++ b/backend/internal/nativeapi/cindy_account_test_plan.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi // A separate query keeps the strict CatalogSnapshotV1 and pricing companion // contracts unchanged for hosts that do not request this presentation view. diff --git a/backend/pkg/extensionapi/v1/cindy_alpha_search.go b/backend/internal/nativeapi/cindy_alpha_search.go similarity index 98% rename from backend/pkg/extensionapi/v1/cindy_alpha_search.go rename to backend/internal/nativeapi/cindy_alpha_search.go index a0f56df4f9ca..45c0cdd29753 100644 --- a/backend/pkg/extensionapi/v1/cindy_alpha_search.go +++ b/backend/internal/nativeapi/cindy_alpha_search.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi // CindyAlphaSearchPlan is the bounded provider decision for the host's // first-class Cindy alpha-search bridge. It intentionally carries only model diff --git a/backend/pkg/extensionapi/v1/cindy_catalog_snapshot.go b/backend/internal/nativeapi/cindy_catalog_snapshot.go similarity index 99% rename from backend/pkg/extensionapi/v1/cindy_catalog_snapshot.go rename to backend/internal/nativeapi/cindy_catalog_snapshot.go index 4649de8d3bab..56cce5abf23c 100644 --- a/backend/pkg/extensionapi/v1/cindy_catalog_snapshot.go +++ b/backend/internal/nativeapi/cindy_catalog_snapshot.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi // CindyCatalogSnapshotMethodV1 identifies an additive, atomic catalog query. // Its schema version is a protocol value, not a provider catalog revision. diff --git a/backend/pkg/extensionapi/v1/cindy_clone.go b/backend/internal/nativeapi/cindy_clone.go similarity index 92% rename from backend/pkg/extensionapi/v1/cindy_clone.go rename to backend/internal/nativeapi/cindy_clone.go index 8a3f39d338bf..7f5ada2ce88e 100644 --- a/backend/pkg/extensionapi/v1/cindy_clone.go +++ b/backend/internal/nativeapi/cindy_clone.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi func CloneCindyImageRequestControls(in *CindyImageRequestControls) *CindyImageRequestControls { if in == nil { diff --git a/backend/pkg/extensionapi/v1/cindy_codex.go b/backend/internal/nativeapi/cindy_codex.go similarity index 99% rename from backend/pkg/extensionapi/v1/cindy_codex.go rename to backend/internal/nativeapi/cindy_codex.go index 8c931fe0c1bf..14f43d5119ca 100644 --- a/backend/pkg/extensionapi/v1/cindy_codex.go +++ b/backend/internal/nativeapi/cindy_codex.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi // CindyCodexModel is a typed presentation plan for the supported native Codex // descriptor. The host supplies source isolation and native base instructions. diff --git a/backend/pkg/extensionapi/v1/cindy_health.go b/backend/internal/nativeapi/cindy_health.go similarity index 97% rename from backend/pkg/extensionapi/v1/cindy_health.go rename to backend/internal/nativeapi/cindy_health.go index 003e3378bd2a..b33293700058 100644 --- a/backend/pkg/extensionapi/v1/cindy_health.go +++ b/backend/internal/nativeapi/cindy_health.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi // Only structured error fields cross the boundary; message text, response // bodies, account credentials and prompt content are not policy inputs. diff --git a/backend/pkg/extensionapi/v1/cindy_management.go b/backend/internal/nativeapi/cindy_management.go similarity index 98% rename from backend/pkg/extensionapi/v1/cindy_management.go rename to backend/internal/nativeapi/cindy_management.go index 5d190d9ecb88..ebebbf1b4905 100644 --- a/backend/pkg/extensionapi/v1/cindy_management.go +++ b/backend/internal/nativeapi/cindy_management.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi import "time" diff --git a/backend/pkg/extensionapi/v1/cindy_probe.go b/backend/internal/nativeapi/cindy_probe.go similarity index 97% rename from backend/pkg/extensionapi/v1/cindy_probe.go rename to backend/internal/nativeapi/cindy_probe.go index 3ea65d7dda64..54c47141640c 100644 --- a/backend/pkg/extensionapi/v1/cindy_probe.go +++ b/backend/internal/nativeapi/cindy_probe.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi type CindyProbeOutcome uint8 diff --git a/backend/pkg/extensionapi/v1/codex_identity.go b/backend/internal/nativeapi/codex_identity.go similarity index 98% rename from backend/pkg/extensionapi/v1/codex_identity.go rename to backend/internal/nativeapi/codex_identity.go index 32495da4e35f..db01fed7667a 100644 --- a/backend/pkg/extensionapi/v1/codex_identity.go +++ b/backend/internal/nativeapi/codex_identity.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi // Profile fields are persisted by the host. Their generation and interpretation // belong to the Codex domain process; no credential is part of this contract. diff --git a/backend/pkg/extensionapi/v1/codex_recovery.go b/backend/internal/nativeapi/codex_recovery.go similarity index 99% rename from backend/pkg/extensionapi/v1/codex_recovery.go rename to backend/internal/nativeapi/codex_recovery.go index b01d4841b67a..6ae83974cd70 100644 --- a/backend/pkg/extensionapi/v1/codex_recovery.go +++ b/backend/internal/nativeapi/codex_recovery.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi // Recovery receives protocol shape and item indices. Neither ciphertext, // prompt history, credentials nor complete upstream errors cross this boundary. diff --git a/backend/pkg/extensionapi/v1/codex_routing.go b/backend/internal/nativeapi/codex_routing.go similarity index 99% rename from backend/pkg/extensionapi/v1/codex_routing.go rename to backend/internal/nativeapi/codex_routing.go index d41861eb7754..93ccbdb57627 100644 --- a/backend/pkg/extensionapi/v1/codex_routing.go +++ b/backend/internal/nativeapi/codex_routing.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi import "time" diff --git a/backend/pkg/extensionapi/v1/codex_transport.go b/backend/internal/nativeapi/codex_transport.go similarity index 95% rename from backend/pkg/extensionapi/v1/codex_transport.go rename to backend/internal/nativeapi/codex_transport.go index eec2a2f23126..a2330cc50d96 100644 --- a/backend/pkg/extensionapi/v1/codex_transport.go +++ b/backend/internal/nativeapi/codex_transport.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi // Transport planning receives bounded request metadata, never request bodies. type CodexTransportQuery struct { diff --git a/backend/pkg/extensionapi/v1/decision.go b/backend/internal/nativeapi/decision.go similarity index 99% rename from backend/pkg/extensionapi/v1/decision.go rename to backend/internal/nativeapi/decision.go index d48745325933..e14b2f9c2cb8 100644 --- a/backend/pkg/extensionapi/v1/decision.go +++ b/backend/internal/nativeapi/decision.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi import ( "errors" diff --git a/backend/pkg/extensionapi/v1/document.go b/backend/internal/nativeapi/document.go similarity index 96% rename from backend/pkg/extensionapi/v1/document.go rename to backend/internal/nativeapi/document.go index 8ce257ea03ca..2aee75580b1a 100644 --- a/backend/pkg/extensionapi/v1/document.go +++ b/backend/internal/nativeapi/document.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi import ( "crypto/sha256" diff --git a/backend/pkg/extensionapi/v1/document_path.go b/backend/internal/nativeapi/document_path.go similarity index 98% rename from backend/pkg/extensionapi/v1/document_path.go rename to backend/internal/nativeapi/document_path.go index 519c08f89b0c..75506671e70f 100644 --- a/backend/pkg/extensionapi/v1/document_path.go +++ b/backend/internal/nativeapi/document_path.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi import ( "errors" diff --git a/backend/pkg/extensionapi/v1/image.go b/backend/internal/nativeapi/image.go similarity index 99% rename from backend/pkg/extensionapi/v1/image.go rename to backend/internal/nativeapi/image.go index 5a1ed4edc06f..12c4a10618db 100644 --- a/backend/pkg/extensionapi/v1/image.go +++ b/backend/internal/nativeapi/image.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi type ImageToolsConfig struct { StudioEnabled bool `json:"studio_enabled"` diff --git a/backend/pkg/extensionapi/v1/observability.go b/backend/internal/nativeapi/observability.go similarity index 98% rename from backend/pkg/extensionapi/v1/observability.go rename to backend/internal/nativeapi/observability.go index e2b9745f19a3..2dd681239cb8 100644 --- a/backend/pkg/extensionapi/v1/observability.go +++ b/backend/internal/nativeapi/observability.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi type AdminObservabilityConfig struct { TelemetryEnabled bool `json:"telemetry_enabled"` diff --git a/backend/pkg/extensionapi/v1/prompt.go b/backend/internal/nativeapi/prompt.go similarity index 99% rename from backend/pkg/extensionapi/v1/prompt.go rename to backend/internal/nativeapi/prompt.go index ed8a71462a32..8e1caa8b2f36 100644 --- a/backend/pkg/extensionapi/v1/prompt.go +++ b/backend/internal/nativeapi/prompt.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi import "time" diff --git a/backend/pkg/extensionapi/v1/prompt_rules.go b/backend/internal/nativeapi/prompt_rules.go similarity index 99% rename from backend/pkg/extensionapi/v1/prompt_rules.go rename to backend/internal/nativeapi/prompt_rules.go index 4a7f4ccdf236..97370e3dad4e 100644 --- a/backend/pkg/extensionapi/v1/prompt_rules.go +++ b/backend/internal/nativeapi/prompt_rules.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi // PromptRule references immutable template content. Account bindings contain // only rule IDs; customer history and account credentials are never policy RPCs. diff --git a/backend/pkg/extensionapi/v1/publication.go b/backend/internal/nativeapi/publication.go similarity index 99% rename from backend/pkg/extensionapi/v1/publication.go rename to backend/internal/nativeapi/publication.go index 61a22549f064..206eb3a6abc5 100644 --- a/backend/pkg/extensionapi/v1/publication.go +++ b/backend/internal/nativeapi/publication.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi // Publication actions are policy inputs. The host remains responsible for // authorization, content integrity and the durable atomic switch. diff --git a/backend/pkg/extensionapi/v1/resources.go b/backend/internal/nativeapi/resources.go similarity index 98% rename from backend/pkg/extensionapi/v1/resources.go rename to backend/internal/nativeapi/resources.go index ede5e9e6aa99..32b50f9f161d 100644 --- a/backend/pkg/extensionapi/v1/resources.go +++ b/backend/internal/nativeapi/resources.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi // ResourceGrant authorizes one named host data operation. Network addresses and // HTTP methods come from the host registry, never from a plugin manifest. diff --git a/backend/pkg/extensionapi/v1/skills.go b/backend/internal/nativeapi/skills.go similarity index 99% rename from backend/pkg/extensionapi/v1/skills.go rename to backend/internal/nativeapi/skills.go index 18d511e748f6..ca2bb2b806b6 100644 --- a/backend/pkg/extensionapi/v1/skills.go +++ b/backend/internal/nativeapi/skills.go @@ -1,4 +1,4 @@ -package extensionv1 +package nativeapi type PromptComposition struct { Mode string `json:"composition_mode"` diff --git a/backend/internal/pkg/proxyutil/dialer.go b/backend/internal/pkg/proxyutil/dialer.go index 5f1b607c8aba..3c9bbea29abb 100644 --- a/backend/internal/pkg/proxyutil/dialer.go +++ b/backend/internal/pkg/proxyutil/dialer.go @@ -10,7 +10,7 @@ package proxyutil import ( - proxytransport "github.com/Wei-Shaw/sub2api/pkg/extensionapi/proxy" + proxytransport "github.com/Wei-Shaw/sub2api/internal/proxytransport" "net" "net/http" "net/url" diff --git a/plugins/prompt-skills/policy/management.go b/backend/internal/promptskills/policy/management.go similarity index 98% rename from plugins/prompt-skills/policy/management.go rename to backend/internal/promptskills/policy/management.go index 353529c87423..cb21ed4e9a0d 100644 --- a/plugins/prompt-skills/policy/management.go +++ b/backend/internal/promptskills/policy/management.go @@ -8,9 +8,9 @@ import ( "fmt" "strings" - "github.com/HTExplicit/sub2api-plugins/promptskills/source" + "github.com/Wei-Shaw/sub2api/internal/promptskills/source" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/pmezard/go-difflib/difflib" ) diff --git a/plugins/prompt-skills/policy/module.go b/backend/internal/promptskills/policy/module.go similarity index 97% rename from plugins/prompt-skills/policy/module.go rename to backend/internal/promptskills/policy/module.go index a06410d87037..d527204d8688 100644 --- a/plugins/prompt-skills/policy/module.go +++ b/backend/internal/promptskills/policy/module.go @@ -7,9 +7,9 @@ import ( "fmt" "strings" - "github.com/HTExplicit/sub2api-plugins/promptskills/registry" - "github.com/HTExplicit/sub2api-plugins/promptskills/source" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + "github.com/Wei-Shaw/sub2api/internal/promptskills/registry" + "github.com/Wei-Shaw/sub2api/internal/promptskills/source" ) type BusinessSystemPromptSnapshot = extensionv1.BusinessSystemPromptSnapshot diff --git a/plugins/prompt-skills/policy/module_test.go b/backend/internal/promptskills/policy/module_test.go similarity index 97% rename from plugins/prompt-skills/policy/module_test.go rename to backend/internal/promptskills/policy/module_test.go index 6c297f1aeb27..03734a5fc0bf 100644 --- a/plugins/prompt-skills/policy/module_test.go +++ b/backend/internal/promptskills/policy/module_test.go @@ -5,7 +5,7 @@ import ( "encoding/json" "testing" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" ) diff --git a/plugins/prompt-skills/policy/rules.go b/backend/internal/promptskills/policy/rules.go similarity index 98% rename from plugins/prompt-skills/policy/rules.go rename to backend/internal/promptskills/policy/rules.go index 314f05d63b80..57fa7de4fa84 100644 --- a/plugins/prompt-skills/policy/rules.go +++ b/backend/internal/promptskills/policy/rules.go @@ -11,7 +11,7 @@ import ( "sort" "strings" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) var ruleIDPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$`) @@ -134,9 +134,10 @@ func planRules(snapshot BusinessSystemPromptSnapshot, target BusinessSystemPromp return application, err } selected := policy.DefaultRuleIDs - if binding.Mode == "off" { + switch binding.Mode { + case "off": selected = nil - } else if binding.Mode == "custom" { + case "custom": selected = binding.RuleIDs } resolved := map[string]extensionv1.ResolvedPromptRule{} diff --git a/plugins/prompt-skills/policy/rules_test.go b/backend/internal/promptskills/policy/rules_test.go similarity index 98% rename from plugins/prompt-skills/policy/rules_test.go rename to backend/internal/promptskills/policy/rules_test.go index 87d990c1acc7..1ede39148efb 100644 --- a/plugins/prompt-skills/policy/rules_test.go +++ b/backend/internal/promptskills/policy/rules_test.go @@ -4,7 +4,7 @@ import ( "errors" "testing" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func ruleSnapshot(delivery, position string) BusinessSystemPromptSnapshot { diff --git a/plugins/prompt-skills/registry/controller.go b/backend/internal/promptskills/registry/controller.go similarity index 99% rename from plugins/prompt-skills/registry/controller.go rename to backend/internal/promptskills/registry/controller.go index d5855cb9e658..3b73994897ab 100644 --- a/plugins/prompt-skills/registry/controller.go +++ b/backend/internal/promptskills/registry/controller.go @@ -9,7 +9,7 @@ import ( "strings" "sync" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "golang.org/x/text/unicode/norm" ) diff --git a/plugins/prompt-skills/registry/manifest.go b/backend/internal/promptskills/registry/manifest.go similarity index 91% rename from plugins/prompt-skills/registry/manifest.go rename to backend/internal/promptskills/registry/manifest.go index 8c7c66a89fb3..879463674567 100644 --- a/plugins/prompt-skills/registry/manifest.go +++ b/backend/internal/promptskills/registry/manifest.go @@ -10,7 +10,7 @@ import ( "strings" "unicode/utf8" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "golang.org/x/text/unicode/norm" ) @@ -35,8 +35,6 @@ type remoteSkillManifest = extensionv1.SkillManifest type remoteSkillManifestEntry = extensionv1.SkillManifestEntry -type remoteSkillPinnedAssetProvenance = extensionv1.SkillAssetProvenance - func loadRemoteSkillManifest() (remoteSkillManifest, error) { raw, err := fs.ReadFile(remoteSkillSeedFS, remoteSkillManifestEmbeddedPath) if err != nil { @@ -177,20 +175,6 @@ func remoteSkillManifestEntryMatches(entry remoteSkillManifestEntry, body []byte return len(body) == entry.ByteLength && len(body) > 0 && utf8.Valid(body) && hashBusinessSystemPromptBundleBytes(body) == entry.SHA256 } -func loadRemoteSkillPinnedAsset(entry remoteSkillManifestEntry) ([]byte, error) { - if entry.SourceKind != "pinned" || entry.EmbeddedPath != remoteSkillPinnedWAFEmbeddedPath { - return nil, fmt.Errorf("%w: pinned manifest entry invalid", ErrBusinessSystemPromptBundleInvalid) - } - body, err := fs.ReadFile(remoteSkillSeedFS, "seed/"+entry.EmbeddedPath) - if err != nil { - return nil, err - } - if !remoteSkillManifestEntryMatches(entry, body) { - return nil, fmt.Errorf("%w: pinned asset content mismatch", ErrBusinessSystemPromptBundleInvalid) - } - return append([]byte(nil), body...), nil -} - func sortRemoteSkillPaths(paths []string) { sort.Slice(paths, func(i, j int) bool { left := portableRemoteSkillPathKey(paths[i]) diff --git a/plugins/prompt-skills/registry/prompts/codexrip_reverse_skill_system_prompt.txt b/backend/internal/promptskills/registry/prompts/codexrip_reverse_skill_system_prompt.txt similarity index 100% rename from plugins/prompt-skills/registry/prompts/codexrip_reverse_skill_system_prompt.txt rename to backend/internal/promptskills/registry/prompts/codexrip_reverse_skill_system_prompt.txt diff --git a/plugins/prompt-skills/registry/publication_test.go b/backend/internal/promptskills/registry/publication_test.go similarity index 97% rename from plugins/prompt-skills/registry/publication_test.go rename to backend/internal/promptskills/registry/publication_test.go index fd5bb7391ddf..c781eab3cbcd 100644 --- a/plugins/prompt-skills/registry/publication_test.go +++ b/backend/internal/promptskills/registry/publication_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" ) diff --git a/plugins/prompt-skills/registry/references.go b/backend/internal/promptskills/registry/references.go similarity index 100% rename from plugins/prompt-skills/registry/references.go rename to backend/internal/promptskills/registry/references.go diff --git a/plugins/prompt-skills/registry/seed/manifest.json b/backend/internal/promptskills/registry/seed/manifest.json similarity index 100% rename from plugins/prompt-skills/registry/seed/manifest.json rename to backend/internal/promptskills/registry/seed/manifest.json diff --git a/plugins/prompt-skills/registry/seed/pinned/waf-bypass.md b/backend/internal/promptskills/registry/seed/pinned/waf-bypass.md similarity index 100% rename from plugins/prompt-skills/registry/seed/pinned/waf-bypass.md rename to backend/internal/promptskills/registry/seed/pinned/waf-bypass.md diff --git a/plugins/prompt-skills/registry/seed/tree/CHANGELOG.md b/backend/internal/promptskills/registry/seed/tree/CHANGELOG.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/CHANGELOG.md rename to backend/internal/promptskills/registry/seed/tree/CHANGELOG.md diff --git a/plugins/prompt-skills/registry/seed/tree/LICENSE b/backend/internal/promptskills/registry/seed/tree/LICENSE similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/LICENSE rename to backend/internal/promptskills/registry/seed/tree/LICENSE diff --git a/plugins/prompt-skills/registry/seed/tree/NOTICE.md b/backend/internal/promptskills/registry/seed/tree/NOTICE.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/NOTICE.md rename to backend/internal/promptskills/registry/seed/tree/NOTICE.md diff --git a/plugins/prompt-skills/registry/seed/tree/README_AI.md b/backend/internal/promptskills/registry/seed/tree/README_AI.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/README_AI.md rename to backend/internal/promptskills/registry/seed/tree/README_AI.md diff --git a/plugins/prompt-skills/registry/seed/tree/RULES.md b/backend/internal/promptskills/registry/seed/tree/RULES.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/RULES.md rename to backend/internal/promptskills/registry/seed/tree/RULES.md diff --git a/plugins/prompt-skills/registry/seed/tree/SKILL.md b/backend/internal/promptskills/registry/seed/tree/SKILL.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/SKILL.md rename to backend/internal/promptskills/registry/seed/tree/SKILL.md diff --git a/plugins/prompt-skills/registry/seed/tree/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/assets/templates/cloud-handoff-prompt.md b/backend/internal/promptskills/registry/seed/tree/assets/templates/cloud-handoff-prompt.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/assets/templates/cloud-handoff-prompt.md rename to backend/internal/promptskills/registry/seed/tree/assets/templates/cloud-handoff-prompt.md diff --git a/plugins/prompt-skills/registry/seed/tree/assets/templates/ctf-writeup.md b/backend/internal/promptskills/registry/seed/tree/assets/templates/ctf-writeup.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/assets/templates/ctf-writeup.md rename to backend/internal/promptskills/registry/seed/tree/assets/templates/ctf-writeup.md diff --git a/plugins/prompt-skills/registry/seed/tree/assets/templates/mission-state.md b/backend/internal/promptskills/registry/seed/tree/assets/templates/mission-state.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/assets/templates/mission-state.md rename to backend/internal/promptskills/registry/seed/tree/assets/templates/mission-state.md diff --git a/plugins/prompt-skills/registry/seed/tree/assets/templates/research-result.json b/backend/internal/promptskills/registry/seed/tree/assets/templates/research-result.json similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/assets/templates/research-result.json rename to backend/internal/promptskills/registry/seed/tree/assets/templates/research-result.json diff --git a/plugins/prompt-skills/registry/seed/tree/assets/templates/writeups-index.md b/backend/internal/promptskills/registry/seed/tree/assets/templates/writeups-index.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/assets/templates/writeups-index.md rename to backend/internal/promptskills/registry/seed/tree/assets/templates/writeups-index.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/LICENSE b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/LICENSE similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/LICENSE rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/LICENSE diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/README.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/README.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/README.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/README.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-ad-certificate-abuse/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-ad-certificate-abuse/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-ad-certificate-abuse/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-ad-certificate-abuse/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-ad-certificate-abuse/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-ad-certificate-abuse/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-ad-certificate-abuse/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-ad-certificate-abuse/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-ad-certificate-abuse/references/ad-certificate-abuse.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-ad-certificate-abuse/references/ad-certificate-abuse.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-ad-certificate-abuse/references/ad-certificate-abuse.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-ad-certificate-abuse/references/ad-certificate-abuse.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-agent-cloud/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-agent-cloud/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-agent-cloud/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-agent-cloud/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-agent-cloud/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-agent-cloud/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-agent-cloud/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-agent-cloud/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-agent-cloud/references/agent-cloud.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-agent-cloud/references/agent-cloud.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-agent-cloud/references/agent-cloud.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-agent-cloud/references/agent-cloud.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-android-hooking/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-android-hooking/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-android-hooking/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-android-hooking/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-android-hooking/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-android-hooking/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-android-hooking/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-android-hooking/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-android-hooking/references/android-hooking.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-android-hooking/references/android-hooking.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-android-hooking/references/android-hooking.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-android-hooking/references/android-hooking.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-browser-persistence/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-browser-persistence/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-browser-persistence/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-browser-persistence/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-browser-persistence/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-browser-persistence/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-browser-persistence/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-browser-persistence/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-browser-persistence/references/browser-persistence.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-browser-persistence/references/browser-persistence.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-browser-persistence/references/browser-persistence.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-browser-persistence/references/browser-persistence.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-bundle-sourcemap-recovery/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-bundle-sourcemap-recovery/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-bundle-sourcemap-recovery/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-bundle-sourcemap-recovery/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-bundle-sourcemap-recovery/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-bundle-sourcemap-recovery/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-bundle-sourcemap-recovery/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-bundle-sourcemap-recovery/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-bundle-sourcemap-recovery/references/bundle-sourcemap-recovery.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-bundle-sourcemap-recovery/references/bundle-sourcemap-recovery.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-bundle-sourcemap-recovery/references/bundle-sourcemap-recovery.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-bundle-sourcemap-recovery/references/bundle-sourcemap-recovery.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-cloud-metadata-path/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-cloud-metadata-path/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-cloud-metadata-path/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-cloud-metadata-path/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-cloud-metadata-path/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-cloud-metadata-path/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-cloud-metadata-path/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-cloud-metadata-path/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-cloud-metadata-path/references/cloud-metadata-path.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-cloud-metadata-path/references/cloud-metadata-path.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-cloud-metadata-path/references/cloud-metadata-path.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-cloud-metadata-path/references/cloud-metadata-path.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-container-runtime/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-container-runtime/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-container-runtime/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-container-runtime/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-container-runtime/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-container-runtime/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-container-runtime/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-container-runtime/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-container-runtime/references/container-runtime.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-container-runtime/references/container-runtime.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-container-runtime/references/container-runtime.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-container-runtime/references/container-runtime.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-crypto-mobile/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-crypto-mobile/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-crypto-mobile/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-crypto-mobile/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-crypto-mobile/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-crypto-mobile/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-crypto-mobile/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-crypto-mobile/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-crypto-mobile/references/crypto-mobile.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-crypto-mobile/references/crypto-mobile.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-crypto-mobile/references/crypto-mobile.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-crypto-mobile/references/crypto-mobile.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-custom-protocol-replay/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-custom-protocol-replay/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-custom-protocol-replay/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-custom-protocol-replay/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-custom-protocol-replay/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-custom-protocol-replay/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-custom-protocol-replay/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-custom-protocol-replay/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-custom-protocol-replay/references/custom-protocol-replay.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-custom-protocol-replay/references/custom-protocol-replay.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-custom-protocol-replay/references/custom-protocol-replay.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-custom-protocol-replay/references/custom-protocol-replay.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-dpapi-credential-chain/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-dpapi-credential-chain/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-dpapi-credential-chain/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-dpapi-credential-chain/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-dpapi-credential-chain/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-dpapi-credential-chain/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-dpapi-credential-chain/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-dpapi-credential-chain/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-dpapi-credential-chain/references/dpapi-credential-chain.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-dpapi-credential-chain/references/dpapi-credential-chain.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-dpapi-credential-chain/references/dpapi-credential-chain.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-dpapi-credential-chain/references/dpapi-credential-chain.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-file-parser-chain/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-file-parser-chain/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-file-parser-chain/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-file-parser-chain/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-file-parser-chain/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-file-parser-chain/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-file-parser-chain/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-file-parser-chain/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-file-parser-chain/references/file-parser-chain.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-file-parser-chain/references/file-parser-chain.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-file-parser-chain/references/file-parser-chain.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-file-parser-chain/references/file-parser-chain.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-firmware-layout/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-firmware-layout/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-firmware-layout/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-firmware-layout/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-firmware-layout/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-firmware-layout/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-firmware-layout/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-firmware-layout/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-firmware-layout/references/firmware-layout.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-firmware-layout/references/firmware-layout.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-firmware-layout/references/firmware-layout.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-firmware-layout/references/firmware-layout.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-forensic-timeline/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-forensic-timeline/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-forensic-timeline/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-forensic-timeline/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-forensic-timeline/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-forensic-timeline/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-forensic-timeline/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-forensic-timeline/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-forensic-timeline/references/forensic-timeline.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-forensic-timeline/references/forensic-timeline.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-forensic-timeline/references/forensic-timeline.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-forensic-timeline/references/forensic-timeline.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-graphql-rpc-drift/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-graphql-rpc-drift/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-graphql-rpc-drift/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-graphql-rpc-drift/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-graphql-rpc-drift/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-graphql-rpc-drift/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-graphql-rpc-drift/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-graphql-rpc-drift/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-graphql-rpc-drift/references/graphql-rpc-drift.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-graphql-rpc-drift/references/graphql-rpc-drift.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-graphql-rpc-drift/references/graphql-rpc-drift.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-graphql-rpc-drift/references/graphql-rpc-drift.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-identity-windows/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-identity-windows/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-identity-windows/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-identity-windows/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-identity-windows/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-identity-windows/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-identity-windows/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-identity-windows/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-identity-windows/references/identity-windows.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-identity-windows/references/identity-windows.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-identity-windows/references/identity-windows.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-identity-windows/references/identity-windows.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-ios-runtime/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-ios-runtime/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-ios-runtime/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-ios-runtime/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-ios-runtime/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-ios-runtime/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-ios-runtime/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-ios-runtime/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-ios-runtime/references/ios-runtime.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-ios-runtime/references/ios-runtime.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-ios-runtime/references/ios-runtime.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-ios-runtime/references/ios-runtime.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-jwt-claim-confusion/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-jwt-claim-confusion/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-jwt-claim-confusion/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-jwt-claim-confusion/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-jwt-claim-confusion/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-jwt-claim-confusion/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-jwt-claim-confusion/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-jwt-claim-confusion/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-jwt-claim-confusion/references/jwt-claim-confusion.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-jwt-claim-confusion/references/jwt-claim-confusion.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-jwt-claim-confusion/references/jwt-claim-confusion.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-jwt-claim-confusion/references/jwt-claim-confusion.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-k8s-control-plane/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-k8s-control-plane/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-k8s-control-plane/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-k8s-control-plane/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-k8s-control-plane/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-k8s-control-plane/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-k8s-control-plane/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-k8s-control-plane/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-k8s-control-plane/references/k8s-control-plane.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-k8s-control-plane/references/k8s-control-plane.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-k8s-control-plane/references/k8s-control-plane.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-k8s-control-plane/references/k8s-control-plane.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-kerberos-delegation/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-kerberos-delegation/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-kerberos-delegation/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-kerberos-delegation/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-kerberos-delegation/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-kerberos-delegation/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-kerberos-delegation/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-kerberos-delegation/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-kerberos-delegation/references/kerberos-delegation.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-kerberos-delegation/references/kerberos-delegation.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-kerberos-delegation/references/kerberos-delegation.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-kerberos-delegation/references/kerberos-delegation.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-kernel-container-escape/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-kernel-container-escape/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-kernel-container-escape/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-kernel-container-escape/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-kernel-container-escape/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-kernel-container-escape/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-kernel-container-escape/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-kernel-container-escape/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-kernel-container-escape/references/kernel-container-escape.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-kernel-container-escape/references/kernel-container-escape.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-kernel-container-escape/references/kernel-container-escape.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-kernel-container-escape/references/kernel-container-escape.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-linux-credential-pivot/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-linux-credential-pivot/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-linux-credential-pivot/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-linux-credential-pivot/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-linux-credential-pivot/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-linux-credential-pivot/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-linux-credential-pivot/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-linux-credential-pivot/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-linux-credential-pivot/references/linux-credential-pivot.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-linux-credential-pivot/references/linux-credential-pivot.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-linux-credential-pivot/references/linux-credential-pivot.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-linux-credential-pivot/references/linux-credential-pivot.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-lsass-ticket-material/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-lsass-ticket-material/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-lsass-ticket-material/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-lsass-ticket-material/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-lsass-ticket-material/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-lsass-ticket-material/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-lsass-ticket-material/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-lsass-ticket-material/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-lsass-ticket-material/references/lsass-ticket-material.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-lsass-ticket-material/references/lsass-ticket-material.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-lsass-ticket-material/references/lsass-ticket-material.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-lsass-ticket-material/references/lsass-ticket-material.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-mailbox-abuse/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-mailbox-abuse/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-mailbox-abuse/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-mailbox-abuse/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-mailbox-abuse/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-mailbox-abuse/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-mailbox-abuse/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-mailbox-abuse/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-mailbox-abuse/references/mailbox-abuse.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-mailbox-abuse/references/mailbox-abuse.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-mailbox-abuse/references/mailbox-abuse.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-mailbox-abuse/references/mailbox-abuse.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-malware-config/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-malware-config/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-malware-config/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-malware-config/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-malware-config/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-malware-config/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-malware-config/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-malware-config/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-malware-config/references/malware-config.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-malware-config/references/malware-config.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-malware-config/references/malware-config.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-malware-config/references/malware-config.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-oauth-oidc-chain/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-oauth-oidc-chain/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-oauth-oidc-chain/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-oauth-oidc-chain/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-oauth-oidc-chain/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-oauth-oidc-chain/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-oauth-oidc-chain/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-oauth-oidc-chain/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-oauth-oidc-chain/references/oauth-oidc-chain.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-oauth-oidc-chain/references/oauth-oidc-chain.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-oauth-oidc-chain/references/oauth-oidc-chain.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-oauth-oidc-chain/references/oauth-oidc-chain.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-pcap-protocol/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-pcap-protocol/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-pcap-protocol/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-pcap-protocol/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-pcap-protocol/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-pcap-protocol/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-pcap-protocol/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-pcap-protocol/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-pcap-protocol/references/pcap-protocol.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-pcap-protocol/references/pcap-protocol.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-pcap-protocol/references/pcap-protocol.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-pcap-protocol/references/pcap-protocol.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-prompt-injection/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-prompt-injection/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-prompt-injection/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-prompt-injection/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-prompt-injection/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-prompt-injection/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-prompt-injection/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-prompt-injection/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-prompt-injection/references/prompt-injection.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-prompt-injection/references/prompt-injection.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-prompt-injection/references/prompt-injection.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-prompt-injection/references/prompt-injection.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-queue-worker-drift/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-queue-worker-drift/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-queue-worker-drift/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-queue-worker-drift/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-queue-worker-drift/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-queue-worker-drift/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-queue-worker-drift/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-queue-worker-drift/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-queue-worker-drift/references/queue-worker-drift.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-queue-worker-drift/references/queue-worker-drift.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-queue-worker-drift/references/queue-worker-drift.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-queue-worker-drift/references/queue-worker-drift.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-race-condition-state-drift/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-race-condition-state-drift/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-race-condition-state-drift/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-race-condition-state-drift/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-race-condition-state-drift/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-race-condition-state-drift/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-race-condition-state-drift/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-race-condition-state-drift/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-race-condition-state-drift/references/race-condition-state-drift.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-race-condition-state-drift/references/race-condition-state-drift.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-race-condition-state-drift/references/race-condition-state-drift.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-race-condition-state-drift/references/race-condition-state-drift.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-relay-coercion-chain/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-relay-coercion-chain/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-relay-coercion-chain/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-relay-coercion-chain/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-relay-coercion-chain/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-relay-coercion-chain/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-relay-coercion-chain/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-relay-coercion-chain/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-relay-coercion-chain/references/relay-coercion-chain.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-relay-coercion-chain/references/relay-coercion-chain.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-relay-coercion-chain/references/relay-coercion-chain.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-relay-coercion-chain/references/relay-coercion-chain.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-request-normalization-smuggling/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-request-normalization-smuggling/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-request-normalization-smuggling/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-request-normalization-smuggling/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-request-normalization-smuggling/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-request-normalization-smuggling/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-request-normalization-smuggling/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-request-normalization-smuggling/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-request-normalization-smuggling/references/request-normalization-smuggling.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-request-normalization-smuggling/references/request-normalization-smuggling.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-request-normalization-smuggling/references/request-normalization-smuggling.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-request-normalization-smuggling/references/request-normalization-smuggling.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-reverse-pwn/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-reverse-pwn/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-reverse-pwn/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-reverse-pwn/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-reverse-pwn/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-reverse-pwn/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-reverse-pwn/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-reverse-pwn/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-reverse-pwn/references/reverse-pwn.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-reverse-pwn/references/reverse-pwn.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-reverse-pwn/references/reverse-pwn.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-reverse-pwn/references/reverse-pwn.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-runtime-routing/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-runtime-routing/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-runtime-routing/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-runtime-routing/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-runtime-routing/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-runtime-routing/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-runtime-routing/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-runtime-routing/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-runtime-routing/references/runtime-routing.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-runtime-routing/references/runtime-routing.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-runtime-routing/references/runtime-routing.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-runtime-routing/references/runtime-routing.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-ssrf-metadata-pivot/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-ssrf-metadata-pivot/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-ssrf-metadata-pivot/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-ssrf-metadata-pivot/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-ssrf-metadata-pivot/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-ssrf-metadata-pivot/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-ssrf-metadata-pivot/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-ssrf-metadata-pivot/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-ssrf-metadata-pivot/references/ssrf-metadata-pivot.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-ssrf-metadata-pivot/references/ssrf-metadata-pivot.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-ssrf-metadata-pivot/references/ssrf-metadata-pivot.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-ssrf-metadata-pivot/references/ssrf-metadata-pivot.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-stego-media/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-stego-media/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-stego-media/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-stego-media/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-stego-media/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-stego-media/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-stego-media/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-stego-media/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-stego-media/references/stego-media.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-stego-media/references/stego-media.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-stego-media/references/stego-media.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-stego-media/references/stego-media.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-supply-chain/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-supply-chain/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-supply-chain/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-supply-chain/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-supply-chain/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-supply-chain/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-supply-chain/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-supply-chain/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-supply-chain/references/supply-chain.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-supply-chain/references/supply-chain.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-supply-chain/references/supply-chain.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-supply-chain/references/supply-chain.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-template-render-path/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-template-render-path/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-template-render-path/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-template-render-path/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-template-render-path/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-template-render-path/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-template-render-path/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-template-render-path/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-template-render-path/references/template-render-path.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-template-render-path/references/template-render-path.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-template-render-path/references/template-render-path.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-template-render-path/references/template-render-path.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-web-runtime/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-web-runtime/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-web-runtime/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-web-runtime/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-web-runtime/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-web-runtime/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-web-runtime/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-web-runtime/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-web-runtime/references/cookie-hmac-key-reuse-auth-bypass.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-web-runtime/references/cookie-hmac-key-reuse-auth-bypass.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-web-runtime/references/cookie-hmac-key-reuse-auth-bypass.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-web-runtime/references/cookie-hmac-key-reuse-auth-bypass.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-web-runtime/references/routing-runtime.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-web-runtime/references/routing-runtime.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-web-runtime/references/routing-runtime.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-web-runtime/references/routing-runtime.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-websocket-runtime/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-websocket-runtime/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-websocket-runtime/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-websocket-runtime/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-websocket-runtime/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-websocket-runtime/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-websocket-runtime/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-websocket-runtime/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-websocket-runtime/references/websocket-runtime.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-websocket-runtime/references/websocket-runtime.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-websocket-runtime/references/websocket-runtime.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-websocket-runtime/references/websocket-runtime.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-windows-pivot/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-windows-pivot/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-windows-pivot/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-windows-pivot/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-windows-pivot/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-windows-pivot/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-windows-pivot/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-windows-pivot/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-windows-pivot/references/windows-pivot.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-windows-pivot/references/windows-pivot.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-windows-pivot/references/windows-pivot.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/competition-windows-pivot/references/windows-pivot.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/agents/openai.yaml b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/agents/openai.yaml similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/agents/openai.yaml rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/agents/openai.yaml diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/agent-cloud.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/agent-cloud.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/agent-cloud.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/agent-cloud.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/crypto-mobile.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/crypto-mobile.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/crypto-mobile.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/crypto-mobile.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/ctf-resources-digest.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/ctf-resources-digest.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/ctf-resources-digest.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/ctf-resources-digest.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/identity-windows.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/identity-windows.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/identity-windows.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/identity-windows.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/reporting.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/reporting.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/reporting.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/reporting.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/reverse-native.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/reverse-native.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/reverse-native.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/reverse-native.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/router-matrix.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/router-matrix.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/router-matrix.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/router-matrix.md diff --git a/plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/web-api.md b/backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/web-api.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/web-api.md rename to backend/internal/promptskills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/web-api.md diff --git a/plugins/prompt-skills/registry/seed/tree/evals/evals.json b/backend/internal/promptskills/registry/seed/tree/evals/evals.json similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/evals/evals.json rename to backend/internal/promptskills/registry/seed/tree/evals/evals.json diff --git a/plugins/prompt-skills/registry/seed/tree/evals/files/decoy-flags.txt b/backend/internal/promptskills/registry/seed/tree/evals/files/decoy-flags.txt similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/evals/files/decoy-flags.txt rename to backend/internal/promptskills/registry/seed/tree/evals/files/decoy-flags.txt diff --git a/plugins/prompt-skills/registry/seed/tree/evals/files/redaction-sample.har b/backend/internal/promptskills/registry/seed/tree/evals/files/redaction-sample.har similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/evals/files/redaction-sample.har rename to backend/internal/promptskills/registry/seed/tree/evals/files/redaction-sample.har diff --git a/plugins/prompt-skills/registry/seed/tree/evals/files/runtime-conflict/capture-summary.json b/backend/internal/promptskills/registry/seed/tree/evals/files/runtime-conflict/capture-summary.json similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/evals/files/runtime-conflict/capture-summary.json rename to backend/internal/promptskills/registry/seed/tree/evals/files/runtime-conflict/capture-summary.json diff --git a/plugins/prompt-skills/registry/seed/tree/evals/files/runtime-conflict/source-note.md b/backend/internal/promptskills/registry/seed/tree/evals/files/runtime-conflict/source-note.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/evals/files/runtime-conflict/source-note.md rename to backend/internal/promptskills/registry/seed/tree/evals/files/runtime-conflict/source-note.md diff --git a/plugins/prompt-skills/registry/seed/tree/evals/files/safe_handler.py b/backend/internal/promptskills/registry/seed/tree/evals/files/safe_handler.py similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/evals/files/safe_handler.py rename to backend/internal/promptskills/registry/seed/tree/evals/files/safe_handler.py diff --git a/plugins/prompt-skills/registry/seed/tree/evals/files/untrusted-agent-document.txt b/backend/internal/promptskills/registry/seed/tree/evals/files/untrusted-agent-document.txt similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/evals/files/untrusted-agent-document.txt rename to backend/internal/promptskills/registry/seed/tree/evals/files/untrusted-agent-document.txt diff --git a/plugins/prompt-skills/registry/seed/tree/evals/trigger-evals.json b/backend/internal/promptskills/registry/seed/tree/evals/trigger-evals.json similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/evals/trigger-evals.json rename to backend/internal/promptskills/registry/seed/tree/evals/trigger-evals.json diff --git a/plugins/prompt-skills/registry/seed/tree/references/ai-security.md b/backend/internal/promptskills/registry/seed/tree/references/ai-security.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ai-security.md rename to backend/internal/promptskills/registry/seed/tree/references/ai-security.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/ai-ml/adversarial-ml.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/ai-ml/adversarial-ml.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/ai-ml/adversarial-ml.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/ai-ml/adversarial-ml.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/ai-ml/index.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/ai-ml/index.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/ai-ml/index.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/ai-ml/index.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/ai-ml/llm-attacks.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/ai-ml/llm-attacks.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/ai-ml/llm-attacks.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/ai-ml/llm-attacks.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/ai-ml/model-attacks.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/ai-ml/model-attacks.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/ai-ml/model-attacks.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/ai-ml/model-attacks.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/advanced-math.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/advanced-math.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/advanced-math.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/advanced-math.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/classic-ciphers.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/classic-ciphers.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/classic-ciphers.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/classic-ciphers.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/ecc-attacks.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/ecc-attacks.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/ecc-attacks.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/ecc-attacks.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/exotic-crypto-2.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/exotic-crypto-2.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/exotic-crypto-2.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/exotic-crypto-2.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/exotic-crypto.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/exotic-crypto.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/exotic-crypto.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/exotic-crypto.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/historical.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/historical.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/historical.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/historical.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/index.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/index.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/index.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/index.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/lattice-and-lwe.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/lattice-and-lwe.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/lattice-and-lwe.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/lattice-and-lwe.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/modern-ciphers-2.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/modern-ciphers-2.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/modern-ciphers-2.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/modern-ciphers-2.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/modern-ciphers-3.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/modern-ciphers-3.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/modern-ciphers-3.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/modern-ciphers-3.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/modern-ciphers.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/modern-ciphers.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/modern-ciphers.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/modern-ciphers.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/prng-attacks.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/prng-attacks.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/prng-attacks.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/prng-attacks.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/prng.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/prng.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/prng.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/prng.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/rsa-attacks-2.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/rsa-attacks-2.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/rsa-attacks-2.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/rsa-attacks-2.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/rsa-attacks.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/rsa-attacks.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/rsa-attacks.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/rsa-attacks.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/stream-ciphers.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/stream-ciphers.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/stream-ciphers.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/stream-ciphers.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/zkp-and-advanced.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/zkp-and-advanced.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/zkp-and-advanced.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/crypto/zkp-and-advanced.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/3d-printing.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/3d-printing.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/3d-printing.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/3d-printing.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/disk-advanced.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/disk-advanced.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/disk-advanced.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/disk-advanced.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/disk-and-memory.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/disk-and-memory.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/disk-and-memory.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/disk-and-memory.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/disk-recovery.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/disk-recovery.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/disk-recovery.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/disk-recovery.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/index.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/index.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/index.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/index.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/linux-forensics.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/linux-forensics.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/linux-forensics.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/linux-forensics.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/network-advanced.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/network-advanced.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/network-advanced.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/network-advanced.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/network.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/network.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/network.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/network.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/peripheral-capture.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/peripheral-capture.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/peripheral-capture.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/peripheral-capture.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/signals-and-hardware.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/signals-and-hardware.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/signals-and-hardware.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/signals-and-hardware.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/steganography.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/steganography.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/steganography.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/steganography.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/stego-advanced-2.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/stego-advanced-2.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/stego-advanced-2.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/stego-advanced-2.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/stego-advanced.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/stego-advanced.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/stego-advanced.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/stego-advanced.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/stego-image.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/stego-image.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/stego-image.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/stego-image.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/windows.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/windows.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/windows.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/forensics/windows.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/malware/c2-and-protocols.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/malware/c2-and-protocols.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/malware/c2-and-protocols.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/malware/c2-and-protocols.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/malware/index.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/malware/index.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/malware/index.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/malware/index.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/malware/pe-and-dotnet.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/malware/pe-and-dotnet.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/malware/pe-and-dotnet.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/malware/pe-and-dotnet.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/malware/scripts-and-obfuscation.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/malware/scripts-and-obfuscation.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/malware/scripts-and-obfuscation.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/malware/scripts-and-obfuscation.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/misc/bashjails.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/misc/bashjails.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/misc/bashjails.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/misc/bashjails.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/misc/ctfd-navigation.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/misc/ctfd-navigation.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/misc/ctfd-navigation.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/misc/ctfd-navigation.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/misc/dns.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/misc/dns.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/misc/dns.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/misc/dns.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/misc/encodings-advanced.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/misc/encodings-advanced.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/misc/encodings-advanced.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/misc/encodings-advanced.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/misc/encodings.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/misc/encodings.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/misc/encodings.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/misc/encodings.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/misc/games-and-vms-2.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/misc/games-and-vms-2.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/misc/games-and-vms-2.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/misc/games-and-vms-2.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/misc/games-and-vms-3.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/misc/games-and-vms-3.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/misc/games-and-vms-3.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/misc/games-and-vms-3.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/misc/games-and-vms-4.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/misc/games-and-vms-4.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/misc/games-and-vms-4.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/misc/games-and-vms-4.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/misc/games-and-vms.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/misc/games-and-vms.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/misc/games-and-vms.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/misc/games-and-vms.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/misc/index.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/misc/index.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/misc/index.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/misc/index.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/misc/linux-privesc.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/misc/linux-privesc.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/misc/linux-privesc.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/misc/linux-privesc.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/misc/pyjails.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/misc/pyjails.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/misc/pyjails.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/misc/pyjails.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/misc/rf-sdr.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/misc/rf-sdr.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/misc/rf-sdr.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/misc/rf-sdr.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/osint/geolocation-and-media.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/osint/geolocation-and-media.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/osint/geolocation-and-media.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/osint/geolocation-and-media.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/osint/index.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/osint/index.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/osint/index.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/osint/index.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/osint/social-media.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/osint/social-media.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/osint/social-media.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/osint/social-media.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/osint/web-and-dns.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/osint/web-and-dns.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/osint/web-and-dns.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/osint/web-and-dns.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/advanced-exploits-2.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/advanced-exploits-2.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/advanced-exploits-2.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/advanced-exploits-2.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/advanced-exploits-3.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/advanced-exploits-3.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/advanced-exploits-3.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/advanced-exploits-3.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/advanced-exploits-4.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/advanced-exploits-4.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/advanced-exploits-4.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/advanced-exploits-4.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/advanced-exploits-5.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/advanced-exploits-5.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/advanced-exploits-5.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/advanced-exploits-5.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/advanced-exploits.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/advanced-exploits.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/advanced-exploits.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/advanced-exploits.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/advanced.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/advanced.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/advanced.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/advanced.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/field-notes.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/field-notes.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/field-notes.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/field-notes.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/format-string.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/format-string.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/format-string.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/format-string.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/heap-fsop.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/heap-fsop.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/heap-fsop.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/heap-fsop.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/heap-techniques-2.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/heap-techniques-2.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/heap-techniques-2.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/heap-techniques-2.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/heap-techniques.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/heap-techniques.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/heap-techniques.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/heap-techniques.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/index.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/index.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/index.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/index.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/kernel-bypass.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/kernel-bypass.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/kernel-bypass.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/kernel-bypass.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/kernel-techniques.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/kernel-techniques.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/kernel-techniques.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/kernel-techniques.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/kernel.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/kernel.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/kernel.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/kernel.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/overflow-basics.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/overflow-basics.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/overflow-basics.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/overflow-basics.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/rop-advanced.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/rop-advanced.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/rop-advanced.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/rop-advanced.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/rop-and-shellcode.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/rop-and-shellcode.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/rop-and-shellcode.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/rop-and-shellcode.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/sandbox-escape.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/sandbox-escape.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/sandbox-escape.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/pwn/sandbox-escape.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/anti-analysis-ctf.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/anti-analysis-ctf.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/anti-analysis-ctf.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/anti-analysis-ctf.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/anti-analysis.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/anti-analysis.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/anti-analysis.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/anti-analysis.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/field-notes.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/field-notes.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/field-notes.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/field-notes.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/index.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/index.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/index.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/index.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/languages-compiled.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/languages-compiled.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/languages-compiled.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/languages-compiled.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/languages-platforms.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/languages-platforms.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/languages-platforms.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/languages-platforms.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/languages.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/languages.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/languages.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/languages.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/patterns-ctf-2.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/patterns-ctf-2.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/patterns-ctf-2.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/patterns-ctf-2.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/patterns-ctf-3.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/patterns-ctf-3.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/patterns-ctf-3.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/patterns-ctf-3.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/patterns-ctf.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/patterns-ctf.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/patterns-ctf.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/patterns-ctf.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/patterns-runtime.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/patterns-runtime.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/patterns-runtime.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/patterns-runtime.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/patterns.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/patterns.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/patterns.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/patterns.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/platforms-hardware.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/platforms-hardware.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/platforms-hardware.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/platforms-hardware.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/platforms.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/platforms.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/platforms.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/platforms.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/tools-advanced-2.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/tools-advanced-2.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/tools-advanced-2.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/tools-advanced-2.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/tools-advanced.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/tools-advanced.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/tools-advanced.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/tools-advanced.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/tools-dynamic.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/tools-dynamic.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/tools-dynamic.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/tools-dynamic.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/tools-emulation.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/tools-emulation.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/tools-emulation.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/tools-emulation.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/tools.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/tools.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/tools.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/reverse/tools.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/web/auth-and-access-2.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/web/auth-and-access-2.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/web/auth-and-access-2.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/web/auth-and-access-2.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/web/auth-and-access.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/web/auth-and-access.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/web/auth-and-access.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/web/auth-and-access.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/web/auth-infra.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/web/auth-infra.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/web/auth-infra.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/web/auth-infra.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/web/auth-jwt.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/web/auth-jwt.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/web/auth-jwt.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/web/auth-jwt.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/web/client-side-advanced.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/web/client-side-advanced.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/web/client-side-advanced.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/web/client-side-advanced.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/web/client-side.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/web/client-side.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/web/client-side.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/web/client-side.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/web/cves.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/web/cves.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/web/cves.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/web/cves.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/web/field-notes.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/web/field-notes.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/web/field-notes.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/web/field-notes.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/web/index.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/web/index.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/web/index.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/web/index.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/web/node-and-prototype.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/web/node-and-prototype.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/web/node-and-prototype.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/web/node-and-prototype.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/web/server-side-2.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/web/server-side-2.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/web/server-side-2.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/web/server-side-2.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/web/server-side-advanced-2.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/web/server-side-advanced-2.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/web/server-side-advanced-2.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/web/server-side-advanced-2.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/web/server-side-advanced-3.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/web/server-side-advanced-3.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/web/server-side-advanced-3.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/web/server-side-advanced-3.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/web/server-side-advanced-4.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/web/server-side-advanced-4.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/web/server-side-advanced-4.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/web/server-side-advanced-4.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/web/server-side-advanced.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/web/server-side-advanced.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/web/server-side-advanced.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/web/server-side-advanced.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/web/server-side-deser.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/web/server-side-deser.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/web/server-side-deser.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/web/server-side-deser.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/web/server-side-exec-2.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/web/server-side-exec-2.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/web/server-side-exec-2.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/web/server-side-exec-2.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/web/server-side-exec.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/web/server-side-exec.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/web/server-side-exec.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/web/server-side-exec.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/web/server-side.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/web/server-side.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/web/server-side.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/web/server-side.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/web/sql-injection.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/web/sql-injection.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/web/sql-injection.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/web/sql-injection.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/web/web3.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/web/web3.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/web/web3.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/web/web3.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/ctf/writeup/index.md b/backend/internal/promptskills/registry/seed/tree/references/ctf/writeup/index.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/ctf/writeup/index.md rename to backend/internal/promptskills/registry/seed/tree/references/ctf/writeup/index.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/environment-and-resources.md b/backend/internal/promptskills/registry/seed/tree/references/environment-and-resources.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/environment-and-resources.md rename to backend/internal/promptskills/registry/seed/tree/references/environment-and-resources.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/evidence-workflow.md b/backend/internal/promptskills/registry/seed/tree/references/evidence-workflow.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/evidence-workflow.md rename to backend/internal/promptskills/registry/seed/tree/references/evidence-workflow.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/experience-index.md b/backend/internal/promptskills/registry/seed/tree/references/experience-index.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/experience-index.md rename to backend/internal/promptskills/registry/seed/tree/references/experience-index.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/experience/deobfuscation.md b/backend/internal/promptskills/registry/seed/tree/references/experience/deobfuscation.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/experience/deobfuscation.md rename to backend/internal/promptskills/registry/seed/tree/references/experience/deobfuscation.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/experience/pitfalls.md b/backend/internal/promptskills/registry/seed/tree/references/experience/pitfalls.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/experience/pitfalls.md rename to backend/internal/promptskills/registry/seed/tree/references/experience/pitfalls.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/experience/reverse-unpacking.md b/backend/internal/promptskills/registry/seed/tree/references/experience/reverse-unpacking.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/experience/reverse-unpacking.md rename to backend/internal/promptskills/registry/seed/tree/references/experience/reverse-unpacking.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/experience/web-captcha.md b/backend/internal/promptskills/registry/seed/tree/references/experience/web-captcha.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/experience/web-captcha.md rename to backend/internal/promptskills/registry/seed/tree/references/experience/web-captcha.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/field-journal-template.md b/backend/internal/promptskills/registry/seed/tree/references/field-journal-template.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/field-journal-template.md rename to backend/internal/promptskills/registry/seed/tree/references/field-journal-template.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/malware.md b/backend/internal/promptskills/registry/seed/tree/references/malware.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/malware.md rename to backend/internal/promptskills/registry/seed/tree/references/malware.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/pentest.md b/backend/internal/promptskills/registry/seed/tree/references/pentest.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/pentest.md rename to backend/internal/promptskills/registry/seed/tree/references/pentest.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/precedent-auth.md b/backend/internal/promptskills/registry/seed/tree/references/precedent-auth.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/precedent-auth.md rename to backend/internal/promptskills/registry/seed/tree/references/precedent-auth.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/progressive-reasoning.md b/backend/internal/promptskills/registry/seed/tree/references/progressive-reasoning.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/progressive-reasoning.md rename to backend/internal/promptskills/registry/seed/tree/references/progressive-reasoning.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/reporting.md b/backend/internal/promptskills/registry/seed/tree/references/reporting.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/reporting.md rename to backend/internal/promptskills/registry/seed/tree/references/reporting.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/reverse.md b/backend/internal/promptskills/registry/seed/tree/references/reverse.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/reverse.md rename to backend/internal/promptskills/registry/seed/tree/references/reverse.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/routing.md b/backend/internal/promptskills/registry/seed/tree/references/routing.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/routing.md rename to backend/internal/promptskills/registry/seed/tree/references/routing.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/scanner.md b/backend/internal/promptskills/registry/seed/tree/references/scanner.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/scanner.md rename to backend/internal/promptskills/registry/seed/tree/references/scanner.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/scope-and-evidence.md b/backend/internal/promptskills/registry/seed/tree/references/scope-and-evidence.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/scope-and-evidence.md rename to backend/internal/promptskills/registry/seed/tree/references/scope-and-evidence.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/source-provenance.md b/backend/internal/promptskills/registry/seed/tree/references/source-provenance.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/source-provenance.md rename to backend/internal/promptskills/registry/seed/tree/references/source-provenance.md diff --git a/plugins/prompt-skills/registry/seed/tree/references/tools.md b/backend/internal/promptskills/registry/seed/tree/references/tools.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/references/tools.md rename to backend/internal/promptskills/registry/seed/tree/references/tools.md diff --git a/plugins/prompt-skills/registry/seed/tree/schemas/research-result.schema.json b/backend/internal/promptskills/registry/seed/tree/schemas/research-result.schema.json similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/schemas/research-result.schema.json rename to backend/internal/promptskills/registry/seed/tree/schemas/research-result.schema.json diff --git a/plugins/prompt-skills/registry/seed/tree/scripts/env_probe.py b/backend/internal/promptskills/registry/seed/tree/scripts/env_probe.py similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/scripts/env_probe.py rename to backend/internal/promptskills/registry/seed/tree/scripts/env_probe.py diff --git a/plugins/prompt-skills/registry/seed/tree/scripts/reusable/artifact_inventory.py b/backend/internal/promptskills/registry/seed/tree/scripts/reusable/artifact_inventory.py similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/scripts/reusable/artifact_inventory.py rename to backend/internal/promptskills/registry/seed/tree/scripts/reusable/artifact_inventory.py diff --git a/plugins/prompt-skills/registry/seed/tree/scripts/reusable/har_summary.py b/backend/internal/promptskills/registry/seed/tree/scripts/reusable/har_summary.py similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/scripts/reusable/har_summary.py rename to backend/internal/promptskills/registry/seed/tree/scripts/reusable/har_summary.py diff --git a/plugins/prompt-skills/registry/seed/tree/scripts/reusable/new-experience-entry.ps1 b/backend/internal/promptskills/registry/seed/tree/scripts/reusable/new-experience-entry.ps1 similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/scripts/reusable/new-experience-entry.ps1 rename to backend/internal/promptskills/registry/seed/tree/scripts/reusable/new-experience-entry.ps1 diff --git a/plugins/prompt-skills/registry/seed/tree/scripts/reusable/new_experience_entry.py b/backend/internal/promptskills/registry/seed/tree/scripts/reusable/new_experience_entry.py similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/scripts/reusable/new_experience_entry.py rename to backend/internal/promptskills/registry/seed/tree/scripts/reusable/new_experience_entry.py diff --git a/plugins/prompt-skills/registry/seed/tree/scripts/reusable/pack_cloud_handoff.py b/backend/internal/promptskills/registry/seed/tree/scripts/reusable/pack_cloud_handoff.py similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/scripts/reusable/pack_cloud_handoff.py rename to backend/internal/promptskills/registry/seed/tree/scripts/reusable/pack_cloud_handoff.py diff --git a/plugins/prompt-skills/registry/seed/tree/scripts/reusable/pe_entropy_triage.py b/backend/internal/promptskills/registry/seed/tree/scripts/reusable/pe_entropy_triage.py similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/scripts/reusable/pe_entropy_triage.py rename to backend/internal/promptskills/registry/seed/tree/scripts/reusable/pe_entropy_triage.py diff --git a/plugins/prompt-skills/registry/seed/tree/scripts/reusable/route_task.py b/backend/internal/promptskills/registry/seed/tree/scripts/reusable/route_task.py similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/scripts/reusable/route_task.py rename to backend/internal/promptskills/registry/seed/tree/scripts/reusable/route_task.py diff --git a/plugins/prompt-skills/registry/seed/tree/scripts/reusable/scaffold_project.py b/backend/internal/promptskills/registry/seed/tree/scripts/reusable/scaffold_project.py similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/scripts/reusable/scaffold_project.py rename to backend/internal/promptskills/registry/seed/tree/scripts/reusable/scaffold_project.py diff --git a/plugins/prompt-skills/registry/seed/tree/scripts/validate_result.py b/backend/internal/promptskills/registry/seed/tree/scripts/validate_result.py similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/scripts/validate_result.py rename to backend/internal/promptskills/registry/seed/tree/scripts/validate_result.py diff --git a/plugins/prompt-skills/registry/seed/tree/scripts/validate_skill.py b/backend/internal/promptskills/registry/seed/tree/scripts/validate_skill.py similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/scripts/validate_skill.py rename to backend/internal/promptskills/registry/seed/tree/scripts/validate_skill.py diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-ai-security/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-ai-security/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-ai-security/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-ai-security/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-ai-security/references/llm-deep/_llm-security-workflow.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-ai-security/references/llm-deep/_llm-security-workflow.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-ai-security/references/llm-deep/_llm-security-workflow.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-ai-security/references/llm-deep/_llm-security-workflow.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-ai-security/references/llm-deep/agent-obedience-engineering.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-ai-security/references/llm-deep/agent-obedience-engineering.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-ai-security/references/llm-deep/agent-obedience-engineering.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-ai-security/references/llm-deep/agent-obedience-engineering.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-ai-security/references/llm-deep/agent-security-testing.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-ai-security/references/llm-deep/agent-security-testing.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-ai-security/references/llm-deep/agent-security-testing.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-ai-security/references/llm-deep/agent-security-testing.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-ai-security/references/llm-deep/owasp-llm-top10.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-ai-security/references/llm-deep/owasp-llm-top10.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-ai-security/references/llm-deep/owasp-llm-top10.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-ai-security/references/llm-deep/owasp-llm-top10.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-ai-security/references/llm-deep/prompt-injection-methodology.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-ai-security/references/llm-deep/prompt-injection-methodology.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-ai-security/references/llm-deep/prompt-injection-methodology.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-ai-security/references/llm-deep/prompt-injection-methodology.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/payloads/README.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/payloads/README.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/payloads/README.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/payloads/README.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/ai-pentest-agents.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/ai-pentest-agents.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/ai-pentest-agents.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/ai-pentest-agents.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/ai-pentesting-landscape-2026.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/ai-pentesting-landscape-2026.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/ai-pentesting-landscape-2026.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/ai-pentesting-landscape-2026.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/automation-loop-pattern.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/automation-loop-pattern.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/automation-loop-pattern.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/automation-loop-pattern.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/awesome-pentest-digest.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/awesome-pentest-digest.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/awesome-pentest-digest.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/awesome-pentest-digest.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/burpsuite-mcp-guide.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/burpsuite-mcp-guide.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/burpsuite-mcp-guide.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/burpsuite-mcp-guide.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/kali-mcp-ecosystem.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/kali-mcp-ecosystem.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/kali-mcp-ecosystem.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/kali-mcp-ecosystem.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/msf-protocol.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/msf-protocol.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/msf-protocol.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/msf-protocol.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/network-attack-defense.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/network-attack-defense.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/network-attack-defense.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/network-attack-defense.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/nuclei-guide-2026.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/nuclei-guide-2026.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/nuclei-guide-2026.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/nuclei-guide-2026.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/pentest-ai-agents-matrix.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/pentest-ai-agents-matrix.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/pentest-ai-agents-matrix.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/pentest-ai-agents-matrix.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/pentest-loop.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/pentest-loop.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/pentest-loop.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/pentest-loop.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/web-attack-cheatsheet.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/web-attack-cheatsheet.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/web-attack-cheatsheet.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/web-attack-cheatsheet.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/.claude-plugin/marketplace.json b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/.claude-plugin/marketplace.json similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/.claude-plugin/marketplace.json rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/.claude-plugin/marketplace.json diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/.gitignore b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/.gitignore similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/.gitignore rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/.gitignore diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/LICENSE b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/LICENSE similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/LICENSE rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/LICENSE diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/README.en.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/README.en.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/README.en.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/README.en.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/README.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/README.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/README.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/README.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/dictionaries/00-index.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/dictionaries/00-index.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/dictionaries/00-index.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/dictionaries/00-index.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/dictionaries/chinese-srcfingerprints.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/dictionaries/chinese-srcfingerprints.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/dictionaries/chinese-srcfingerprints.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/dictionaries/chinese-srcfingerprints.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/dictionaries/default-credentials-cn.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/dictionaries/default-credentials-cn.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/dictionaries/default-credentials-cn.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/dictionaries/default-credentials-cn.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/industry/00-index.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/industry/00-index.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/industry/00-index.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/industry/00-index.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/industry/banking-finance.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/industry/banking-finance.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/industry/banking-finance.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/industry/banking-finance.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/industry/telecom-isp.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/industry/telecom-isp.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/industry/telecom-isp.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/industry/telecom-isp.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/00-index.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/00-index.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/00-index.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/00-index.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/01-attack-priority.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/01-attack-priority.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/01-attack-priority.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/01-attack-priority.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/02-bypass-toolkit.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/02-bypass-toolkit.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/02-bypass-toolkit.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/02-bypass-toolkit.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/03-evidence-discipline.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/03-evidence-discipline.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/03-evidence-discipline.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/03-evidence-discipline.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/04-control-gap-hunting.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/04-control-gap-hunting.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/04-control-gap-hunting.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/04-control-gap-hunting.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/05-srctimebox-priority.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/05-srctimebox-priority.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/05-srctimebox-priority.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/05-srctimebox-priority.md diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/adcs\346\224\273\345\207\273.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/adcs\346\224\273\345\207\273.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/adcs\346\224\273\345\207\273.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/adcs\346\224\273\345\207\273.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/exchange\346\224\273\345\207\273.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/exchange\346\224\273\345\207\273.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/exchange\346\224\273\345\207\273.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/exchange\346\224\273\345\207\273.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/sharepoint\346\224\273\345\207\273.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/sharepoint\346\224\273\345\207\273.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/sharepoint\346\224\273\345\207\273.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/sharepoint\346\224\273\345\207\273.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\344\277\241\346\201\257\346\224\266\351\233\206.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\344\277\241\346\201\257\346\224\266\351\233\206.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\344\277\241\346\201\257\346\224\266\351\233\206.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\344\277\241\346\201\257\346\224\266\351\233\206.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\345\205\215\346\235\200\344\270\216\350\247\204\351\201\277.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\345\205\215\346\235\200\344\270\216\350\247\204\351\201\277.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\345\205\215\346\235\200\344\270\216\350\247\204\351\201\277.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\345\205\215\346\235\200\344\270\216\350\247\204\351\201\277.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\345\207\255\350\257\201\347\252\203\345\217\226.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\345\207\255\350\257\201\347\252\203\345\217\226.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\345\207\255\350\257\201\347\252\203\345\217\226.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\345\207\255\350\257\201\347\252\203\345\217\226.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\345\237\237\346\270\227\351\200\217\346\224\273\345\207\273.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\345\237\237\346\270\227\351\200\217\346\224\273\345\207\273.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\345\237\237\346\270\227\351\200\217\346\224\273\345\207\273.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\345\237\237\346\270\227\351\200\217\346\224\273\345\207\273.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\346\235\203\351\231\220\346\217\220\345\215\207.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\346\235\203\351\231\220\346\217\220\345\215\207.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\346\235\203\351\231\220\346\217\220\345\215\207.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\346\235\203\351\231\220\346\217\220\345\215\207.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\346\235\203\351\231\220\347\273\264\346\214\201.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\346\235\203\351\231\220\347\273\264\346\214\201.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\346\235\203\351\231\220\347\273\264\346\214\201.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\346\235\203\351\231\220\347\273\264\346\214\201.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\346\250\252\345\220\221\347\247\273\345\212\250.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\346\250\252\345\220\221\347\247\273\345\212\250.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\346\250\252\345\220\221\347\247\273\345\212\250.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\346\250\252\345\220\221\347\247\273\345\212\250.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\351\232\247\351\201\223\344\273\243\347\220\206.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\351\232\247\351\201\223\344\273\243\347\220\206.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\351\232\247\351\201\223\344\273\243\347\220\206.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\351\232\247\351\201\223\344\273\243\347\220\206.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/ai\345\256\211\345\205\250.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/ai\345\256\211\345\205\250.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/ai\345\256\211\345\205\250.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/ai\345\256\211\345\205\250.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/api\345\256\211\345\205\250.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/api\345\256\211\345\205\250.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/api\345\256\211\345\205\250.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/api\345\256\211\345\205\250.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/csrf\350\267\250\347\253\231\350\257\267\346\261\202\344\274\252\351\200\240.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/csrf\350\267\250\347\253\231\350\257\267\346\261\202\344\274\252\351\200\240.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/csrf\350\267\250\347\253\231\350\257\267\346\261\202\344\274\252\351\200\240.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/csrf\350\267\250\347\253\231\350\257\267\346\261\202\344\274\252\351\200\240.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/jwt\345\256\211\345\205\250.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/jwt\345\256\211\345\205\250.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/jwt\345\256\211\345\205\250.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/jwt\345\256\211\345\205\250.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/lfi-rfi\346\226\207\344\273\266\345\214\205\345\220\253.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/lfi-rfi\346\226\207\344\273\266\345\214\205\345\220\253.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/lfi-rfi\346\226\207\344\273\266\345\214\205\345\220\253.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/lfi-rfi\346\226\207\344\273\266\345\214\205\345\220\253.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/rce\350\277\234\347\250\213\344\273\243\347\240\201\346\211\247\350\241\214.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/rce\350\277\234\347\250\213\344\273\243\347\240\201\346\211\247\350\241\214.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/rce\350\277\234\347\250\213\344\273\243\347\240\201\346\211\247\350\241\214.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/rce\350\277\234\347\250\213\344\273\243\347\240\201\346\211\247\350\241\214.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/sql-nosql\346\263\250\345\205\245.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/sql-nosql\346\263\250\345\205\245.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/sql-nosql\346\263\250\345\205\245.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/sql-nosql\346\263\250\345\205\245.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/ssrf\346\234\215\345\212\241\347\253\257\350\257\267\346\261\202\344\274\252\351\200\240.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/ssrf\346\234\215\345\212\241\347\253\257\350\257\267\346\261\202\344\274\252\351\200\240.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/ssrf\346\234\215\345\212\241\347\253\257\350\257\267\346\261\202\344\274\252\351\200\240.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/ssrf\346\234\215\345\212\241\347\253\257\350\257\267\346\261\202\344\274\252\351\200\240.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/ssti\346\250\241\346\235\277\346\263\250\345\205\245.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/ssti\346\250\241\346\235\277\346\263\250\345\205\245.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/ssti\346\250\241\346\235\277\346\263\250\345\205\245.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/ssti\346\250\241\346\235\277\346\263\250\345\205\245.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/websocket\345\256\211\345\205\250.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/websocket\345\256\211\345\205\250.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/websocket\345\256\211\345\205\250.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/websocket\345\256\211\345\205\250.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/xss\350\267\250\347\253\231\350\204\232\346\234\254.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/xss\350\267\250\347\253\231\350\204\232\346\234\254.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/xss\350\267\250\347\253\231\350\204\232\346\234\254.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/xss\350\267\250\347\253\231\350\204\232\346\234\254.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/xxe\345\256\236\344\275\223\346\263\250\345\205\245.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/xxe\345\256\236\344\275\223\346\263\250\345\205\245.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/xxe\345\256\236\344\275\223\346\263\250\345\205\245.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/xxe\345\256\236\344\275\223\346\263\250\345\205\245.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\344\270\232\345\212\241\351\200\273\350\276\221\346\274\217\346\264\236.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\344\270\232\345\212\241\351\200\273\350\276\221\346\274\217\346\264\236.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\344\270\232\345\212\241\351\200\273\350\276\221\346\274\217\346\264\236.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\344\270\232\345\212\241\351\200\273\350\276\221\346\274\217\346\264\236.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\344\272\221\345\256\211\345\205\250\346\274\217\346\264\236.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\344\272\221\345\256\211\345\205\250\346\274\217\346\264\236.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\344\272\221\345\256\211\345\205\250\346\274\217\346\264\236.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\344\272\221\345\256\211\345\205\250\346\274\217\346\264\236.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\344\276\233\345\272\224\351\223\276\346\224\273\345\207\273.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\344\276\233\345\272\224\351\223\276\346\224\273\345\207\273.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\344\276\233\345\272\224\351\223\276\346\224\273\345\207\273.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\344\276\233\345\272\224\351\223\276\346\224\273\345\207\273.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\345\216\237\345\236\213\351\223\276\346\261\241\346\237\223.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\345\216\237\345\236\213\351\223\276\346\261\241\346\237\223.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\345\216\237\345\236\213\351\223\276\346\261\241\346\237\223.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\345\216\237\345\236\213\351\223\276\346\261\241\346\237\223.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\345\274\200\346\224\276\351\207\215\345\256\232\345\220\221.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\345\274\200\346\224\276\351\207\215\345\256\232\345\220\221.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\345\274\200\346\224\276\351\207\215\345\256\232\345\220\221.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\345\274\200\346\224\276\351\207\215\345\256\232\345\220\221.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\346\226\207\344\273\266\346\274\217\346\264\236.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\346\226\207\344\273\266\346\274\217\346\264\236.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\346\226\207\344\273\266\346\274\217\346\264\236.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\346\226\207\344\273\266\346\274\217\346\264\236.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\346\241\206\346\236\266\346\274\217\346\264\236.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\346\241\206\346\236\266\346\274\217\346\264\236.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\346\241\206\346\236\266\346\274\217\346\264\236.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\346\241\206\346\236\266\346\274\217\346\264\236.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\347\202\271\345\207\273\345\212\253\346\214\201.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\347\202\271\345\207\273\345\212\253\346\214\201.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\347\202\271\345\207\273\345\212\253\346\214\201.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\347\202\271\345\207\273\345\212\253\346\214\201.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\347\274\223\345\255\230\344\270\216cdn\345\256\211\345\205\250.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\347\274\223\345\255\230\344\270\216cdn\345\256\211\345\205\250.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\347\274\223\345\255\230\344\270\216cdn\345\256\211\345\205\250.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\347\274\223\345\255\230\344\270\216cdn\345\256\211\345\205\250.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\350\256\244\350\257\201\346\274\217\346\264\236.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\350\256\244\350\257\201\346\274\217\346\264\236.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\350\256\244\350\257\201\346\274\217\346\264\236.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\350\256\244\350\257\201\346\274\217\346\264\236.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\350\257\267\346\261\202\350\265\260\347\247\201.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\350\257\267\346\261\202\350\265\260\347\247\201.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\350\257\267\346\261\202\350\265\260\347\247\201.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\350\257\267\346\261\202\350\265\260\347\247\201.md" diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/index.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/index.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/index.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/index.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/raw/navigation.json b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/raw/navigation.json similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/raw/navigation.json rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/raw/navigation.json diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/raw/tools.json b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/raw/tools.json similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/raw/tools.json rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/raw/tools.json diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/raw/web.json b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/raw/web.json similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/raw/web.json rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/raw/web.json diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/web\346\270\227\351\200\217.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/web\346\270\227\351\200\217.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/web\346\270\227\351\200\217.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/web\346\270\227\351\200\217.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/windows\346\270\227\351\200\217.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/windows\346\270\227\351\200\217.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/windows\346\270\227\351\200\217.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/windows\346\270\227\351\200\217.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\344\277\241\346\201\257\346\224\266\351\233\206.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\344\277\241\346\201\257\346\224\266\351\233\206.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\344\277\241\346\201\257\346\224\266\351\233\206.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\344\277\241\346\201\257\346\224\266\351\233\206.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\345\206\205\347\275\221\346\270\227\351\200\217.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\345\206\205\347\275\221\346\270\227\351\200\217.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\345\206\205\347\275\221\346\270\227\351\200\217.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\345\206\205\347\275\221\346\270\227\351\200\217.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\345\207\255\350\257\201\347\252\203\345\217\226.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\345\207\255\350\257\201\347\252\203\345\217\226.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\345\207\255\350\257\201\347\252\203\345\217\226.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\345\207\255\350\257\201\347\252\203\345\217\226.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\345\237\237\346\270\227\351\200\217.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\345\237\237\346\270\227\351\200\217.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\345\237\237\346\270\227\351\200\217.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\345\237\237\346\270\227\351\200\217.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\345\257\206\347\240\201\346\224\273\345\207\273.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\345\257\206\347\240\201\346\224\273\345\207\273.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\345\257\206\347\240\201\346\224\273\345\207\273.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\345\257\206\347\240\201\346\224\273\345\207\273.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\346\235\203\351\231\220\346\217\220\345\215\207.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\346\235\203\351\231\220\346\217\220\345\215\207.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\346\235\203\351\231\220\346\217\220\345\215\207.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\346\235\203\351\231\220\346\217\220\345\215\207.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\346\274\217\346\264\236\345\210\251\347\224\250.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\346\274\217\346\264\236\345\210\251\347\224\250.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\346\274\217\346\264\236\345\210\251\347\224\250.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\346\274\217\346\264\236\345\210\251\347\224\250.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\347\263\273\347\273\237\345\221\275\344\273\244.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\347\263\273\347\273\237\345\221\275\344\273\244.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\347\263\273\347\273\237\345\221\275\344\273\244.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\347\263\273\347\273\237\345\221\275\344\273\244.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\347\272\242\351\230\237\345\267\245\345\205\267.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\347\272\242\351\230\237\345\267\245\345\205\267.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\347\272\242\351\230\237\345\267\245\345\205\267.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\347\272\242\351\230\237\345\267\245\345\205\267.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\347\274\226\347\240\201\350\247\243\347\240\201.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\347\274\226\347\240\201\350\247\243\347\240\201.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\347\274\226\347\240\201\350\247\243\347\240\201.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\347\274\226\347\240\201\350\247\243\347\240\201.md" diff --git "a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\351\232\247\351\201\223\344\273\243\347\220\206.md" "b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\351\232\247\351\201\223\344\273\243\347\220\206.md" similarity index 100% rename from "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\351\232\247\351\201\223\344\273\243\347\220\206.md" rename to "backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\351\232\247\351\201\223\344\273\243\347\220\206.md" diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/00-index.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/00-index.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/00-index.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/00-index.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/api-rest.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/api-rest.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/api-rest.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/api-rest.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/arbitrary-x-authz.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/arbitrary-x-authz.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/arbitrary-x-authz.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/arbitrary-x-authz.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/dos.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/dos.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/dos.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/dos.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/file-upload.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/file-upload.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/file-upload.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/file-upload.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/graphql.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/graphql.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/graphql.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/graphql.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/http-smuggling.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/http-smuggling.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/http-smuggling.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/http-smuggling.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/info-disclosure.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/info-disclosure.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/info-disclosure.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/info-disclosure.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/intranet-postexp.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/intranet-postexp.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/intranet-postexp.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/intranet-postexp.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/llm-prompt-injection.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/llm-prompt-injection.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/llm-prompt-injection.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/llm-prompt-injection.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/logic-flaws.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/logic-flaws.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/logic-flaws.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/logic-flaws.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/mobile.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/mobile.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/mobile.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/mobile.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/oauth-saml-jwt.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/oauth-saml-jwt.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/oauth-saml-jwt.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/oauth-saml-jwt.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/path-traversal.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/path-traversal.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/path-traversal.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/path-traversal.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/race-conditions.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/race-conditions.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/race-conditions.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/race-conditions.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/rce.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/rce.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/rce.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/rce.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/sqli.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/sqli.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/sqli.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/sqli.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/ssrf-cache-host.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/ssrf-cache-host.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/ssrf-cache-host.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/ssrf-cache-host.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/unauth-access.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/unauth-access.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/unauth-access.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/unauth-access.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/xss.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/xss.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/xss.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/xss.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/templates/report-submission.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/templates/report-submission.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/templates/report-submission.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/templates/report-submission.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/tools/mcp-jshook.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/tools/mcp-jshook.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/tools/mcp-jshook.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/tools/mcp-jshook.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/templates/findings.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/templates/findings.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/templates/findings.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/templates/findings.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/templates/progress.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/templates/progress.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/templates/progress.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/templates/progress.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/templates/rules.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/templates/rules.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/templates/rules.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/templates/rules.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/templates/scope.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/templates/scope.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/templates/scope.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/templates/scope.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/templates/task_plan.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/templates/task_plan.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/templates/task_plan.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/templates/task_plan.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-attack-chain/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-attack-chain/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-attack-chain/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-attack-chain/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-attack-chain/references/attack-playbooks.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-attack-chain/references/attack-playbooks.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-attack-chain/references/attack-playbooks.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-attack-chain/references/attack-playbooks.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-attack-chain/references/evasion-cheatsheet.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-attack-chain/references/evasion-cheatsheet.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-attack-chain/references/evasion-cheatsheet.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-attack-chain/references/evasion-cheatsheet.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-crypto/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-crypto/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-crypto/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-crypto/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-edr-bypass/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-edr-bypass/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-edr-bypass/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-edr-bypass/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-edr-bypass/references/hook-survey.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-edr-bypass/references/hook-survey.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-edr-bypass/references/hook-survey.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-edr-bypass/references/hook-survey.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-edr-bypass/references/telemetry-blinding.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-edr-bypass/references/telemetry-blinding.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-edr-bypass/references/telemetry-blinding.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-edr-bypass/references/telemetry-blinding.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-edr-bypass/references/unhook-techniques.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-edr-bypass/references/unhook-techniques.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-edr-bypass/references/unhook-techniques.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-edr-bypass/references/unhook-techniques.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-firmware/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-firmware/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-firmware/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-firmware/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-firmware/references/emba-automated-analysis.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-firmware/references/emba-automated-analysis.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-firmware/references/emba-automated-analysis.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-firmware/references/emba-automated-analysis.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-firmware/references/emulation-and-fuzz.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-firmware/references/emulation-and-fuzz.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-firmware/references/emulation-and-fuzz.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-firmware/references/emulation-and-fuzz.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-firmware/references/extraction-methodology.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-firmware/references/extraction-methodology.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-firmware/references/extraction-methodology.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-firmware/references/extraction-methodology.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-forensics-dfir/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-forensics-dfir/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-forensics-dfir/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-forensics-dfir/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-malware/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-malware/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-malware/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-malware/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-malware/references/malware-deep/_malware-workflow.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-malware/references/malware-deep/_malware-workflow.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-malware/references/malware-deep/_malware-workflow.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-malware/references/malware-deep/_malware-workflow.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-malware/references/malware-deep/anti-analysis-techniques.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-malware/references/malware-deep/anti-analysis-techniques.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-malware/references/malware-deep/anti-analysis-techniques.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-malware/references/malware-deep/anti-analysis-techniques.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-malware/references/malware-deep/sandbox-orchestration.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-malware/references/malware-deep/sandbox-orchestration.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-malware/references/malware-deep/sandbox-orchestration.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-malware/references/malware-deep/sandbox-orchestration.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-malware/references/malware-deep/yara-sigma-rules.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-malware/references/malware-deep/yara-sigma-rules.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-malware/references/malware-deep/yara-sigma-rules.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-malware/references/malware-deep/yara-sigma-rules.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-misc/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-misc/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-misc/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-misc/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-osint/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-osint/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-osint/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-osint/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-patch-diff/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-patch-diff/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-patch-diff/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-patch-diff/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-patch-diff/references/diff-tools-comparison.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-patch-diff/references/diff-tools-comparison.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-patch-diff/references/diff-tools-comparison.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-patch-diff/references/diff-tools-comparison.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-patch-diff/references/patch-tuesday-workflow.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-patch-diff/references/patch-tuesday-workflow.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-patch-diff/references/patch-tuesday-workflow.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-patch-diff/references/patch-tuesday-workflow.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-patch-diff/references/root-cause-and-poc.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-patch-diff/references/root-cause-and-poc.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-patch-diff/references/root-cause-and-poc.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-patch-diff/references/root-cause-and-poc.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-pwn-chain/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-pwn-chain/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-pwn-chain/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-pwn-chain/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-pwn-chain/references/heap-pwn.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-pwn-chain/references/heap-pwn.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-pwn-chain/references/heap-pwn.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-pwn-chain/references/heap-pwn.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-pwn-chain/references/kernel-pwn.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-pwn-chain/references/kernel-pwn.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-pwn-chain/references/kernel-pwn.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-pwn-chain/references/kernel-pwn.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-pwn-chain/references/stack-pwn.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-pwn-chain/references/stack-pwn.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-pwn-chain/references/stack-pwn.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-pwn-chain/references/stack-pwn.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-pwn-native/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-pwn-native/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-pwn-native/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-pwn-native/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-reporting/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-reporting/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-reporting/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-reporting/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-reverse/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-reverse/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-reverse/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-reverse/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-supply-chain/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-supply-chain/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-supply-chain/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-supply-chain/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-supply-chain/references/cicd-pipeline-security.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-supply-chain/references/cicd-pipeline-security.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-supply-chain/references/cicd-pipeline-security.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-supply-chain/references/cicd-pipeline-security.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-supply-chain/references/sbom-sca-methodology.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-supply-chain/references/sbom-sca-methodology.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-supply-chain/references/sbom-sca-methodology.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-supply-chain/references/sbom-sca-methodology.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-web-api/INSTRUCTIONS.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-web-api/INSTRUCTIONS.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-web-api/INSTRUCTIONS.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-web-api/INSTRUCTIONS.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-web-api/references/api-deep/_api-security-workflow.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-web-api/references/api-deep/_api-security-workflow.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-web-api/references/api-deep/_api-security-workflow.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-web-api/references/api-deep/_api-security-workflow.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-web-api/references/api-deep/jwt-oauth-testing.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-web-api/references/api-deep/jwt-oauth-testing.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-web-api/references/api-deep/jwt-oauth-testing.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-web-api/references/api-deep/jwt-oauth-testing.md diff --git a/plugins/prompt-skills/registry/seed/tree/skills/sec-web-api/references/api-deep/rest-graphql-testing.md b/backend/internal/promptskills/registry/seed/tree/skills/sec-web-api/references/api-deep/rest-graphql-testing.md similarity index 100% rename from plugins/prompt-skills/registry/seed/tree/skills/sec-web-api/references/api-deep/rest-graphql-testing.md rename to backend/internal/promptskills/registry/seed/tree/skills/sec-web-api/references/api-deep/rest-graphql-testing.md diff --git a/plugins/prompt-skills/registry/support.go b/backend/internal/promptskills/registry/support.go similarity index 96% rename from plugins/prompt-skills/registry/support.go rename to backend/internal/promptskills/registry/support.go index edc3f9d682dd..211d991185bc 100644 --- a/plugins/prompt-skills/registry/support.go +++ b/backend/internal/promptskills/registry/support.go @@ -8,7 +8,7 @@ import ( "io/fs" "strings" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) const ( diff --git a/plugins/prompt-skills/registry/validation.go b/backend/internal/promptskills/registry/validation.go similarity index 100% rename from plugins/prompt-skills/registry/validation.go rename to backend/internal/promptskills/registry/validation.go diff --git a/plugins/prompt-skills/source/gpt56.go b/backend/internal/promptskills/source/gpt56.go similarity index 99% rename from plugins/prompt-skills/source/gpt56.go rename to backend/internal/promptskills/source/gpt56.go index 2c44508585dd..92d21c471d94 100644 --- a/plugins/prompt-skills/source/gpt56.go +++ b/backend/internal/promptskills/source/gpt56.go @@ -18,7 +18,7 @@ import ( "time" "unicode/utf8" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) const ( diff --git a/plugins/prompt-skills/source/gpt56_test.go b/backend/internal/promptskills/source/gpt56_test.go similarity index 100% rename from plugins/prompt-skills/source/gpt56_test.go rename to backend/internal/promptskills/source/gpt56_test.go diff --git a/plugins/prompt-skills/source/prompts/gpt_5_6_instruct_v45.md b/backend/internal/promptskills/source/prompts/gpt_5_6_instruct_v45.md similarity index 100% rename from plugins/prompt-skills/source/prompts/gpt_5_6_instruct_v45.md rename to backend/internal/promptskills/source/prompts/gpt_5_6_instruct_v45.md diff --git a/plugins/prompt-skills/source/support.go b/backend/internal/promptskills/source/support.go similarity index 90% rename from plugins/prompt-skills/source/support.go rename to backend/internal/promptskills/source/support.go index 1e4622910bbc..619a978f887a 100644 --- a/plugins/prompt-skills/source/support.go +++ b/backend/internal/promptskills/source/support.go @@ -4,7 +4,7 @@ import ( _ "embed" "net/http" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) const BusinessSystemPromptMaxBytes = 64 << 10 @@ -21,7 +21,7 @@ func isLowerHexSHA256(value string) bool { return false } for _, char := range value { - if !(char >= '0' && char <= '9' || char >= 'a' && char <= 'f') { + if (char < '0' || char > '9') && (char < 'a' || char > 'f') { return false } } diff --git a/backend/pkg/extensionapi/proxy/address.go b/backend/internal/proxytransport/address.go similarity index 100% rename from backend/pkg/extensionapi/proxy/address.go rename to backend/internal/proxytransport/address.go diff --git a/backend/pkg/extensionapi/proxy/transport.go b/backend/internal/proxytransport/transport.go similarity index 100% rename from backend/pkg/extensionapi/proxy/transport.go rename to backend/internal/proxytransport/transport.go diff --git a/backend/internal/repository/account_create_fence_integration_test.go b/backend/internal/repository/account_create_fence_integration_test.go index 3af3fdb90995..7b64a39df727 100644 --- a/backend/internal/repository/account_create_fence_integration_test.go +++ b/backend/internal/repository/account_create_fence_integration_test.go @@ -4,111 +4,22 @@ package repository import ( "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "os" - "strings" - "testing" - dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/jackc/pgx/v5/pgconn" "github.com/stretchr/testify/require" + "strings" + "testing" ) +// Native creation still writes the account inside its caller's transaction. func TestAccountCreateEntFenceIntegration(t *testing.T) { ctx := context.Background() - raw, err := os.ReadFile("../../../plugins/cindy-provider/manifest.source.json") - require.NoError(t, err) - var source service.PluginManifest - require.NoError(t, json.Unmarshal(raw, &source)) - var create extensionv1.Contribution - for _, contribution := range source.Contributions { - if contribution.Slot == extensionv1.AccountCreateSlot { - create = contribution - } - } - require.NotNil(t, create.AccountCreate) - manifest := service.PluginManifest{ID: service.CindyAccountViewPluginKey, - Capabilities: []service.PluginCapability{ - {ID: extensionv1.CapabilityProvider, Platform: service.PlatformCindy, AccountType: service.AccountTypeAPIKey}, - {ID: extensionv1.CapabilityAdmin, Platform: service.PlatformCindy, AccountType: service.AccountTypeAPIKey}, - }, Contributions: []extensionv1.Contribution{create}} - plugins := &pluginRepository{db: integrationDB} - artifact := &service.PluginInstallation{PluginKey: manifest.ID, Name: "synthetic-create-provider", Version: "1.0.0", - BinarySHA256: strings.Repeat("a", 64), SignatureStatus: service.PluginSignatureTrusted, - Manifest: manifest, ArtifactData: []byte("synthetic create fence package")} - installed, err := plugins.PrepareBundledPlugin(ctx, artifact, "account-create-fixture", "", true, "synthetic-encrypted-config") - require.NoError(t, err) - t.Cleanup(func() { - for _, table := range []string{"sub2api_plugin_state", "sub2api_plugin_bootstrap", "sub2api_plugin_installations"} { - _, err := integrationDB.ExecContext(context.Background(), "DELETE FROM "+table+" WHERE plugin_key=$1", manifest.ID) - require.NoError(t, err) - } - }) - require.NoError(t, plugins.CompleteBundledPlugin(ctx, installed.ID, "account-create-fixture")) - current, err := plugins.GetByID(ctx, installed.ID) - require.NoError(t, err) - for index := range current.Bindings { - current.Bindings[index].Enabled, current.Bindings[index].RolloutPercent = true, 100 - } - require.NoError(t, plugins.UpdateBindingsAndState(ctx, current.ID, current.Bindings, service.PluginStateEnabled, "", nil, current.State, current.BinarySHA256)) - current, err = plugins.GetByID(ctx, installed.ID) - require.NoError(t, err) - configDigest := sha256.Sum256([]byte(current.ConfigEncrypted)) - fence := service.PluginExecutionFence{ID: current.ID, Generation: current.RuntimeGeneration, PluginKey: current.PluginKey, - PackageSHA256: current.PackageSHA256, PolicyRevision: current.Revision, OriginCreate: true, - CreateContributionID: create.ID, CreateDefinitionSHA256: service.AccountCreateDefinitionDigest(&create), ConfigSHA256: hex.EncodeToString(configDigest[:])} - var writes int - tryDenied := func(wanted service.PluginExecutionFence) { - t.Helper() - tx, err := testEntClient(t).Tx(ctx) - require.NoError(t, err) - defer tx.Rollback() - err = lockAccountCreatePolicy(ctx, tx.Client(), wanted) - if err == nil { - writes++ - } - require.ErrorIs(t, err, service.ErrAccountCreateUnavailable) - } - badConfig := fence - badConfig.ConfigSHA256 = strings.Repeat("0", 64) - tryDenied(badConfig) - badDefinition := fence - badDefinition.CreateDefinitionSHA256 = strings.Repeat("0", 64) - tryDenied(badDefinition) - for _, capability := range []string{extensionv1.CapabilityProvider, extensionv1.CapabilityAdmin} { - _, err = integrationDB.ExecContext(ctx, - `UPDATE sub2api_plugin_bindings SET rollout_percent=99 WHERE plugin_id=$1 AND capability=$2`, current.ID, capability) - require.NoError(t, err) - tryDenied(fence) - _, err = integrationDB.ExecContext(ctx, - `UPDATE sub2api_plugin_bindings SET rollout_percent=100 WHERE plugin_id=$1 AND capability=$2`, current.ID, capability) - require.NoError(t, err) - } - require.Zero(t, writes, "invalid config, definition or partial binding never reaches account persistence") var before int require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT count(*) FROM accounts`).Scan(&before)) require.Zero(t, before) - tx, err := testEntClient(t).Tx(ctx) require.NoError(t, err) defer tx.Rollback() - require.NoError(t, lockAccountCreatePolicy(ctx, tx.Client(), fence)) - // The Ent adapter must hold the same installation lock through the account - // transaction; a separate completed guard transaction would not block this. - other, err := integrationDB.BeginTx(ctx, nil) - require.NoError(t, err) - defer other.Rollback() - _, err = other.ExecContext(ctx, `SET LOCAL lock_timeout='150ms'`) - require.NoError(t, err) - _, err = other.ExecContext(ctx, `UPDATE sub2api_plugin_installations SET config_encrypted='changed' WHERE id=$1`, current.ID) - var pgError *pgconn.PgError - require.ErrorAs(t, err, &pgError) - require.Equal(t, "55P03", pgError.Code) - require.NoError(t, other.Rollback()) account := &service.Account{Name: "synthetic-create-fenced", Platform: service.PlatformCindy, WirePlatform: service.WirePlatformOpenAI, ProviderProfile: service.ProviderProfileCindyLaxaV1, Type: service.AccountTypeAPIKey, Status: service.StatusActive, Schedulable: true, Concurrency: 3, diff --git a/backend/internal/repository/account_edit_fence_integration_test.go b/backend/internal/repository/account_edit_fence_integration_test.go index 906e0261a708..794d4a5943bd 100644 --- a/backend/internal/repository/account_edit_fence_integration_test.go +++ b/backend/internal/repository/account_edit_fence_integration_test.go @@ -4,59 +4,38 @@ package repository import ( "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "os" - "strings" - "testing" - + "database/sql" dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/jackc/pgx/v5/pgconn" "github.com/stretchr/testify/require" + "strings" + "testing" ) -// One local PostgreSQL method covers the newly introduced edit fence/codec -// persistence seam. It invokes no provider process, model or external service. -func TestAccountEditEntFenceNullDeletionAndStateCASIntegration(t *testing.T) { - ctx := context.Background() - raw, err := os.ReadFile("../../../plugins/cindy-provider/manifest.source.json") - require.NoError(t, err) - var source service.PluginManifest - require.NoError(t, json.Unmarshal(raw, &source)) - var edit extensionv1.Contribution - for _, contribution := range source.Contributions { - if contribution.Slot == extensionv1.AccountEditSlot { - edit = contribution - } +type cindyEditTestQuerier interface { + QueryContext(context.Context, string, ...any) (*sql.Rows, error) +} + +func scanCindyEditTestRow(ctx context.Context, q cindyEditTestQuerier, query string, args []any, dest ...any) error { + rows, err := q.QueryContext(ctx, query, args...) + if err != nil { + return err } - require.NotNil(t, edit.AccountEdit) - manifest := service.PluginManifest{ID: service.CindyAccountViewPluginKey, Contributions: []extensionv1.Contribution{edit}, Capabilities: []service.PluginCapability{ - {ID: extensionv1.CapabilityProvider, Platform: service.PlatformCindy, AccountType: service.AccountTypeAPIKey}, - {ID: extensionv1.CapabilityAdmin, Platform: service.PlatformCindy, AccountType: service.AccountTypeAPIKey}, - }} - plugins := &pluginRepository{db: integrationDB} - artifact := &service.PluginInstallation{PluginKey: manifest.ID, Name: "synthetic-edit-provider", Version: "1.0.0", Manifest: manifest, - BinarySHA256: strings.Repeat("a", 64), SignatureStatus: service.PluginSignatureTrusted, ArtifactData: []byte("synthetic edit fence package")} - installed, err := plugins.PrepareBundledPlugin(ctx, artifact, "account-edit-fixture", "", true, "synthetic-edit-config") - require.NoError(t, err) - t.Cleanup(func() { - for _, table := range []string{"sub2api_plugin_state", "sub2api_plugin_bootstrap", "sub2api_plugin_installations"} { - _, err := integrationDB.ExecContext(context.Background(), "DELETE FROM "+table+" WHERE plugin_key=$1", manifest.ID) - require.NoError(t, err) + defer rows.Close() + if !rows.Next() { + if err := rows.Err(); err != nil { + return err } - }) - require.NoError(t, plugins.CompleteBundledPlugin(ctx, installed.ID, "account-edit-fixture")) - current, err := plugins.GetByID(ctx, installed.ID) - require.NoError(t, err) - for index := range current.Bindings { - current.Bindings[index].Enabled, current.Bindings[index].RolloutPercent = true, 100 + return sql.ErrNoRows } - require.NoError(t, plugins.UpdateBindingsAndState(ctx, current.ID, current.Bindings, service.PluginStateEnabled, "", nil, current.State, current.BinarySHA256)) - current, err = plugins.GetByID(ctx, installed.ID) - require.NoError(t, err) + return rows.Scan(dest...) +} + +// The native account transaction retains raw presence, state CAS, row locking, +// and atomic account/outbox rollback without an installation fixture. +func TestAccountEditEntFenceNullDeletionAndStateCASIntegration(t *testing.T) { + ctx := context.Background() accountRepo := NewAccountRepository(testEntClient(t), integrationDB, nil) account := &service.Account{Name: "synthetic-edit-original", Platform: service.PlatformCindy, WirePlatform: service.WirePlatformOpenAI, ProviderProfile: service.ProviderProfileCindyLaxaV1, Type: service.AccountTypeAPIKey, Status: service.StatusActive, Concurrency: 1, Schedulable: false, @@ -72,42 +51,7 @@ func TestAccountEditEntFenceNullDeletionAndStateCASIntegration(t *testing.T) { _, err = integrationDB.ExecContext(context.Background(), `DELETE FROM accounts WHERE id=$1`, account.ID) require.NoError(t, err) }) - account, err = accountRepo.GetByID(ctx, account.ID) - require.NoError(t, err) - configDigest := sha256.Sum256([]byte(current.ConfigEncrypted)) - fence := service.PluginExecutionFence{ID: current.ID, Generation: current.RuntimeGeneration, PluginKey: current.PluginKey, PackageSHA256: current.PackageSHA256, - PolicyRevision: current.Revision, OriginEdit: true, EditAccountID: account.ID, EditContributionID: edit.ID, EditDefinitionSHA256: service.AccountEditDefinitionDigest(&edit), ConfigSHA256: hex.EncodeToString(configDigest[:])} - denied := func(wanted service.PluginExecutionFence) { - t.Helper() - tx, err := testEntClient(t).Tx(ctx) - require.NoError(t, err) - defer tx.Rollback() - require.ErrorIs(t, lockAccountEditPolicy(ctx, tx.Client(), wanted), service.ErrAccountEditUnavailable) - } - wrong := fence - wrong.ConfigSHA256 = strings.Repeat("0", 64) - denied(wrong) - wrong = fence - wrong.EditDefinitionSHA256 = strings.Repeat("0", 64) - denied(wrong) - wrong = fence - wrong.Generation++ - denied(wrong) - for _, capability := range []string{extensionv1.CapabilityProvider, extensionv1.CapabilityAdmin} { - _, err := integrationDB.ExecContext(ctx, `UPDATE sub2api_plugin_bindings SET rollout_percent=0 WHERE plugin_id=$1 AND capability=$2`, current.ID, capability) - require.NoError(t, err) - denied(fence) - _, err = integrationDB.ExecContext(ctx, `UPDATE sub2api_plugin_bindings SET rollout_percent=100 WHERE plugin_id=$1 AND capability=$2`, current.ID, capability) - require.NoError(t, err) - } - // Demonstrate real-account rollout (not Create's full-100 rule) using the - // exact stable bucket algorithm on the newly inserted positive account ID. - value := uint64(account.ID) - value ^= value >> 33 - value *= 0xff51afd7ed558ccd - value ^= value >> 33 - percent := int(value%100) + 1 - _, err = integrationDB.ExecContext(ctx, `UPDATE sub2api_plugin_bindings SET rollout_percent=$2 WHERE plugin_id=$1`, current.ID, percent) + account, err := accountRepo.GetByID(ctx, account.ID) require.NoError(t, err) noopExtra := map[string]any{"openai_compact_mode": nil} bound, release, err := service.PrepareAccountEdit(ctx, account, nil, noopExtra, nil) @@ -130,10 +74,10 @@ func TestAccountEditEntFenceNullDeletionAndStateCASIntegration(t *testing.T) { delete(stored.Credentials, "compact_model_mapping") require.NoError(t, accountRepo.Update(txCtx, stored)) } - checkStored := func(q pluginFenceQuerier, cleared bool) { + checkStored := func(q cindyEditTestQuerier, cleared bool) { t.Helper() var isCleared, compactNull, normalNull, compactRemoved, independent bool - err := scanPluginFenceRow(ctx, q, `SELECT NOT(extra ? 'openai_responses_mode') AND NOT(extra ? 'openai_apikey_responses_websockets_v2_mode') AND NOT(extra ? 'openai_apikey_responses_websockets_v2_enabled') AND NOT(extra ? 'responses_websockets_v2_enabled') AND NOT(extra ? 'openai_ws_enabled'), + err := scanCindyEditTestRow(ctx, q, `SELECT NOT(extra ? 'openai_responses_mode') AND NOT(extra ? 'openai_apikey_responses_websockets_v2_mode') AND NOT(extra ? 'openai_apikey_responses_websockets_v2_enabled') AND NOT(extra ? 'responses_websockets_v2_enabled') AND NOT(extra ? 'openai_ws_enabled'), extra ? 'openai_compact_mode' AND extra->'openai_compact_mode'='null'::jsonb, credentials ? 'model_mapping' AND credentials->'model_mapping'='null'::jsonb, NOT(credentials ? 'compact_model_mapping'), @@ -149,7 +93,6 @@ func TestAccountEditEntFenceNullDeletionAndStateCASIntegration(t *testing.T) { tx, err := testEntClient(t).Tx(ctx) require.NoError(t, err) defer tx.Rollback() - require.NoError(t, lockAccountEditPolicy(ctx, tx.Client(), fence)) require.NoError(t, lockCindyAccountJobTarget(ctx, tx.Client(), account.ID)) txCtx := dbent.NewTxContext(bound, tx) locked, err := accountRepo.GetByID(txCtx, account.ID) @@ -165,7 +108,7 @@ func TestAccountEditEntFenceNullDeletionAndStateCASIntegration(t *testing.T) { defer other.Rollback() _, err = other.ExecContext(ctx, `SET LOCAL lock_timeout='150ms'`) require.NoError(t, err) - _, err = other.ExecContext(ctx, `UPDATE sub2api_plugin_installations SET config_encrypted='late' WHERE id=$1`, current.ID) + _, err = other.ExecContext(ctx, `UPDATE accounts SET name='blocked-native-write' WHERE id=$1`, account.ID) var pgError *pgconn.PgError require.ErrorAs(t, err, &pgError) require.Equal(t, "55P03", pgError.Code) @@ -184,7 +127,6 @@ func TestAccountEditEntFenceNullDeletionAndStateCASIntegration(t *testing.T) { committed, err := testEntClient(t).Tx(ctx) require.NoError(t, err) defer committed.Rollback() - require.NoError(t, lockAccountEditPolicy(ctx, committed.Client(), fence)) require.NoError(t, lockCindyAccountJobTarget(ctx, committed.Client(), account.ID)) applyStorage(dbent.NewTxContext(ctx, committed), unchanged) require.NoError(t, committed.Commit()) diff --git a/backend/internal/repository/account_job_cindy_mutation.go b/backend/internal/repository/account_job_cindy_mutation.go index 20ebaf8aed4b..86cac3a6f1f8 100644 --- a/backend/internal/repository/account_job_cindy_mutation.go +++ b/backend/internal/repository/account_job_cindy_mutation.go @@ -131,9 +131,6 @@ func (r *accountJobCindyMutationRunner) Run( if err := service.ValidateAccountCreateFresh(ctx); err != nil { return nil, err } - if err := service.ValidateAccountViewFresh(ctx); err != nil { - return nil, err - } if err := service.ValidateAccountEditFresh(ctx); err != nil { return nil, err } @@ -163,7 +160,7 @@ func (r *accountJobCindyMutationRunner) Run( // Reuse the same ordered multi-owner fence on the exact Ent transaction that // creates the account. This is also called by nested batch/import creation. func (r *accountJobCindyMutationRunner) FenceAccountCreate(ctx context.Context) error { - create, bound := service.AccountCreateFromContext(ctx) + _, bound := service.AccountCreateFromContext(ctx) if !bound { return nil } @@ -174,12 +171,7 @@ func (r *accountJobCindyMutationRunner) FenceAccountCreate(ctx context.Context) if tx == nil { return service.ErrAccountCreateUnavailable } - if err := lockPluginExecution(ctx, tx.Client(), create.PrimaryPluginKey); err != nil { - if errors.Is(err, service.ErrAccountViewUnavailable) { - return service.ErrAccountCreateUnavailable - } - return err - } + return nil } @@ -196,9 +188,6 @@ func (r *accountJobCindyMutationRunner) FenceAccountEdit(ctx context.Context) er if tx == nil { return service.ErrAccountEditUnavailable } - if err := lockPluginExecution(ctx, tx.Client(), ""); err != nil { - return err - } service.MarkAccountEditFenced(ctx) return nil } diff --git a/backend/internal/repository/account_job_legacy_digest_integration_test.go b/backend/internal/repository/account_job_legacy_digest_integration_test.go new file mode 100644 index 000000000000..8ee0e1aff5d4 --- /dev/null +++ b/backend/internal/repository/account_job_legacy_digest_integration_test.go @@ -0,0 +1,56 @@ +//go:build integration + +package repository + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "strings" + "testing" + "time" +) + +// Preserve the historical digest contract verified in 9774e09fb. The old owner +// metadata is an immutable record; executing native jobs needs no plugin install. +func TestAccountJobPluginActionDigestRoundTrip(t *testing.T) { + ctx := context.Background() + user := mustCreateUser(t, testEntClient(t), &service.User{Email: "job-digest-" + uuid.NewString() + "@example.com", PasswordHash: "fixture"}) + jobs := NewAccountJobRepository(integrationDB) + var jobID int64 + t.Cleanup(func() { + _, _ = integrationDB.ExecContext(ctx, "DELETE FROM admin_account_jobs WHERE id=$1", jobID) + _, _ = integrationDB.ExecContext(ctx, "DELETE FROM users WHERE id=$1", user.ID) + }) + // SubmitJob uses the complete SHA-256 hex value to address an encrypted + // per-target payload. The stored action must remain the same lookup key. + digest := sha256.Sum256([]byte("7\x00{\"model\":\"gpt-6-astra\"}")) + action := hex.EncodeToString(digest[:]) + params := service.CreateAccountJobParams{ + CreatedBy: user.ID, Kind: service.AccountJobKindExtensionOperation, + IdempotencyKey: uuid.NewString(), RequestHash: strings.Repeat("b", 64), + PayloadCipher: "fixture", PayloadExpires: time.Now().Add(time.Hour), + Metadata: json.RawMessage(`{"plugin_id":1,"plugin_generation":8}`), + Items: []service.AccountJobItemSeed{{Ordinal: 1, Action: action, Metadata: []byte(`{}`)}}, Attempt: 1, + } + job, replayed, err := jobs.Create(ctx, params) + require.NoError(t, err) + jobID = job.ID + require.False(t, replayed) + items, err := jobs.ListItems(ctx, jobID, "", 1, 10) + require.NoError(t, err) + require.Len(t, items.Items, 1) + require.Equal(t, action, items.Items[0].Action, "the payload digest must not be truncated or re-encoded") + + again, replayed, err := jobs.Create(ctx, params) + require.NoError(t, err) + require.True(t, replayed) + require.Equal(t, jobID, again.ID) + items, err = jobs.ListItems(ctx, jobID, "", 1, 10) + require.NoError(t, err) + require.Len(t, items.Items, 1, "an idempotent submission must not duplicate its target") +} diff --git a/backend/internal/repository/account_job_plugin_integration_test.go b/backend/internal/repository/account_job_plugin_integration_test.go deleted file mode 100644 index 0cd6ce744d52..000000000000 --- a/backend/internal/repository/account_job_plugin_integration_test.go +++ /dev/null @@ -1,49 +0,0 @@ -//go:build integration - -package repository - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "testing" - "time" - - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/google/uuid" - "github.com/stretchr/testify/require" -) - -func TestAccountJobPluginFenceRejectsStaleSubmissionAndKeepsHostLease(t *testing.T) { - ctx := context.Background() - plugins, current, candidate := installedUpdateFixture(t) - user := mustCreateUser(t, testEntClient(t), &service.User{Email: "job-fence-" + uuid.NewString() + "@example.com", PasswordHash: "fixture"}) - jobs := NewAccountJobRepository(integrationDB) - var jobID int64 - t.Cleanup(func() { - _, _ = integrationDB.ExecContext(ctx, "DELETE FROM admin_account_jobs WHERE id=$1", jobID) - _, _ = integrationDB.ExecContext(ctx, "DELETE FROM users WHERE id=$1", user.ID) - }) - params := service.CreateAccountJobParams{CreatedBy: user.ID, Kind: service.AccountJobKindImportData, IdempotencyKey: uuid.NewString(), RequestHash: strings.Repeat("b", 64), PayloadCipher: "fixture", PayloadExpires: time.Now().Add(time.Hour), Metadata: json.RawMessage(fmt.Sprintf(`{"plugin_id":%d,"plugin_generation":%d}`, current.ID, current.RuntimeGeneration)), Items: []service.AccountJobItemSeed{{Ordinal: 1, Metadata: []byte(`{}`)}}, Attempt: 1} - job, _, err := jobs.Create(ctx, params) - require.NoError(t, err) - jobID = job.ID - releaseHostTask, err := plugins.HoldPluginRuntime(ctx, current) - require.NoError(t, err) - defer releaseHostTask() - require.NoError(t, plugins.StagePluginUpdate(ctx, current, candidate, service.PluginUpdatePinned)) - pending, err := plugins.GetByID(ctx, current.ID) - require.NoError(t, err) - require.ErrorIs(t, plugins.CommitPluginUpdate(ctx, pending, candidate), service.ErrPluginUpdateWaiting) - params.IdempotencyKey = uuid.NewString() - _, _, err = jobs.Create(ctx, params) - require.ErrorIs(t, err, service.ErrAccountJobPluginUnavailable, "updating must reject late submissions") - releaseHostTask() - require.NoError(t, plugins.CommitPluginUpdate(ctx, pending, candidate)) - _, _, err = jobs.Create(ctx, params) - require.ErrorIs(t, err, service.ErrAccountJobPluginUnavailable, "old generation cannot create work after promotion") - finished, err := jobs.Get(ctx, jobID) - require.NoError(t, err) - require.Equal(t, service.AccountJobStatusCanceled, finished.Status) -} diff --git a/backend/internal/repository/account_job_repo.go b/backend/internal/repository/account_job_repo.go index 80d1c474338a..bbe0a0798879 100644 --- a/backend/internal/repository/account_job_repo.go +++ b/backend/internal/repository/account_job_repo.go @@ -124,9 +124,6 @@ func (r *accountJobRepository) Create(ctx context.Context, params service.Create return nil, false, err } defer func() { _ = tx.Rollback() }() - if err := lockAccountJobPlugin(ctx, tx, params.Metadata, params.Kind); err != nil { - return nil, false, err - } job, err := scanAccountJob(tx.QueryRowContext(ctx, ` INSERT INTO admin_account_jobs @@ -184,58 +181,6 @@ func (r *accountJobRepository) Create(ctx context.Context, params service.Create return job, false, nil } -func lockAccountJobPlugin(ctx context.Context, tx *sql.Tx, metadata json.RawMessage, kinds ...string) error { - fences, err := service.AccountJobExecutionFences(metadata) - if err != nil { - return err - } - for _, fence := range fences { - if fence.Primary { - var generation int64 - var state string - var enabled bool - err = tx.QueryRowContext(ctx, `SELECT p.runtime_generation,p.state, - EXISTS (SELECT 1 FROM sub2api_plugin_bindings b WHERE b.plugin_id=p.id AND b.capability='extensions.admin.v1' AND b.enabled) - FROM sub2api_plugin_installations p WHERE p.id=$1 FOR SHARE`, fence.ID).Scan(&generation, &state, &enabled) - if errors.Is(err, sql.ErrNoRows) || (err == nil && (generation != fence.Generation || state != service.PluginStateEnabled || !enabled)) { - return service.ErrAccountJobPluginUnavailable - } - if err != nil { - return err - } - if len(kinds) > 0 && (kinds[0] == service.AccountJobKindCindyConfirmedCleanup || kinds[0] == service.AccountJobKindCindyBannedCleanup) { - var allowed bool - err = tx.QueryRowContext(ctx, `SELECT p.plugin_key='codexrip.cindy-provider' AND - EXISTS (SELECT 1 FROM sub2api_plugin_bindings b WHERE b.plugin_id=p.id AND b.capability='extensions.provider.v1' AND b.enabled AND b.rollout_percent=100 AND b.platform IN ('*','cindy') AND b.account_type IN ('*','apikey')) AND - EXISTS (SELECT 1 FROM sub2api_plugin_bindings b WHERE b.plugin_id=p.id AND b.capability='extensions.admin.v1' AND b.enabled AND b.rollout_percent=100 AND b.platform IN ('*','cindy') AND b.account_type IN ('*','apikey')) - FROM sub2api_plugin_installations p WHERE p.id=$1`, fence.ID).Scan(&allowed) - if err != nil { - return err - } - if !allowed { - return service.ErrAccountJobPluginUnavailable - } - } - } - if fence.OriginView { - if err := lockOriginAccountView(ctx, tx, fence); err != nil { - return err - } - } - } - return nil -} - -func lockOriginAccountView(ctx context.Context, tx pluginFenceQuerier, fence service.PluginExecutionFence) error { - var generation, revision int64 - var state, digest string - err := scanPluginFenceRow(ctx, tx, `SELECT runtime_generation,state,package_sha256,revision FROM sub2api_plugin_installations WHERE id=$1 AND plugin_key=$2 FOR SHARE`, []any{fence.ID, fence.PluginKey}, &generation, &state, &digest, &revision) - if errors.Is(err, sql.ErrNoRows) || (err == nil && (generation != fence.Generation || state != service.PluginStateEnabled || digest != fence.PackageSHA256 || revision != fence.PolicyRevision)) { - return service.ErrAccountViewUnavailable - } - return err -} - func normalizeRepositoryJobMetadata(raw json.RawMessage) json.RawMessage { if len(raw) == 0 || string(raw) == "null" { return json.RawMessage(`{}`) diff --git a/backend/internal/repository/account_plugin_projection.go b/backend/internal/repository/account_plugin_projection.go index a921693c6c17..e1f73f14aa8f 100644 --- a/backend/internal/repository/account_plugin_projection.go +++ b/backend/internal/repository/account_plugin_projection.go @@ -6,22 +6,22 @@ import ( ) func withoutPluginAccountProjection(extra map[string]any) map[string]any { - if _, exists := extra[service.PluginAccountProjectionKey]; !exists { + if _, exists := extra[service.NativeCodexAccountProjectionKey]; !exists { return extra } copy := maps.Clone(extra) - delete(copy, service.PluginAccountProjectionKey) + delete(copy, service.NativeCodexAccountProjectionKey) return copy } func preservePluginAccountProjection(incoming, current map[string]any) map[string]any { extra := withoutPluginAccountProjection(incoming) - if value, exists := current[service.PluginAccountProjectionKey]; exists { + if value, exists := current[service.NativeCodexAccountProjectionKey]; exists { extra = maps.Clone(extra) if extra == nil { extra = make(map[string]any) } - extra[service.PluginAccountProjectionKey] = value + extra[service.NativeCodexAccountProjectionKey] = value } return extra } diff --git a/backend/internal/repository/account_prompt_binding.go b/backend/internal/repository/account_prompt_binding.go index 3bbbf91f010f..ad92963c0d16 100644 --- a/backend/internal/repository/account_prompt_binding.go +++ b/backend/internal/repository/account_prompt_binding.go @@ -7,8 +7,8 @@ import ( "time" dbent "github.com/Wei-Shaw/sub2api/ent" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" ) func (r *accountRepository) UpdatePromptBindingIfRevision(ctx context.Context, id int64, expected time.Time, policyRevision int64, binding extensionv1.PromptAccountBinding) (bool, error) { diff --git a/backend/internal/repository/account_repo_codex_lifecycle.go b/backend/internal/repository/account_repo_codex_lifecycle.go deleted file mode 100644 index ee20a02e8379..000000000000 --- a/backend/internal/repository/account_repo_codex_lifecycle.go +++ /dev/null @@ -1,36 +0,0 @@ -package repository - -import ( - "database/sql" - "encoding/json" - - "github.com/Wei-Shaw/sub2api/internal/service" -) - -const ticketStateColumns = `account_id,model,identity,phase,next_at,expires_at,lease_id,lease_until,operation,job_id,manual_was_enrolled,last_attempt_at,last_result` - -func scanTicketState(row interface{ Scan(...any) error }) (*service.CodexTicketLifecycle, error) { - s := &service.CodexTicketLifecycle{} - var next, expiry, lease, attempt sql.NullTime - var raw []byte - err := row.Scan(&s.AccountID, &s.Model, &s.Identity, &s.Phase, &next, &expiry, &s.LeaseID, &lease, &s.Operation, &s.JobID, &s.ManualWasEnrolled, &attempt, &raw) - if err != nil { - return nil, err - } - if next.Valid { - s.NextAt = &next.Time - } - if expiry.Valid { - s.ExpiresAt = &expiry.Time - } - if lease.Valid { - s.LeaseUntil = &lease.Time - } - if attempt.Valid { - s.LastAttemptAt = &attempt.Time - } - if len(raw) > 0 { - _ = json.Unmarshal(raw, &s.LastResult) - } - return s, nil -} diff --git a/backend/internal/repository/account_view_atomic_integration_test.go b/backend/internal/repository/account_view_atomic_integration_test.go index cd537bc80bf3..e93e96e9dea4 100644 --- a/backend/internal/repository/account_view_atomic_integration_test.go +++ b/backend/internal/repository/account_view_atomic_integration_test.go @@ -11,103 +11,9 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/jackc/pgx/v5/pgconn" "github.com/stretchr/testify/require" ) -func accountViewIntegrationMetadata(t *testing.T, primary, origin *service.PluginInstallation) json.RawMessage { - t.Helper() - view := service.AccountJobViewMetadata{ - AccountViewIdentityV1: extensionv1.AccountViewIdentityV1{ - Version: 1, PluginID: origin.ID, PluginKey: origin.PluginKey, - PackageSHA256: origin.PackageSHA256, ViewID: "fixture-view", PresetID: "all", - ViewDefinitionDigest: strings.Repeat("b", 64), - }, - RuntimeGeneration: origin.RuntimeGeneration, PolicyRevision: origin.Revision, - NormalizedQueryDigest: strings.Repeat("c", 64), - } - raw, err := json.Marshal(map[string]any{ - "plugin_id": primary.ID, "plugin_generation": primary.RuntimeGeneration, "account_view": view, - }) - require.NoError(t, err) - return raw -} - -func TestAccountViewDualOwnerFenceIntegration(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) - defer cancel() - plugins, origin, _ := installedUpdateFixture(t) - _, primary, _ := installedUpdateFixture(t) - require.Less(t, origin.ID, primary.ID, "the origin is deliberately the lower ID") - metadata := accountViewIntegrationMetadata(t, primary, origin) - - blocker, err := integrationDB.BeginTx(ctx, nil) - require.NoError(t, err) - defer blocker.Rollback() - var id int64 - require.NoError(t, blocker.QueryRowContext(ctx, - `SELECT id FROM sub2api_plugin_installations WHERE id=$1 FOR UPDATE`, origin.ID).Scan(&id)) - fenced, err := integrationDB.BeginTx(ctx, nil) - require.NoError(t, err) - defer fenced.Rollback() - var fencePID int - require.NoError(t, fenced.QueryRowContext(ctx, `SELECT pg_backend_pid()`).Scan(&fencePID)) - lockResult := make(chan error, 1) - go func() { - lockResult <- lockAccountJobPlugin(ctx, fenced, metadata, service.AccountJobKindBulkUpdate) - }() - // Observe the server-side wait before testing the other row; a sleep alone - // could pass while the production helper had not started its first query. - require.Eventually(t, func() bool { - var waiting bool - err := integrationDB.QueryRowContext(ctx, - `SELECT COALESCE(wait_event_type='Lock',false) FROM pg_stat_activity WHERE pid=$1`, fencePID).Scan(&waiting) - return err == nil && waiting - }, 2*time.Second, 10*time.Millisecond) - probe, err := integrationDB.BeginTx(ctx, nil) - require.NoError(t, err) - defer probe.Rollback() - require.NoError(t, probe.QueryRowContext(ctx, - `SELECT id FROM sub2api_plugin_installations WHERE id=$1 FOR UPDATE NOWAIT`, primary.ID).Scan(&id), - "the higher primary row must remain unlocked while the lower origin row is blocked") - require.NoError(t, probe.Rollback()) - require.NoError(t, blocker.Rollback()) - select { - case err := <-lockResult: - require.NoError(t, err) - case <-ctx.Done(): - t.Fatal("production dual-owner fence did not finish after the blocker released") - } - for _, ownerID := range []int64{origin.ID, primary.ID} { - other, err := integrationDB.BeginTx(ctx, nil) - require.NoError(t, err) - _, err = other.ExecContext(ctx, `SET LOCAL lock_timeout='150ms'`) - require.NoError(t, err) - _, err = other.ExecContext(ctx, - `UPDATE sub2api_plugin_installations SET runtime_generation=runtime_generation+1 WHERE id=$1`, ownerID) - var pgError *pgconn.PgError - require.ErrorAs(t, err, &pgError, "each actual FOR SHARE row lock must block a second connection's update") - require.Equal(t, "55P03", pgError.Code) - require.NoError(t, other.Rollback()) - } - require.NoError(t, fenced.Commit()) - _, err = integrationDB.ExecContext(ctx, - `UPDATE sub2api_plugin_installations SET runtime_generation=runtime_generation+1 WHERE id=$1`, origin.ID) - require.NoError(t, err, "the second connection may update after fence commit") - staleOrigin := testTx(t) - require.ErrorIs(t, lockAccountJobPlugin(ctx, staleOrigin, metadata), service.ErrAccountViewUnavailable) - require.NoError(t, staleOrigin.Rollback()) - freshOrigin, err := plugins.GetByID(ctx, origin.ID) - require.NoError(t, err) - _, err = integrationDB.ExecContext(ctx, - `UPDATE sub2api_plugin_installations SET runtime_generation=runtime_generation+1 WHERE id=$1`, primary.ID) - require.NoError(t, err) - stalePrimary := testTx(t) - require.ErrorIs(t, lockAccountJobPlugin(ctx, stalePrimary, accountViewIntegrationMetadata(t, primary, freshOrigin)), service.ErrAccountJobPluginUnavailable) - require.NoError(t, stalePrimary.Rollback()) -} - func TestAccountViewScopedProbeTerminalAtomicIntegration(t *testing.T) { ctx := context.Background() fixture := newCindyBalanceProbeFinalizeFixture(t, false, "terra_running") @@ -167,7 +73,7 @@ func TestAccountViewScopedProbeTerminalAtomicIntegration(t *testing.T) { require.Zero(t, failedEpisode.AccountID) require.NoError(t, failedTx.Rollback()) var markerMissing bool - var itemState string + var jobStatus, itemState string var healthCount, outboxAfter, failures int require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT cindy_balance_insufficient_at IS NULL FROM accounts WHERE id=$1`, fixture.account.ID).Scan(&markerMissing)) @@ -179,8 +85,12 @@ func TestAccountViewScopedProbeTerminalAtomicIntegration(t *testing.T) { `SELECT state FROM cindy_balance_probe_items WHERE id=$1`, fixture.reservation.ItemID).Scan(&itemState)) require.Equal(t, "terra_running", itemState) require.NoError(t, integrationDB.QueryRowContext(ctx, - `SELECT consecutive_upstream_failures FROM cindy_balance_probe_jobs WHERE id=$1`, fixture.reservation.JobID).Scan(&failures)) + `SELECT status,consecutive_upstream_failures FROM cindy_balance_probe_jobs WHERE id=$1`, fixture.reservation.JobID).Scan(&jobStatus, &failures)) + require.Equal(t, "running", jobStatus) require.Equal(t, 2, failures) + done, err := repo.FinishIfDone(ctx, fixture.reservation.JobID, fixture.leaseToken) + require.NoError(t, err) + require.False(t, done, "the rolled-back item must keep its job unfinished") require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT count(*) FROM scheduler_outbox WHERE account_id=$1`, fixture.account.ID).Scan(&outboxAfter)) require.Equal(t, outboxBefore, outboxAfter) @@ -210,54 +120,76 @@ func TestAccountViewScopedProbeTerminalAtomicIntegration(t *testing.T) { require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT count(*) FROM scheduler_outbox WHERE account_id=$1`, fixture.account.ID).Scan(&outboxAfter)) require.Equal(t, outboxBefore+1, outboxAfter, "scoped terminal uses one shared writer, without a second legacy marker/outbox write") + done, err = repo.FinishIfDone(ctx, fixture.reservation.JobID, fixture.leaseToken) + require.NoError(t, err) + require.True(t, done) + var jobFinished, leaseReleased bool + require.NoError(t, integrationDB.QueryRowContext(ctx, + `SELECT status,consecutive_upstream_failures,finished_at IS NOT NULL,lease_token IS NULL AND lease_until IS NULL + FROM cindy_balance_probe_jobs WHERE id=$1`, fixture.reservation.JobID).Scan(&jobStatus, &failures, &jobFinished, &leaseReleased)) + require.Equal(t, "completed", jobStatus) + require.Zero(t, failures) + require.True(t, jobFinished) + require.True(t, leaseReleased) } func TestAccountViewProbeResumeJSONBCASIntegration(t *testing.T) { ctx := context.Background() + previousConfig := service.EffectiveCindyProviderConfig() + nativeConfig := previousConfig + nativeConfig.BalanceDetection = true + service.ConfigureCindyProvider(&nativeConfig) + t.Cleanup(func() { service.ConfigureCindyProvider(&previousConfig) }) first := newCindyBalanceProbeLifecycleAccount(t, "view-resume-first") second := newCindyBalanceProbeLifecycleAccount(t, "view-resume-second") jobID := insertCindyBalanceProbeLifecycleJob(t, "paused", "", time.Time{}) insertCindyBalanceProbeLifecycleItem(t, jobID, first, 1) insertCindyBalanceProbeLifecycleItem(t, jobID, second, 2) ids := []int64{first.ID, second.ID} + viewJSON, err := json.Marshal(map[string]any{ + "version": 1, "plugin_id": 102, "plugin_key": "fixture.origin-view", "package_sha256": strings.Repeat("b", 64), + "view_id": "fixture-view", "preset_id": "all", "view_definition_digest": strings.Repeat("c", 64), + "runtime_generation": 5, "policy_revision": 7, "normalized_query_digest": strings.Repeat("e", 64), + "opaque_future_field": true, + }) + require.NoError(t, err) origin := &service.CindyBalanceProbeOrigin{ Version: 1, PluginID: 101, PluginKey: service.CindyAccountViewPluginKey, PackageSHA256: strings.Repeat("a", 64), RuntimeGeneration: 4, FrozenAccountIDs: ids, OperationKey: "synthetic-view-resume", RequestDigest: strings.Repeat("d", 64), - View: service.AccountJobViewMetadata{ - AccountViewIdentityV1: extensionv1.AccountViewIdentityV1{ - Version: 1, PluginID: 102, PluginKey: "fixture.origin-view", PackageSHA256: strings.Repeat("b", 64), - ViewID: "fixture-view", PresetID: "all", ViewDefinitionDigest: strings.Repeat("c", 64), - }, - RuntimeGeneration: 5, PolicyRevision: 7, NormalizedQueryDigest: strings.Repeat("e", 64), - }, + View: json.RawMessage(viewJSON), } + originJSON, err := json.Marshal(origin) + require.NoError(t, err) scope := service.CindyBalanceProbeScope{Mode: "selected", AccountIDs: ids, Origin: origin} - _, err := integrationDB.ExecContext(ctx, + _, err = integrationDB.ExecContext(ctx, `UPDATE cindy_balance_probe_jobs SET scope=$2::jsonb,consecutive_upstream_failures=2 WHERE id=$1`, jobID, service.EncodeCindyBalanceProbeScope(scope)) require.NoError(t, err) - next := *origin - next.RuntimeGeneration, next.View.RuntimeGeneration, next.View.PolicyRevision = 6, 8, 9 repo := &cindyBalanceProbeRepository{db: integrationDB} - invalid := next + invalid := *origin invalid.FrozenAccountIDs = []int64{first.ID} _, err = repo.ResumeScoped(ctx, jobID, origin, &invalid) - require.ErrorIs(t, err, service.ErrAccountViewUnavailable, "the public repository gate rejects target replacement before transaction admission") - expectedJSON, err := json.Marshal(origin) - require.NoError(t, err) - tx := testTx(t) - require.NoError(t, resumeScopedProbeTx(ctx, tx, jobID, expectedJSON, &next)) - job, err := repo.GetJob(ctx, jobID) + require.ErrorIs(t, err, service.ErrCindyBalanceProbeChanged, "the public repository gate rejects target replacement before transaction admission") + _, err = repo.ResumeScoped(ctx, jobID, origin, origin) + require.ErrorIs(t, err, service.ErrCindyBalanceProbeChanged, "historical metadata alone cannot supply the private native worker context") + // Resume binds the private native context through the real service. The + // worker is never started, so this fixture cannot send a probe request. + probe := service.NewCindyBalanceProbeService(repo, nil, nil, nil) + t.Cleanup(probe.Stop) + job, err := probe.Resume(ctx, jobID) require.NoError(t, err) require.Equal(t, "queued", job.Status) require.Zero(t, job.ConsecutiveFailures) - require.Equal(t, &next, job.Scope.Origin, "only generation and revision fields may change") + require.NotNil(t, job.Scope.Origin) + expectedJSON, err := json.Marshal(job.Scope.Origin) + require.NoError(t, err) + require.JSONEq(t, string(originJSON), string(expectedJSON), "native resume preserves the entire historical origin") require.Equal(t, ids, job.Scope.AccountIDs) require.Equal(t, origin.FrozenAccountIDs, job.Scope.Origin.FrozenAccountIDs) require.Equal(t, origin.OperationKey, job.Scope.Origin.OperationKey) require.Equal(t, origin.RequestDigest, job.Scope.Origin.RequestDigest) - require.Equal(t, origin.View.NormalizedQueryDigest, job.Scope.Origin.View.NormalizedQueryDigest) + require.JSONEq(t, string(origin.View), string(job.Scope.Origin.View)) rows, err := integrationDB.QueryContext(ctx, `SELECT account_id FROM cindy_balance_probe_items WHERE job_id=$1 ORDER BY ordinal`, jobID) require.NoError(t, err) var persistedIDs []int64 @@ -271,11 +203,27 @@ func TestAccountViewProbeResumeJSONBCASIntegration(t *testing.T) { require.Equal(t, ids, persistedIDs, "resume neither reselects nor reseeds items") _, err = repo.Pause(ctx, jobID) require.NoError(t, err) + // Native resume leaves generations and revisions untouched. Model a real + // concurrent origin change on this job so the saved expected JSON is stale. + next := *job.Scope.Origin + changed := next + changed.RequestDigest = strings.Repeat("f", 64) + changedScope := job.Scope + changedScope.Origin = &changed + _, err = integrationDB.ExecContext(ctx, + `UPDATE cindy_balance_probe_jobs SET scope=$2::jsonb WHERE id=$1 AND status='paused'`, + jobID, service.EncodeCindyBalanceProbeScope(changedScope)) + require.NoError(t, err) staleTx := testTx(t) require.ErrorIs(t, resumeScopedProbeTx(ctx, staleTx, jobID, expectedJSON, &next), service.ErrCindyBalanceProbeChanged) require.NoError(t, staleTx.Rollback()) unchanged, err := repo.GetJob(ctx, jobID) require.NoError(t, err) require.Equal(t, "paused", unchanged.Status) - require.Equal(t, &next, unchanged.Scope.Origin, "stale expected JSON must not overwrite the already refreshed origin") + changedJSON, err := json.Marshal(&changed) + require.NoError(t, err) + unchangedJSON, err := json.Marshal(unchanged.Scope.Origin) + require.NoError(t, err) + require.JSONEq(t, string(changedJSON), string(unchangedJSON), "stale expected JSON must not overwrite the concurrent origin") + require.Equal(t, ids, unchanged.Scope.AccountIDs) } diff --git a/backend/internal/repository/account_view_fence_test.go b/backend/internal/repository/account_view_fence_test.go deleted file mode 100644 index e885f87dda66..000000000000 --- a/backend/internal/repository/account_view_fence_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package repository - -import ( - "context" - "encoding/json" - "strings" - "testing" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/stretchr/testify/require" -) - -func accountViewFenceMetadata() json.RawMessage { - view := service.AccountJobViewMetadata{AccountViewIdentityV1: extensionv1.AccountViewIdentityV1{Version: 1, PluginID: 3, PluginKey: service.CindyAccountViewPluginKey, PackageSHA256: strings.Repeat("a", 64), ViewID: service.CindyAccountViewID, PresetID: "cindy", ViewDefinitionDigest: strings.Repeat("b", 64)}, RuntimeGeneration: 11, PolicyRevision: 7, NormalizedQueryDigest: strings.Repeat("c", 64)} - raw, _ := json.Marshal(map[string]any{"plugin_id": 8, "plugin_generation": 4, "account_view": view}) - return raw -} - -func TestAccountViewJobFenceLocksBothOwnersInStableOrder(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer func() { _ = db.Close() }() - mock.ExpectBegin() - mock.ExpectQuery(`SELECT runtime_generation,state,package_sha256,revision.*FOR SHARE`).WithArgs(int64(3), service.CindyAccountViewPluginKey).WillReturnRows(sqlmock.NewRows([]string{"generation", "state", "package", "revision"}).AddRow(11, service.PluginStateEnabled, strings.Repeat("a", 64), 7)) - mock.ExpectQuery(`SELECT p.runtime_generation,p.state,.*FOR SHARE`).WithArgs(int64(8)).WillReturnRows(sqlmock.NewRows([]string{"generation", "state", "enabled"}).AddRow(4, service.PluginStateEnabled, true)) - mock.ExpectRollback() - tx, err := db.BeginTx(context.Background(), nil) - require.NoError(t, err) - require.NoError(t, lockAccountJobPlugin(context.Background(), tx, accountViewFenceMetadata(), service.AccountJobKindBulkUpdate)) - require.NoError(t, tx.Rollback()) - require.NoError(t, mock.ExpectationsWereMet()) -} - -func TestAccountViewJobFenceRejectsRevokedOriginBeforeAnyWrite(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer func() { _ = db.Close() }() - mock.ExpectBegin() - mock.ExpectQuery(`SELECT runtime_generation,state,package_sha256,revision.*FOR SHARE`).WithArgs(int64(3), service.CindyAccountViewPluginKey).WillReturnRows(sqlmock.NewRows([]string{"generation", "state", "package", "revision"}).AddRow(11, service.PluginStateDisabled, strings.Repeat("a", 64), 8)) - mock.ExpectRollback() - tx, err := db.BeginTx(context.Background(), nil) - require.NoError(t, err) - require.ErrorIs(t, lockAccountJobPlugin(context.Background(), tx, accountViewFenceMetadata(), service.AccountJobKindBulkUpdate), service.ErrAccountViewUnavailable) - require.NoError(t, tx.Rollback()) - require.NoError(t, mock.ExpectationsWereMet(), "no action-owner or business SQL is permitted after origin rejection") -} diff --git a/backend/internal/repository/business_system_prompt_repository_test.go b/backend/internal/repository/business_system_prompt_repository_test.go index 1401ba194cca..c6926eeac609 100644 --- a/backend/internal/repository/business_system_prompt_repository_test.go +++ b/backend/internal/repository/business_system_prompt_repository_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - promptsource "github.com/HTExplicit/sub2api-plugins/promptskills/source" + promptsource "github.com/Wei-Shaw/sub2api/internal/promptskills/source" "github.com/DATA-DOG/go-sqlmock" "github.com/Wei-Shaw/sub2api/internal/service" diff --git a/backend/internal/repository/business_system_prompt_rules.go b/backend/internal/repository/business_system_prompt_rules.go index af404123c412..25fe65a8f6c2 100644 --- a/backend/internal/repository/business_system_prompt_rules.go +++ b/backend/internal/repository/business_system_prompt_rules.go @@ -6,8 +6,8 @@ import ( "encoding/json" "errors" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" ) func (r *businessSystemPromptRepository) LoadBusinessSystemPromptRules(ctx context.Context) (service.BusinessSystemPromptSnapshot, error) { diff --git a/backend/internal/repository/business_system_prompt_rules_integration_test.go b/backend/internal/repository/business_system_prompt_rules_integration_test.go index 73adb176e0d9..9cdad31fadaf 100644 --- a/backend/internal/repository/business_system_prompt_rules_integration_test.go +++ b/backend/internal/repository/business_system_prompt_rules_integration_test.go @@ -14,9 +14,9 @@ import ( "entgo.io/ent/dialect" entsql "entgo.io/ent/dialect/sql" dbent "github.com/Wei-Shaw/sub2api/ent" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/migrations" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/stdlib" "github.com/stretchr/testify/require" diff --git a/backend/internal/repository/business_system_prompt_rules_test.go b/backend/internal/repository/business_system_prompt_rules_test.go index 2b5af4d3c1c7..46d7ece6ca4b 100644 --- a/backend/internal/repository/business_system_prompt_rules_test.go +++ b/backend/internal/repository/business_system_prompt_rules_test.go @@ -6,8 +6,8 @@ import ( "testing" "github.com/DATA-DOG/go-sqlmock" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/stretchr/testify/require" ) diff --git a/backend/internal/repository/cindy_account_view_atomic_test.go b/backend/internal/repository/cindy_account_view_atomic_test.go index ded979b891f1..b208d03ee2b5 100644 --- a/backend/internal/repository/cindy_account_view_atomic_test.go +++ b/backend/internal/repository/cindy_account_view_atomic_test.go @@ -100,13 +100,12 @@ func TestAccountViewProbeResumeUsesExactOriginCAS(t *testing.T) { for _, conflict := range []bool{false, true} { db, mock, err := sqlmock.New() require.NoError(t, err) - origin := &service.CindyBalanceProbeOrigin{Version: 1, PluginID: 3, PluginKey: service.CindyAccountViewPluginKey, PackageSHA256: strings.Repeat("a", 64), RuntimeGeneration: 4, OperationKey: "fixed", RequestDigest: strings.Repeat("b", 64), FrozenAccountIDs: []int64{2, 3}} - origin.View.RuntimeGeneration, origin.View.PolicyRevision = 4, 7 + origin := &service.CindyBalanceProbeOrigin{Version: 1, PluginID: 3, PluginKey: "codexrip.cindy-provider", PackageSHA256: strings.Repeat("a", 64), RuntimeGeneration: 4, OperationKey: "fixed", RequestDigest: strings.Repeat("b", 64), FrozenAccountIDs: []int64{2, 3}} + origin.View = json.RawMessage(`{"runtime_generation":4,"policy_revision":7}`) scope := service.CindyBalanceProbeScope{Mode: "all", Origin: origin} originalJSON := service.EncodeCindyBalanceProbeScope(scope) expectedJSON, _ := json.Marshal(origin) next := *origin - next.RuntimeGeneration, next.View.RuntimeGeneration, next.View.PolicyRevision = 5, 5, 8 updatedScope := scope updatedScope.Origin = &next mock.ExpectBegin() diff --git a/backend/internal/repository/cindy_balance_probe_repo.go b/backend/internal/repository/cindy_balance_probe_repo.go index 18c75203493f..4fcccb638b42 100644 --- a/backend/internal/repository/cindy_balance_probe_repo.go +++ b/backend/internal/repository/cindy_balance_probe_repo.go @@ -63,7 +63,7 @@ func (r *cindyBalanceProbeRepository) CreateJob( if origin := scope.Origin; origin != nil { if origin.OperationKey != "" { if requestedBy == nil || *requestedBy <= 0 { - return nil, service.ErrAccountViewInvalid + return nil, service.ErrCindyBalanceProbeChanged } key := fmt.Sprintf("cindy-probe-view:%d:%s", *requestedBy, origin.OperationKey) if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, key); err != nil { @@ -86,9 +86,6 @@ func (r *cindyBalanceProbeRepository) CreateJob( return nil, err } } - if err := lockPluginExecution(ctx, tx, origin.PluginKey); err != nil { - return nil, err - } } preview, err := loadCindyBalanceProbePreview(ctx, tx, scope, rateRPS) if err != nil { @@ -149,7 +146,7 @@ func loadCindyBalanceProbePreview( if err != nil { return nil, err } - if _, bound := service.AccountViewFromContext(ctx); bound { + { rows, err := tx.QueryContext(ctx, `SELECT id,wire_platform,provider_profile,cindy_banned_at FROM accounts WHERE deleted_at IS NULL ORDER BY id`) if err != nil { return nil, err @@ -869,7 +866,7 @@ func (r *cindyBalanceProbeRepository) FinalizeExhausted( func (r *cindyBalanceProbeRepository) FinalizeScopedExhausted(ctx context.Context, reservation *service.CindyBalanceProbeReservation, leaseToken string, observedAt time.Time, confirmationWindow time.Duration) (string, *service.CindyHealthEpisode, error) { if _, scoped := service.CindyBalanceProbeOriginOwner(ctx); !scoped { - return "", nil, service.ErrAccountViewUnavailable + return "", nil, service.ErrCindyBalanceProbeChanged } var committed service.CindyHealthEpisode state, err := r.finalizeAccountMarker(ctx, reservation, nil, leaseToken, observedAt, confirmationWindow, true, &committed) @@ -908,12 +905,7 @@ func (r *cindyBalanceProbeRepository) finalizeAccountMarker( return "", err } defer func() { _ = tx.Rollback() }() - owner, scoped := service.CindyBalanceProbeOriginOwner(ctx) - if scoped { - if err := lockPluginExecution(ctx, tx, owner); err != nil { - return "", err - } - } + _, scoped := service.CindyBalanceProbeOriginOwner(ctx) return r.finalizeAccountMarkerTx(ctx, tx, reservation, accountSnapshot, leaseToken, observedAt, confirmationWindow, mark, scoped, committedResult...) } @@ -1253,14 +1245,13 @@ func (r *cindyBalanceProbeRepository) Resume(ctx context.Context, jobID int64) ( func (r *cindyBalanceProbeRepository) ResumeScoped(ctx context.Context, jobID int64, expected, next *service.CindyBalanceProbeOrigin) (*service.CindyBalanceProbeJob, error) { if expected == nil || next == nil { - return nil, service.ErrAccountViewInvalid + return nil, service.ErrCindyBalanceProbeChanged } semantic := *next - semantic.RuntimeGeneration, semantic.View.RuntimeGeneration, semantic.View.PolicyRevision = expected.RuntimeGeneration, expected.View.RuntimeGeneration, expected.View.PolicyRevision before, _ := json.Marshal(expected) after, _ := json.Marshal(semantic) if !bytes.Equal(before, after) { - return nil, service.ErrAccountViewUnavailable + return nil, service.ErrCindyBalanceProbeChanged } if err := service.ValidateCindyBalanceProbeOriginContext(ctx, next); err != nil { return nil, err @@ -1270,9 +1261,6 @@ func (r *cindyBalanceProbeRepository) ResumeScoped(ctx context.Context, jobID in return nil, err } defer func() { _ = tx.Rollback() }() - if err := lockPluginExecution(ctx, tx, next.PluginKey); err != nil { - return nil, err - } if err := resumeScopedProbeTx(ctx, tx, jobID, before, next); err != nil { return nil, err } diff --git a/backend/internal/repository/cindy_balance_probe_repo_test.go b/backend/internal/repository/cindy_balance_probe_repo_test.go index 6d6a6f046d34..e7bd7d45494e 100644 --- a/backend/internal/repository/cindy_balance_probe_repo_test.go +++ b/backend/internal/repository/cindy_balance_probe_repo_test.go @@ -124,6 +124,7 @@ func TestCindyBalanceProbeRepositoryCreateJobRejectsTransactionalCandidateDrift( } previewedAccount := service.Account{ ID: 17, Name: "candidate-one", Platform: service.PlatformCindy, + WirePlatform: service.WirePlatformOpenAI, ProviderProfile: service.ProviderProfileCindyLaxaV1, Type: service.AccountTypeAPIKey, Status: service.StatusActive, Schedulable: true, Credentials: credentials, Extra: map[string]any{}, UpdatedAt: now, } @@ -134,10 +135,12 @@ func TestCindyBalanceProbeRepositoryCreateJobRejectsTransactionalCandidateDrift( now, ) require.NoError(t, err) + require.Equal(t, 1, expected.CandidateCount, "the confirmed preview must contain the canonical Cindy account") tests := []struct { - name string - rows *sqlmock.Rows + name string + rows *sqlmock.Rows + accountIDs []int64 }{ { name: "existing candidate generation changed", @@ -146,6 +149,7 @@ func TestCindyBalanceProbeRepositoryCreateJobRejectsTransactionalCandidateDrift( mustMarshalCindyBalanceProbeTestJSON(t, credentials), []byte(`{}`), nil, nil, service.StatusActive, true, now.Add(time.Second), nil, nil, nil, "{}", "{}", ), + accountIDs: []int64{17}, }, { name: "new matching candidate appeared", @@ -160,6 +164,7 @@ func TestCindyBalanceProbeRepositoryCreateJobRejectsTransactionalCandidateDrift( []byte(`{"api_key":"sk-candidate-two","base_url":"https://api.laxarouter.ai"}`), []byte(`{}`), nil, nil, service.StatusActive, true, now, nil, nil, nil, "{}", "{}", ), + accountIDs: []int64{17, 18}, }, } @@ -171,6 +176,13 @@ func TestCindyBalanceProbeRepositoryCreateJobRejectsTransactionalCandidateDrift( mock.ExpectBegin() mock.ExpectQuery(`SELECT a\.id, a\.name, a\.platform, a\.type, a\.credentials, a\.extra,[\s\S]+FROM accounts a[\s\S]+ORDER BY a\.id`). WillReturnRows(tt.rows) + identityRows := sqlmock.NewRows([]string{"id", "wire_platform", "provider_profile", "cindy_banned_at"}) + for _, id := range tt.accountIDs { + identityRows.AddRow(id, service.WirePlatformOpenAI, service.ProviderProfileCindyLaxaV1, nil) + } + mock.ExpectQuery(regexp.QuoteMeta(`SELECT id,wire_platform,provider_profile,cindy_banned_at FROM accounts WHERE deleted_at IS NULL ORDER BY id`)). + WillReturnRows(identityRows). + RowsWillBeClosed() mock.ExpectRollback() job, err := (&cindyBalanceProbeRepository{db: db}).CreateJob( diff --git a/backend/internal/repository/codex_native_lease.go b/backend/internal/repository/codex_native_lease.go new file mode 100644 index 000000000000..274e81af54b0 --- /dev/null +++ b/backend/internal/repository/codex_native_lease.go @@ -0,0 +1,154 @@ +package repository + +import ( + "context" + "errors" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/stdlib" + "sync" + "time" +) + +const nativeCodexRuntimeLeaseProbeInterval = time.Second +const nativeCodexRuntimeLeaseProbeTimeout = time.Second + +type nativeCodexRuntimeLeaseSession interface { + Ping(context.Context) error + Close(context.Context) error +} + +type nativeCodexRuntimeSessionLease struct { + conn nativeCodexRuntimeLeaseSession + done chan struct{} + stopped chan struct{} + cancel context.CancelFunc + releaseOnce sync.Once + mu sync.RWMutex + releasing bool + err error +} + +func newNativeCodexRuntimeSessionLease(conn nativeCodexRuntimeLeaseSession, interval, timeout time.Duration) *nativeCodexRuntimeSessionLease { + ctx, cancel := context.WithCancel(context.Background()) + lease := &nativeCodexRuntimeSessionLease{conn: conn, done: make(chan struct{}), stopped: make(chan struct{}), cancel: cancel} + go lease.monitor(ctx, interval, timeout) + return lease +} + +func (l *nativeCodexRuntimeSessionLease) Done() <-chan struct{} { return l.done } +func (l *nativeCodexRuntimeSessionLease) Err() error { + l.mu.RLock() + defer l.mu.RUnlock() + return l.err +} +func (l *nativeCodexRuntimeSessionLease) Release() { + l.releaseOnce.Do(func() { + l.mu.Lock() + l.releasing = true + l.mu.Unlock() + l.cancel() + }) + <-l.stopped +} + +func (l *nativeCodexRuntimeSessionLease) monitor(ctx context.Context, interval, timeout time.Duration) { + var lost bool + defer func() { + l.cancel() + l.mu.Lock() + if lost && !l.releasing { + l.err = service.ErrNativeCodexRuntimeUnavailable + } + close(l.done) + l.mu.Unlock() + // This goroutine alone owns Ping/Close. Done is published before the + // bounded close so callers can revoke work without waiting for IO. + closeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = l.conn.Close(closeCtx) + close(l.stopped) + }() + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + probeCtx, cancel := context.WithTimeout(ctx, timeout) + err := l.conn.Ping(probeCtx) + cancel() + if err != nil { + lost = true + return + } + } +} + +func (r *nativeCodexRepository) HoldNativeCodexRuntime(ctx context.Context, installation *service.NativeCodexMetadata) (service.NativeCodexRuntimeLease, error) { + if installation == nil || !service.NativeCodexBusinessIORequired(ctx) { + return nil, service.ErrNativeCodexRuntimeChanged + } + pooled, err := r.db.Conn(ctx) + if err != nil { + return nil, err + } + var config *pgx.ConnConfig + err = pooled.Raw(func(raw any) error { + conn, ok := raw.(*stdlib.Conn) + if !ok { + return errors.New("plugin runtime leases require PostgreSQL") + } + config = conn.Conn().Config().Copy() + return nil + }) + _ = pooled.Close() + if err != nil { + return nil, err + } + // A process-lifetime session must not occupy the business query pool: a + // small configured pool would deadlock during startup or package replacement. + if config.RuntimeParams == nil { + config.RuntimeParams = map[string]string{} + } + config.RuntimeParams["application_name"] = "sub2api-plugin-lease" + conn, err := pgx.ConnectConfig(ctx, config) + if err != nil { + return nil, err + } + var locked bool + err = conn.QueryRow(ctx, `SELECT pg_try_advisory_lock_shared(hashtextextended($1,0))`, nativeCodexRuntimeLockName(installation.ID)).Scan(&locked) + if err != nil || !locked { + _ = conn.Close(ctx) + if err == nil { + err = service.ErrNativeCodexRuntimeChanged + } + return nil, err + } + var generation int64 + var state, retired, configRecord string + err = conn.QueryRow(ctx, `SELECT runtime_generation,state FROM sub2api_plugin_installations WHERE id=$1 AND plugin_key=$2`, installation.ID, service.NativeCodexPluginKey).Scan(&generation, &state) + stale := err != nil || generation != installation.RuntimeGeneration || state != "disabled" + if !stale { + err = conn.QueryRow(ctx, `SELECT value FROM settings WHERE key=$1`, service.NativeCodexRetirementSettingKey).Scan(&retired) + stale = err != nil || !nativeCodexRetired(retired) + } + if !stale { + err = conn.QueryRow(ctx, `SELECT value FROM settings WHERE key=$1`, service.NativeCodexConfigSettingKey).Scan(&configRecord) + record, decodeErr := nativeCodexConfigRecord(configRecord) + stale = err != nil || decodeErr != nil || record.ConfigVersion != installation.ConfigVersion || record.ConfigSHA256 != installation.ConfigSHA256 + } + + if stale { + closeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = conn.Close(closeCtx) + if err == nil { + err = service.ErrNativeCodexRuntimeChanged + } + return nil, err + } + return newNativeCodexRuntimeSessionLease(conn, nativeCodexRuntimeLeaseProbeInterval, nativeCodexRuntimeLeaseProbeTimeout), nil +} diff --git a/backend/internal/repository/plugin_runtime_lease_monitor_test.go b/backend/internal/repository/codex_native_lease_monitor_test.go similarity index 80% rename from backend/internal/repository/plugin_runtime_lease_monitor_test.go rename to backend/internal/repository/codex_native_lease_monitor_test.go index 734cd9df32ca..02b704fc60a9 100644 --- a/backend/internal/repository/plugin_runtime_lease_monitor_test.go +++ b/backend/internal/repository/codex_native_lease_monitor_test.go @@ -43,9 +43,9 @@ func (s *runtimeLeaseMonitorSession) Close(context.Context) error { return nil } -func TestRuntimeLeaseNormalReleaseCancelsProbeWithoutConcurrentClose(t *testing.T) { +func TestNativeRuntimeLeaseNormalReleaseCancelsProbeWithoutConcurrentClose(t *testing.T) { session := &runtimeLeaseMonitorSession{entered: make(chan struct{}), block: true} - lease := newPluginRuntimeSessionLease(session, time.Millisecond, time.Second) + lease := newNativeCodexRuntimeSessionLease(session, time.Millisecond, time.Second) select { case <-session.entered: case <-time.After(time.Second): @@ -74,9 +74,9 @@ func TestRuntimeLeaseNormalReleaseCancelsProbeWithoutConcurrentClose(t *testing. } } -func TestRuntimeLeaseLossListenerCanReleaseWithoutDeadlock(t *testing.T) { +func TestNativeRuntimeLeaseLossListenerCanReleaseWithoutDeadlock(t *testing.T) { session := &runtimeLeaseMonitorSession{entered: make(chan struct{}), pingErr: errors.New("synthetic lost session")} - lease := newPluginRuntimeSessionLease(session, time.Millisecond, time.Second) + lease := newNativeCodexRuntimeSessionLease(session, time.Millisecond, time.Second) listenerDone := make(chan struct{}) go func() { <-lease.Done() @@ -88,22 +88,22 @@ func TestRuntimeLeaseLossListenerCanReleaseWithoutDeadlock(t *testing.T) { case <-time.After(time.Second): t.Fatal("loss notification and caller Release formed a wait cycle") } - require.ErrorIs(t, lease.Err(), service.ErrPluginRuntimeLeaseLost) + require.ErrorIs(t, lease.Err(), service.ErrNativeCodexRuntimeUnavailable) lease.Release() require.Equal(t, int32(1), session.pings.Load(), "loss must not reconnect or re-acquire a lease") require.Equal(t, int32(1), session.closes.Load()) require.False(t, session.concurrent.Load()) } -func TestRuntimeLeaseProbeTimeoutReportsLossWithoutRetry(t *testing.T) { +func TestNativeRuntimeLeaseProbeTimeoutReportsLossWithoutRetry(t *testing.T) { session := &runtimeLeaseMonitorSession{entered: make(chan struct{}), block: true} - lease := newPluginRuntimeSessionLease(session, time.Millisecond, 5*time.Millisecond) + lease := newNativeCodexRuntimeSessionLease(session, time.Millisecond, 5*time.Millisecond) select { case <-lease.Done(): case <-time.After(time.Second): t.Fatal("a stalled session probe did not reach its bounded timeout") } - require.ErrorIs(t, lease.Err(), service.ErrPluginRuntimeLeaseLost) + require.ErrorIs(t, lease.Err(), service.ErrNativeCodexRuntimeUnavailable) lease.Release() require.Equal(t, int32(1), session.pings.Load()) require.Equal(t, int32(1), session.closes.Load()) diff --git a/backend/internal/repository/codex_native_runtime.go b/backend/internal/repository/codex_native_runtime.go new file mode 100644 index 000000000000..2da8780d3a87 --- /dev/null +++ b/backend/internal/repository/codex_native_runtime.go @@ -0,0 +1,229 @@ +package repository + +import ( + "context" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +type nativeCodexRepository struct { + db *sql.DB + refreshAccount func(context.Context, int64) +} + +func NewNativeCodexRepository(db *sql.DB, accounts service.AccountRepository) service.NativeCodexRepository { + r := &nativeCodexRepository{db: db} + if concrete, ok := accounts.(*accountRepository); ok { + r.refreshAccount = concrete.syncSchedulerAccountSnapshotDetached + } + return r +} + +func nativeCodexRuntimeLockName(id int64) string { + // Same lock as the retired plugin: old and native owners cannot overlap a cutover. + return fmt.Sprintf("sub2api-plugin-runtime:%d", id) +} + +func (r *nativeCodexRepository) ReadNativeCodexStoredConfig(ctx context.Context) (string, bool, error) { + var cipher string + err := r.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key=$1`, service.NativeCodexSourceSettingKey).Scan(&cipher) + if err == nil { + return cipher, true, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return "", false, err + } + err = r.db.QueryRowContext(ctx, `SELECT config_encrypted FROM sub2api_plugin_installations WHERE plugin_key=$1`, service.NativeCodexPluginKey).Scan(&cipher) + if errors.Is(err, sql.ErrNoRows) { + return "", false, nil + } + return cipher, cipher != "", err +} + +type nativeCodexMetadataQuerier interface { + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +func nativeCodexRetired(raw string) bool { + var record struct { + Version int `json:"version"` + Completed bool `json:"completed"` + Plugins map[string]json.RawMessage `json:"plugins"` + } + if json.Unmarshal([]byte(raw), &record) != nil || record.Version != 1 || !record.Completed { + return false + } + plugin, ok := record.Plugins[service.NativeCodexPluginKey] + return ok && len(plugin) > 0 && string(plugin) != "null" +} + +func nativeCodexConfigRecord(raw string) (service.NativeCodexConfigRecord, error) { + var record service.NativeCodexConfigRecord + if json.Unmarshal([]byte(raw), &record) != nil || record.Version != 1 || record.ConfigVersion <= 0 || len(record.ConfigSHA256) != 64 { + return record, service.ErrNativeCodexRuntimeChanged + } + if _, err := hex.DecodeString(record.ConfigSHA256); err != nil { + return record, service.ErrNativeCodexRuntimeChanged + } + return record, nil +} + +func readNativeCodexMetadata(ctx context.Context, q nativeCodexMetadataQuerier, lock string) (*service.NativeCodexMetadata, error) { + metadata := &service.NativeCodexMetadata{} + var state, retired, config string + err := q.QueryRowContext(ctx, `SELECT id,runtime_generation,state FROM sub2api_plugin_installations WHERE plugin_key=$1`+lock, service.NativeCodexPluginKey).Scan(&metadata.ID, &metadata.RuntimeGeneration, &state) + if err != nil { + return nil, err + } + if state != "disabled" || metadata.ID <= 0 || metadata.RuntimeGeneration <= 0 { + return nil, service.ErrNativeCodexRuntimeChanged + } + if err = q.QueryRowContext(ctx, `SELECT value FROM settings WHERE key=$1`, service.NativeCodexRetirementSettingKey).Scan(&retired); err != nil { + return nil, err + } + if !nativeCodexRetired(retired) { + return nil, service.ErrNativeCodexRuntimeChanged + } + err = q.QueryRowContext(ctx, `SELECT value FROM settings WHERE key=$1`, service.NativeCodexConfigSettingKey).Scan(&config) + if errors.Is(err, sql.ErrNoRows) { + // A historical run can be read before the native config hash is first recorded. + return metadata, nil + } + if err != nil { + return nil, err + } + record, err := nativeCodexConfigRecord(config) + if err != nil { + return nil, err + } + metadata.ConfigVersion, metadata.ConfigSHA256 = record.ConfigVersion, record.ConfigSHA256 + return metadata, nil +} + +func (r *nativeCodexRepository) LoadNativeCodexMetadata(ctx context.Context) (*service.NativeCodexMetadata, error) { + return readNativeCodexMetadata(ctx, r.db, "") +} + +func (r *nativeCodexRepository) ValidateLegacyCodexJobSource(ctx context.Context, pluginID int64) error { + metadata, err := r.LoadNativeCodexMetadata(ctx) + if err != nil { + return err + } + if pluginID <= 0 || metadata.ID != pluginID { + return service.ErrNativeCodexRuntimeChanged + } + var raw string + if err = r.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key=$1`, service.NativeCodexRetirementSettingKey).Scan(&raw); err != nil { + return err + } + var snapshot service.NativeRetirementSnapshot + if json.Unmarshal([]byte(raw), &snapshot) != nil || snapshot.Version != 1 || !snapshot.Completed { + return service.ErrNativeCodexRuntimeChanged + } + origin, exists := snapshot.Plugins[service.NativeCodexPluginKey] + if !exists || origin.ID != pluginID || origin.NativeCreated { + return service.ErrNativeCodexRuntimeChanged + } + return nil +} + +func (r *nativeCodexRepository) SyncNativeCodexConfig(ctx context.Context, hash string) (*service.NativeCodexMetadata, error) { + return r.syncNativeCodexConfig(ctx, hash, nil) +} +func (r *nativeCodexRepository) StoreNativeCodexConfig(ctx context.Context, cipher, hash string) (*service.NativeCodexMetadata, error) { + if cipher == "" { + return nil, service.ErrNativeCodexRuntimeChanged + } + return r.syncNativeCodexConfig(ctx, hash, &cipher) +} +func (r *nativeCodexRepository) syncNativeCodexConfig(ctx context.Context, hash string, cipher *string) (*service.NativeCodexMetadata, error) { + if len(hash) != 64 { + return nil, service.ErrNativeCodexRuntimeChanged + } + if _, err := hex.DecodeString(hash); err != nil { + return nil, service.ErrNativeCodexRuntimeChanged + } + var id int64 + if err := r.db.QueryRowContext(ctx, `SELECT id FROM sub2api_plugin_installations WHERE plugin_key=$1`, service.NativeCodexPluginKey).Scan(&id); err != nil { + return nil, err + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + var locked bool + if err = tx.QueryRowContext(ctx, `SELECT pg_try_advisory_xact_lock_shared(hashtextextended($1,0))`, nativeCodexRuntimeLockName(id)).Scan(&locked); err != nil { + return nil, err + } + if !locked { + return nil, service.ErrNativeCodexRuntimeChanged + } + metadata, err := readNativeCodexMetadata(ctx, tx, " FOR UPDATE") + if err != nil { + return nil, err + } + saveSource := func() error { + if cipher == nil { + return nil + } + _, saveErr := tx.ExecContext(ctx, `INSERT INTO settings(key,value,updated_at) VALUES($1,$2,NOW()) ON CONFLICT(key) DO UPDATE SET value=EXCLUDED.value,updated_at=EXCLUDED.updated_at`, service.NativeCodexSourceSettingKey, *cipher) + return saveErr + } + if metadata.ConfigSHA256 == hash { + if err = saveSource(); err != nil { + return nil, err + } + return metadata, tx.Commit() + } + // Same-policy saves need not disturb shared business leases. A real policy + // change still requires the original exclusive cutover lock after drain. + if err = tx.QueryRowContext(ctx, `SELECT pg_try_advisory_xact_lock(hashtextextended($1,0))`, nativeCodexRuntimeLockName(id)).Scan(&locked); err != nil { + return nil, err + } + if !locked { + return nil, service.ErrNativeCodexRuntimeChanged + } + if metadata.ConfigVersion > 0 { + if err = tx.QueryRowContext(ctx, `UPDATE sub2api_plugin_installations SET runtime_generation=runtime_generation+1,updated_at=NOW() WHERE id=$1 AND plugin_key=$2 AND state='disabled' AND runtime_generation=$3 RETURNING runtime_generation`, metadata.ID, service.NativeCodexPluginKey, metadata.RuntimeGeneration).Scan(&metadata.RuntimeGeneration); err != nil { + return nil, err + } + } + // The first hash records the generation already advanced by the retirement transaction. + metadata.ConfigVersion++ + metadata.ConfigSHA256 = hash + raw, err := json.Marshal(service.NativeCodexConfigRecord{Version: 1, ConfigVersion: metadata.ConfigVersion, ConfigSHA256: hash}) + if err != nil { + return nil, err + } + if _, err = tx.ExecContext(ctx, `INSERT INTO settings(key,value,updated_at) VALUES($1,$2,NOW()) ON CONFLICT(key) DO UPDATE SET value=EXCLUDED.value,updated_at=EXCLUDED.updated_at`, service.NativeCodexConfigSettingKey, string(raw)); err != nil { + return nil, err + } + if err = saveSource(); err != nil { + return nil, err + } + return metadata, tx.Commit() +} + +func lockNativeCodexExecution(ctx context.Context, tx *sql.Tx, key string) error { + if key != service.NativeCodexPluginKey { + return service.ErrNativeCodexRuntimeChanged + } + wanted, ok := service.NativeCodexExecutionFromContext(ctx) + if !ok { + return service.ErrNativeCodexRuntimeChanged + } + current, err := readNativeCodexMetadata(ctx, tx, " FOR SHARE") + if err != nil { + return err + } + if *current != wanted { + return service.ErrNativeCodexRuntimeChanged + } + return nil +} diff --git a/backend/internal/repository/codex_native_runtime_test.go b/backend/internal/repository/codex_native_runtime_test.go new file mode 100644 index 000000000000..bebbc62dd7c2 --- /dev/null +++ b/backend/internal/repository/codex_native_runtime_test.go @@ -0,0 +1,87 @@ +//go:build unit + +package repository + +import ( + "context" + "database/sql" + "errors" + "regexp" + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestNativeCodexConfigGenerationIsAtomicAndIdempotent(t *testing.T) { + for _, phase := range []string{"initial", "unchanged", "changed"} { + t.Run(phase, func(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + r := &nativeCodexRepository{db: db} + hash := strings.Repeat("a", 64) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT id FROM sub2api_plugin_installations WHERE plugin_key=$1`)).WithArgs(service.NativeCodexPluginKey).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(7)) + mock.ExpectBegin() + mock.ExpectQuery(regexp.QuoteMeta(`SELECT pg_try_advisory_xact_lock_shared(hashtextextended($1,0))`)).WithArgs("sub2api-plugin-runtime:7").WillReturnRows(sqlmock.NewRows([]string{"locked"}).AddRow(true)) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT id,runtime_generation,state FROM sub2api_plugin_installations WHERE plugin_key=$1 FOR UPDATE`)).WithArgs(service.NativeCodexPluginKey).WillReturnRows(sqlmock.NewRows([]string{"id", "generation", "state"}).AddRow(7, 12, "disabled")) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT value FROM settings WHERE key=$1`)).WithArgs(service.NativeCodexRetirementSettingKey).WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow(`{"version":1,"completed":true,"plugins":{"codexrip.codex-runtime":{}}}`)) + read := mock.ExpectQuery(regexp.QuoteMeta(`SELECT value FROM settings WHERE key=$1`)).WithArgs(service.NativeCodexConfigSettingKey) + if phase == "initial" { + read.WillReturnError(sql.ErrNoRows) + } else { + oldHash := hash + if phase == "changed" { + oldHash = strings.Repeat("b", 64) + } + read.WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow(`{"version":1,"config_version":3,"config_sha256":"` + oldHash + `"}`)) + } + if phase != "unchanged" { + mock.ExpectQuery(regexp.QuoteMeta(`SELECT pg_try_advisory_xact_lock(hashtextextended($1,0))`)).WithArgs("sub2api-plugin-runtime:7").WillReturnRows(sqlmock.NewRows([]string{"locked"}).AddRow(true)) + } + if phase == "changed" { + mock.ExpectQuery(regexp.QuoteMeta(`UPDATE sub2api_plugin_installations SET runtime_generation=runtime_generation+1,updated_at=NOW() WHERE id=$1 AND plugin_key=$2 AND state='disabled' AND runtime_generation=$3 RETURNING runtime_generation`)).WithArgs(int64(7), service.NativeCodexPluginKey, int64(12)).WillReturnRows(sqlmock.NewRows([]string{"generation"}).AddRow(13)) + } + if phase != "unchanged" { + mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO settings(key,value,updated_at) VALUES($1,$2,NOW()) ON CONFLICT(key) DO UPDATE SET value=EXCLUDED.value,updated_at=EXCLUDED.updated_at`)).WithArgs(service.NativeCodexConfigSettingKey, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1)) + } + mock.ExpectCommit() + metadata, err := r.SyncNativeCodexConfig(context.Background(), hash) + require.NoError(t, err) + expected := int64(12) + if phase == "changed" { + expected = 13 + } + require.Equal(t, expected, metadata.RuntimeGeneration) + require.Equal(t, hash, metadata.ConfigSHA256) + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestNativeCodexStoredConfigDistinguishesMissingAndReadError(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + r := &nativeCodexRepository{db: db} + query := regexp.QuoteMeta(`SELECT config_encrypted FROM sub2api_plugin_installations WHERE plugin_key=$1`) + sourceQuery := regexp.QuoteMeta(`SELECT value FROM settings WHERE key=$1`) + mock.ExpectQuery(sourceQuery).WithArgs(service.NativeCodexSourceSettingKey).WillReturnError(sql.ErrNoRows) + mock.ExpectQuery(query).WithArgs(service.NativeCodexPluginKey).WillReturnError(sql.ErrNoRows) + _, present, err := r.ReadNativeCodexStoredConfig(context.Background()) + require.NoError(t, err) + require.False(t, present) + failure := errors.New("fixture storage unavailable") + mock.ExpectQuery(sourceQuery).WithArgs(service.NativeCodexSourceSettingKey).WillReturnError(failure) + _, _, err = r.ReadNativeCodexStoredConfig(context.Background()) + require.ErrorIs(t, err, failure, "a read failure cannot select the deployment defaults") + mock.ExpectQuery(sourceQuery).WithArgs(service.NativeCodexSourceSettingKey).WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow("native-cipher-fixture")) + value, present, err := r.ReadNativeCodexStoredConfig(context.Background()) + require.NoError(t, err) + require.True(t, present) + require.Equal(t, "native-cipher-fixture", value) + + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/backend/internal/repository/plugin_extension_state.go b/backend/internal/repository/codex_native_state.go similarity index 77% rename from backend/internal/repository/plugin_extension_state.go rename to backend/internal/repository/codex_native_state.go index c7258db55b63..d5ff90fc8a1d 100644 --- a/backend/internal/repository/plugin_extension_state.go +++ b/backend/internal/repository/codex_native_state.go @@ -7,10 +7,10 @@ import ( "errors" "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) -func (r *pluginRepository) ReadExtensionState(ctx context.Context, plugin string, req extensionv1.StateRequest) (extensionv1.StateResult, error) { +func (r *nativeCodexRepository) ReadExtensionState(ctx context.Context, plugin string, req extensionv1.StateRequest) (extensionv1.StateResult, error) { var out extensionv1.StateResult err := r.db.QueryRowContext(ctx, `SELECT revision,value FROM sub2api_plugin_state WHERE plugin_key=$1 AND namespace=$2 AND state_key=$3`, plugin, req.Namespace, req.Key).Scan(&out.Revision, &out.Value) if errors.Is(err, sql.ErrNoRows) { @@ -20,19 +20,19 @@ func (r *pluginRepository) ReadExtensionState(ctx context.Context, plugin string return out, err } -func (r *pluginRepository) CompareSwapExtensionState(ctx context.Context, plugin string, req extensionv1.StateRequest) (extensionv1.StateResult, error) { +func (r *nativeCodexRepository) CompareSwapExtensionState(ctx context.Context, plugin string, req extensionv1.StateRequest) (extensionv1.StateResult, error) { var out extensionv1.StateResult tx, err := r.db.BeginTx(ctx, nil) if err != nil { return out, err } defer func() { _ = tx.Rollback() }() - if err := lockPluginExecution(ctx, tx, plugin); err != nil { + if err := lockNativeCodexExecution(ctx, tx, plugin); err != nil { return out, err } var projectionData map[string]any if req.Projection != nil { - projectionData, err = lockPluginAccountProjection(ctx, tx, plugin, req.Projection) + projectionData, err = lockNativeCodexAccountProjection(ctx, tx, plugin, req.Projection) if err != nil { return out, err } @@ -58,7 +58,7 @@ func (r *pluginRepository) CompareSwapExtensionState(ctx context.Context, plugin if marshalErr != nil { return out, marshalErr } - if _, err = tx.ExecContext(ctx, `UPDATE accounts SET extra=jsonb_set(COALESCE(NULLIF(extra,'null'::jsonb),'{}'::jsonb),ARRAY[$2],$3::jsonb,true),updated_at=NOW() WHERE id=$1`, req.Projection.AccountID, service.PluginAccountProjectionKey, raw); err != nil { + if _, err = tx.ExecContext(ctx, `UPDATE accounts SET extra=jsonb_set(COALESCE(NULLIF(extra,'null'::jsonb),'{}'::jsonb),ARRAY[$2],$3::jsonb,true),updated_at=NOW() WHERE id=$1`, req.Projection.AccountID, service.NativeCodexAccountProjectionKey, raw); err != nil { return out, err } if err = enqueueSchedulerOutbox(ctx, tx, service.SchedulerOutboxEventAccountChanged, &req.Projection.AccountID, nil, nil); err != nil { @@ -75,7 +75,7 @@ func (r *pluginRepository) CompareSwapExtensionState(ctx context.Context, plugin return out, err } -func lockPluginAccountProjection(ctx context.Context, tx *sql.Tx, plugin string, projection *extensionv1.AccountProjection) (map[string]any, error) { +func lockNativeCodexAccountProjection(ctx context.Context, tx *sql.Tx, plugin string, projection *extensionv1.AccountProjection) (map[string]any, error) { var platform, accountType string var credentials, extra []byte if err := tx.QueryRowContext(ctx, `SELECT platform,type,credentials,extra FROM accounts WHERE id=$1 AND deleted_at IS NULL FOR UPDATE`, projection.AccountID).Scan(&platform, &accountType, &credentials, &extra); err != nil { @@ -88,13 +88,13 @@ func lockPluginAccountProjection(ctx context.Context, tx *sql.Tx, plugin string, if service.CodexTicketAccountIdentity(a) != projection.Identity { return nil, errors.New("credential owner changed") } - values, _ := a.Extra[service.PluginAccountProjectionKey].(map[string]any) + values, _ := a.Extra[service.NativeCodexAccountProjectionKey].(map[string]any) if values == nil { values = make(map[string]any) } - current := service.PluginAccountProjection{Identity: projection.Identity, Scheduling: make(map[string]extensionv1.SchedulingConstraint)} + current := service.NativeCodexAccountProjection{Identity: projection.Identity, Scheduling: make(map[string]extensionv1.SchedulingConstraint)} if raw, err := json.Marshal(values[plugin]); err == nil { - var previous service.PluginAccountProjection + var previous service.NativeCodexAccountProjection if json.Unmarshal(raw, &previous) == nil && previous.Identity == projection.Identity && previous.Scheduling != nil { current = previous } @@ -112,7 +112,7 @@ func lockPluginAccountProjection(ctx context.Context, tx *sql.Tx, plugin string, return values, nil } -func (r *pluginRepository) DueExtensionStates(ctx context.Context, plugin string, req extensionv1.DueStateRequest) ([]extensionv1.DueState, error) { +func (r *nativeCodexRepository) DueExtensionStates(ctx context.Context, plugin string, req extensionv1.DueStateRequest) ([]extensionv1.DueState, error) { rows, err := r.db.QueryContext(ctx, `SELECT state_key,revision,value FROM sub2api_plugin_state WHERE plugin_key=$1 AND namespace=$2 AND next_at<=NOW() ORDER BY next_at,state_key LIMIT $3`, plugin, req.Namespace, req.Limit) if err != nil { return nil, err @@ -129,14 +129,14 @@ func (r *pluginRepository) DueExtensionStates(ctx context.Context, plugin string return out, rows.Err() } -func (r *pluginRepository) AcquireExtensionLease(ctx context.Context, plugin string, req extensionv1.LeaseRequest) (extensionv1.LeaseResult, error) { +func (r *nativeCodexRepository) AcquireExtensionLease(ctx context.Context, plugin string, req extensionv1.LeaseRequest) (extensionv1.LeaseResult, error) { var out extensionv1.LeaseResult tx, err := r.db.BeginTx(ctx, nil) if err != nil { return out, err } defer func() { _ = tx.Rollback() }() - if err = lockPluginExecution(ctx, tx, plugin); err != nil { + if err = lockNativeCodexExecution(ctx, tx, plugin); err != nil { return out, err } err = tx.QueryRowContext(ctx, `INSERT INTO sub2api_plugin_leases(plugin_key,namespace,lease_key,owner,expires_at) @@ -155,13 +155,13 @@ func (r *pluginRepository) AcquireExtensionLease(ctx context.Context, plugin str return out, tx.Commit() } -func (r *pluginRepository) ReleaseExtensionLease(ctx context.Context, plugin string, req extensionv1.LeaseRequest) (extensionv1.LeaseResult, error) { +func (r *nativeCodexRepository) ReleaseExtensionLease(ctx context.Context, plugin string, req extensionv1.LeaseRequest) (extensionv1.LeaseResult, error) { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return extensionv1.LeaseResult{}, err } defer func() { _ = tx.Rollback() }() - if err = lockPluginExecution(ctx, tx, plugin); err != nil { + if err = lockNativeCodexExecution(ctx, tx, plugin); err != nil { return extensionv1.LeaseResult{}, err } // Keep the row and generation as a fencing token; deleting it would allow ABA. diff --git a/backend/internal/repository/native_feature_bootstrap.go b/backend/internal/repository/native_feature_bootstrap.go new file mode 100644 index 000000000000..3fa361970ab6 --- /dev/null +++ b/backend/internal/repository/native_feature_bootstrap.go @@ -0,0 +1,178 @@ +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.codex-runtime','codexrip.image-tools','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 and native + // business IO. 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',runtime_generation=runtime_generation+CASE WHEN plugin_key='codexrip.codex-runtime' THEN 1 ELSE 0 END,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 + } + if _, exists := snapshot.Plugins[service.NativeCodexPluginKey]; !exists { + // Fresh databases have no retired package. This row is only a disabled + // state anchor; it has no artifact, binary, signature, or active binding. + var id int64 + if err = tx.QueryRowContext(ctx, `INSERT INTO sub2api_plugin_installations + (plugin_key,name,version,description,manifest,artifact_path,install_path,binary_path,binary_sha256,state,runtime_generation) + VALUES($1,'Native Codex state anchor','0.0.0','Persistent native runtime generation; no plugin executable','{}'::jsonb,'','','','','disabled',1) + RETURNING id`, service.NativeCodexPluginKey).Scan(&id); err != nil { + return nil, err + } + snapshot.Plugins[service.NativeCodexPluginKey] = service.NativeRetirementPlugin{ + ID: id, Key: service.NativeCodexPluginKey, State: "disabled", RuntimeGeneration: 1, NativeCreated: true, + } + } + 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 new file mode 100644 index 000000000000..29912ea231dc --- /dev/null +++ b/backend/internal/repository/native_feature_bootstrap_integration_test.go @@ -0,0 +1,100 @@ +//go:build integration + +package repository + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestNativeFeatureRetirementIsAtomicAndPreservesLedger(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.codex-runtime','codexrip.image-tools')`).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','image_tools_config')`) + _, _ = integrationDB.ExecContext(ctx, `DELETE FROM sub2api_plugin_state WHERE plugin_key='codexrip.codex-runtime' AND namespace='codex-routing-private' AND state_key='quality-run.native-retirement-fixture'`) + _, _ = integrationDB.ExecContext(ctx, `DELETE FROM sub2api_plugin_bootstrap WHERE plugin_key='codexrip.image-tools'`) + _, _ = integrationDB.ExecContext(ctx, `DELETE FROM sub2api_plugin_installations WHERE plugin_key IN ('codexrip.codex-runtime','codexrip.image-tools')`) + }) + 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 + } + imageID := insert("codexrip.image-tools", 3) + codexID := insert(service.NativeCodexPluginKey, 9) + _, err := integrationDB.ExecContext(ctx, `INSERT INTO sub2api_plugin_bindings(plugin_id,capability,platform,account_type,enabled,rollout_percent) VALUES($1,'extensions.request.v1','*','*',true,100)`, imageID) + 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.image-tools','fixture','image-tools-v1',true,true,true)`) + require.NoError(t, err) + _, err = integrationDB.ExecContext(ctx, `INSERT INTO settings(key,value,updated_at) VALUES('image_tools_config','{"studio_enabled":false,"responses_image_enabled":false}',NOW())`) + require.NoError(t, err) + _, err = integrationDB.ExecContext(ctx, `INSERT INTO sub2api_plugin_state(plugin_key,namespace,state_key,value) VALUES('codexrip.codex-runtime','codex-routing-private','quality-run.native-retirement-fixture','{"status":"closed","used_sends":40,"attempts":[{"id":"kept"}],"scope":{"account_id":7}}')`) + require.NoError(t, err) + var ledgerBefore string + require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT value::text FROM sub2api_plugin_state WHERE plugin_key='codexrip.codex-runtime' AND namespace='codex-routing-private' AND state_key='quality-run.native-retirement-fixture'`).Scan(&ledgerBefore)) + repo := NewNativeFeatureBootstrapRepository(integrationDB) + convert := func(plugin service.NativeRetirementPlugin) (map[string]json.RawMessage, error) { + if plugin.Key == "codexrip.image-tools" { + return map[string]json.RawMessage{service.SettingKeyImageToolsConfig: json.RawMessage(`{"studio_enabled":true,"responses_image_enabled":true}`)}, nil + } + return nil, nil + } + _, err = repo.RetireNativeFeatures(ctx, func(plugin service.NativeRetirementPlugin) (map[string]json.RawMessage, error) { + if plugin.Key == service.NativeCodexPluginKey { + 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`, imageID).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[service.NativeCodexPluginKey].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`, codexID).Scan(&state, &generation, &cipher, &artifact)) + require.Equal(t, "disabled", state) + require.EqualValues(t, 10, 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='image_tools_config'`).Scan(&setting)) + require.JSONEq(t, `{"studio_enabled":false,"responses_image_enabled":false}`, setting) + var bindingEnabled, removed bool + require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT enabled FROM sub2api_plugin_bindings WHERE plugin_id=$1`, imageID).Scan(&bindingEnabled)) + require.False(t, bindingEnabled) + require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT user_removed FROM sub2api_plugin_bootstrap WHERE plugin_key='codexrip.image-tools'`).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, import, or generation changes") + 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)) + require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT runtime_generation FROM sub2api_plugin_installations WHERE id=$1`, codexID).Scan(&generation)) + require.EqualValues(t, 10, generation) + var ledgerAfter string + require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT value::text FROM sub2api_plugin_state WHERE plugin_key='codexrip.codex-runtime' AND namespace='codex-routing-private' AND state_key='quality-run.native-retirement-fixture'`).Scan(&ledgerAfter)) + require.Equal(t, ledgerBefore, ledgerAfter) +} diff --git a/backend/internal/repository/native_plugin_boundary.go b/backend/internal/repository/native_plugin_boundary.go new file mode 100644 index 000000000000..8d7ed156ceda --- /dev/null +++ b/backend/internal/repository/native_plugin_boundary.go @@ -0,0 +1,138 @@ +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 { + return newNativePluginBoundary(NewPluginRepository(db), bootstrap) +} + +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 +} + +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 new file mode 100644 index 000000000000..5b72e158d288 --- /dev/null +++ b/backend/internal/repository/native_plugin_boundary_test.go @@ -0,0 +1,90 @@ +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) + 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/plugin_contract_fixture_test.go b/backend/internal/repository/native_policy_fixture_test.go similarity index 100% rename from backend/internal/repository/plugin_contract_fixture_test.go rename to backend/internal/repository/native_policy_fixture_test.go diff --git a/backend/internal/repository/plugin_bootstrap_update_integration_test.go b/backend/internal/repository/plugin_bootstrap_update_integration_test.go deleted file mode 100644 index cdfbdbf2742a..000000000000 --- a/backend/internal/repository/plugin_bootstrap_update_integration_test.go +++ /dev/null @@ -1,178 +0,0 @@ -//go:build integration - -package repository - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "testing" - - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/stretchr/testify/require" -) - -func unfinishedBootstrapUpdateFixture(t *testing.T) (*pluginRepository, *service.PluginInstallation, *service.PluginInstallation, *service.PluginInstallation) { - t.Helper() - ctx := context.Background() - repo := &pluginRepository{db: integrationDB} - artifact := bundleFixture(t, repo) - artifact.Manifest.Version = artifact.Version - prepared, err := repo.PrepareBundledPlugin(ctx, artifact, "unfinished-bundle", "", true, "saved-bootstrap-config") - require.NoError(t, err) - candidate := *artifact - candidate.Version = "1.0.1" - candidate.Manifest.Version = candidate.Version - candidate.ArtifactData = []byte("independent pending replacement") - sum := sha256.Sum256(candidate.ArtifactData) - candidate.PackageSHA256 = hex.EncodeToString(sum[:]) - return repo, artifact, prepared, &candidate -} - -func bootstrapJournalSnapshot(t *testing.T, repo *pluginRepository, key string) string { - t.Helper() - var snapshot string - require.NoError(t, repo.db.QueryRowContext(context.Background(), `SELECT to_jsonb(b)::text FROM sub2api_plugin_bootstrap b WHERE plugin_key=$1`, key).Scan(&snapshot)) - return snapshot -} - -// Recreate a pending row accepted by the pre-fix Stage path. New admission now -// rejects this state, but a restart must still preserve already-persisted work. -func seedLegacyUnfinishedBootstrapUpdate(t *testing.T, repo *pluginRepository, previous, candidate *service.PluginInstallation) { - t.Helper() - ctx := context.Background() - tx, err := repo.db.BeginTx(ctx, nil) - require.NoError(t, err) - defer tx.Rollback() - var generation int64 - require.NoError(t, tx.QueryRowContext(ctx, `UPDATE sub2api_plugin_installations SET state='updating' WHERE id=$1 RETURNING runtime_generation`, previous.ID).Scan(&generation)) - _, err = tx.ExecContext(ctx, `INSERT INTO sub2api_plugin_updates(plugin_id,artifact_data,package_sha256,source_generation,update_policy) VALUES($1,$2,$3,$4,'pinned')`, previous.ID, candidate.ArtifactData, candidate.PackageSHA256, generation) - require.NoError(t, err) - require.NoError(t, tx.Commit()) -} - -func TestBootstrapPrepareDoesNotOverwritePendingIndependentUpdate(t *testing.T) { - ctx := context.Background() - repo, artifact, prepared, candidate := unfinishedBootstrapUpdateFixture(t) - seedLegacyUnfinishedBootstrapUpdate(t, repo, prepared, candidate) - before, err := repo.GetByID(ctx, prepared.ID) - require.NoError(t, err) - journal := bootstrapJournalSnapshot(t, repo, prepared.PluginKey) - resumed, err := repo.PrepareBundledPlugin(ctx, artifact, "unfinished-bundle", "", true, "new-default-config") - require.NoError(t, err) - after, err := repo.GetByID(ctx, prepared.ID) - require.NoError(t, err) - pending, pendingErr := repo.PendingPluginArtifact(ctx, prepared.ID) - t.Logf("before=%s/generation:%d after=%s/generation:%d pending_readable=%t", before.State, before.RuntimeGeneration, after.State, after.RuntimeGeneration, pendingErr == nil) - require.Nil(t, resumed, "a pending independent update owns the installed generation") - require.Equal(t, before, after) - require.NoError(t, pendingErr) - require.Equal(t, candidate.ArtifactData, pending) - require.Equal(t, journal, bootstrapJournalSnapshot(t, repo, prepared.PluginKey)) -} - -func TestBootstrapCompleteDoesNotOverwritePendingIndependentUpdate(t *testing.T) { - ctx := context.Background() - repo, _, prepared, candidate := unfinishedBootstrapUpdateFixture(t) - seedLegacyUnfinishedBootstrapUpdate(t, repo, prepared, candidate) - before, err := repo.GetByID(ctx, prepared.ID) - require.NoError(t, err) - journal := bootstrapJournalSnapshot(t, repo, prepared.PluginKey) - completeErr := repo.CompleteBundledPlugin(ctx, prepared.ID, "unfinished-bundle") - after, err := repo.GetByID(ctx, prepared.ID) - require.NoError(t, err) - pending, pendingErr := repo.PendingPluginArtifact(ctx, prepared.ID) - t.Logf("before=%s/generation:%d after=%s/generation:%d pending_readable=%t", before.State, before.RuntimeGeneration, after.State, after.RuntimeGeneration, pendingErr == nil) - require.ErrorIs(t, completeErr, service.ErrPluginStateChanged) - require.Equal(t, before, after) - require.NoError(t, pendingErr) - require.Equal(t, candidate.ArtifactData, pending) - require.Equal(t, journal, bootstrapJournalSnapshot(t, repo, prepared.PluginKey)) -} - -func TestPluginUpdateWaitsForUnfinishedBundledIntent(t *testing.T) { - ctx := context.Background() - repo, _, prepared, candidate := unfinishedBootstrapUpdateFixture(t) - journal := bootstrapJournalSnapshot(t, repo, prepared.PluginKey) - require.ErrorIs(t, repo.StagePluginUpdate(ctx, prepared, candidate, service.PluginUpdatePinned), service.ErrPluginStateChanged) - unchanged, err := repo.GetByID(ctx, prepared.ID) - require.NoError(t, err) - require.Equal(t, prepared, unchanged) - require.Equal(t, journal, bootstrapJournalSnapshot(t, repo, prepared.PluginKey)) - var pendingCount int - require.NoError(t, repo.db.QueryRowContext(ctx, `SELECT count(*) FROM sub2api_plugin_updates WHERE plugin_id=$1`, prepared.ID).Scan(&pendingCount)) - require.Zero(t, pendingCount) - require.NoError(t, repo.ImportLegacyPluginState(ctx, prepared, "", nil)) - require.NoError(t, repo.CompleteBundledPlugin(ctx, prepared.ID, "unfinished-bundle")) - current, err := repo.GetByID(ctx, prepared.ID) - require.NoError(t, err) - require.True(t, current.Bindings[0].Enabled, "normal first bootstrap retains its desired activation") - require.Equal(t, "saved-bootstrap-config", current.ConfigEncrypted) - require.NoError(t, repo.StagePluginUpdate(ctx, current, candidate, service.PluginUpdatePinned)) - pending, err := repo.PendingPluginArtifact(ctx, prepared.ID) - require.NoError(t, err) - require.Equal(t, candidate.ArtifactData, pending) -} - -func TestBootstrapCompletionPreservesOperatorIntent(t *testing.T) { - for _, choice := range []string{"disable_before_complete", "disable_after_complete", "remove", "pin"} { - t.Run(choice, func(t *testing.T) { - ctx := context.Background() - repo, artifact, prepared, candidate := unfinishedBootstrapUpdateFixture(t) - if choice == "remove" { - require.NoError(t, repo.Delete(ctx, prepared.ID, prepared.BinarySHA256)) - journal := bootstrapJournalSnapshot(t, repo, prepared.PluginKey) - resumed, err := repo.PrepareBundledPlugin(ctx, artifact, "unfinished-bundle", "", true, "new-default") - require.NoError(t, err) - require.Nil(t, resumed) - require.ErrorIs(t, repo.CompleteBundledPlugin(ctx, prepared.ID, "unfinished-bundle"), service.ErrPluginStateChanged) - require.Equal(t, journal, bootstrapJournalSnapshot(t, repo, prepared.PluginKey)) - return - } - if choice == "pin" { - require.NoError(t, repo.SetPluginUpdatePolicy(ctx, prepared.ID, prepared.Revision, service.PluginUpdatePinned)) - before, err := repo.GetByID(ctx, prepared.ID) - require.NoError(t, err) - journal := bootstrapJournalSnapshot(t, repo, prepared.PluginKey) - resumed, err := repo.PrepareBundledPlugin(ctx, artifact, "unfinished-bundle", "", true, "new-default") - require.NoError(t, err) - require.Nil(t, resumed) - require.ErrorIs(t, repo.CompleteBundledPlugin(ctx, prepared.ID, "unfinished-bundle"), service.ErrPluginStateChanged) - after, err := repo.GetByID(ctx, prepared.ID) - require.NoError(t, err) - require.Equal(t, before, after) - require.Equal(t, journal, bootstrapJournalSnapshot(t, repo, prepared.PluginKey)) - require.NoError(t, repo.StagePluginUpdate(ctx, before, candidate, service.PluginUpdatePinned), "an explicitly pinned installation is not owned by the stale bundle journal") - return - } - if choice == "disable_after_complete" { - require.NoError(t, repo.CompleteBundledPlugin(ctx, prepared.ID, "unfinished-bundle")) - } - current, err := repo.GetByID(ctx, prepared.ID) - require.NoError(t, err) - for index := range current.Bindings { - current.Bindings[index].Enabled = false - current.Bindings[index].RolloutPercent = 37 - } - require.NoError(t, repo.UpdateBindingsAndState(ctx, current.ID, current.Bindings, service.PluginStateDisabled, "operator disabled", nil, current.State, current.BinarySHA256)) - before, err := repo.GetByID(ctx, current.ID) - require.NoError(t, err) - if choice == "disable_before_complete" { - restarted := &pluginRepository{db: integrationDB} - resumed, err := restarted.PrepareBundledPlugin(ctx, artifact, "unfinished-bundle", "", true, "new-default") - require.NoError(t, err) - require.NotNil(t, resumed) - require.Equal(t, "saved-bootstrap-config", resumed.ConfigEncrypted) - } - require.NoError(t, repo.CompleteBundledPlugin(ctx, prepared.ID, "unfinished-bundle")) - after, err := repo.GetByID(ctx, prepared.ID) - require.NoError(t, err) - require.Equal(t, service.PluginStateDisabled, after.State) - require.False(t, after.Bindings[0].Enabled) - require.Equal(t, 37, after.Bindings[0].RolloutPercent) - if choice == "disable_after_complete" { - require.Equal(t, before, after, "duplicate completion is a no-op, not a replay of old intent") - } - }) - } -} diff --git a/backend/internal/repository/plugin_bundle.go b/backend/internal/repository/plugin_bundle.go deleted file mode 100644 index 949301be18ae..000000000000 --- a/backend/internal/repository/plugin_bundle.go +++ /dev/null @@ -1,244 +0,0 @@ -package repository - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "strconv" - - "github.com/Wei-Shaw/sub2api/internal/service" -) - -func (r *pluginRepository) BundleApplied(ctx context.Context, key, bundle string) (bool, error) { - var applied bool - err := r.db.QueryRowContext(ctx, `SELECT COALESCE((SELECT user_removed OR (completed AND bundle_sha256=$2) FROM sub2api_plugin_bootstrap WHERE plugin_key=$1),false) - OR EXISTS(SELECT 1 FROM sub2api_plugin_installations WHERE plugin_key=$1 AND (update_policy='pinned' OR state='updating'))`, key, bundle).Scan(&applied) - if errors.Is(err, sql.ErrNoRows) { - return false, nil - } - return applied, err -} - -// Package replacement is staged with the previous activation intent recorded -// durably. A restart during migration cannot silently reset an enabled plugin. -func (r *pluginRepository) PrepareBundledPlugin(ctx context.Context, plugin *service.PluginInstallation, bundle, profile string, enabled bool, config string) (_ *service.PluginInstallation, resultErr error) { - tx, err := r.beginPluginRegistryTx(ctx) - if err != nil { - return nil, err - } - defer rollbackPluginRegistryTx(tx, &resultErr) - if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext($1))`, "plugin-bundle:"+plugin.PluginKey); err != nil { - return nil, err - } - var journalBundle string - var journalEnabled bool - var imported, complete, removed bool - var oldProfile string - var bindingSnapshot []byte - err = tx.QueryRowContext(ctx, `SELECT bundle_sha256,desired_enabled,migration_profile,state_imported,completed,user_removed,desired_bindings FROM sub2api_plugin_bootstrap WHERE plugin_key=$1 FOR UPDATE`, plugin.PluginKey).Scan(&journalBundle, &journalEnabled, &oldProfile, &imported, &complete, &removed, &bindingSnapshot) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return nil, err - } - if removed || (journalBundle == bundle && complete) { - return nil, nil - } - if oldProfile != profile { - imported = false - } - var oldID int64 - var oldConfig string - var oldPolicy string - var oldState string - var oldBindings []service.PluginBinding - err = tx.QueryRowContext(ctx, `SELECT id,config_encrypted,update_policy,state FROM sub2api_plugin_installations WHERE plugin_key=$1 FOR UPDATE`, plugin.PluginKey).Scan(&oldID, &oldConfig, &oldPolicy, &oldState) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return nil, err - } - if oldID > 0 { - if oldPolicy == service.PluginUpdatePinned || oldState == service.PluginStateUpdating { - return nil, nil - } - if oldConfig != "" { - config = oldConfig - } - rows, queryErr := tx.QueryContext(ctx, `SELECT capability,platform,account_type,enabled,rollout_percent FROM sub2api_plugin_bindings WHERE plugin_id=$1 ORDER BY id`, oldID) - if queryErr != nil { - return nil, queryErr - } - for rows.Next() { - var binding service.PluginBinding - if err = rows.Scan(&binding.Capability, &binding.Platform, &binding.AccountType, &binding.Enabled, &binding.RolloutPercent); err != nil { - _ = rows.Close() - return nil, err - } - oldBindings = append(oldBindings, binding) - } - err = rows.Err() - _ = rows.Close() - if err != nil { - return nil, err - } - } - if journalBundle != "" && !complete { - if len(bindingSnapshot) == 0 || json.Unmarshal(bindingSnapshot, &oldBindings) != nil { - return nil, errors.New("pending plugin migration has no valid binding intent") - } - } - if journalBundle == bundle { - enabled = journalEnabled - } else if oldID > 0 { - enabled = false - for _, binding := range oldBindings { - enabled = enabled || binding.Enabled - } - } - manifest, err := json.Marshal(plugin.Manifest) - if err != nil { - return nil, err - } - var id int64 - err = tx.QueryRowContext(ctx, `INSERT INTO sub2api_plugin_installations(plugin_key,name,version,description,author,manifest,artifact_data,artifact_path,install_path,binary_path,binary_sha256,signature_status,state,config_encrypted,last_error,installed_at,updated_at,package_sha256,update_policy) - VALUES($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10,$11,$12,'disabled',$13,'',NOW(),NOW(),encode(sha256($7),'hex'),'bundled') - ON CONFLICT(plugin_key) DO UPDATE SET name=EXCLUDED.name,version=EXCLUDED.version,description=EXCLUDED.description,author=EXCLUDED.author,manifest=EXCLUDED.manifest, - artifact_data=EXCLUDED.artifact_data,artifact_path=EXCLUDED.artifact_path,install_path=EXCLUDED.install_path,binary_path=EXCLUDED.binary_path,binary_sha256=EXCLUDED.binary_sha256, - signature_status=EXCLUDED.signature_status,state='disabled',package_sha256=EXCLUDED.package_sha256,update_policy='bundled',runtime_generation=sub2api_plugin_installations.runtime_generation+1,config_encrypted=CASE WHEN sub2api_plugin_installations.config_encrypted='' THEN EXCLUDED.config_encrypted ELSE sub2api_plugin_installations.config_encrypted END,last_error='',updated_at=NOW() - RETURNING id`, plugin.PluginKey, plugin.Name, plugin.Version, plugin.Description, plugin.Author, manifest, plugin.ArtifactData, plugin.ArtifactPath, plugin.InstallPath, plugin.BinaryPath, plugin.BinarySHA256, plugin.SignatureStatus, config).Scan(&id) - if err != nil { - return nil, err - } - bindings := make([]service.PluginBinding, 0, len(plugin.Manifest.Capabilities)) - for _, capability := range plugin.Manifest.Capabilities { - binding := service.PluginBinding{Capability: capability.ID, Platform: capability.Platform, AccountType: capability.AccountType, RolloutPercent: 100, Enabled: enabled && oldID == 0} - for _, previous := range oldBindings { - if previous.Capability == binding.Capability && previous.Platform == binding.Platform && previous.AccountType == binding.AccountType { - binding.Enabled, binding.RolloutPercent = previous.Enabled, previous.RolloutPercent - break - } - } - bindings = append(bindings, binding) - } - bindingSnapshot, err = json.Marshal(bindings) - if err != nil { - return nil, err - } - stagedBindings := append([]service.PluginBinding(nil), bindings...) - for index := range stagedBindings { - stagedBindings[index].Enabled = false - } - if err = replacePluginBindings(ctx, tx, id, stagedBindings); err != nil { - return nil, err - } - _, err = tx.ExecContext(ctx, `INSERT INTO sub2api_plugin_bootstrap(plugin_key,bundle_sha256,migration_profile,desired_enabled,completed,state_imported,desired_bindings) VALUES($1,$2,$3,$4,false,$5,$6::jsonb) - ON CONFLICT(plugin_key) DO UPDATE SET bundle_sha256=EXCLUDED.bundle_sha256,migration_profile=EXCLUDED.migration_profile,desired_enabled=EXCLUDED.desired_enabled,completed=false,state_imported=EXCLUDED.state_imported,desired_bindings=EXCLUDED.desired_bindings,updated_at=NOW()`, plugin.PluginKey, bundle, profile, enabled, imported, bindingSnapshot) - if err != nil { - return nil, err - } - if err = commitPluginRegistryTx(ctx, tx); err != nil { - return nil, err - } - return r.GetByID(ctx, id) -} - -func (r *pluginRepository) CompleteBundledPlugin(ctx context.Context, id int64, bundle string) (resultErr error) { - tx, err := r.beginPluginRegistryTx(ctx) - if err != nil { - return err - } - defer rollbackPluginRegistryTx(tx, &resultErr) - var key string - var enabled bool - var completed, removed bool - var currentState, policy string - var bindingSnapshot []byte - err = tx.QueryRowContext(ctx, `SELECT p.plugin_key,b.desired_enabled,b.desired_bindings,b.completed,b.user_removed,p.state,p.update_policy - FROM sub2api_plugin_installations p JOIN sub2api_plugin_bootstrap b ON b.plugin_key=p.plugin_key - WHERE p.id=$1 AND b.bundle_sha256=$2 FOR UPDATE OF p,b`, id, bundle).Scan(&key, &enabled, &bindingSnapshot, &completed, &removed, ¤tState, &policy) - if errors.Is(err, sql.ErrNoRows) { - return service.ErrPluginStateChanged - } - if err != nil { - return err - } - if removed || policy != service.PluginUpdateBundled || currentState == service.PluginStateUpdating { - return service.ErrPluginStateChanged - } - // A delayed completion must not replay old binding intent after an operator - // has disabled a completed bundle. Only an unfinished disabled generation is - // still owned by bootstrap; dedicated updates own every updating generation. - if completed { - return nil - } - if currentState != service.PluginStateDisabled { - return service.ErrPluginStateChanged - } - var bindings []service.PluginBinding - if len(bindingSnapshot) == 0 || json.Unmarshal(bindingSnapshot, &bindings) != nil { - return errors.New("plugin binding intent is unavailable") - } - if err = replacePluginBindings(ctx, tx, id, bindings); err != nil { - return err - } - enabled = false - for _, binding := range bindings { - enabled = enabled || binding.Enabled - } - state := service.PluginStateDisabled - if enabled { - state = service.PluginStateEnabled - } - if _, err = tx.ExecContext(ctx, `UPDATE sub2api_plugin_installations SET state=$2,updated_at=NOW() WHERE id=$1`, id, state); err != nil { - return err - } - if _, err = tx.ExecContext(ctx, `UPDATE sub2api_plugin_bootstrap SET completed=true,state_imported=true,updated_at=NOW() WHERE plugin_key=$1 AND bundle_sha256=$2`, key, bundle); err != nil { - return err - } - return commitPluginRegistryTx(ctx, tx) -} - -func (r *pluginRepository) LegacyBundleSeed(ctx context.Context, key, profile string, fallback service.PluginBundleSeed) (service.PluginBundleSeed, error) { - var imported bool - err := r.db.QueryRowContext(ctx, `SELECT state_imported FROM sub2api_plugin_bootstrap WHERE plugin_key=$1 AND migration_profile=$2`, key, profile).Scan(&imported) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return fallback, err - } - if imported { - return fallback, nil - } - if profile == "" || profile == "cindy-provider-v1" || profile == "image-tools-v1" || profile == "admin-observability-v1" { - return fallback, nil - } - if profile != "codex-tickets-v1" { - return fallback, errors.New("unsupported legacy plugin migration") - } - var config map[string]any - if err := json.Unmarshal(fallback.Config, &config); err != nil { - return fallback, err - } - rows, err := r.db.QueryContext(ctx, `SELECT key,value FROM settings WHERE key IN ('openai_codex_ticket_enabled','openai_codex_ticket_harvest_proxy_url')`) - if err != nil { - return fallback, err - } - defer func() { _ = rows.Close() }() - for rows.Next() { - var key, value string - if err := rows.Scan(&key, &value); err != nil { - return fallback, err - } - switch key { - case "openai_codex_ticket_enabled": - v, err := strconv.ParseBool(value) - if err != nil { - return fallback, errors.New("invalid legacy ticket switch") - } - config["enabled"] = v - case "openai_codex_ticket_harvest_proxy_url": - config["proxy_url"] = value - } - } - if err := rows.Err(); err != nil { - return fallback, err - } - fallback.Config, err = json.Marshal(config) - return fallback, err -} diff --git a/backend/internal/repository/plugin_bundle_integration_test.go b/backend/internal/repository/plugin_bundle_integration_test.go deleted file mode 100644 index a37819cbb388..000000000000 --- a/backend/internal/repository/plugin_bundle_integration_test.go +++ /dev/null @@ -1,159 +0,0 @@ -//go:build integration - -package repository - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "strconv" - "strings" - "testing" - "time" - - "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/google/uuid" - "github.com/stretchr/testify/require" -) - -func bundleFixture(t *testing.T, repo *pluginRepository) *service.PluginInstallation { - t.Helper() - key := "codexrip.test-" + uuid.NewString() - t.Cleanup(func() { - for _, table := range []string{"sub2api_plugin_state", "sub2api_plugin_bootstrap", "sub2api_plugin_installations"} { - _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM "+table+" WHERE plugin_key=$1", key) - } - }) - return &service.PluginInstallation{PluginKey: key, Name: "bundle fixture", Version: "1.0.0", BinarySHA256: strings.Repeat("a", 64), SignatureStatus: service.PluginSignatureTrusted, Manifest: service.PluginManifest{ID: key, Capabilities: []service.PluginCapability{{ID: extensionv1.CapabilityAdmin, Platform: service.PlatformOpenAI, AccountType: service.AccountTypeOAuth}}}, ArtifactData: []byte("fixture")} -} - -func TestPluginBundleResumesWithoutResettingActivationOrReinstallingRemovedPlugin(t *testing.T) { - ctx := context.Background() - repo := &pluginRepository{db: integrationDB} - artifact := bundleFixture(t, repo) - first, err := repo.PrepareBundledPlugin(ctx, artifact, "bundle-one", "", true, "first-config") - require.NoError(t, err) - require.Equal(t, service.PluginStateDisabled, first.State) - resumed, err := repo.PrepareBundledPlugin(ctx, artifact, "bundle-one", "", false, "replacement-config") - require.NoError(t, err) - require.Equal(t, "first-config", resumed.ConfigEncrypted) - require.NoError(t, repo.CompleteBundledPlugin(ctx, first.ID, "bundle-one")) - current, err := repo.GetByID(ctx, first.ID) - require.NoError(t, err) - require.True(t, current.Bindings[0].Enabled) - current.Bindings[0].Enabled = false - require.NoError(t, repo.UpdateBindingsAndState(ctx, current.ID, current.Bindings, service.PluginStateDisabled, "", nil, service.PluginStateEnabled, current.BinarySHA256)) - artifact.Version = "1.0.1" - artifact.BinarySHA256 = strings.Repeat("b", 64) - updated, err := repo.PrepareBundledPlugin(ctx, artifact, "bundle-two", "", true, "new-defaults") - require.NoError(t, err) - require.Equal(t, "first-config", updated.ConfigEncrypted) - require.NoError(t, repo.CompleteBundledPlugin(ctx, updated.ID, "bundle-two")) - updated, err = repo.GetByID(ctx, updated.ID) - require.NoError(t, err) - require.False(t, updated.Bindings[0].Enabled) - require.NoError(t, repo.Delete(ctx, updated.ID, updated.BinarySHA256)) - applied, err := repo.BundleApplied(ctx, artifact.PluginKey, "a-future-bundle") - require.NoError(t, err) - require.True(t, applied, "explicit removal must survive later bundle upgrades") -} - -func TestPluginBundleUpgradePreservesPerCapabilityChoicesAndRollout(t *testing.T) { - ctx := context.Background() - repo := &pluginRepository{db: integrationDB} - artifact := bundleFixture(t, repo) - artifact.Manifest.Capabilities = append(artifact.Manifest.Capabilities, service.PluginCapability{ID: extensionv1.CapabilityRequest, Platform: service.PlatformOpenAI, AccountType: service.AccountTypeOAuth}) - first, err := repo.PrepareBundledPlugin(ctx, artifact, "scope-one", "", true, "config") - require.NoError(t, err) - require.NoError(t, repo.CompleteBundledPlugin(ctx, first.ID, "scope-one")) - current, err := repo.GetByID(ctx, first.ID) - require.NoError(t, err) - for index := range current.Bindings { - if current.Bindings[index].Capability == extensionv1.CapabilityRequest { - current.Bindings[index].Enabled = false - } else { - current.Bindings[index].RolloutPercent = 37 - } - } - require.NoError(t, repo.UpdateBindingsAndState(ctx, current.ID, current.Bindings, service.PluginStateEnabled, "", nil, service.PluginStateEnabled, current.BinarySHA256)) - artifact.Version = "1.0.1" - artifact.BinarySHA256 = strings.Repeat("b", 64) - artifact.Manifest.Capabilities = append(artifact.Manifest.Capabilities, service.PluginCapability{ID: extensionv1.CapabilityRecovery, Platform: service.PlatformOpenAI, AccountType: "*"}) - prepared, err := repo.PrepareBundledPlugin(ctx, artifact, "scope-two", "", true, "new-default") - require.NoError(t, err) - require.Equal(t, service.PluginStateDisabled, prepared.State) - for _, binding := range prepared.Bindings { - require.False(t, binding.Enabled, "no instance may execute a partially migrated package") - } - require.ErrorIs(t, repo.UpdateBindingsAndState(ctx, prepared.ID, prepared.Bindings, service.PluginStateStarting, "", nil, service.PluginStateDisabled, prepared.BinarySHA256), service.ErrPluginStateChanged) - _, err = repo.PrepareBundledPlugin(ctx, artifact, "scope-two", "", false, "other-default") - require.NoError(t, err) - // A newer package can replace an interrupted upgrade without learning the - // temporary all-disabled rows as the operator's activation choices. - _, err = repo.PrepareBundledPlugin(ctx, artifact, "scope-three", "", false, "other-default") - require.NoError(t, err) - require.NoError(t, repo.CompleteBundledPlugin(ctx, prepared.ID, "scope-three")) - current, err = repo.GetByID(ctx, prepared.ID) - require.NoError(t, err) - for _, binding := range current.Bindings { - if binding.Capability == extensionv1.CapabilityAdmin { - require.True(t, binding.Enabled) - require.Equal(t, 37, binding.RolloutPercent) - } else { - require.False(t, binding.Enabled, "disabled and newly introduced capabilities must not be enabled by upgrade") - } - } -} - -func TestPluginBundlePendingMigrationRespectsAnExplicitDisable(t *testing.T) { - ctx := context.Background() - repo := &pluginRepository{db: integrationDB} - artifact := bundleFixture(t, repo) - prepared, err := repo.PrepareBundledPlugin(ctx, artifact, "pending-disable", "", true, "config") - require.NoError(t, err) - require.NoError(t, repo.UpdateBindingsAndState(ctx, prepared.ID, prepared.Bindings, service.PluginStateDisabled, "", nil, service.PluginStateDisabled, prepared.BinarySHA256)) - require.NoError(t, repo.CompleteBundledPlugin(ctx, prepared.ID, "pending-disable")) - current, err := repo.GetByID(ctx, prepared.ID) - require.NoError(t, err) - require.Equal(t, service.PluginStateDisabled, current.State) - for _, binding := range current.Bindings { - require.False(t, binding.Enabled) - } -} - -func TestPluginBundleDoesNotResurrectStoppedTicketWhenLegacyRowsChange(t *testing.T) { - ctx := context.Background() - repo := &pluginRepository{db: integrationDB} - artifact := bundleFixture(t, repo) - now := time.Now().UTC().Truncate(time.Second) - expiry := now.Add(time.Hour) - account := mustCreateAccount(t, testEntClient(t), &service.Account{Name: "migration-" + uuid.NewString(), Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "owner"}}) - identity := service.CodexTicketAccountIdentity(account) - ticket := service.CodexTicketRecord{State: "gAAAAA" + strings.Repeat("x", 286), AccountID: account.ID, Identity: identity, Model: "gpt-6-astra", CapturedAt: now, ExpiresAt: expiry, Length: 292} - extra, _ := json.Marshal(map[string]any{"codex_turn_ticket:gpt-6-astra": ticket}) - _, err := integrationDB.ExecContext(ctx, `UPDATE accounts SET extra=$2::jsonb WHERE id=$1`, account.ID, extra) - require.NoError(t, err) - _, err = integrationDB.ExecContext(ctx, `INSERT INTO openai_codex_ticket_runtime(account_id,model,identity,phase,expires_at) VALUES($1,'gpt-6-astra',$2,'stopped',$3)`, account.ID, identity, expiry) - require.NoError(t, err) - t.Cleanup(func() { - _, _ = integrationDB.ExecContext(ctx, `DELETE FROM scheduler_outbox WHERE account_id=$1`, account.ID) - _, _ = integrationDB.ExecContext(ctx, `DELETE FROM accounts WHERE id=$1`, account.ID) - }) - installation, err := repo.PrepareBundledPlugin(ctx, artifact, "migration-bundle", "codex-tickets-v1", true, "config") - require.NoError(t, err) - configuration := json.RawMessage(`{"models":["gpt-6-astra"],"proxy_url":""}`) - require.NoError(t, repo.ImportLegacyPluginState(ctx, installation, "codex-tickets-v1", configuration)) - require.NoError(t, repo.CompleteBundledPlugin(ctx, installation.ID, "migration-bundle")) - _, err = integrationDB.ExecContext(ctx, `UPDATE openai_codex_ticket_runtime SET phase='ready',next_at=NOW() WHERE account_id=$1`, account.ID) - require.NoError(t, err) - require.NoError(t, repo.ImportLegacyPluginState(ctx, installation, "codex-tickets-v1", configuration)) - modelHash := sha256.Sum256([]byte("gpt-6-astra")) - stored, err := repo.ReadExtensionState(ctx, artifact.PluginKey, extensionv1.StateRequest{Namespace: "tickets", Key: strconv.FormatInt(account.ID, 10) + "." + hex.EncodeToString(modelHash[:])}) - require.NoError(t, err) - var state map[string]any - require.NoError(t, json.Unmarshal(stored.Value, &state)) - require.Equal(t, "stopped", state["phase"]) - require.Nil(t, state["next_attempt_at"]) -} diff --git a/backend/internal/repository/plugin_config_receipt_integration_test.go b/backend/internal/repository/plugin_config_receipt_integration_test.go deleted file mode 100644 index d096a6c3d44f..000000000000 --- a/backend/internal/repository/plugin_config_receipt_integration_test.go +++ /dev/null @@ -1,59 +0,0 @@ -//go:build integration - -package repository - -import ( - "context" - "strings" - "testing" - - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/stretchr/testify/require" -) - -func TestPluginConfigReturningReceiptRejectsOldDraftAndDoesNotBorrowLaterRevision(t *testing.T) { - ctx := context.Background() - repo, current, _ := installedUpdateFixture(t) - oldDraft := service.WithPluginExpectedPackage(service.WithPluginExpectedRevision(ctx, current.Revision), current.PackageSHA256) - revision, err := repo.UpdateConfigReturningRevision(oldDraft, current.ID, "first-canonical-cipher", current.BinarySHA256) - require.NoError(t, err) - require.Equal(t, current.Revision+1, revision) - secondDraft := service.WithPluginExpectedPackage(service.WithPluginExpectedRevision(ctx, revision), current.PackageSHA256) - secondRevision, err := repo.UpdateConfigReturningRevision(secondDraft, current.ID, "second-admin-cipher", current.BinarySHA256) - require.NoError(t, err) - require.Equal(t, revision+1, secondRevision) - require.Equal(t, current.Revision+1, revision, "the first RETURNING receipt remains the first write's version") - _, err = repo.UpdateConfigReturningRevision(oldDraft, current.ID, "old-tab-cipher", current.BinarySHA256) - require.ErrorIs(t, err, service.ErrPluginStateChanged) - wrongPackage := service.WithPluginExpectedPackage(service.WithPluginExpectedRevision(ctx, secondRevision), strings.Repeat("f", 64)) - _, err = repo.UpdateConfigReturningRevision(wrongPackage, current.ID, "wrong-package-cipher", current.BinarySHA256) - require.ErrorIs(t, err, service.ErrPluginStateChanged) - unchanged := service.WithPluginExpectedPackage(service.WithPluginExpectedRevision(ctx, secondRevision), current.PackageSHA256) - unchangedRevision, err := repo.UpdateConfigReturningRevision(unchanged, current.ID, "second-admin-cipher", current.BinarySHA256) - require.NoError(t, err) - require.Equal(t, secondRevision, unchangedRevision, "same encrypted configuration is a CAS receipt without revision inflation") - latest, err := repo.GetByID(ctx, current.ID) - require.NoError(t, err) - require.Equal(t, "second-admin-cipher", latest.ConfigEncrypted) - require.Equal(t, secondRevision, latest.Revision) -} - -func TestPluginDeleteUsesReadTimeRevisionAndPackageWithinGraphTransaction(t *testing.T) { - ctx := context.Background() - repo, current, _ := installedUpdateFixture(t) - for index := range current.Bindings { - current.Bindings[index].Enabled = false - } - require.NoError(t, repo.UpdateBindingsAndState(ctx, current.ID, current.Bindings, service.PluginStateDisabled, "", nil, current.State, current.BinarySHA256)) - disabled, err := repo.GetByID(ctx, current.ID) - require.NoError(t, err) - oldDraft := service.WithPluginExpectedPackage(service.WithPluginExpectedRevision(ctx, disabled.Revision), disabled.PackageSHA256) - require.NoError(t, repo.UpdateConfig(ctx, current.ID, "admin-edited-before-delete", current.BinarySHA256)) - require.ErrorIs(t, repo.Delete(oldDraft, current.ID, current.BinarySHA256), service.ErrPluginStateChanged) - latest, err := repo.GetByID(ctx, current.ID) - require.NoError(t, err) - wrongPackage := service.WithPluginExpectedPackage(service.WithPluginExpectedRevision(ctx, latest.Revision), strings.Repeat("f", 64)) - require.ErrorIs(t, repo.Delete(wrongPackage, current.ID, current.BinarySHA256), service.ErrPluginStateChanged) - confirmed := service.WithPluginExpectedPackage(service.WithPluginExpectedRevision(ctx, latest.Revision), latest.PackageSHA256) - require.NoError(t, repo.Delete(confirmed, current.ID, current.BinarySHA256)) -} diff --git a/backend/internal/repository/plugin_extension_state_integration_test.go b/backend/internal/repository/plugin_extension_state_integration_test.go deleted file mode 100644 index b15fc6cb68fb..000000000000 --- a/backend/internal/repository/plugin_extension_state_integration_test.go +++ /dev/null @@ -1,135 +0,0 @@ -//go:build integration - -package repository - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "testing" - "time" - - "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/google/uuid" - "github.com/stretchr/testify/require" -) - -func TestPluginExtensionStateAndSchedulingProjectionCommitTogether(t *testing.T) { - ctx := context.Background() - client := testEntClient(t) - account := mustCreateAccount(t, client, &service.Account{Name: "projection-" + uuid.NewString(), Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, Status: service.StatusError, Schedulable: false, ErrorMessage: "operator note", Credentials: map[string]any{"chatgpt_account_id": "subject"}, Extra: map[string]any{"user_setting": "preserve"}}) - // The shared fixture helper defaults accounts to schedulable. Establish the - // operator-disabled state explicitly before exercising the plugin write. - _, setupErr := integrationDB.ExecContext(ctx, `UPDATE accounts SET schedulable=false WHERE id=$1`, account.ID) - require.NoError(t, setupErr) - plugin := "local.test.projection-" + uuid.NewString() - t.Cleanup(func() { - _, _ = integrationDB.ExecContext(ctx, `DELETE FROM sub2api_plugin_state WHERE plugin_key=$1`, plugin) - _, _ = integrationDB.ExecContext(ctx, `DELETE FROM scheduler_outbox WHERE account_id=$1`, account.ID) - _, _ = integrationDB.ExecContext(ctx, `DELETE FROM accounts WHERE id=$1`, account.ID) - }) - refreshed := 0 - repo := &pluginRepository{db: integrationDB, refreshAccount: func(context.Context, int64) { refreshed++ }} - until := time.Now().UTC().Add(time.Hour).Truncate(time.Second) - request := extensionv1.StateRequest{Namespace: "tickets", Key: "one", Value: json.RawMessage(`{"phase":"ready","private_material":"test-only-secret"}`), Projection: &extensionv1.AccountProjection{AccountID: account.ID, Identity: service.CodexTicketAccountIdentity(account), Scheduling: []extensionv1.SchedulingConstraint{{Model: "gpt-6-astra", Effect: "allow", Until: &until, Reason: "ticket_ready"}}}} - written, err := repo.CompareSwapExtensionState(ctx, plugin, request) - require.NoError(t, err) - require.True(t, written.Applied) - require.EqualValues(t, 1, written.Revision) - require.Equal(t, 1, refreshed) - var extra []byte - var state, note string - var schedulable bool - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT extra,status,error_message,schedulable FROM accounts WHERE id=$1`, account.ID).Scan(&extra, &state, ¬e, &schedulable)) - require.Contains(t, string(extra), "ticket_ready") - require.Contains(t, string(extra), "user_setting") - require.NotContains(t, string(extra), "test-only-secret") - require.Equal(t, service.StatusError, state) - require.Equal(t, "operator note", note) - require.False(t, schedulable) - request.Value = json.RawMessage(`{"phase":"stopped"}`) - request.Projection.Scheduling[0].Effect = "deny" - stale, err := repo.CompareSwapExtensionState(ctx, plugin, request) - require.NoError(t, err) - require.False(t, stale.Applied) - require.Equal(t, 1, refreshed) - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT extra FROM accounts WHERE id=$1`, account.ID).Scan(&extra)) - require.Contains(t, string(extra), `"effect": "allow"`) - _, err = integrationDB.ExecContext(ctx, `UPDATE accounts SET credentials='{"chatgpt_account_id":"another-subject"}'::jsonb WHERE id=$1`, account.ID) - require.NoError(t, err) - request.ExpectedRevision = 1 - _, err = repo.CompareSwapExtensionState(ctx, plugin, request) - require.Error(t, err) - stored, err := repo.ReadExtensionState(ctx, plugin, request) - require.NoError(t, err) - require.EqualValues(t, 1, stored.Revision) -} - -func TestPluginExtensionLeaseGenerationPreventsStaleRelease(t *testing.T) { - ctx := context.Background() - plugin := "local.test.lease-" + uuid.NewString() - repo := &pluginRepository{db: integrationDB} - t.Cleanup(func() { - _, _ = integrationDB.ExecContext(ctx, `DELETE FROM sub2api_plugin_leases WHERE plugin_key=$1`, plugin) - }) - req := extensionv1.LeaseRequest{Namespace: "tickets", Key: "one", Owner: "worker", TTLSeconds: 30} - first, err := repo.AcquireExtensionLease(ctx, plugin, req) - require.NoError(t, err) - require.True(t, first.Acquired) - req.Generation = first.Generation - _, err = repo.ReleaseExtensionLease(ctx, plugin, req) - require.NoError(t, err) - second, err := repo.AcquireExtensionLease(ctx, plugin, req) - require.NoError(t, err) - require.True(t, second.Acquired) - require.Greater(t, second.Generation, first.Generation) - _, err = repo.ReleaseExtensionLease(ctx, plugin, req) - require.NoError(t, err) - third, err := repo.AcquireExtensionLease(ctx, plugin, req) - require.NoError(t, err) - require.False(t, third.Acquired) -} - -func TestDisablingPluginCancelsOwnedJobsInSameTransaction(t *testing.T) { - ctx := context.Background() - client := testEntClient(t) - user := mustCreateUser(t, client, &service.User{Email: "plugin-job-" + uuid.NewString() + "@example.com", PasswordHash: "test-hash"}) - plugins := &pluginRepository{db: integrationDB} - plugin, err := plugins.Install(ctx, &service.PluginInstallation{PluginKey: "local.test.cancel-" + uuid.NewString(), Name: "test", Version: "1.0.0", Manifest: service.PluginManifest{ID: "test.plugin"}, BinarySHA256: strings.Repeat("c", 64), ArtifactData: []byte("synthetic plugin package"), SignatureStatus: service.PluginSignatureTrusted, State: service.PluginStateEnabled}, []service.PluginBinding{{Capability: extensionv1.CapabilityAdmin, Platform: service.PlatformOpenAI, AccountType: service.AccountTypeOAuth, Enabled: true, RolloutPercent: 100}}) - require.NoError(t, err) - jobs := NewAccountJobRepository(integrationDB) - var ids []int64 - t.Cleanup(func() { - for _, id := range ids { - _, _ = integrationDB.ExecContext(ctx, `DELETE FROM admin_account_jobs WHERE id=$1`, id) - } - _, _ = integrationDB.ExecContext(ctx, `DELETE FROM sub2api_plugin_installations WHERE id=$1`, plugin.ID) - _, _ = integrationDB.ExecContext(ctx, `DELETE FROM users WHERE id=$1`, user.ID) - }) - for _, kind := range []string{service.AccountJobKindExtensionOperation, service.AccountJobKindBatchTest} { - job, _, err := jobs.Create(ctx, service.CreateAccountJobParams{CreatedBy: user.ID, Kind: kind, IdempotencyKey: uuid.NewString(), RequestHash: strings.Repeat("a", 64), PayloadCipher: "test-payload", PayloadExpires: time.Now().Add(time.Hour), Metadata: json.RawMessage(fmt.Sprintf(`{"plugin_id":%d,"plugin_generation":%d}`, plugin.ID, plugin.RuntimeGeneration)), Items: []service.AccountJobItemSeed{{Ordinal: 1, Metadata: json.RawMessage(`{}`)}}, Attempt: 1}) - require.NoError(t, err) - ids = append(ids, job.ID) - } - unowned, _, err := jobs.Create(ctx, service.CreateAccountJobParams{CreatedBy: user.ID, Kind: service.AccountJobKindBatchTest, IdempotencyKey: uuid.NewString(), RequestHash: strings.Repeat("b", 64), PayloadCipher: "other-payload", PayloadExpires: time.Now().Add(time.Hour), Metadata: json.RawMessage(`{}`), Items: []service.AccountJobItemSeed{{Ordinal: 1, Metadata: json.RawMessage(`{}`)}}, Attempt: 1}) - require.NoError(t, err) - ids = append(ids, unowned.ID) - _, err = integrationDB.ExecContext(ctx, `UPDATE admin_account_jobs SET status='running',started_at=NOW() WHERE id=$1`, ids[1]) - require.NoError(t, err) - for i := range plugin.Bindings { - plugin.Bindings[i].Enabled = false - } - require.NoError(t, plugins.UpdateBindingsAndState(ctx, plugin.ID, plugin.Bindings, service.PluginStateDisabled, "", nil, service.PluginStateEnabled, plugin.BinarySHA256)) - pending, err := jobs.Get(ctx, ids[0]) - require.NoError(t, err) - require.Equal(t, service.AccountJobStatusCanceled, pending.Status) - require.Equal(t, pending.TargetCount, pending.CanceledCount) - running, err := jobs.Get(ctx, ids[1]) - require.NoError(t, err) - require.NotNil(t, running.CancelRequestedAt) - unowned, err = jobs.Get(ctx, unowned.ID) - require.NoError(t, err) - require.Equal(t, service.AccountJobStatusPending, unowned.Status) -} diff --git a/backend/internal/repository/plugin_host_io_admission_integration_test.go b/backend/internal/repository/plugin_host_io_admission_integration_test.go deleted file mode 100644 index 19b303a137bb..000000000000 --- a/backend/internal/repository/plugin_host_io_admission_integration_test.go +++ /dev/null @@ -1,108 +0,0 @@ -//go:build integration - -package repository - -import ( - "context" - "testing" - "time" - - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/stretchr/testify/require" -) - -func TestPluginHostIOLeaseRejectsStaleAdmissionSnapshot(t *testing.T) { - for _, change := range []string{"disabled", "config", "bindings", "config-current-revision"} { - t.Run(change, func(t *testing.T) { - ctx := context.Background() - repo, current, _ := installedUpdateFixture(t) - business := service.WithPluginBusinessIOLease(ctx) - release, err := repo.HoldPluginRuntime(business, current) - require.NoError(t, err, "positive business control must acquire a real PostgreSQL lease") - release() - switch change { - case "disabled": - bindings := append([]service.PluginBinding(nil), current.Bindings...) - for i := range bindings { - bindings[i].Enabled = false - } - require.NoError(t, repo.UpdateBindingsAndState(ctx, current.ID, bindings, service.PluginStateDisabled, "", nil, current.State, current.BinarySHA256)) - case "config", "config-current-revision": - require.NoError(t, repo.UpdateConfig(ctx, current.ID, "new-config", current.BinarySHA256)) - case "bindings": - bindings := append([]service.PluginBinding(nil), current.Bindings...) - bindings[0].RolloutPercent = 0 - require.NoError(t, repo.UpdateBindingsAndState(ctx, current.ID, bindings, service.PluginStateEnabled, "", nil, current.State, current.BinarySHA256)) - } - fresh, err := repo.GetByID(ctx, current.ID) - require.NoError(t, err) - require.Greater(t, fresh.Revision, current.Revision) - if change == "config-current-revision" { - current.Revision = fresh.Revision - // A fresh revision number alone cannot authorize a stale config. - require.NotEqual(t, fresh.ConfigEncrypted, current.ConfigEncrypted) - } - release, err = repo.HoldPluginRuntime(business, current) - if release != nil { - t.Cleanup(release) - } - require.ErrorIs(t, err, service.ErrPluginStateChanged, "stale business admission must be denied, even while package/generation still match") - require.Nil(t, release) - }) - } -} - -func TestPluginProcessDiagnosticsLeaseAndBusinessPromotionBarrier(t *testing.T) { - ctx := context.Background() - repo, current, candidate := installedUpdateFixture(t) - disabledBindings := append([]service.PluginBinding(nil), current.Bindings...) - for i := range disabledBindings { - disabledBindings[i].Enabled = false - } - require.NoError(t, repo.UpdateBindingsAndState(ctx, current.ID, disabledBindings, service.PluginStateDisabled, "", nil, current.State, current.BinarySHA256)) - disabled, err := repo.GetByID(ctx, current.ID) - require.NoError(t, err) - processRelease, err := repo.HoldPluginRuntime(ctx, disabled) - require.NoError(t, err, "a disabled plugin must still support a temporary Validate/Test process lease") - t.Cleanup(processRelease) - businessRelease, err := repo.HoldPluginRuntime(service.WithPluginBusinessIOLease(ctx), disabled) - if businessRelease != nil { - t.Cleanup(businessRelease) - } - require.ErrorIs(t, err, service.ErrPluginStateChanged) - processRelease() - require.NoError(t, repo.UpdateBindingsAndState(ctx, current.ID, current.Bindings, service.PluginStateEnabled, "", nil, service.PluginStateDisabled, current.BinarySHA256)) - enabled, err := repo.GetByID(ctx, current.ID) - require.NoError(t, err) - businessRelease, err = repo.HoldPluginRuntime(service.WithPluginBusinessIOLease(ctx), enabled) - require.NoError(t, err) - t.Cleanup(businessRelease) - require.NoError(t, repo.StagePluginUpdate(ctx, enabled, candidate, service.PluginUpdatePinned)) - pending, err := repo.GetByID(ctx, enabled.ID) - require.NoError(t, err) - require.ErrorIs(t, repo.CommitPluginUpdate(ctx, pending, candidate), service.ErrPluginUpdateWaiting, "business IO must hold the shared promotion barrier") - businessRelease() - // Release closes the client session; PostgreSQL may observe that close and - // release its advisory lock slightly later. Retry only that lock state. - promotionCtx, cancelPromotion := context.WithTimeout(ctx, 5*time.Second) - defer cancelPromotion() - poll := time.NewTicker(10 * time.Millisecond) - defer poll.Stop() - for { - err = repo.CommitPluginUpdate(promotionCtx, pending, candidate) - if err == nil { - break - } - require.ErrorIs(t, err, service.ErrPluginUpdateWaiting, "only the server-side release barrier may delay promotion") - select { - case <-promotionCtx.Done(): - t.Fatal("PostgreSQL did not release the promotion barrier within the bounded wait") - case <-poll.C: - } - } - updated, err := repo.GetByID(ctx, current.ID) - require.NoError(t, err) - require.Equal(t, enabled.RuntimeGeneration+1, updated.RuntimeGeneration) - require.Equal(t, candidate.Version, updated.Version) - require.Equal(t, candidate.PackageSHA256, updated.PackageSHA256) -} diff --git a/backend/internal/repository/plugin_install_guard_integration_test.go b/backend/internal/repository/plugin_install_guard_integration_test.go deleted file mode 100644 index 4d5932349be9..000000000000 --- a/backend/internal/repository/plugin_install_guard_integration_test.go +++ /dev/null @@ -1,87 +0,0 @@ -//go:build integration - -package repository - -import ( - "context" - "testing" - - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/stretchr/testify/require" -) - -func TestPluginInstallCannotReplaceDisabledIdentityWithLiveLease(t *testing.T) { - ctx := context.Background() - repo, current, candidate := installedUpdateFixture(t) - release, err := repo.HoldPluginRuntime(ctx, current) - require.NoError(t, err) - defer release() - for index := range current.Bindings { - current.Bindings[index].Enabled = false - } - require.NoError(t, repo.UpdateBindingsAndState(ctx, current.ID, current.Bindings, service.PluginStateDisabled, "operator disabled", nil, current.State, current.BinarySHA256)) - before, err := repo.GetByID(ctx, current.ID) - require.NoError(t, err) - candidate.State = service.PluginStateDisabled - candidate.Version = before.Version - candidate.Manifest.Version = before.Manifest.Version - // Even a same-version package with different UI bytes must use the update - // contract; the legacy insert must not advance a still-owned generation. - require.NotEqual(t, before.PackageSHA256, candidate.PackageSHA256) - _, err = repo.Install(ctx, candidate, []service.PluginBinding{{Capability: before.Bindings[0].Capability, Platform: before.Bindings[0].Platform, AccountType: before.Bindings[0].AccountType, RolloutPercent: 100}}) - require.ErrorIs(t, err, service.ErrPluginAlreadyInstalled) - after, err := repo.GetByID(ctx, current.ID) - require.NoError(t, err) - require.Equal(t, before, after, "conflict must not change package, config, revision, generation, policy, bindings or rollout") - release() - _, err = repo.Install(ctx, candidate, nil) - require.ErrorIs(t, err, service.ErrPluginAlreadyInstalled, "lease release must not reopen legacy replacement") -} - -func TestPluginInstallConflictPreservesPendingArtifactAndIntent(t *testing.T) { - ctx := context.Background() - repo, current, candidate := installedUpdateFixture(t) - require.NoError(t, repo.StagePluginUpdate(ctx, current, candidate, service.PluginUpdatePinned)) - before, err := repo.GetByID(ctx, current.ID) - require.NoError(t, err) - pending, err := repo.PendingPluginArtifact(ctx, current.ID) - require.NoError(t, err) - candidate.State = service.PluginStateDisabled - _, err = repo.Install(ctx, candidate, nil) - require.ErrorIs(t, err, service.ErrPluginAlreadyInstalled) - after, err := repo.GetByID(ctx, current.ID) - require.NoError(t, err) - require.Equal(t, before, after) - retained, err := repo.PendingPluginArtifact(ctx, current.ID) - require.NoError(t, err) - require.Equal(t, pending, retained) -} - -func TestPluginInstallAfterExplicitDeleteRetainsPluginLedger(t *testing.T) { - ctx := context.Background() - repo, current, candidate := installedUpdateFixture(t) - _, err := repo.db.ExecContext(ctx, `INSERT INTO sub2api_plugin_state(plugin_key,namespace,state_key,value) VALUES($1,'install-test','diagnostic','{"retained":true}')`, current.PluginKey) - require.NoError(t, err) - for index := range current.Bindings { - current.Bindings[index].Enabled = false - } - require.NoError(t, repo.UpdateBindingsAndState(ctx, current.ID, current.Bindings, service.PluginStateDisabled, "", nil, current.State, current.BinarySHA256)) - require.NoError(t, repo.Delete(ctx, current.ID, current.BinarySHA256)) - var removed bool - require.NoError(t, repo.db.QueryRowContext(ctx, `SELECT user_removed FROM sub2api_plugin_bootstrap WHERE plugin_key=$1`, current.PluginKey).Scan(&removed)) - require.True(t, removed) - candidate.State = service.PluginStateDisabled - installed, err := repo.Install(ctx, candidate, []service.PluginBinding{{Capability: current.Bindings[0].Capability, Platform: current.Bindings[0].Platform, AccountType: current.Bindings[0].AccountType, RolloutPercent: 100}}) - require.NoError(t, err) - require.NotEqual(t, current.ID, installed.ID) - require.Equal(t, service.PluginStateDisabled, installed.State) - require.Equal(t, service.PluginUpdatePinned, installed.UpdatePolicy) - require.False(t, installed.Bindings[0].Enabled) - var enabled bool - require.NoError(t, repo.db.QueryRowContext(ctx, `SELECT user_removed,desired_enabled FROM sub2api_plugin_bootstrap WHERE plugin_key=$1`, current.PluginKey).Scan(&removed, &enabled)) - require.False(t, removed) - require.False(t, enabled) - var retained bool - require.NoError(t, repo.db.QueryRowContext(ctx, `SELECT (value->>'retained')::boolean FROM sub2api_plugin_state WHERE plugin_key=$1 AND namespace='install-test' AND state_key='diagnostic'`, current.PluginKey).Scan(&retained)) - require.True(t, retained, "explicit uninstall/reinstall must not erase the plugin diagnostic ledger") -} diff --git a/backend/internal/repository/plugin_legacy_import.go b/backend/internal/repository/plugin_legacy_import.go deleted file mode 100644 index ff3275420564..000000000000 --- a/backend/internal/repository/plugin_legacy_import.go +++ /dev/null @@ -1,160 +0,0 @@ -package repository - -import ( - "context" - "crypto/sha256" - "database/sql" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "strconv" - "strings" - "time" - - "github.com/Wei-Shaw/sub2api/internal/service" - proxytransport "github.com/Wei-Shaw/sub2api/pkg/extensionapi/proxy" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" -) - -// This is a one-time representation adapter. Lifecycle decisions run in the -// plugin after cutover; completed bundle journals never read these old tables. -func (r *pluginRepository) ImportLegacyPluginState(ctx context.Context, plugin *service.PluginInstallation, profile string, configuration json.RawMessage) error { - var imported bool - if err := r.db.QueryRowContext(ctx, `SELECT state_imported FROM sub2api_plugin_bootstrap WHERE plugin_key=$1`, plugin.PluginKey).Scan(&imported); err != nil { - return err - } - if imported { - return nil - } - if profile == "" || profile == "cindy-provider-v1" || profile == "image-tools-v1" || profile == "admin-observability-v1" { - return nil - } - if profile != "codex-tickets-v1" { - return errors.New("unknown legacy state profile") - } - var config struct { - Models []string `json:"models"` - ProxyURL string `json:"proxy_url"` - } - if err := json.Unmarshal(configuration, &config); err != nil { - return err - } - rows, err := r.db.QueryContext(ctx, `SELECT id,platform,type,credentials,extra FROM accounts WHERE deleted_at IS NULL AND platform='openai' AND type IN ('oauth','setup-token') AND parent_account_id IS NULL ORDER BY id`) - if err != nil { - return err - } - var accounts []*service.Account - for rows.Next() { - a := &service.Account{} - var credentials, extra []byte - if err := rows.Scan(&a.ID, &a.Platform, &a.Type, &credentials, &extra); err != nil { - _ = rows.Close() - return err - } - if json.Unmarshal(credentials, &a.Credentials) != nil || json.Unmarshal(extra, &a.Extra) != nil { - _ = rows.Close() - return fmt.Errorf("invalid legacy account JSON for %d", a.ID) - } - accounts = append(accounts, a) - } - readErr := rows.Err() - closeErr := rows.Close() - if readErr != nil { - return readErr - } - if closeErr != nil { - return closeErr - } - for _, account := range accounts { - for _, model := range config.Models { - if err := r.importLegacyTicket(ctx, plugin.PluginKey, account, model); err != nil { - return err - } - } - } - if config.ProxyURL != "" { - return r.importLegacyProxyTrust(ctx, plugin.PluginKey, config.ProxyURL) - } - return nil -} - -func (r *pluginRepository) importLegacyTicket(ctx context.Context, plugin string, account *service.Account, model string) error { - legacy, err := scanTicketState(r.db.QueryRowContext(ctx, `SELECT `+ticketStateColumns+` FROM openai_codex_ticket_runtime WHERE account_id=$1 AND model=$2`, account.ID, model)) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return err - } - _, hasLegacyMaterial := account.Extra["codex_turn_ticket:"+model] - if legacy == nil && !hasLegacyMaterial { - return nil - } - request, err := legacyCodexRoutingImport(account, model, legacy, time.Now().UTC()) - if err != nil { - return err - } - _, err = r.CompareSwapExtensionState(ctx, plugin, request) - return err -} - -// Retired STATE values never grant access, regardless of length or shape. -// Only the persisted lifecycle can carry renewal intent across the cutover. -func legacyCodexRoutingImport(account *service.Account, model string, legacy *service.CodexTicketLifecycle, now time.Time) (extensionv1.StateRequest, error) { - identity := service.CodexTicketAccountIdentity(account) - phase := "stopped" - var next, attempt *time.Time - var lastCode, operation string - enrolled := false - if legacy != nil { - phase, attempt = legacy.Phase, legacy.LastAttemptAt - if legacy.LastResult != nil { - lastCode = legacy.LastResult.Code - } - if legacy.JobID > 0 { - operation = fmt.Sprintf("job-%d", legacy.JobID) - } else if parts := strings.Split(legacy.Operation, ":"); len(parts) == 4 && parts[0] == "job" { - operation = "job-" + parts[1] - } - if (legacy.Identity != "" && legacy.Identity != identity) || (phase != "ready" && phase != "retry" && phase != "stopped") { - phase = "stopped" - next = nil - lastCode = "ticket_interrupted" - } - } - if phase == "ready" || phase == "retry" { - phase, enrolled, next = "needs_cookie_verification", true, &now - } - if lastCode == "" || lastCode == "ticket_ready" || lastCode == "ticket_skipped" { - lastCode = "routing_legacy_retired" - } - value, err := json.Marshal(map[string]any{"schema": extensionv1.CodexRoutingSchema, "phase": phase, "identity": identity, "enrolled": enrolled, "operation_id": operation, "next_attempt_at": next, "last_code": lastCode, "last_attempt_at": attempt}) - if err != nil { - return extensionv1.StateRequest{}, err - } - constraint := extensionv1.SchedulingConstraint{Model: model, Effect: "deny", Reason: "ticket_missing"} - sum := sha256.Sum256([]byte(model)) - observation := extensionv1.AccountObservation{Key: model, Kind: "codex_routing", State: phase, NextAt: next, CheckedAt: attempt, Code: lastCode} - return extensionv1.StateRequest{Namespace: "tickets", Key: strconv.FormatInt(account.ID, 10) + "." + hex.EncodeToString(sum[:]), Value: value, NextAt: next, Projection: &extensionv1.AccountProjection{AccountID: account.ID, Identity: identity, Scheduling: []extensionv1.SchedulingConstraint{constraint}, Observations: []extensionv1.AccountObservation{observation}}}, nil -} - -func (r *pluginRepository) importLegacyProxyTrust(ctx context.Context, plugin, raw string) error { - normal, err := proxytransport.Normalize(raw) - if err != nil { - return errors.New("invalid legacy proxy configuration") - } - var generation int64 - if err := r.db.QueryRowContext(ctx, `SELECT generation FROM openai_codex_ticket_proxy_generation WHERE id=1`).Scan(&generation); err != nil { - return err - } - oldKey := sha256.Sum256([]byte(normal + "\x00" + strconv.FormatInt(generation, 10) + "\x00chatgpt.com")) - var value json.RawMessage - err = r.db.QueryRowContext(ctx, `SELECT certificates FROM openai_codex_ticket_proxy_trust WHERE proxy_key=$1`, hex.EncodeToString(oldKey[:])).Scan(&value) - if errors.Is(err, sql.ErrNoRows) { - return nil - } - if err != nil { - return err - } - key := sha256.Sum256([]byte(normal + "\x00chatgpt.com")) - _, err = r.CompareSwapExtensionState(ctx, plugin, extensionv1.StateRequest{Namespace: "proxy-trust", Key: hex.EncodeToString(key[:]), Value: value}) - return err -} diff --git a/backend/internal/repository/plugin_legacy_ticket_state_test.go b/backend/internal/repository/plugin_legacy_ticket_state_test.go deleted file mode 100644 index c3d08adf5927..000000000000 --- a/backend/internal/repository/plugin_legacy_ticket_state_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package repository - -import ( - "encoding/json" - "strings" - "testing" - "time" - - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/stretchr/testify/require" -) - -func TestLegacyCodexRoutingImportRetiresStateWithoutGrantingAccess(t *testing.T) { - now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC) - for _, length := range []int{292, 312, 332, 356, 780} { - account := &service.Account{ID: 7, Platform: "openai", Type: "oauth", Credentials: map[string]any{"chatgpt_account_id": "team", "plan_type": "business"}, Extra: map[string]any{"codex_turn_ticket:gpt-6-astra": map[string]any{"state": strings.Repeat("x", length), "expires_at": now.Add(time.Hour)}}} - for _, phase := range []string{"", "ready", "retry", "stopped", "manual_running"} { - var legacy *service.CodexTicketLifecycle - if phase != "" { - legacy = &service.CodexTicketLifecycle{Phase: phase, JobID: 17} - } - request, err := legacyCodexRoutingImport(account, "gpt-6-astra", legacy, now) - require.NoError(t, err) - var state map[string]any - require.NoError(t, json.Unmarshal(request.Value, &state)) - require.NotContains(t, state, "ticket") - require.NotContains(t, state, "qualification") - require.NotContains(t, state, "expires_at") - require.Equal(t, "deny", request.Projection.Scheduling[0].Effect) - require.Nil(t, request.Projection.Scheduling[0].Until) - require.Zero(t, request.Projection.Observations[0].Count) - if phase == "ready" || phase == "retry" { - require.Equal(t, "needs_cookie_verification", state["phase"]) - require.Equal(t, true, state["enrolled"]) - require.Equal(t, now, *request.NextAt) - } else { - require.Equal(t, "stopped", state["phase"]) - require.Equal(t, false, state["enrolled"]) - require.Nil(t, request.NextAt) - } - if legacy != nil { - require.Equal(t, "job-17", state["operation_id"]) - } - } - } -} diff --git a/backend/internal/repository/plugin_registry_atomicity_design_integration_test.go b/backend/internal/repository/plugin_registry_atomicity_design_integration_test.go deleted file mode 100644 index 6554764452d8..000000000000 --- a/backend/internal/repository/plugin_registry_atomicity_design_integration_test.go +++ /dev/null @@ -1,320 +0,0 @@ -//go:build integration - -package repository - -import ( - "context" - "crypto/sha256" - "database/sql" - "encoding/hex" - "encoding/json" - "sync" - "testing" - "time" - - "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/jackc/pgx/v5/pgconn" - "github.com/stretchr/testify/require" -) - -// Design probes deliberately stay in their own database. Disable exercises the -// real production path. The remaining cases are test-only isolation models, -// including negative controls which intentionally bypass the production helper. -func registryAtomicityContext(t *testing.T) context.Context { - t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - t.Cleanup(cancel) - var database string - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT current_database()`).Scan(&database)) - if database != "sub2api_test_astra_registry_20260921" { - t.Skip("registry interleaving design probes require the dedicated local database") - } - return ctx -} - -func installRegistryAtomicityFixture(t *testing.T, repo *pluginRepository, capability, platform, kind, operation string, enabled bool) (*service.PluginInstallation, *service.PluginInstallation) { - t.Helper() - artifact := bundleFixture(t, repo) - artifact.State = service.PluginStateDisabled - if enabled { - artifact.State = service.PluginStateEnabled - } - artifact.Manifest.Capabilities = []service.PluginCapability{{ID: capability, Platform: platform, AccountType: kind}} - artifact.Manifest.Operations = map[string][]string{capability: {operation}} - installed, err := repo.Install(context.Background(), artifact, []service.PluginBinding{{Capability: capability, Platform: platform, AccountType: kind, Enabled: enabled, RolloutPercent: 100}}) - require.NoError(t, err) - return installed, artifact -} - -type pauseRegistryDisableRepository struct { - *pluginRepository - target int64 - ready chan struct{} - proceed chan struct{} - once sync.Once -} - -func (r *pauseRegistryDisableRepository) GetByID(ctx context.Context, id int64) (*service.PluginInstallation, error) { - installation, err := r.pluginRepository.GetByID(ctx, id) - if err != nil || id != r.target { - return installation, err - } - // Manager.Disable reaches GetByID only after its real dependency check. - r.once.Do(func() { - close(r.ready) - select { - case <-r.proceed: - case <-ctx.Done(): - } - }) - return installation, ctx.Err() -} - -func TestRegistryDisableCannotFollowDependentPromotion(t *testing.T) { - ctx := registryAtomicityContext(t) - repo := &pluginRepository{db: integrationDB} - provider, _ := installRegistryAtomicityFixture(t, repo, extensionv1.CapabilityProvider, "cindy", "apikey", "provider.read", true) - consumer, artifact := installRegistryAtomicityFixture(t, repo, extensionv1.CapabilityAdmin, "openai", "oauth", "consumer.run", true) - candidate := *artifact - candidate.Version = "1.0.1" - candidate.Manifest.Version = candidate.Version - candidate.Manifest.Dependencies = []extensionv1.Dependency{{Capability: extensionv1.CapabilityProvider, Platform: "cindy", AccountType: "apikey"}} - candidate.ArtifactData = []byte("registry atomicity dependent replacement") - digest := sha256.Sum256(candidate.ArtifactData) - candidate.PackageSHA256 = hex.EncodeToString(digest[:]) - require.NoError(t, repo.StagePluginUpdate(ctx, consumer, &candidate, service.PluginUpdatePinned)) - pending, err := repo.GetByID(ctx, consumer.ID) - require.NoError(t, err) - paused := &pauseRegistryDisableRepository{pluginRepository: repo, target: provider.ID, ready: make(chan struct{}), proceed: make(chan struct{})} - manager := service.NewPluginManager(paused, nil, nil, service.PluginHostInfo{}, nil) - finished := make(chan error, 1) - go func() { - _, err := manager.Disable(ctx, provider.ID) - finished <- err - }() - select { - case <-paused.ready: - case <-ctx.Done(): - t.Fatal("disable did not reach its post-admission barrier") - } - // Different target transactions: A's new dependency commits after B's - // actual service admission but before B's conditional binding write. - commitErr := repo.CommitPluginUpdate(ctx, pending, &candidate) - close(paused.proceed) - require.NoError(t, commitErr) - var disableErr error - select { - case disableErr = <-finished: - case <-ctx.Done(): - t.Fatal("disable did not finish after the controlled promotion") - } - afterConsumer, err := repo.GetByID(ctx, consumer.ID) - require.NoError(t, err) - afterProvider, err := repo.GetByID(ctx, provider.ID) - require.NoError(t, err) - missing := len(afterConsumer.Manifest.Dependencies) == 1 && !afterProvider.Bindings[0].Enabled - t.Logf("dependent_promoted=%t disable_returned_nil=%t persisted_missing_dependency=%t", afterConsumer.PackageSHA256 == candidate.PackageSHA256, disableErr == nil, missing) - require.False(t, missing, "target-only CAS must not admit a provider disable based on a pre-promotion dependency graph") -} - -// Both tables are read by the same transaction/query. This represents the -// minimal desired-graph projection a future production graphTx can validate. -func readRegistryGraphPrototype(ctx context.Context, tx *sql.Tx) ([]*service.PluginInstallation, error) { - rows, err := tx.QueryContext(ctx, `SELECT p.id,p.manifest,COALESCE(jsonb_agg(jsonb_build_object( - 'capability',b.capability,'platform',b.platform,'account_type',b.account_type,'enabled',b.enabled,'rollout_percent',b.rollout_percent) - ORDER BY b.id) FILTER (WHERE b.id IS NOT NULL),'[]'::jsonb) - FROM sub2api_plugin_installations p LEFT JOIN sub2api_plugin_bindings b ON b.plugin_id=p.id GROUP BY p.id ORDER BY p.id`) - if err != nil { - return nil, err - } - defer rows.Close() - var installations []*service.PluginInstallation - for rows.Next() { - installation := &service.PluginInstallation{} - var manifest, bindings []byte - if err = rows.Scan(&installation.ID, &manifest, &bindings); err != nil { - return nil, err - } - if err = json.Unmarshal(manifest, &installation.Manifest); err != nil { - return nil, err - } - if err = json.Unmarshal(bindings, &installation.Bindings); err != nil { - return nil, err - } - installations = append(installations, installation) - } - return installations, rows.Err() -} - -func countRegistryPrototypeOwners(installations []*service.PluginInstallation) int { - count := 0 - for _, installation := range installations { - for _, operation := range installation.Manifest.Operations[extensionv1.CapabilityAdmin] { - if operation != "registry.same-operation" { - continue - } - for _, binding := range installation.Bindings { - if binding.Enabled && binding.Capability == extensionv1.CapabilityAdmin { - count++ - } - } - } - } - return count -} - -func registryPrototypeEnable(ctx context.Context, tx *sql.Tx, installation *service.PluginInstallation) error { - result, err := tx.ExecContext(ctx, `UPDATE sub2api_plugin_installations SET state='starting',revision=revision+1 - WHERE id=$1 AND revision=$2 AND state='disabled' AND binary_sha256=$3`, installation.ID, installation.Revision, installation.BinarySHA256) - if err != nil { - return err - } - changed, err := result.RowsAffected() - if err != nil { - return err - } - if changed != 1 { - return service.ErrPluginStateChanged - } - bindings := append([]service.PluginBinding(nil), installation.Bindings...) - bindings[0].Enabled = true - return replacePluginBindings(ctx, tx, installation.ID, bindings) -} - -func runRegistryEnableInterleaving(t *testing.T, isolation sql.IsolationLevel, rightOptions ...sql.IsolationLevel) (error, int) { - t.Helper() - ctx := registryAtomicityContext(t) - repo := &pluginRepository{db: integrationDB} - first, _ := installRegistryAtomicityFixture(t, repo, extensionv1.CapabilityAdmin, "openai", "oauth", "registry.same-operation", false) - second, _ := installRegistryAtomicityFixture(t, repo, extensionv1.CapabilityAdmin, "openai", "oauth", "registry.same-operation", false) - left, err := integrationDB.BeginTx(ctx, &sql.TxOptions{Isolation: isolation}) - require.NoError(t, err) - defer left.Rollback() - rightIsolation := isolation - if len(rightOptions) > 0 { - rightIsolation = rightOptions[0] - } - right, err := integrationDB.BeginTx(ctx, &sql.TxOptions{Isolation: rightIsolation}) - require.NoError(t, err) - defer right.Rollback() - leftSnapshot, err := readRegistryGraphPrototype(ctx, left) - require.NoError(t, err) - rightSnapshot, err := readRegistryGraphPrototype(ctx, right) - require.NoError(t, err) - require.Zero(t, countRegistryPrototypeOwners(leftSnapshot)) - require.Zero(t, countRegistryPrototypeOwners(rightSnapshot)) - // Both admissions precede either write. The transactions remain open; - // explicit sequencing, not timing/sleep, chooses the winning commit. - require.NoError(t, registryPrototypeEnable(ctx, left, first)) - require.NoError(t, left.Commit()) - rightErr := registryPrototypeEnable(ctx, right, second) - if rightErr == nil { - rightErr = right.Commit() - } else { - _ = right.Rollback() - } - current, err := repo.List(ctx) - require.NoError(t, err) - owners := countRegistryPrototypeOwners(current) - t.Logf("left_isolation=%s right_isolation=%s both_preflight_owners=0 second_write_or_commit_error=%t committed_overlapping_owners=%d", isolation, rightIsolation, rightErr != nil, owners) - return rightErr, owners -} - -func TestRegistryReadCommittedPrototypeDoesNotProtectAdmission(t *testing.T) { - err, owners := runRegistryEnableInterleaving(t, sql.LevelReadCommitted) - require.NoError(t, err) - require.Equal(t, 2, owners, "negative control: the old two-transaction mechanism has no graph fence") -} - -func TestRegistrySerializablePrototypeRejectsOverlappingOwners(t *testing.T) { - err, owners := runRegistryEnableInterleaving(t, sql.LevelSerializable) - require.Error(t, err) - var postgres *pgconn.PgError - require.ErrorAs(t, err, &postgres) - require.Equal(t, "40001", postgres.Code) - require.Equal(t, 1, owners) -} - -func TestRegistryMixedIsolationPrototypeDoesNotProtectAdmission(t *testing.T) { - err, owners := runRegistryEnableInterleaving(t, sql.LevelSerializable, sql.LevelReadCommitted) - require.NoError(t, err) - require.Equal(t, 2, owners, "an older nonparticipating writer defeats a global serializability claim") -} - -func TestRegistrySerializableOutsideReadPrototypeDoesNotProtectAdmission(t *testing.T) { - ctx := registryAtomicityContext(t) - repo := &pluginRepository{db: integrationDB} - first, _ := installRegistryAtomicityFixture(t, repo, extensionv1.CapabilityAdmin, "openai", "oauth", "registry.same-operation", false) - second, _ := installRegistryAtomicityFixture(t, repo, extensionv1.CapabilityAdmin, "openai", "oauth", "registry.same-operation", false) - leftOutside, err := repo.List(ctx) - require.NoError(t, err) - rightOutside, err := repo.List(ctx) - require.NoError(t, err) - require.Zero(t, countRegistryPrototypeOwners(leftOutside)) - require.Zero(t, countRegistryPrototypeOwners(rightOutside)) - left, err := integrationDB.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) - require.NoError(t, err) - defer left.Rollback() - right, err := integrationDB.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) - require.NoError(t, err) - defer right.Rollback() - require.NoError(t, registryPrototypeEnable(ctx, left, first)) - require.NoError(t, left.Commit()) - require.NoError(t, registryPrototypeEnable(ctx, right, second)) - require.NoError(t, right.Commit()) - current, err := repo.List(ctx) - require.NoError(t, err) - require.Equal(t, 2, countRegistryPrototypeOwners(current)) - t.Log("both_transactions_serializable=true graph_read_outside_transactions=true both_committed=true overlapping_owners=2") -} - -func TestRegistrySerializablePostWriteValidationPrototypeProtectsAdmission(t *testing.T) { - ctx := registryAtomicityContext(t) - repo := &pluginRepository{db: integrationDB} - first, _ := installRegistryAtomicityFixture(t, repo, extensionv1.CapabilityAdmin, "openai", "oauth", "registry.same-operation", false) - second, _ := installRegistryAtomicityFixture(t, repo, extensionv1.CapabilityAdmin, "openai", "oauth", "registry.same-operation", false) - left, err := integrationDB.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) - require.NoError(t, err) - defer left.Rollback() - right, err := integrationDB.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) - require.NoError(t, err) - defer right.Rollback() - require.NoError(t, registryPrototypeEnable(ctx, left, first)) - require.NoError(t, registryPrototypeEnable(ctx, right, second)) - leftGraph, leftErr := readRegistryGraphPrototype(ctx, left) - if leftErr == nil { - require.Equal(t, 1, countRegistryPrototypeOwners(leftGraph)) - } - rightGraph, rightErr := readRegistryGraphPrototype(ctx, right) - if rightErr == nil { - require.Equal(t, 1, countRegistryPrototypeOwners(rightGraph)) - } - if leftErr == nil { - leftErr = left.Commit() - } else { - _ = left.Rollback() - } - if rightErr == nil { - rightErr = right.Commit() - } else { - _ = right.Rollback() - } - conflicts, commits := 0, 0 - for _, transactionErr := range []error{leftErr, rightErr} { - if transactionErr == nil { - commits++ - continue - } - var postgres *pgconn.PgError - require.ErrorAs(t, transactionErr, &postgres) - require.Equal(t, "40001", postgres.Code) - conflicts++ - } - require.Equal(t, 1, commits) - require.Equal(t, 1, conflicts) - current, err := repo.List(ctx) - require.NoError(t, err) - require.Equal(t, 1, countRegistryPrototypeOwners(current)) - t.Log("post_write_graph_read_inside_each_transaction=true commits=1 serialization_conflicts=1 overlapping_owners=1") -} diff --git a/backend/internal/repository/plugin_registry_tx.go b/backend/internal/repository/plugin_registry_tx.go deleted file mode 100644 index 2356751c3de8..000000000000 --- a/backend/internal/repository/plugin_registry_tx.go +++ /dev/null @@ -1,74 +0,0 @@ -package repository - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/jackc/pgx/v5/pgconn" -) - -// Only desired-graph mutations participate. Runtime health/config publication -// and business reads do not acquire a registry-wide lock or read the graph. -func (r *pluginRepository) beginPluginRegistryTx(ctx context.Context) (*sql.Tx, error) { - tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) - return tx, pluginRegistryTxError(err) -} - -func pluginRegistryTxError(err error) error { - var postgres *pgconn.PgError - if errors.As(err, &postgres) && postgres.Code == "40001" { - return service.ErrPluginStateChanged - } - return err -} - -func rollbackPluginRegistryTx(tx *sql.Tx, resultErr *error) { - _ = tx.Rollback() - *resultErr = pluginRegistryTxError(*resultErr) -} - -// Read both graph tables through this transaction, including our own writes. -// PostgreSQL SSI then also tracks absent/overlapping owners and dependencies: -// target-only row locks or a List outside the transaction cannot do that. -func readPluginRegistryTx(ctx context.Context, tx *sql.Tx) ([]*service.PluginInstallation, error) { - rows, err := tx.QueryContext(ctx, `SELECT p.id,p.manifest,COALESCE(jsonb_agg(jsonb_build_object( - 'capability',b.capability,'platform',b.platform,'account_type',b.account_type,'enabled',b.enabled,'rollout_percent',b.rollout_percent) - ORDER BY b.id) FILTER (WHERE b.id IS NOT NULL),'[]'::jsonb) - FROM sub2api_plugin_installations p LEFT JOIN sub2api_plugin_bindings b ON b.plugin_id=p.id GROUP BY p.id ORDER BY p.id`) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - var installations []*service.PluginInstallation - for rows.Next() { - installation := &service.PluginInstallation{} - var manifest, bindings []byte - if err := rows.Scan(&installation.ID, &manifest, &bindings); err != nil { - return nil, err - } - if err := json.Unmarshal(manifest, &installation.Manifest); err != nil { - return nil, err - } - if err := json.Unmarshal(bindings, &installation.Bindings); err != nil { - return nil, err - } - installations = append(installations, installation) - } - return installations, rows.Err() -} - -func commitPluginRegistryTx(ctx context.Context, tx *sql.Tx) error { - installations, err := readPluginRegistryTx(ctx, tx) - if err != nil { - return pluginRegistryTxError(err) - } - // Desired bindings, not process health, define the graph. An error or an - // updating process may remain temporarily unavailable without losing intent. - if err := service.ValidatePluginRegistry(installations); err != nil { - return err - } - return pluginRegistryTxError(tx.Commit()) -} diff --git a/backend/internal/repository/plugin_registry_tx_integration_test.go b/backend/internal/repository/plugin_registry_tx_integration_test.go deleted file mode 100644 index e0a8d5210d56..000000000000 --- a/backend/internal/repository/plugin_registry_tx_integration_test.go +++ /dev/null @@ -1,257 +0,0 @@ -//go:build integration - -package repository - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/google/uuid" - "github.com/stretchr/testify/require" -) - -func TestPluginRegistryTxRejectsConcurrentOperationOwners(t *testing.T) { - ctx := registryAtomicityContext(t) - repo := &pluginRepository{db: integrationDB} - first, _ := installRegistryAtomicityFixture(t, repo, extensionv1.CapabilityAdmin, "openai", "oauth", "registry.same-operation", false) - second, _ := installRegistryAtomicityFixture(t, repo, extensionv1.CapabilityAdmin, "openai", "oauth", "registry.same-operation", false) - left, err := repo.beginPluginRegistryTx(ctx) - require.NoError(t, err) - defer left.Rollback() - right, err := repo.beginPluginRegistryTx(ctx) - require.NoError(t, err) - defer right.Rollback() - // Both real production transaction helpers see their own enabled target - // and the other transaction's old disabled version. SSI chooses a loser. - require.NoError(t, registryPrototypeEnable(ctx, left, first)) - require.NoError(t, registryPrototypeEnable(ctx, right, second)) - leftErr := commitPluginRegistryTx(ctx, left) - if leftErr != nil { - _ = left.Rollback() - } - rightErr := commitPluginRegistryTx(ctx, right) - if rightErr != nil { - _ = right.Rollback() - } - commits, conflicts := 0, 0 - for _, transactionErr := range []error{leftErr, rightErr} { - if transactionErr == nil { - commits++ - } else { - require.ErrorIs(t, transactionErr, service.ErrPluginStateChanged) - conflicts++ - } - } - require.Equal(t, 1, commits) - require.Equal(t, 1, conflicts) - current, err := repo.List(ctx) - require.NoError(t, err) - require.Equal(t, 1, countRegistryPrototypeOwners(current)) - t.Log("production_graph_tx_commits=1 retryable_conflicts=1 committed_overlapping_owners=1") -} - -func TestPluginRegistryBindingAndInstallConflictsRollback(t *testing.T) { - ctx := registryAtomicityContext(t) - repo := &pluginRepository{db: integrationDB} - _, _ = installRegistryAtomicityFixture(t, repo, extensionv1.CapabilityAdmin, "openai", "oauth", "registry.same-operation", true) - disabled, artifact := installRegistryAtomicityFixture(t, repo, extensionv1.CapabilityAdmin, "openai", "oauth", "registry.same-operation", false) - bindings := append([]service.PluginBinding(nil), disabled.Bindings...) - bindings[0].Enabled = true - err := repo.UpdateBindingsAndState(service.WithPluginExpectedRevision(ctx, disabled.Revision), disabled.ID, bindings, service.PluginStateStarting, "", nil, disabled.State, disabled.BinarySHA256) - require.ErrorContains(t, err, "overlapping enabled owners") - after, err := repo.GetByID(ctx, disabled.ID) - require.NoError(t, err) - require.Equal(t, disabled, after) - other := bundleFixture(t, repo) - other.State = service.PluginStateEnabled - other.Manifest = artifact.Manifest - other.Manifest.ID = other.PluginKey - _, err = repo.Install(ctx, other, bindings) - require.ErrorContains(t, err, "overlapping enabled owners") - _, err = repo.GetByKey(ctx, other.PluginKey) - require.ErrorIs(t, err, sql.ErrNoRows, "failed final admission must not leave a partially installed identity") -} - -func TestPluginRegistryPromotionConflictPreservesPendingConfigAndIntent(t *testing.T) { - ctx := registryAtomicityContext(t) - repo, consumer, candidate := installedUpdateFixture(t) - provider, _ := installRegistryAtomicityFixture(t, repo, extensionv1.CapabilityProvider, "cindy", "apikey", "provider.read", true) - candidate.Manifest.Dependencies = []extensionv1.Dependency{{Capability: extensionv1.CapabilityProvider, Platform: "cindy", AccountType: "apikey"}} - require.NoError(t, repo.StagePluginUpdate(ctx, consumer, candidate, service.PluginUpdatePinned)) - pending, err := repo.GetByID(ctx, consumer.ID) - require.NoError(t, err) - journal := bootstrapJournalSnapshot(t, repo, consumer.PluginKey) - bindings := append([]service.PluginBinding(nil), provider.Bindings...) - bindings[0].Enabled = false - require.NoError(t, repo.UpdateBindingsAndState(ctx, provider.ID, bindings, service.PluginStateDisabled, "", nil, provider.State, provider.BinarySHA256)) - err = repo.CommitPluginUpdate(ctx, pending, candidate) - require.ErrorContains(t, err, "missing enabled plugin dependency") - after, err := repo.GetByID(ctx, consumer.ID) - require.NoError(t, err) - require.Equal(t, pending, after) - require.Equal(t, journal, bootstrapJournalSnapshot(t, repo, consumer.PluginKey)) - artifact, err := repo.PendingPluginArtifact(ctx, consumer.ID) - require.NoError(t, err) - require.Equal(t, candidate.ArtifactData, artifact) - bindings[0].Enabled = true - require.NoError(t, repo.UpdateBindingsAndState(ctx, provider.ID, bindings, service.PluginStateEnabled, "", nil, service.PluginStateDisabled, provider.BinarySHA256)) - require.NoError(t, repo.CommitPluginUpdate(ctx, pending, candidate)) - updated, err := repo.GetByID(ctx, consumer.ID) - require.NoError(t, err) - require.Equal(t, pending.ConfigEncrypted, updated.ConfigEncrypted) - require.Equal(t, candidate.PackageSHA256, updated.PackageSHA256) - for _, binding := range updated.Bindings { - if binding.Capability == extensionv1.CapabilityAdmin { - require.True(t, binding.Enabled) - require.Equal(t, 37, binding.RolloutPercent) - } else { - require.False(t, binding.Enabled) - } - } -} - -func TestPluginRegistryBundleFinalAdmissionPreservesStagedIntent(t *testing.T) { - t.Run("prepare_cannot_remove_required_provider", func(t *testing.T) { - ctx := registryAtomicityContext(t) - repo := &pluginRepository{db: integrationDB} - provider := bundleFixture(t, repo) - provider.Manifest.Capabilities = []service.PluginCapability{{ID: extensionv1.CapabilityProvider, Platform: "cindy", AccountType: "apikey"}} - prepared, err := repo.PrepareBundledPlugin(ctx, provider, "provider-one", "", true, "saved-provider-config") - require.NoError(t, err) - require.NoError(t, repo.CompleteBundledPlugin(ctx, prepared.ID, "provider-one")) - current, err := repo.GetByID(ctx, prepared.ID) - require.NoError(t, err) - consumer := bundleFixture(t, repo) - consumer.State = service.PluginStateEnabled - consumer.Manifest.Dependencies = []extensionv1.Dependency{{Capability: extensionv1.CapabilityProvider, Platform: "cindy", AccountType: "apikey"}} - _, err = repo.Install(ctx, consumer, []service.PluginBinding{{Capability: extensionv1.CapabilityAdmin, Platform: "openai", AccountType: "oauth", Enabled: true, RolloutPercent: 100}}) - require.NoError(t, err) - journal := bootstrapJournalSnapshot(t, repo, provider.PluginKey) - provider.Version = "1.0.1" - _, err = repo.PrepareBundledPlugin(ctx, provider, "provider-two", "", true, "replacement-default") - require.ErrorContains(t, err, "missing enabled plugin dependency") - after, err := repo.GetByID(ctx, current.ID) - require.NoError(t, err) - require.Equal(t, current, after) - require.Equal(t, journal, bootstrapJournalSnapshot(t, repo, provider.PluginKey)) - }) - t.Run("completion_conflict_keeps_journal_pending", func(t *testing.T) { - ctx := registryAtomicityContext(t) - repo := &pluginRepository{db: integrationDB} - owner, artifact := installRegistryAtomicityFixture(t, repo, extensionv1.CapabilityAdmin, "openai", "oauth", "registry.same-operation", true) - bundle := bundleFixture(t, repo) - bundle.Manifest = artifact.Manifest - bundle.Manifest.ID = bundle.PluginKey - prepared, err := repo.PrepareBundledPlugin(ctx, bundle, "conflicting-bundle", "", true, "saved-bundle-config") - require.NoError(t, err) - journal := bootstrapJournalSnapshot(t, repo, bundle.PluginKey) - err = repo.CompleteBundledPlugin(ctx, prepared.ID, "conflicting-bundle") - require.ErrorContains(t, err, "overlapping enabled owners") - after, err := repo.GetByID(ctx, prepared.ID) - require.NoError(t, err) - require.Equal(t, prepared, after) - require.Equal(t, journal, bootstrapJournalSnapshot(t, repo, bundle.PluginKey)) - owner.Bindings[0].Enabled = false - require.NoError(t, repo.UpdateBindingsAndState(ctx, owner.ID, owner.Bindings, service.PluginStateDisabled, "", nil, owner.State, owner.BinarySHA256)) - require.NoError(t, repo.CompleteBundledPlugin(ctx, prepared.ID, "conflicting-bundle")) - }) -} - -func TestPluginRegistryFaultStateDoesNotRemoveDependencyGraph(t *testing.T) { - ctx := registryAtomicityContext(t) - repo := &pluginRepository{db: integrationDB} - provider, _ := installRegistryAtomicityFixture(t, repo, extensionv1.CapabilityProvider, "cindy", "apikey", "provider.read", true) - require.NoError(t, repo.UpdateState(ctx, provider.ID, service.PluginStateError, "runtime unavailable", nil, provider.BinarySHA256, provider.State)) - consumer := bundleFixture(t, repo) - consumer.State = service.PluginStateEnabled - consumer.Manifest.Dependencies = []extensionv1.Dependency{{Capability: extensionv1.CapabilityProvider, Platform: "cindy", AccountType: "apikey"}} - _, err := repo.Install(ctx, consumer, []service.PluginBinding{{Capability: extensionv1.CapabilityAdmin, Platform: "openai", AccountType: "oauth", Enabled: true, RolloutPercent: 100}}) - require.NoError(t, err, "desired graph admission does not require a live provider process") - require.NoError(t, repo.UpdateConfig(ctx, provider.ID, "new-provider-config", provider.BinarySHA256)) - spare, _ := installRegistryAtomicityFixture(t, repo, extensionv1.CapabilityAdmin, "openai", "oauth", "spare.operation", false) - require.NoError(t, repo.Delete(ctx, spare.ID, spare.BinarySHA256)) - current, err := repo.GetByID(ctx, provider.ID) - require.NoError(t, err) - require.Equal(t, service.PluginStateError, current.State) - require.True(t, current.Bindings[0].Enabled) - require.Equal(t, "new-provider-config", current.ConfigEncrypted) -} - -func TestPluginRegistryFirstBundleOrderKeepsTemporaryDisabledBindings(t *testing.T) { - ctx := registryAtomicityContext(t) - repo := &pluginRepository{db: integrationDB} - var source struct { - Plugins []struct { - Directory string `json:"directory"` - DefaultEnabled bool `json:"default_enabled"` - } `json:"plugins"` - } - root := filepath.Join("..", "..", "..") - raw, err := os.ReadFile(filepath.Join(root, "plugins", "bundle.source.json")) - require.NoError(t, err) - require.NoError(t, json.Unmarshal(raw, &source)) - require.NotEmpty(t, source.Plugins) - for _, entry := range source.Plugins { - manifestRaw, err := os.ReadFile(filepath.Join(root, "plugins", entry.Directory, "manifest.source.json")) - require.NoError(t, err) - var manifest service.PluginManifest - require.NoError(t, json.Unmarshal(manifestRaw, &manifest)) - artifact := bundleFixture(t, repo) - manifest.ID = artifact.PluginKey - artifact.Manifest, artifact.Version, artifact.ArtifactData = manifest, manifest.Version, manifestRaw - prepared, err := repo.PrepareBundledPlugin(ctx, artifact, "first-bundle-order", "", entry.DefaultEnabled, "saved-config") - require.NoError(t, err, entry.Directory) - for _, binding := range prepared.Bindings { - require.False(t, binding.Enabled) - } - require.NoError(t, repo.CompleteBundledPlugin(ctx, prepared.ID, "first-bundle-order"), entry.Directory) - } - t.Logf("actual_bundle_source_order_entries=%d initial_prepare_disabled=true final_admission_passed=true", len(source.Plugins)) -} - -func TestPluginRegistryRejectedDisableRollsBackOwnedJobCancellation(t *testing.T) { - ctx := registryAtomicityContext(t) - repo := &pluginRepository{db: integrationDB} - provider, _ := installRegistryAtomicityFixture(t, repo, extensionv1.CapabilityAdmin, "openai", "oauth", "provider.admin", true) - consumer := bundleFixture(t, repo) - consumer.State = service.PluginStateEnabled - consumer.Manifest.Capabilities = []service.PluginCapability{{ID: extensionv1.CapabilityRequest, Platform: "openai", AccountType: "oauth"}} - consumer.Manifest.Dependencies = []extensionv1.Dependency{{Capability: extensionv1.CapabilityAdmin, Platform: "openai", AccountType: "oauth"}} - consumerInstalled, err := repo.Install(ctx, consumer, []service.PluginBinding{{Capability: extensionv1.CapabilityRequest, Platform: "openai", AccountType: "oauth", Enabled: true, RolloutPercent: 100}}) - require.NoError(t, err) - user := mustCreateUser(t, testEntClient(t), &service.User{Email: "registry-cancel-" + uuid.NewString() + "@example.com", PasswordHash: "fixture"}) - jobs := NewAccountJobRepository(integrationDB) - var jobID int64 - t.Cleanup(func() { - _, _ = integrationDB.ExecContext(context.Background(), `DELETE FROM admin_account_jobs WHERE id=$1`, jobID) - _, _ = integrationDB.ExecContext(context.Background(), `DELETE FROM users WHERE id=$1`, user.ID) - }) - job, _, err := jobs.Create(ctx, service.CreateAccountJobParams{ - CreatedBy: user.ID, Kind: service.AccountJobKindImportData, IdempotencyKey: uuid.NewString(), RequestHash: strings.Repeat("b", 64), PayloadCipher: "fixture", PayloadExpires: time.Now().Add(time.Hour), - Metadata: json.RawMessage(fmt.Sprintf(`{"plugin_id":%d,"plugin_generation":%d}`, provider.ID, provider.RuntimeGeneration)), Items: []service.AccountJobItemSeed{{Ordinal: 1, Metadata: []byte(`{}`)}}, Attempt: 1, - }) - require.NoError(t, err) - jobID = job.ID - disabled := append([]service.PluginBinding(nil), provider.Bindings...) - disabled[0].Enabled = false - err = repo.UpdateBindingsAndState(ctx, provider.ID, disabled, service.PluginStateDisabled, "", nil, provider.State, provider.BinarySHA256) - require.ErrorContains(t, err, "missing enabled plugin dependency") - retained, err := jobs.Get(ctx, jobID) - require.NoError(t, err) - require.Equal(t, service.AccountJobStatusPending, retained.Status, "invalid graph rollback includes job cancellation") - consumerInstalled.Bindings[0].Enabled = false - require.NoError(t, repo.UpdateBindingsAndState(ctx, consumerInstalled.ID, consumerInstalled.Bindings, service.PluginStateDisabled, "", nil, consumerInstalled.State, consumerInstalled.BinarySHA256)) - require.NoError(t, repo.UpdateBindingsAndState(ctx, provider.ID, disabled, service.PluginStateDisabled, "", nil, provider.State, provider.BinarySHA256)) - canceled, err := jobs.Get(ctx, jobID) - require.NoError(t, err) - require.Equal(t, service.AccountJobStatusCanceled, canceled.Status) -} diff --git a/backend/internal/repository/plugin_registry_tx_test.go b/backend/internal/repository/plugin_registry_tx_test.go deleted file mode 100644 index d247d8165a90..000000000000 --- a/backend/internal/repository/plugin_registry_tx_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package repository - -import ( - "fmt" - "testing" - - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/jackc/pgx/v5/pgconn" - "github.com/stretchr/testify/require" -) - -func TestPluginRegistrySerializationConflictMappingPreservesOtherErrors(t *testing.T) { - conflict := &pgconn.PgError{Code: "40001", Message: "fixture serialization conflict"} - require.ErrorIs(t, pluginRegistryTxError(conflict), service.ErrPluginStateChanged) - require.ErrorIs(t, pluginRegistryTxError(fmt.Errorf("statement: %w", conflict)), service.ErrPluginStateChanged) - require.ErrorIs(t, pluginRegistryTxError(service.ErrPluginStateChanged), service.ErrPluginStateChanged) - constraint := &pgconn.PgError{Code: "23505", Message: "fixture constraint"} - require.Same(t, constraint, pluginRegistryTxError(constraint), "unrelated errors must not turn into hidden automatic retries") - require.NoError(t, pluginRegistryTxError(nil)) -} diff --git a/backend/internal/repository/plugin_repo.go b/backend/internal/repository/plugin_repo.go index 8ae368028979..4c942a10d900 100644 --- a/backend/internal/repository/plugin_repo.go +++ b/backend/internal/repository/plugin_repo.go @@ -11,16 +11,7 @@ import ( ) type pluginRepository struct { - db *sql.DB - refreshAccount func(context.Context, int64) -} - -func ProvidePluginRepository(db *sql.DB, accounts service.AccountRepository) service.PluginRepository { - repo := &pluginRepository{db: db} - if concrete, ok := accounts.(*accountRepository); ok { - repo.refreshAccount = concrete.syncSchedulerAccountSnapshotDetached - } - return repo + db *sql.DB } func NewPluginRepository(db *sql.DB) service.PluginRepository { @@ -72,23 +63,45 @@ func (r *pluginRepository) GetByKey(ctx context.Context, key string) (*service.P return plugin, err } -func (r *pluginRepository) Install(ctx context.Context, plugin *service.PluginInstallation, bindings []service.PluginBinding) (_ *service.PluginInstallation, resultErr error) { +func (r *pluginRepository) Install(ctx context.Context, plugin *service.PluginInstallation, bindings []service.PluginBinding) (*service.PluginInstallation, error) { manifestJSON, err := json.Marshal(plugin.Manifest) if err != nil { return nil, fmt.Errorf("序列化插件清单: %w", err) } - tx, err := r.beginPluginRegistryTx(ctx) + tx, err := r.db.BeginTx(ctx, nil) if err != nil { return nil, err } - defer rollbackPluginRegistryTx(tx, &resultErr) + defer func() { _ = tx.Rollback() }() row := tx.QueryRowContext(ctx, ` INSERT INTO sub2api_plugin_installations ( plugin_key, name, version, description, author, manifest, artifact_data, artifact_path, install_path, binary_path, binary_sha256, - signature_status, state, last_error, installed_by, installed_at, updated_at,package_sha256,update_policy - ) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12, $13, '', $14, NOW(), NOW(),encode(sha256($7),'hex'),'pinned') - ON CONFLICT (plugin_key) DO NOTHING + signature_status, state, last_error, installed_by, installed_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12, $13, '', $14, NOW(), NOW()) + ON CONFLICT (plugin_key) DO UPDATE SET + name = EXCLUDED.name, + version = EXCLUDED.version, + description = EXCLUDED.description, + author = EXCLUDED.author, + manifest = EXCLUDED.manifest, + artifact_data = EXCLUDED.artifact_data, + artifact_path = EXCLUDED.artifact_path, + install_path = EXCLUDED.install_path, + binary_path = EXCLUDED.binary_path, + binary_sha256 = EXCLUDED.binary_sha256, + signature_status = EXCLUDED.signature_status, + state = EXCLUDED.state, + last_error = '', + installed_by = EXCLUDED.installed_by, + installed_at = NOW(), + enabled_at = NULL, + updated_at = NOW() + WHERE sub2api_plugin_installations.state IN ('disabled', 'error', 'incompatible') + AND NOT EXISTS ( + SELECT 1 FROM sub2api_plugin_bindings b + WHERE b.plugin_id = sub2api_plugin_installations.id AND b.enabled = TRUE + ) RETURNING id `, plugin.PluginKey, plugin.Name, plugin.Version, plugin.Description, plugin.Author, manifestJSON, plugin.ArtifactData, plugin.ArtifactPath, plugin.InstallPath, plugin.BinaryPath, plugin.BinarySHA256, @@ -96,17 +109,14 @@ func (r *pluginRepository) Install(ctx context.Context, plugin *service.PluginIn var id int64 if err := row.Scan(&id); err != nil { if err == sql.ErrNoRows { - return nil, service.ErrPluginAlreadyInstalled + return nil, service.ErrPluginStateChanged } return nil, err } if err := replacePluginBindings(ctx, tx, id, bindings); err != nil { return nil, err } - if _, err := tx.ExecContext(ctx, `UPDATE sub2api_plugin_bootstrap SET user_removed=false,desired_enabled=false,updated_at=NOW() WHERE plugin_key=$1`, plugin.PluginKey); err != nil { - return nil, err - } - if err := commitPluginRegistryTx(ctx, tx); err != nil { + if err := tx.Commit(); err != nil { return nil, err } return r.GetByID(ctx, id) @@ -118,30 +128,23 @@ func (r *pluginRepository) GetArtifact(ctx context.Context, id int64) ([]byte, e return artifact, err } -func (r *pluginRepository) Delete(ctx context.Context, id int64, expectedBinarySHA256 string) (resultErr error) { - tx, err := r.beginPluginRegistryTx(ctx) - if err != nil { - return err - } - defer rollbackPluginRegistryTx(tx, &resultErr) - var key string - err = tx.QueryRowContext(ctx, ` +func (r *pluginRepository) Delete(ctx context.Context, id int64, expectedBinarySHA256 string) error { + result, err := r.db.ExecContext(ctx, ` DELETE FROM sub2api_plugin_installations p - WHERE p.id = $1 AND p.binary_sha256 = $2 AND p.state NOT IN ('starting', 'enabled', 'updating') - AND ($3 = 0 OR p.revision = $3) AND ($4 = '' OR p.package_sha256 = $4) + WHERE p.id = $1 AND p.binary_sha256 = $2 AND p.state NOT IN ('starting', 'enabled') AND NOT EXISTS (SELECT 1 FROM sub2api_plugin_bindings b WHERE b.plugin_id = p.id AND b.enabled = TRUE) - RETURNING p.plugin_key - `, id, expectedBinarySHA256, service.PluginExpectedRevision(ctx), service.PluginExpectedPackage(ctx)).Scan(&key) - if err == sql.ErrNoRows { - return service.ErrPluginStateChanged - } + `, id, expectedBinarySHA256) if err != nil { return err } - if _, err = tx.ExecContext(ctx, `UPDATE sub2api_plugin_bootstrap SET user_removed=true,desired_enabled=false,updated_at=NOW() WHERE plugin_key=$1`, key); err != nil { + rows, err := result.RowsAffected() + if err != nil { return err } - return commitPluginRegistryTx(ctx, tx) + if rows != 1 { + return service.ErrPluginStateChanged + } + return nil } func (r *pluginRepository) BeginEnable(ctx context.Context, id int64, binarySHA256, expectedState string) error { @@ -164,14 +167,12 @@ func (r *pluginRepository) BeginEnable(ctx context.Context, id int64, binarySHA2 } func (r *pluginRepository) MarkRuntimeHealthy(ctx context.Context, id int64, binarySHA256, configEncrypted string) error { - execution, _ := service.PluginExecutionFromContext(ctx) result, err := r.db.ExecContext(ctx, ` UPDATE sub2api_plugin_installations p SET state = 'enabled', last_error = '', enabled_at = COALESCE(enabled_at, NOW()), updated_at = NOW() WHERE p.id = $1 AND p.binary_sha256 = $2 AND p.config_encrypted = $3 - AND p.state<>'updating' AND ($4=0 OR p.runtime_generation=$4) AND EXISTS (SELECT 1 FROM sub2api_plugin_bindings b WHERE b.plugin_id = p.id AND b.enabled = TRUE) - `, id, binarySHA256, configEncrypted, execution.Generation) + `, id, binarySHA256, configEncrypted) if err != nil { return err } @@ -186,13 +187,11 @@ func (r *pluginRepository) MarkRuntimeHealthy(ctx context.Context, id int64, bin } func (r *pluginRepository) UpdateState(ctx context.Context, id int64, state, lastError string, enabledAt *time.Time, expectedBinarySHA256, expectedState string) error { - execution, _ := service.PluginExecutionFromContext(ctx) result, err := r.db.ExecContext(ctx, ` UPDATE sub2api_plugin_installations SET state = $2, last_error = $3, enabled_at = $4, updated_at = NOW() WHERE id = $1 AND binary_sha256 = $5 AND state = $6 - AND state<>'updating' AND ($7=0 OR runtime_generation=$7) - `, id, state, lastError, enabledAt, expectedBinarySHA256, expectedState, execution.Generation) + `, id, state, lastError, enabledAt, expectedBinarySHA256, expectedState) if err != nil { return err } @@ -207,24 +206,22 @@ func (r *pluginRepository) UpdateState(ctx context.Context, id int64, state, las } func (r *pluginRepository) UpdateConfig(ctx context.Context, id int64, encrypted, expectedBinarySHA256 string) error { - _, err := r.UpdateConfigReturningRevision(ctx, id, encrypted, expectedBinarySHA256) - return err -} - -func (r *pluginRepository) UpdateConfigReturningRevision(ctx context.Context, id int64, encrypted, expectedBinarySHA256 string) (int64, error) { - var revision int64 - err := r.db.QueryRowContext(ctx, ` + result, err := r.db.ExecContext(ctx, ` UPDATE sub2api_plugin_installations SET config_encrypted = $2, updated_at = NOW() WHERE id = $1 AND binary_sha256 = $3 - AND state <> 'updating' AND ($4 = 0 OR revision = $4) - AND ($5 = '' OR package_sha256 = $5) - RETURNING revision - `, id, encrypted, expectedBinarySHA256, service.PluginExpectedRevision(ctx), service.PluginExpectedPackage(ctx)).Scan(&revision) - if err == sql.ErrNoRows { - return 0, service.ErrPluginStateChanged - } - return revision, err + `, id, encrypted, expectedBinarySHA256) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return service.ErrPluginStateChanged + } + return nil } func (r *pluginRepository) UpdateBindingsAndState( @@ -236,19 +233,17 @@ func (r *pluginRepository) UpdateBindingsAndState( enabledAt *time.Time, expectedState string, expectedBinarySHA256 string, -) (resultErr error) { - tx, err := r.beginPluginRegistryTx(ctx) +) error { + tx, err := r.db.BeginTx(ctx, nil) if err != nil { return err } - defer rollbackPluginRegistryTx(tx, &resultErr) + defer func() { _ = tx.Rollback() }() result, err := tx.ExecContext(ctx, ` UPDATE sub2api_plugin_installations - SET state = CASE WHEN state='updating' THEN state ELSE $2 END, last_error = $3, enabled_at = $4, updated_at = NOW(), revision=revision+1 + SET state = $2, last_error = $3, enabled_at = $4, updated_at = NOW() WHERE id = $1 AND ($5 = '' OR state = $5) AND binary_sha256 = $6 - AND (state <> 'updating' OR $2='disabled') AND ($7 = 0 OR revision = $7) - AND ($2 = 'disabled' OR NOT EXISTS (SELECT 1 FROM sub2api_plugin_bootstrap b WHERE b.plugin_key=sub2api_plugin_installations.plugin_key AND NOT b.completed)) - `, pluginID, state, lastError, enabledAt, expectedState, expectedBinarySHA256, service.PluginExpectedRevision(ctx)) + `, pluginID, state, lastError, enabledAt, expectedState, expectedBinarySHA256) if err != nil { return err } @@ -262,53 +257,7 @@ func (r *pluginRepository) UpdateBindingsAndState( if err := replacePluginBindings(ctx, tx, pluginID, bindings); err != nil { return err } - desired := false - for _, binding := range bindings { - desired = desired || binding.Enabled - } - desiredBindings, err := json.Marshal(bindings) - if err != nil { - return err - } - if _, err := tx.ExecContext(ctx, `UPDATE sub2api_plugin_bootstrap SET desired_enabled=$2,desired_bindings=$3::jsonb,updated_at=NOW() WHERE plugin_key=(SELECT plugin_key FROM sub2api_plugin_installations WHERE id=$1)`, pluginID, desired, desiredBindings); err != nil { - return err - } - if state == service.PluginStateDisabled { - if err := cancelPluginAccountJobs(ctx, tx, pluginID); err != nil { - return err - } - } - return commitPluginRegistryTx(ctx, tx) -} - -func cancelPluginAccountJobs(ctx context.Context, tx *sql.Tx, pluginID int64) error { - rows, err := tx.QueryContext(ctx, `SELECT `+accountJobSelectColumns+` FROM admin_account_jobs WHERE metadata->>'plugin_id'=$1 AND status IN ('pending','running') ORDER BY id FOR UPDATE`, fmt.Sprint(pluginID)) - if err != nil { - return err - } - var jobs []*service.AccountJob - for rows.Next() { - job, scanErr := scanAccountJob(rows) - if scanErr != nil { - _ = rows.Close() - return scanErr - } - jobs = append(jobs, job) - } - readErr := rows.Err() - closeErr := rows.Close() - if readErr != nil { - return readErr - } - if closeErr != nil { - return closeErr - } - for _, job := range jobs { - if _, err := cancelLockedAccountJob(ctx, tx, job); err != nil { - return err - } - } - return nil + return tx.Commit() } type pluginBindingExecutor interface { @@ -335,7 +284,7 @@ const pluginSelectSQL = ` SELECT 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,revision,runtime_generation,package_sha256,update_policy + installed_by, installed_at, enabled_at, updated_at FROM sub2api_plugin_installations` type pluginScanner interface { @@ -351,7 +300,6 @@ func scanPlugin(scanner pluginScanner) (*service.PluginInstallation, error) { &plugin.BinaryPath, &plugin.BinarySHA256, &plugin.SignatureStatus, &plugin.State, &plugin.ConfigEncrypted, &plugin.LastError, &plugin.InstalledBy, &plugin.InstalledAt, &plugin.EnabledAt, &plugin.UpdatedAt, - &plugin.Revision, &plugin.RuntimeGeneration, &plugin.PackageSHA256, &plugin.UpdatePolicy, ); err != nil { return nil, err } diff --git a/backend/internal/repository/plugin_repo_integration_test.go b/backend/internal/repository/plugin_repo_integration_test.go index 01d875db1626..c81f5d325788 100644 --- a/backend/internal/repository/plugin_repo_integration_test.go +++ b/backend/internal/repository/plugin_repo_integration_test.go @@ -54,18 +54,15 @@ func TestPluginRepositoryLifecycleIsAtomicAndOptimistic(t *testing.T) { second.BinarySHA256 = strings.Repeat("b", 64) second.ArtifactData = []byte("second-package") _, err = repo.Install(ctx, &second, bindings) - require.ErrorIs(t, err, service.ErrPluginAlreadyInstalled) + require.ErrorIs(t, err, service.ErrPluginStateChanged) bindings[0].Enabled = false require.NoError(t, repo.UpdateBindingsAndState( ctx, installed.ID, bindings, service.PluginStateDisabled, "", nil, "", first.BinarySHA256, )) - _, err = repo.Install(ctx, &second, bindings) - require.ErrorIs(t, err, service.ErrPluginAlreadyInstalled) - require.NoError(t, repo.Delete(ctx, installed.ID, first.BinarySHA256)) replaced, err := repo.Install(ctx, &second, bindings) require.NoError(t, err) - require.NotEqual(t, installed.ID, replaced.ID) + require.Equal(t, installed.ID, replaced.ID) err = repo.Delete(ctx, replaced.ID, first.BinarySHA256) require.True(t, errors.Is(err, service.ErrPluginStateChanged)) diff --git a/backend/internal/repository/plugin_runtime_lease_loss_integration_test.go b/backend/internal/repository/plugin_runtime_lease_loss_integration_test.go deleted file mode 100644 index 9f4cf83155ce..000000000000 --- a/backend/internal/repository/plugin_runtime_lease_loss_integration_test.go +++ /dev/null @@ -1,182 +0,0 @@ -//go:build integration - -package repository - -import ( - "context" - "testing" - "time" - - "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/stretchr/testify/require" -) - -// This is a local, disposable-database observation of the legacy release-only -// API, not a claim that losing its session cancels a process or host operation. -// The exact database gate prevents terminating a shared/CI/production session. -func TestPluginRuntimeLeaseSessionLossObservation(t *testing.T) { - const database = "sub2api_test_astra_lease_loss_20260921" - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - var currentDatabase string - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT current_database()`).Scan(¤tDatabase)) - if currentDatabase != database { - t.Skip("lease session termination is restricted to the dedicated local investigation database") - } - var address string - var port int - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT host(inet_server_addr()),inet_server_port()`).Scan(&address, &port)) - require.Equal(t, "127.0.0.1", address) - require.Equal(t, 55437, port) - leasePIDs := func() []int { - rows, err := integrationDB.QueryContext(ctx, `SELECT pid FROM pg_stat_activity WHERE datname=$1 AND application_name='sub2api-plugin-lease' ORDER BY pid`, database) - require.NoError(t, err) - defer rows.Close() - var pids []int - for rows.Next() { - var pid int - require.NoError(t, rows.Scan(&pid)) - pids = append(pids, pid) - } - require.NoError(t, rows.Err()) - return pids - } - require.Empty(t, leasePIDs(), "the investigation owns every lease session in its isolated database") - repo, current, candidate := installedUpdateFixture(t) - processCtx, cancelProcess := context.WithCancel(ctx) - defer cancelProcess() - processRelease, err := repo.HoldPluginRuntime(processCtx, current) - require.NoError(t, err) - defer processRelease() - pids := leasePIDs() - require.Len(t, pids, 1) - processPID := pids[0] - ioCtx, cancelIO := context.WithCancel(ctx) - defer cancelIO() - ioRelease, err := repo.HoldPluginRuntime(service.WithPluginBusinessIOLease(ioCtx), current) - require.NoError(t, err) - defer ioRelease() - pids = leasePIDs() - require.Len(t, pids, 2) - ioPID := pids[0] - if ioPID == processPID { - ioPID = pids[1] - } - require.NotEqual(t, processPID, ioPID) - leaseRequest := extensionv1.LeaseRequest{Namespace: "lease-loss", Key: "one-ticket-attempt", Owner: "old-owner", TTLSeconds: 30} - oldExecution := service.WithPluginExecution(ctx, current) - durableLease, err := repo.AcquireExtensionLease(oldExecution, current.PluginKey, leaseRequest) - require.NoError(t, err) - require.True(t, durableLease.Acquired) - t.Cleanup(func() { - _, _ = integrationDB.ExecContext(context.Background(), `DELETE FROM sub2api_plugin_leases WHERE plugin_key=$1 AND namespace='lease-loss'`, current.PluginKey) - }) - stateRequest := extensionv1.StateRequest{Namespace: "lease-loss", Key: "one-ticket-attempt", Value: []byte(`{"phase":"pre_running","operation_id":"spent"}`)} - written, err := repo.CompareSwapExtensionState(oldExecution, current.PluginKey, stateRequest) - require.NoError(t, err) - require.True(t, written.Applied) - require.NoError(t, repo.StagePluginUpdate(ctx, current, candidate, service.PluginUpdatePinned)) - pending, err := repo.GetByID(ctx, current.ID) - require.NoError(t, err) - require.ErrorIs(t, repo.CommitPluginUpdate(ctx, pending, candidate), service.ErrPluginUpdateWaiting) - terminateOwnedLease := func(pid int) { - var terminated bool - // Recheck database, role and application on the captured PID at the - // termination statement, rather than trusting an earlier inventory. - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT pg_terminate_backend(pid,1000) FROM pg_stat_activity WHERE pid=$1 AND datname=$2 AND usename=current_user AND application_name='sub2api-plugin-lease'`, pid, database).Scan(&terminated)) - require.True(t, terminated) - } - terminateOwnedLease(processPID) - require.Len(t, leasePIDs(), 1) - require.ErrorIs(t, repo.CommitPluginUpdate(ctx, pending, candidate), service.ErrPluginUpdateWaiting, "one surviving host-IO session still fences promotion") - terminateOwnedLease(ioPID) - require.Empty(t, leasePIDs()) - require.NoError(t, repo.CommitPluginUpdate(ctx, pending, candidate), "database session loss removes both advisory locks before caller cancellation") - updated, err := repo.GetByID(ctx, current.ID) - require.NoError(t, err) - require.Equal(t, current.RuntimeGeneration+1, updated.RuntimeGeneration) - require.NoError(t, processCtx.Err()) - require.NoError(t, ioCtx.Err()) - stateRequest.ExpectedRevision = written.Revision - stateRequest.Value = []byte(`{"phase":"ready","operation_id":"late"}`) - _, err = repo.CompareSwapExtensionState(oldExecution, current.PluginKey, stateRequest) - require.ErrorIs(t, err, service.ErrPluginStateChanged, "the old generation still cannot persist a late result") - newExecution := service.WithPluginExecution(ctx, updated) - leaseRequest.Owner = "new-owner" - newLease, err := repo.AcquireExtensionLease(newExecution, current.PluginKey, leaseRequest) - require.NoError(t, err) - require.False(t, newLease.Acquired, "session loss must not delete the independent 30-second domain lease") - retained, err := repo.ReadExtensionState(newExecution, current.PluginKey, stateRequest) - require.NoError(t, err) - require.Equal(t, written.Revision, retained.Revision) - require.JSONEq(t, `{"phase":"pre_running","operation_id":"spent"}`, string(retained.Value)) - t.Logf("own_process_pid=%d own_io_pid=%d promoted_generation=%d process_context_canceled=%t io_context_canceled=%t durable_lease_still_busy=true old_generation_write_rejected=true", processPID, ioPID, updated.RuntimeGeneration, processCtx.Err() != nil, ioCtx.Err() != nil) -} - -func TestObservedPluginRuntimeLeaseReportsOnlyOwnedSessionLoss(t *testing.T) { - const database = "sub2api_test_astra_lease_loss_20260921" - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - var currentDatabase, address string - var port int - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT current_database(),host(inet_server_addr()),inet_server_port()`).Scan(¤tDatabase, &address, &port)) - if currentDatabase != database { - t.Skip("lease session termination is restricted to the dedicated local investigation database") - } - require.Equal(t, "127.0.0.1", address) - require.Equal(t, 55437, port) - var count int - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT count(*) FROM pg_stat_activity WHERE datname=$1 AND application_name='sub2api-plugin-lease'`, database).Scan(&count)) - require.Zero(t, count) - repo, current, candidate := installedUpdateFixture(t) - lease, err := repo.HoldObservedPluginRuntime(service.WithPluginBusinessIOLease(ctx), current) - require.NoError(t, err) - defer lease.Release() - var pid int - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT pid FROM pg_stat_activity WHERE datname=$1 AND usename=current_user AND application_name='sub2api-plugin-lease'`, database).Scan(&pid)) - require.NoError(t, repo.StagePluginUpdate(ctx, current, candidate, service.PluginUpdatePinned)) - pending, err := repo.GetByID(ctx, current.ID) - require.NoError(t, err) - require.ErrorIs(t, repo.CommitPluginUpdate(ctx, pending, candidate), service.ErrPluginUpdateWaiting) - var terminated bool - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT pg_terminate_backend(pid,1000) FROM pg_stat_activity WHERE pid=$1 AND datname=$2 AND usename=current_user AND application_name='sub2api-plugin-lease'`, pid, database).Scan(&terminated)) - require.True(t, terminated) - // A PostgreSQL lock is already gone when its session ends. Cancellation is - // observable later; the detector must not pretend to order it before commit. - require.NoError(t, repo.CommitPluginUpdate(ctx, pending, candidate)) - observedBeforeCommit := false - select { - case <-lease.Done(): - observedBeforeCommit = true - default: - } - select { - case <-lease.Done(): - case <-time.After(3 * time.Second): - t.Fatal("dedicated-session loss was not observed by the configured monitor") - } - require.ErrorIs(t, lease.Err(), service.ErrPluginRuntimeLeaseLost) - released := make(chan struct{}) - go func() { lease.Release(); close(released) }() - select { - case <-released: - case <-time.After(time.Second): - t.Fatal("observed loss could not finish Release") - } - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT count(*) FROM pg_stat_activity WHERE datname=$1 AND application_name='sub2api-plugin-lease'`, database).Scan(&count)) - require.Zero(t, count, "the lost lease must not reconnect or re-acquire") - updated, err := repo.GetByID(ctx, current.ID) - require.NoError(t, err) - normal, err := repo.HoldObservedPluginRuntime(ctx, updated) - require.NoError(t, err) - normal.Release() - normal.Release() - require.NoError(t, normal.Err()) - select { - case <-normal.Done(): - default: - t.Fatal("normal release left its observer open") - } - t.Logf("own_lease_pid=%d loss_observed_at_commit_return=%t loss_event_received=true release_completed=true normal_release_error=false reconnect_count=0", pid, observedBeforeCommit) -} diff --git a/backend/internal/repository/plugin_update.go b/backend/internal/repository/plugin_update.go deleted file mode 100644 index c7b584bea8fa..000000000000 --- a/backend/internal/repository/plugin_update.go +++ /dev/null @@ -1,409 +0,0 @@ -package repository - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "sync" - "time" - - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/stdlib" -) - -func pluginRuntimeLockName(id int64) string { return fmt.Sprintf("sub2api-plugin-runtime:%d", id) } - -// A detector, not a fencing grace period: PostgreSQL can release a dead -// session's advisory lock before this interval/timeout observes the loss. -const ( - pluginRuntimeLeaseProbeInterval = time.Second - pluginRuntimeLeaseProbeTimeout = time.Second -) - -type pluginRuntimeLeaseSession interface { - Ping(context.Context) error - Close(context.Context) error -} - -type pluginRuntimeSessionLease struct { - conn pluginRuntimeLeaseSession - done chan struct{} - stopped chan struct{} - cancel context.CancelFunc - releaseOnce sync.Once - mu sync.RWMutex - releasing bool - err error -} - -func newPluginRuntimeSessionLease(conn pluginRuntimeLeaseSession, interval, timeout time.Duration) *pluginRuntimeSessionLease { - ctx, cancel := context.WithCancel(context.Background()) - lease := &pluginRuntimeSessionLease{conn: conn, done: make(chan struct{}), stopped: make(chan struct{}), cancel: cancel} - go lease.monitor(ctx, interval, timeout) - return lease -} - -func (l *pluginRuntimeSessionLease) Done() <-chan struct{} { return l.done } -func (l *pluginRuntimeSessionLease) Err() error { - l.mu.RLock() - defer l.mu.RUnlock() - return l.err -} -func (l *pluginRuntimeSessionLease) Release() { - l.releaseOnce.Do(func() { - l.mu.Lock() - l.releasing = true - l.mu.Unlock() - l.cancel() - }) - <-l.stopped -} - -func (l *pluginRuntimeSessionLease) monitor(ctx context.Context, interval, timeout time.Duration) { - var lost bool - defer func() { - l.cancel() - l.mu.Lock() - if lost && !l.releasing { - l.err = service.ErrPluginRuntimeLeaseLost - } - close(l.done) - l.mu.Unlock() - // This goroutine alone owns Ping/Close. Done is published before the - // bounded close so callers can revoke work without waiting for IO. - closeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - _ = l.conn.Close(closeCtx) - close(l.stopped) - }() - ticker := time.NewTicker(interval) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - } - probeCtx, cancel := context.WithTimeout(ctx, timeout) - err := l.conn.Ping(probeCtx) - cancel() - if err != nil { - lost = true - return - } - } -} - -// While its session is healthy, each live owner holds a shared advisory lock. -// Replacement requires the exclusive lock; the observed port additionally -// notifies owners when the session is lost rather than deliberately released. -func (r *pluginRepository) HoldPluginRuntime(ctx context.Context, installation *service.PluginInstallation) (func(), error) { - lease, err := r.HoldObservedPluginRuntime(ctx, installation) - if err != nil { - return nil, err - } - return lease.Release, nil -} - -func (r *pluginRepository) HoldObservedPluginRuntime(ctx context.Context, installation *service.PluginInstallation) (service.PluginRuntimeLease, error) { - pooled, err := r.db.Conn(ctx) - if err != nil { - return nil, err - } - var config *pgx.ConnConfig - err = pooled.Raw(func(raw any) error { - conn, ok := raw.(*stdlib.Conn) - if !ok { - return errors.New("plugin runtime leases require PostgreSQL") - } - config = conn.Conn().Config().Copy() - return nil - }) - _ = pooled.Close() - if err != nil { - return nil, err - } - // A process-lifetime session must not occupy the business query pool: a - // small configured pool would deadlock during startup or package replacement. - if config.RuntimeParams == nil { - config.RuntimeParams = map[string]string{} - } - config.RuntimeParams["application_name"] = "sub2api-plugin-lease" - conn, err := pgx.ConnectConfig(ctx, config) - if err != nil { - return nil, err - } - var locked bool - err = conn.QueryRow(ctx, `SELECT pg_try_advisory_lock_shared(hashtextextended($1,0))`, pluginRuntimeLockName(installation.ID)).Scan(&locked) - if err != nil || !locked { - _ = conn.Close(ctx) - if err == nil { - err = service.ErrPluginUpdateWaiting - } - return nil, err - } - var generation, revision int64 - var state, digest, configEncrypted string - err = conn.QueryRow(ctx, `SELECT runtime_generation,state,package_sha256,revision,config_encrypted FROM sub2api_plugin_installations WHERE id=$1`, installation.ID).Scan(&generation, &state, &digest, &revision, &configEncrypted) - stale := err != nil || generation != installation.RuntimeGeneration || digest != installation.PackageSHA256 || state == service.PluginStateUpdating - if !stale && service.PluginBusinessIOLeaseRequired(ctx) { - // Business IO must be admitted against the exact current policy intent. - // Process-lifetime leases deliberately retain the older startup and - // disabled-plugin diagnostics contract above. - stale = state != service.PluginStateEnabled || revision != installation.Revision || configEncrypted != installation.ConfigEncrypted - } - if stale { - closeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - _ = conn.Close(closeCtx) - if err == nil { - err = service.ErrPluginStateChanged - } - return nil, err - } - return newPluginRuntimeSessionLease(conn, pluginRuntimeLeaseProbeInterval, pluginRuntimeLeaseProbeTimeout), nil -} - -func (r *pluginRepository) StagePluginUpdate(ctx context.Context, previous, candidate *service.PluginInstallation, policy string) error { - if policy != service.PluginUpdateBundled && policy != service.PluginUpdatePinned { - return errors.New("invalid plugin update policy") - } - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return err - } - defer func() { _ = tx.Rollback() }() - var generation int64 - // The disabled rows of an unfinished bundle are temporary, not activation - // intent. Explicit pinned installs no longer belong to that old journal. - err = tx.QueryRowContext(ctx, `UPDATE sub2api_plugin_installations p SET state='updating',last_error='',updated_at=NOW() - WHERE id=$1 AND revision=$2 AND package_sha256=$3 AND state<>'updating' - AND (update_policy='pinned' OR NOT EXISTS ( - SELECT 1 FROM sub2api_plugin_bootstrap b WHERE b.plugin_key=p.plugin_key AND (NOT b.completed OR NOT b.state_imported) - )) - RETURNING runtime_generation`, previous.ID, previous.Revision, previous.PackageSHA256).Scan(&generation) - if errors.Is(err, sql.ErrNoRows) { - return service.ErrPluginStateChanged - } - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, `INSERT INTO sub2api_plugin_updates(plugin_id,artifact_data,package_sha256,source_generation,update_policy) - VALUES($1,$2,$3,$4,$5)`, previous.ID, candidate.ArtifactData, candidate.PackageSHA256, generation, policy) - if err != nil { - return err - } - if err = cancelPluginAccountJobs(ctx, tx, previous.ID); err != nil { - return err - } - return tx.Commit() -} - -func (r *pluginRepository) PendingPluginArtifact(ctx context.Context, id int64) ([]byte, error) { - var artifact []byte - err := r.db.QueryRowContext(ctx, `SELECT u.artifact_data FROM sub2api_plugin_updates u JOIN sub2api_plugin_installations p ON p.id=u.plugin_id WHERE p.id=$1 AND p.state='updating'`, id).Scan(&artifact) - return artifact, err -} - -func (r *pluginRepository) CommitPluginUpdate(ctx context.Context, previous, candidate *service.PluginInstallation) (resultErr error) { - tx, err := r.beginPluginRegistryTx(ctx) - if err != nil { - return err - } - defer rollbackPluginRegistryTx(tx, &resultErr) - var locked bool - if err = tx.QueryRowContext(ctx, `SELECT pg_try_advisory_xact_lock(hashtextextended($1,0))`, pluginRuntimeLockName(previous.ID)).Scan(&locked); err != nil { - return err - } - if !locked { - return service.ErrPluginUpdateWaiting - } - var policy string - err = tx.QueryRowContext(ctx, `SELECT u.update_policy FROM sub2api_plugin_updates u JOIN sub2api_plugin_installations p ON p.id=u.plugin_id - WHERE p.id=$1 AND p.revision=$2 AND p.runtime_generation=u.source_generation AND p.state='updating' AND u.package_sha256=$3 - FOR UPDATE OF p,u`, previous.ID, previous.Revision, candidate.PackageSHA256).Scan(&policy) - if errors.Is(err, sql.ErrNoRows) { - return service.ErrPluginStateChanged - } - if err != nil { - return err - } - bindings := service.PluginReplacementBindings(previous.Bindings, candidate.Manifest) - state := service.PluginStateDisabled - for _, binding := range bindings { - if binding.Enabled { - state = service.PluginStateEnabled - } - } - manifest, err := json.Marshal(candidate.Manifest) - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, `UPDATE sub2api_plugin_installations SET name=$2,version=$3,description=$4,author=$5,manifest=$6::jsonb, - artifact_data=$7,artifact_path=$8,install_path=$9,binary_path=$10,binary_sha256=$11,package_sha256=$12, - signature_status=$13,update_policy=$14,state=$15,runtime_generation=runtime_generation+1,last_error='',updated_at=NOW() - WHERE id=$1`, previous.ID, candidate.Name, candidate.Version, candidate.Description, candidate.Author, manifest, candidate.ArtifactData, - candidate.ArtifactPath, candidate.InstallPath, candidate.BinaryPath, candidate.BinarySHA256, candidate.PackageSHA256, candidate.SignatureStatus, policy, state) - if err != nil { - return err - } - if err = replacePluginBindings(ctx, tx, previous.ID, bindings); err != nil { - return err - } - // Keep the original one-time import journal. An update never replays a seed. - raw, err := json.Marshal(bindings) - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, `UPDATE sub2api_plugin_bootstrap SET desired_bindings=$2::jsonb,desired_enabled=$3,updated_at=NOW() WHERE plugin_key=$1`, previous.PluginKey, raw, state == service.PluginStateEnabled) - if err != nil { - return err - } - if _, err = tx.ExecContext(ctx, `DELETE FROM sub2api_plugin_updates WHERE plugin_id=$1`, previous.ID); err != nil { - return err - } - return commitPluginRegistryTx(ctx, tx) -} - -func (r *pluginRepository) SetPluginUpdatePolicy(ctx context.Context, id, revision int64, policy string) error { - if policy != service.PluginUpdateBundled && policy != service.PluginUpdatePinned { - return errors.New("invalid plugin update policy") - } - result, err := r.db.ExecContext(ctx, `UPDATE sub2api_plugin_installations SET update_policy=$3,updated_at=NOW() - WHERE id=$1 AND revision=$2 AND state<>'updating'`, id, revision, policy) - if err != nil { - return err - } - rows, err := result.RowsAffected() - if err == nil && rows != 1 { - return service.ErrPluginStateChanged - } - return err -} - -// State publication shares the installation lock with package/config changes. -// A stale process cannot race a generation check and then commit its projection. -type pluginFenceQuerier interface { - QueryContext(context.Context, string, ...any) (*sql.Rows, error) -} - -func scanPluginFenceRow(ctx context.Context, tx pluginFenceQuerier, query string, args []any, destinations ...any) error { - rows, err := tx.QueryContext(ctx, query, args...) - if err != nil { - return err - } - defer func() { _ = rows.Close() }() - if !rows.Next() { - if err := rows.Err(); err != nil { - return err - } - return sql.ErrNoRows - } - if err := rows.Scan(destinations...); err != nil { - return err - } - if rows.Next() { - return errors.New("ambiguous plugin execution fence") - } - if err := rows.Err(); err != nil { - return err - } - return rows.Close() -} - -func lockPluginExecution(ctx context.Context, tx pluginFenceQuerier, plugin string) error { - fences, err := service.PluginExecutionFences(ctx, plugin) - if err != nil { - return err - } - for _, fence := range fences { - if fence.Primary { - var generation int64 - var state string - err := scanPluginFenceRow(ctx, tx, `SELECT runtime_generation,state FROM sub2api_plugin_installations WHERE id=$1 AND ($2='' OR plugin_key=$2) FOR SHARE`, []any{fence.ID, fence.PluginKey}, &generation, &state) - if err != nil { - return err - } - if generation != fence.Generation || state == service.PluginStateUpdating { - return service.ErrPluginStateChanged - } - } - if fence.OriginCreate { - if err := lockAccountCreatePolicy(ctx, tx, fence); err != nil { - return err - } - } - if fence.OriginEdit { - if err := lockAccountEditPolicy(ctx, tx, fence); err != nil { - return err - } - } - if fence.OriginView && !fence.OriginCreate && !fence.OriginEdit { - if err := lockOriginAccountView(ctx, tx, fence); err != nil { - return err - } - } - } - return nil -} - -func lockAccountEditPolicy(ctx context.Context, tx pluginFenceQuerier, fence service.PluginExecutionFence) error { - if err := lockOriginAccountView(ctx, tx, fence); err != nil { - if errors.Is(err, service.ErrAccountViewUnavailable) { - return service.ErrAccountEditUnavailable - } - return err - } - var manifest, bindingsJSON []byte - var config string - err := scanPluginFenceRow(ctx, tx, `SELECT p.manifest,p.config_encrypted, - COALESCE((SELECT jsonb_agg(jsonb_build_object('capability',b.capability,'platform',b.platform,'account_type',b.account_type,'enabled',b.enabled,'rollout_percent',b.rollout_percent)) - FROM sub2api_plugin_bindings b WHERE b.plugin_id=p.id),'[]'::jsonb) - FROM sub2api_plugin_installations p WHERE p.id=$1`, []any{fence.ID}, &manifest, &config, &bindingsJSON) - if err != nil { - return err - } - var bindings []service.PluginBinding - if json.Unmarshal(bindingsJSON, &bindings) != nil { - return service.ErrAccountEditUnavailable - } - return service.ValidateAccountEditFenceData(fence, manifest, config, bindings) -} - -// Account creation adds definition/config and IDless composite-capability checks -// under the existing shared row fence. sql.Tx and Ent use this one implementation. -func lockAccountCreatePolicy(ctx context.Context, tx pluginFenceQuerier, fence service.PluginExecutionFence) error { - if err := lockOriginAccountView(ctx, tx, fence); err != nil { - if errors.Is(err, service.ErrAccountViewUnavailable) { - return service.ErrAccountCreateUnavailable - } - return err - } - var manifest []byte - var config string - var fullProviderAdmin bool - err := scanPluginFenceRow(ctx, tx, `SELECT p.manifest,p.config_encrypted, - EXISTS (SELECT 1 FROM sub2api_plugin_bindings b WHERE b.plugin_id=p.id AND b.capability='extensions.provider.v1' AND b.enabled AND b.rollout_percent=100 AND b.platform IN ('*','cindy') AND b.account_type IN ('*','apikey')) AND - EXISTS (SELECT 1 FROM sub2api_plugin_bindings b WHERE b.plugin_id=p.id AND b.capability='extensions.admin.v1' AND b.enabled AND b.rollout_percent=100 AND b.platform IN ('*','cindy') AND b.account_type IN ('*','apikey')) - FROM sub2api_plugin_installations p WHERE p.id=$1`, []any{fence.ID}, &manifest, &config, &fullProviderAdmin) - if err != nil { - return err - } - return service.ValidateAccountCreateFenceData(fence, manifest, config, fullProviderAdmin) -} - -var _ service.PluginUpdateRepository = (*pluginRepository)(nil) -var _ service.PluginRuntimeLocker = (*pluginRepository)(nil) -var _ service.ObservedPluginRuntimeLocker = (*pluginRepository)(nil) - -func (r *pluginRepository) CompletedBundledPlugin(ctx context.Context, key string) (bool, error) { - var completed bool - err := r.db.QueryRowContext(ctx, `SELECT completed AND state_imported FROM sub2api_plugin_bootstrap WHERE plugin_key=$1`, key).Scan(&completed) - if errors.Is(err, sql.ErrNoRows) { - return false, nil - } - return completed, err -} diff --git a/backend/internal/repository/plugin_update_integration_test.go b/backend/internal/repository/plugin_update_integration_test.go deleted file mode 100644 index 161647e95578..000000000000 --- a/backend/internal/repository/plugin_update_integration_test.go +++ /dev/null @@ -1,168 +0,0 @@ -//go:build integration - -package repository - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "testing" - "time" - - "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/stretchr/testify/require" -) - -func installedUpdateFixture(t *testing.T) (*pluginRepository, *service.PluginInstallation, *service.PluginInstallation) { - t.Helper() - ctx := context.Background() - repo := &pluginRepository{db: integrationDB} - artifact := bundleFixture(t, repo) - installed, err := repo.PrepareBundledPlugin(ctx, artifact, "update-fixture", "", true, "saved-config") - require.NoError(t, err) - require.NoError(t, repo.CompleteBundledPlugin(ctx, installed.ID, "update-fixture")) - current, err := repo.GetByID(ctx, installed.ID) - require.NoError(t, err) - current.Bindings[0].RolloutPercent = 37 - require.NoError(t, repo.UpdateBindingsAndState(ctx, current.ID, current.Bindings, service.PluginStateEnabled, "", nil, current.State, current.BinarySHA256)) - current, err = repo.GetByID(ctx, current.ID) - require.NoError(t, err) - candidate := *artifact - candidate.Version = "1.0.1" - candidate.ArtifactData = []byte("new program and UI resource set") - sum := sha256.Sum256(candidate.ArtifactData) - candidate.PackageSHA256 = hex.EncodeToString(sum[:]) - candidate.Manifest.Capabilities = append(append([]service.PluginCapability(nil), artifact.Manifest.Capabilities...), service.PluginCapability{ID: extensionv1.CapabilityUI, Platform: "*", AccountType: "*"}) - return repo, current, &candidate -} - -func TestPluginIndependentUpdateWaitsForAllOldProcessesAndPreservesIntent(t *testing.T) { - ctx := context.Background() - repo, current, candidate := installedUpdateFixture(t) - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - // Process locks cannot monopolize a small application's SQL query pool. - previousMax := integrationDB.Stats().MaxOpenConnections - integrationDB.SetMaxOpenConns(1) - defer integrationDB.SetMaxOpenConns(previousMax) - releaseOne, err := repo.HoldPluginRuntime(ctx, current) - require.NoError(t, err) - defer releaseOne() - releaseTwo, err := repo.HoldPluginRuntime(ctx, current) - require.NoError(t, err) - defer releaseTwo() - require.NoError(t, repo.StagePluginUpdate(ctx, current, candidate, service.PluginUpdatePinned)) - pending, err := repo.GetByID(ctx, current.ID) - require.NoError(t, err) - require.Equal(t, service.PluginStateUpdating, pending.State) - require.ErrorIs(t, repo.CommitPluginUpdate(ctx, pending, candidate), service.ErrPluginUpdateWaiting) - releaseOne() - require.ErrorIs(t, repo.CommitPluginUpdate(ctx, pending, candidate), service.ErrPluginUpdateWaiting) - releaseTwo() - // Client close can precede PostgreSQL releasing both session locks. Keep - // the original deadline and retry only the server-side draining barrier. - poll := time.NewTicker(10 * time.Millisecond) - defer poll.Stop() - for { - err = repo.CommitPluginUpdate(ctx, pending, candidate) - if err == nil { - break - } - require.ErrorIs(t, err, service.ErrPluginUpdateWaiting, "only the server-side release barrier may delay promotion") - select { - case <-ctx.Done(): - t.Fatal("PostgreSQL did not release the promotion barrier within the original deadline") - case <-poll.C: - } - } - updated, err := repo.GetByID(ctx, current.ID) - require.NoError(t, err) - require.Equal(t, "saved-config", updated.ConfigEncrypted) - require.Equal(t, current.RuntimeGeneration+1, updated.RuntimeGeneration) - require.Equal(t, candidate.Version, updated.Version) - require.Equal(t, candidate.PackageSHA256, updated.PackageSHA256) - require.Equal(t, service.PluginUpdatePinned, updated.UpdatePolicy) - for _, binding := range updated.Bindings { - if binding.Capability == extensionv1.CapabilityAdmin { - require.True(t, binding.Enabled) - require.Equal(t, 37, binding.RolloutPercent) - } else { - require.False(t, binding.Enabled) - } - } - // The program bytes intentionally did not change: UI-only releases must - // still revoke the old process generation and survive a future host bundle. - require.Equal(t, current.BinarySHA256, updated.BinarySHA256) - _, err = repo.HoldPluginRuntime(ctx, current) - require.ErrorIs(t, err, service.ErrPluginStateChanged) - applied, err := repo.BundleApplied(ctx, current.PluginKey, "new-host-bundle") - require.NoError(t, err) - require.True(t, applied) - require.NoError(t, repo.SetPluginUpdatePolicy(ctx, current.ID, updated.Revision, service.PluginUpdateBundled)) - applied, err = repo.BundleApplied(ctx, current.PluginKey, "new-host-bundle") - require.NoError(t, err) - require.False(t, applied) -} - -func TestPluginIndependentUpdateRejectsConcurrentConfigurationAndResumesAfterRestart(t *testing.T) { - ctx := context.Background() - repo, current, candidate := installedUpdateFixture(t) - require.NoError(t, repo.UpdateConfig(ctx, current.ID, "edited-config", current.BinarySHA256)) - require.ErrorIs(t, repo.StagePluginUpdate(ctx, current, candidate, service.PluginUpdatePinned), service.ErrPluginStateChanged) - fresh, err := repo.GetByID(ctx, current.ID) - require.NoError(t, err) - require.Greater(t, fresh.Revision, current.Revision) - require.NoError(t, repo.StagePluginUpdate(ctx, fresh, candidate, service.PluginUpdatePinned)) - // A newly constructed repository models recovery without any process memory. - restarted := &pluginRepository{db: integrationDB} - artifact, err := restarted.PendingPluginArtifact(ctx, current.ID) - require.NoError(t, err) - require.Equal(t, candidate.ArtifactData, artifact) - pending, err := restarted.GetByID(ctx, current.ID) - require.NoError(t, err) - require.ErrorIs(t, restarted.UpdateConfig(ctx, current.ID, "late-config", current.BinarySHA256), service.ErrPluginStateChanged) - require.NoError(t, restarted.CommitPluginUpdate(ctx, pending, candidate)) - updated, err := restarted.GetByID(ctx, current.ID) - require.NoError(t, err) - require.Equal(t, "edited-config", updated.ConfigEncrypted) -} - -func TestPluginIndependentUpdatePreservesExplicitDisableAndRejectsOldWrites(t *testing.T) { - ctx := context.Background() - repo, current, candidate := installedUpdateFixture(t) - oldCtx := service.WithPluginExecution(ctx, current) - request := extensionv1.StateRequest{Namespace: "update-test", Key: "record", Value: []byte(`{"value":"old"}`)} - state, err := repo.CompareSwapExtensionState(oldCtx, current.PluginKey, request) - require.NoError(t, err) - require.True(t, state.Applied) - request.ExpectedRevision = state.Revision - require.NoError(t, repo.StagePluginUpdate(ctx, current, candidate, service.PluginUpdatePinned)) - _, err = repo.CompareSwapExtensionState(oldCtx, current.PluginKey, request) - require.ErrorIs(t, err, service.ErrPluginStateChanged) - pending, err := repo.GetByID(ctx, current.ID) - require.NoError(t, err) - for i := range pending.Bindings { - pending.Bindings[i].Enabled = false - } - require.NoError(t, repo.UpdateBindingsAndState(service.WithPluginExpectedRevision(ctx, pending.Revision), pending.ID, pending.Bindings, service.PluginStateDisabled, "", nil, "", pending.BinarySHA256)) - require.ErrorIs(t, repo.CommitPluginUpdate(ctx, pending, candidate), service.ErrPluginStateChanged) - pending, err = repo.GetByID(ctx, current.ID) - require.NoError(t, err) - require.Equal(t, service.PluginStateUpdating, pending.State) - require.NoError(t, repo.CommitPluginUpdate(ctx, pending, candidate)) - updated, err := repo.GetByID(ctx, current.ID) - require.NoError(t, err) - require.Equal(t, service.PluginStateDisabled, updated.State) - for _, binding := range updated.Bindings { - require.False(t, binding.Enabled) - } - _, err = repo.CompareSwapExtensionState(oldCtx, current.PluginKey, request) - require.ErrorIs(t, err, service.ErrPluginStateChanged) - _, err = repo.AcquireExtensionLease(oldCtx, current.PluginKey, extensionv1.LeaseRequest{Namespace: "update-test", Key: "old-work", Owner: "old-worker", TTLSeconds: 30}) - require.ErrorIs(t, err, service.ErrPluginStateChanged) - newCtx := service.WithPluginExecution(ctx, updated) - state, err = repo.CompareSwapExtensionState(newCtx, current.PluginKey, request) - require.NoError(t, err) - require.True(t, state.Applied) -} diff --git a/backend/internal/repository/scheduler_cache.go b/backend/internal/repository/scheduler_cache.go index 96423958d79e..ecfb2451c920 100644 --- a/backend/internal/repository/scheduler_cache.go +++ b/backend/internal/repository/scheduler_cache.go @@ -1110,7 +1110,7 @@ func filterSchedulerExtra(extra map[string]any) map[string]any { "codex_secondary_observed_at", "openai_quota_exhausted", "openai_quota_status", - service.PluginAccountProjectionKey, + service.NativeCodexAccountProjectionKey, "auto_pause_5h_threshold", "auto_pause_7d_threshold", "auto_pause_5h_disabled", diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index 4d6ef927bd90..32c6919bee66 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -92,6 +92,8 @@ var ProviderSet = wire.NewSet( NewUsageCleanupRepository, NewDashboardAggregationRepository, NewSettingRepository, + NewNativeFeatureBootstrapRepository, + NewNativeCodexRepository, NewOpsRepository, NewAuditLogRepository, NewPasskeyRepository, diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index eb25f584e451..19fa03062112 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -956,8 +956,18 @@ func TestAPIContracts(t *testing.T) { "config_source": "cindy_group_managed", "web_search": { "enabled": false, - "verified_text_models": null, - "compatibility_aliases": null, + "verified_text_models": [ + "deepseek-v4-flash", + "deepseek-v4-flash-vision-exp", + "deepseek-v4-pro", + "gemini-3.6-flash", + "glm-5.3-flash", + "gpt-5.6-luna", + "hy3", + "qwen3.8-27b", + "qwen3.8-flash" + ], + "compatibility_aliases": {"gpt-5.4-mini": "gpt-5.6-luna"}, "primary_path": "responses_web_search", "fallback_path": "messages_cindy_web_search" }, @@ -1293,8 +1303,18 @@ func TestAPIContracts(t *testing.T) { "config_source": "cindy_group_managed", "web_search": { "enabled": false, - "verified_text_models": null, - "compatibility_aliases": null, + "verified_text_models": [ + "deepseek-v4-flash", + "deepseek-v4-flash-vision-exp", + "deepseek-v4-pro", + "gemini-3.6-flash", + "glm-5.3-flash", + "gpt-5.6-luna", + "hy3", + "qwen3.8-27b", + "qwen3.8-flash" + ], + "compatibility_aliases": {"gpt-5.4-mini": "gpt-5.6-luna"}, "primary_path": "responses_web_search", "fallback_path": "messages_cindy_web_search" }, @@ -1532,7 +1552,7 @@ func newContractDeps(t *testing.T) *contractDeps { authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, redeemService, nil, nil) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) usageHandler := handler.NewUsageHandler(usageService, apiKeyService, nil, nil) - // This contract fixture has no provider runtime, so plugin catalog fields stay null. + // The native Cindy catalog remains available even when Search is disabled. adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil, nil, nil, nil, nil) adminAccountHandler := adminhandler.NewAccountHandler(adminService, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) adminAccountHandler.SetAccountJobService(service.NewAccountJobService(&contractAccountJobRepo{now: now}, contractAccountJobCipher{})) diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index c1fc86e56b60..da216e20750f 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -6,7 +6,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/gin-gonic/gin" ) @@ -31,7 +30,6 @@ func RegisterAdminRoutes( // 审计中间件挂在认证之后:所有管理面变更类操作 + 敏感读取入审计日志 admin.Use(gin.HandlerFunc(auditLog)) admin.Use(middleware.AdminComplianceGuard(settingService)) - admin.Use(h.Admin.Plugin.AccountViewRequest()) { // 部署与运营合规确认 registerAdminComplianceRoutes(admin, h) @@ -78,7 +76,7 @@ func RegisterAdminRoutes( registerPromoCodeRoutes(admin, h) // 系统设置 - registerSettingsRoutes(admin, h) + registerSettingsRoutes(admin, h, stepUpAuth) // 数据管理 registerDataManagementRoutes(admin, h, stepUpAuth) @@ -154,100 +152,65 @@ func registerAccountJobRoutes(admin *gin.RouterGroup, h *handler.Handlers) { func registerCindyBalanceProbeRoutes(admin *gin.RouterGroup, h *handler.Handlers) { jobs := admin.Group("/cindy/balance-probe-jobs") - for _, route := range []struct { - name, method, path string - retained, scope bool - handler gin.HandlerFunc - }{ - {"cindy.probe.list", "GET", "", true, false, h.Admin.CindyBalanceProbe.List}, - {"cindy.probe.create", "POST", "", false, true, h.Admin.CindyBalanceProbe.Create}, - {"cindy.probe.preview", "POST", "/preview", false, true, h.Admin.CindyBalanceProbe.Preview}, - {"cindy.probe.get", "GET", "/:id", true, false, h.Admin.CindyBalanceProbe.Get}, - {"cindy.probe.items", "GET", "/:id/items", true, false, h.Admin.CindyBalanceProbe.ListItems}, - {"cindy.probe.rate", "PATCH", "/:id/rate", true, false, h.Admin.CindyBalanceProbe.SetRate}, - {"cindy.probe.pause", "POST", "/:id/pause", true, false, h.Admin.CindyBalanceProbe.Pause}, - {"cindy.probe.resume", "POST", "/:id/resume", false, false, h.Admin.CindyBalanceProbe.Resume}, - {"cindy.probe.cancel", "POST", "/:id/cancel", true, false, h.Admin.CindyBalanceProbe.Cancel}, - } { - descriptor := extensionv1.ResourceDescriptor{ResourceGrant: extensionv1.ResourceGrant{Name: route.name, Capability: extensionv1.CapabilityProvider, Permission: "admin"}, Method: route.method, Path: jobs.BasePath() + route.path, Retained: route.retained} - if route.scope { - descriptor.AccountScopeField, descriptor.FilterPlatform, descriptor.FilterAccountType = "scope", service.PlatformCindy, service.AccountTypeAPIKey - } - jobs.Handle(route.method, route.path, h.Admin.Plugin.RegisterResource(descriptor), route.handler) + { + jobs.GET("", h.Admin.CindyBalanceProbe.List) + jobs.POST("", h.Admin.CindyBalanceProbe.Create) + jobs.POST("/preview", h.Admin.CindyBalanceProbe.Preview) + jobs.GET("/:id", h.Admin.CindyBalanceProbe.Get) + jobs.GET("/:id/items", h.Admin.CindyBalanceProbe.ListItems) + jobs.PATCH("/:id/rate", h.Admin.CindyBalanceProbe.SetRate) + jobs.POST("/:id/pause", h.Admin.CindyBalanceProbe.Pause) + jobs.POST("/:id/resume", h.Admin.CindyBalanceProbe.Resume) + jobs.POST("/:id/cancel", h.Admin.CindyBalanceProbe.Cancel) } } func registerSystemPromptRoutes(admin *gin.RouterGroup, h *handler.Handlers) { prompts := admin.Group("/system-prompts") - for _, route := range []struct { - name, method, path string - handler gin.HandlerFunc - }{ - {"prompts.list", "GET", "", h.Admin.SystemPrompt.List}, - {"prompts.create", "POST", "", h.Admin.SystemPrompt.Create}, - {"prompts.runtime.read", "GET", "/runtime", h.Admin.SystemPrompt.Runtime}, - {"prompts.runtime.update", "PUT", "/runtime", h.Admin.SystemPrompt.UpdateRuntime}, - {"prompts.rules.read", "GET", "/rules", h.Admin.SystemPrompt.Rules}, - {"prompts.rules.update", "PUT", "/rules", h.Admin.SystemPrompt.UpdateRules}, - {"prompts.bindings.read", "POST", "/accounts/resolve", h.Admin.SystemPrompt.AccountBindings}, - {"prompts.bindings.update", "POST", "/accounts/bindings", h.Admin.SystemPrompt.UpdateAccountBindings}, - {"prompts.rules.preview", "POST", "/rules/preview/:account_id", h.Admin.SystemPrompt.PreviewRules}, - {"prompts.preview.merge", "POST", "/preview/merge", h.Admin.SystemPrompt.PreviewMerge}, - {"prompts.preview.upstream", "POST", "/preview/upstream", h.Admin.SystemPrompt.PreviewUpstream}, - {"skills.registry", "GET", "/skill-registry", h.Admin.SystemPrompt.SkillRegistry}, - {"skills.versions", "GET", "/skill-registry/versions", h.Admin.SystemPrompt.SkillVersions}, - {"skills.version", "GET", "/skill-registry/versions/:bundle_version_id", h.Admin.SystemPrompt.SkillVersion}, - {"skills.sync.start", "POST", "/skill-registry/syncs", h.Admin.SystemPrompt.StartSkillSync}, - {"skills.sync.read", "GET", "/skill-registry/syncs/:sync_id", h.Admin.SystemPrompt.SkillSync}, - {"skills.publish", "POST", "/skill-registry/versions/:bundle_version_id/publish", h.Admin.SystemPrompt.PublishSkillVersion}, - {"skills.rollback", "POST", "/skill-registry/versions/:bundle_version_id/rollback", h.Admin.SystemPrompt.RollbackSkillVersion}, - {"prompts.read", "GET", "/:id", h.Admin.SystemPrompt.Get}, - {"prompts.versions", "GET", "/:id/versions", h.Admin.SystemPrompt.Versions}, - {"prompts.update", "PATCH", "/:id", h.Admin.SystemPrompt.Update}, - {"prompts.delete", "DELETE", "/:id", h.Admin.SystemPrompt.Delete}, - {"prompts.duplicate", "POST", "/:id/duplicate", h.Admin.SystemPrompt.Duplicate}, - {"prompts.draft", "POST", "/:id/versions", h.Admin.SystemPrompt.SaveVersion}, - {"prompts.source.sync", "POST", "/:id/upstream-sync", h.Admin.SystemPrompt.SyncManagedSource}, - {"prompts.publish", "POST", "/:id/versions/:version_id/publish", h.Admin.SystemPrompt.Publish}, - {"prompts.rollback", "POST", "/:id/versions/:version_id/rollback", h.Admin.SystemPrompt.Rollback}, - } { - descriptor := extensionv1.ResourceDescriptor{ResourceGrant: extensionv1.ResourceGrant{Name: route.name, Capability: extensionv1.CapabilityRequest, Permission: "admin"}, Method: route.method, Path: prompts.BasePath() + route.path} - if route.name == "prompts.bindings.read" { - descriptor.AccountBodyField = "account_ids" - } - if route.name == "prompts.bindings.update" { - descriptor.AccountItemsField = "updates" - } - if route.name == "prompts.rules.preview" { - descriptor.AccountParam = "account_id" - } - if route.name == "prompts.bindings.read" || route.name == "prompts.bindings.update" || route.name == "prompts.rules.preview" { - descriptor.Capability = extensionv1.CapabilityAdmin - } - prompts.Handle(route.method, route.path, h.Admin.Plugin.RegisterResource(descriptor), route.handler) + { + prompts.GET("", h.Admin.SystemPrompt.List) + prompts.POST("", h.Admin.SystemPrompt.Create) + prompts.GET("/runtime", h.Admin.SystemPrompt.Runtime) + prompts.PUT("/runtime", h.Admin.SystemPrompt.UpdateRuntime) + prompts.GET("/rules", h.Admin.SystemPrompt.Rules) + prompts.PUT("/rules", h.Admin.SystemPrompt.UpdateRules) + prompts.POST("/accounts/resolve", h.Admin.SystemPrompt.AccountBindings) + prompts.POST("/accounts/bindings", h.Admin.SystemPrompt.UpdateAccountBindings) + prompts.POST("/rules/preview/:account_id", h.Admin.SystemPrompt.PreviewRules) + prompts.POST("/preview/merge", h.Admin.SystemPrompt.PreviewMerge) + prompts.POST("/preview/upstream", h.Admin.SystemPrompt.PreviewUpstream) + prompts.GET("/skill-registry", h.Admin.SystemPrompt.SkillRegistry) + prompts.GET("/skill-registry/versions", h.Admin.SystemPrompt.SkillVersions) + prompts.GET("/skill-registry/versions/:bundle_version_id", h.Admin.SystemPrompt.SkillVersion) + prompts.POST("/skill-registry/syncs", h.Admin.SystemPrompt.StartSkillSync) + prompts.GET("/skill-registry/syncs/:sync_id", h.Admin.SystemPrompt.SkillSync) + prompts.POST("/skill-registry/versions/:bundle_version_id/publish", h.Admin.SystemPrompt.PublishSkillVersion) + prompts.POST("/skill-registry/versions/:bundle_version_id/rollback", h.Admin.SystemPrompt.RollbackSkillVersion) + prompts.GET("/:id", h.Admin.SystemPrompt.Get) + prompts.GET("/:id/versions", h.Admin.SystemPrompt.Versions) + prompts.PATCH("/:id", h.Admin.SystemPrompt.Update) + prompts.DELETE("/:id", h.Admin.SystemPrompt.Delete) + prompts.POST("/:id/duplicate", h.Admin.SystemPrompt.Duplicate) + prompts.POST("/:id/versions", h.Admin.SystemPrompt.SaveVersion) + prompts.POST("/:id/upstream-sync", h.Admin.SystemPrompt.SyncManagedSource) + prompts.POST("/:id/versions/:version_id/publish", h.Admin.SystemPrompt.Publish) + prompts.POST("/:id/versions/:version_id/rollback", h.Admin.SystemPrompt.Rollback) } } func registerPromptAuditRoutes(admin *gin.RouterGroup, h *handler.Handlers) { promptAudit := admin.Group("/prompt-audit") - for _, route := range []struct { - method, path, name string - handler gin.HandlerFunc - }{ - {"GET", "/config", "config", h.Admin.PromptAudit.GetConfig}, - {"PUT", "/config", "update", h.Admin.PromptAudit.UpdateConfig}, - {"POST", "/endpoints/probe", "probe", h.Admin.PromptAudit.ProbeEndpoint}, - {"GET", "/runtime", "runtime", h.Admin.PromptAudit.GetRuntime}, - {"GET", "/events", "events", h.Admin.PromptAudit.ListEvents}, - {"GET", "/events/:id", "event", h.Admin.PromptAudit.GetEvent}, - {"DELETE", "/events/:id", "delete", h.Admin.PromptAudit.DeleteEvent}, - {"POST", "/events/batch-delete", "batch-delete", h.Admin.PromptAudit.BatchDelete}, - {"POST", "/events/delete-preview", "delete-preview", h.Admin.PromptAudit.DeletePreview}, - {"POST", "/events/delete-by-filter", "delete-by-filter", h.Admin.PromptAudit.DeleteByFilter}, - {"GET", "/groups", "groups", h.Admin.Group.PluginOptions}, - } { - descriptor := extensionv1.ResourceDescriptor{ResourceGrant: extensionv1.ResourceGrant{Name: "prompt-audit." + route.name, Capability: extensionv1.CapabilityUI, Permission: "admin"}, Method: route.method, Path: promptAudit.BasePath() + route.path, AllAccounts: true} - promptAudit.Handle(route.method, route.path, h.Admin.Plugin.RegisterResource(descriptor), route.handler) + { + promptAudit.GET("/config", h.Admin.PromptAudit.GetConfig) + promptAudit.PUT("/config", h.Admin.PromptAudit.UpdateConfig) + promptAudit.POST("/endpoints/probe", h.Admin.PromptAudit.ProbeEndpoint) + promptAudit.GET("/runtime", h.Admin.PromptAudit.GetRuntime) + promptAudit.GET("/events", h.Admin.PromptAudit.ListEvents) + promptAudit.GET("/events/:id", h.Admin.PromptAudit.GetEvent) + promptAudit.DELETE("/events/:id", h.Admin.PromptAudit.DeleteEvent) + promptAudit.POST("/events/batch-delete", h.Admin.PromptAudit.BatchDelete) + promptAudit.POST("/events/delete-preview", h.Admin.PromptAudit.DeletePreview) + promptAudit.POST("/events/delete-by-filter", h.Admin.PromptAudit.DeleteByFilter) } } @@ -343,7 +306,7 @@ func registerOpsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { // Error logs (legacy) ops.GET("/errors", h.Admin.Ops.GetErrorLogs) ops.GET("/errors/:id", h.Admin.Ops.GetErrorLogByID) - ops.GET("/errors/:id/diagnostics", h.Admin.Plugin.RegisterResource(extensionv1.ResourceDescriptor{ResourceGrant: extensionv1.ResourceGrant{Name: "ops.error.diagnostics", Capability: extensionv1.CapabilityRecovery, Permission: "admin"}, Method: "GET", Path: ops.BasePath() + "/errors/:id/diagnostics"}), h.Admin.Plugin.ErrorDiagnostics(h.Admin.Ops)) + ops.GET("/errors/:id/diagnostics", h.Admin.Account.CodexErrorDiagnostics(h.Admin.Ops)) ops.PUT("/errors/:id/resolve", h.Admin.Ops.UpdateErrorResolution) // Request errors (client-visible failures) @@ -429,17 +392,11 @@ func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) { func registerGroupRoutes(admin *gin.RouterGroup, h *handler.Handlers) { cindyGroups := admin.Group("/cindy/groups") - for _, route := range []struct { - name, method, path string - handler gin.HandlerFunc - }{ - {"cindy.groups.audit", "GET", "/audit", h.Admin.Group.AuditCindyGroups}, - {"cindy.groups.keys", "GET", "/:id/keys", h.Admin.Group.CindyGroupKeyChoices}, - {"cindy.groups.preview", "POST", "/:id/split-preview", h.Admin.Group.PreviewCindyGroupSplit}, - {"cindy.groups.split", "POST", "/:id/split", h.Admin.Group.SplitCindyGroup}, - } { - descriptor := extensionv1.ResourceDescriptor{ResourceGrant: extensionv1.ResourceGrant{Name: route.name, Capability: extensionv1.CapabilityProvider, Permission: "admin"}, Method: route.method, Path: cindyGroups.BasePath() + route.path} - cindyGroups.Handle(route.method, route.path, h.Admin.Plugin.RegisterResource(descriptor), route.handler) + { + cindyGroups.GET("/audit", h.Admin.Group.AuditCindyGroups) + cindyGroups.GET("/:id/keys", h.Admin.Group.CindyGroupKeyChoices) + cindyGroups.POST("/:id/split-preview", h.Admin.Group.PreviewCindyGroupSplit) + cindyGroups.POST("/:id/split", h.Admin.Group.SplitCindyGroup) } groups := admin.Group("/groups") @@ -482,21 +439,25 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu accounts.GET("/ollama-cloud-usage/settings", h.Admin.Account.GetOllamaCloudUsageSettings) accounts.PUT("/ollama-cloud-usage/settings", h.Admin.Account.UpdateOllamaCloudUsageSettings) accounts.GET("/facets", h.Admin.Account.GetAccountFacets) - registerAccountToolResources(accounts, h) - for _, route := range []struct { - name, method, path string - handler gin.HandlerFunc - }{ - {"cindy.cleanup.insufficient.preview", "GET", "/cindy/insufficient-delete-preview", h.Admin.Account.PreviewCindyInsufficientDeletion}, - {"cindy.cleanup.insufficient.submit", "POST", "/cindy/delete-insufficient", h.Admin.Account.DeleteCindyInsufficient}, - {"cindy.cleanup.banned.preview", "GET", "/cindy/banned-delete-preview", h.Admin.Account.PreviewCindyBannedDeletion}, - {"cindy.cleanup.banned.submit", "POST", "/cindy/delete-banned", h.Admin.Account.DeleteCindyBanned}, - } { - descriptor := service.CindyCleanupResourcePolicy() - descriptor.Name, descriptor.Method, descriptor.Path = route.name, route.method, accounts.BasePath()+route.path - accounts.Handle(route.method, route.path, h.Admin.Plugin.RegisterResource(descriptor), route.handler) - } - accounts.GET("/cindy/duplicate-identity-inventory", h.Admin.Plugin.RegisterResource(extensionv1.ResourceDescriptor{ResourceGrant: extensionv1.ResourceGrant{Name: "cindy.duplicates", Capability: extensionv1.CapabilityProvider, Permission: "admin"}, Method: "GET", Path: accounts.BasePath() + "/cindy/duplicate-identity-inventory"}), h.Admin.Account.GetCindyDuplicateIdentityInventory) + accounts.GET("/folders", h.Admin.Account.ListAccountFolders) + accounts.POST("/folders", h.Admin.Account.CreateAccountFolder) + accounts.PUT("/folders/order", h.Admin.Account.ReorderAccountFolders) + accounts.PUT("/folders/:id", h.Admin.Account.UpdateAccountFolder) + accounts.DELETE("/folders/:id", h.Admin.Account.DeleteAccountFolder) + accounts.GET("/tags", h.Admin.Account.ListAccountTags) + accounts.POST("/tags", h.Admin.Account.CreateAccountTag) + accounts.PUT("/tags/order", h.Admin.Account.ReorderAccountTags) + accounts.PUT("/tags/:id", h.Admin.Account.UpdateAccountTag) + accounts.DELETE("/tags/:id", h.Admin.Account.DeleteAccountTag) + accounts.PUT("/:id/taxonomy", h.Admin.Account.SetAccountTaxonomy) + accounts.POST("/bulk-taxonomy", h.Admin.Account.BulkUpdateAccountTaxonomy) + accounts.POST("/batch-test-models", h.Admin.Account.BatchTestModels) + accounts.POST("/batch-test", h.Admin.Account.BatchTest) + accounts.GET("/cindy/insufficient-delete-preview", h.Admin.Account.PreviewCindyInsufficientDeletion) + accounts.POST("/cindy/delete-insufficient", h.Admin.Account.DeleteCindyInsufficient) + accounts.GET("/cindy/banned-delete-preview", h.Admin.Account.PreviewCindyBannedDeletion) + accounts.POST("/cindy/delete-banned", h.Admin.Account.DeleteCindyBanned) + accounts.GET("/cindy/duplicate-identity-inventory", h.Admin.Account.GetCindyDuplicateIdentityInventory) accounts.GET("/api-key-visibility", h.Admin.Account.GetAPIKeyVisibility) accounts.PUT("/api-key-visibility", h.Admin.Account.SetAPIKeyVisibility) accounts.GET("/opencode-go-usage/settings", h.Admin.Account.GetOpenCodeGoUsageSettings) @@ -530,17 +491,19 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu accounts.POST("/:id/codex-tickets/harvest", h.Admin.Account.HarvestCodexTicket) accounts.POST("/codex-tickets/batch-harvest", h.Admin.Account.BatchHarvestCodexTickets) accounts.POST("/:id/codex-tickets/stop", h.Admin.Account.StopCodexTicketRenewal) - accounts.GET("/:id/codex-fingerprint", h.Admin.Plugin.RegisterResource(extensionv1.ResourceDescriptor{ResourceGrant: extensionv1.ResourceGrant{Name: "codex.fingerprint", Capability: extensionv1.CapabilityRequest, Permission: "admin"}, OwnerPluginKey: "codexrip.codex-runtime", AccountParam: "id", Method: "GET", Path: accounts.BasePath() + "/:id/codex-fingerprint"}), h.Admin.Account.CodexFingerprint) - accounts.POST("/:id/codex-routing/validate", h.Admin.Plugin.RegisterResource(extensionv1.ResourceDescriptor{ResourceGrant: extensionv1.ResourceGrant{Name: "codex.routing.validate", Capability: extensionv1.CapabilityCredentials, Permission: "admin"}, OwnerPluginKey: "codexrip.codex-runtime", AccountParam: "id", Method: "POST", Path: accounts.BasePath() + "/:id/codex-routing/validate"}), h.Admin.Account.ValidateCodexRouting) + accounts.POST("/:id/codex-tickets/stop-job", h.Admin.Account.StopCodexTicketRenewalJob) + accounts.POST("/codex-tickets/batch-stop", h.Admin.Account.BatchStopCodexTicketRenewal) + accounts.GET("/:id/codex-fingerprint", h.Admin.Account.CodexFingerprint) + accounts.POST("/:id/codex-routing/validate", h.Admin.Account.ValidateCodexRouting) // Host-private, administrator-owned diagnostics. Each operation rechecks // the existing credentials binding through the named routing host broker. accounts.POST("/:id/codex-quality-runs", h.Admin.Account.CreateCodexQualityRun) accounts.GET("/:id/codex-quality-runs/:run_id", h.Admin.Account.ReadCodexQualityRun) accounts.POST("/:id/codex-quality-runs/:run_id/renew-route", h.Admin.Account.RenewCodexQualityRoute) accounts.POST("/:id/codex-quality-runs/:run_id/close", h.Admin.Account.CloseCodexQualityRun) - accounts.PUT("/:id/codex-fingerprint/profile", h.Admin.Plugin.RegisterResource(extensionv1.ResourceDescriptor{ResourceGrant: extensionv1.ResourceGrant{Name: "codex.fingerprint.profile", Capability: extensionv1.CapabilityRequest, Permission: "admin"}, RequiredCapabilities: []string{extensionv1.CapabilityAdmin}, OwnerPluginKey: "codexrip.codex-runtime", AccountParam: "id", Method: "PUT", Path: accounts.BasePath() + "/:id/codex-fingerprint/profile"}), h.Admin.Account.SelectCodexProfile) + accounts.PUT("/:id/codex-fingerprint/profile", h.Admin.Account.SelectCodexProfile) accounts.POST("/:id/recover-state", h.Admin.Account.RecoverState) - accounts.POST("/:id/cindy-balance/recover", h.Admin.Plugin.RegisterResource(extensionv1.ResourceDescriptor{ResourceGrant: extensionv1.ResourceGrant{Name: "cindy.balance.recover", Capability: extensionv1.CapabilityProvider, Permission: "admin"}, RequiredCapabilities: []string{extensionv1.CapabilityAdmin}, OwnerPluginKey: service.CindyAccountViewPluginKey, AccountParam: "id", Method: "POST", Path: accounts.BasePath() + "/:id/cindy-balance/recover"}), h.Admin.Account.ClearCindyBalanceInsufficient) + accounts.POST("/:id/cindy-balance/recover", h.Admin.Account.ClearCindyBalanceInsufficient) accounts.POST("/:id/refresh", h.Admin.Account.Refresh) accounts.POST("/:id/apply-oauth-credentials", h.Admin.Account.ApplyOAuthCredentials) accounts.POST("/:id/set-privacy", h.Admin.Account.SetPrivacy) @@ -566,8 +529,8 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu accounts.POST("/batch", h.Admin.Account.BatchCreate) // 账号导出泄露上游凭证原文——要求 step-up 2FA accounts.GET("/data", gin.HandlerFunc(stepUpAuth), h.Admin.Account.ExportData) - accounts.POST("/data/preview", h.Admin.Plugin.RegisterResource(extensionv1.ResourceDescriptor{ResourceGrant: extensionv1.ResourceGrant{Name: "import.preview", Capability: extensionv1.CapabilityAdmin, Permission: "admin"}, Method: "POST", Path: accounts.BasePath() + "/data/preview", AllAccounts: true}), h.Admin.Account.PreviewImportData) - accounts.POST("/data", h.Admin.Plugin.RegisterResource(extensionv1.ResourceDescriptor{ResourceGrant: extensionv1.ResourceGrant{Name: "import.submit", Capability: extensionv1.CapabilityAdmin, Permission: "admin"}, Method: "POST", Path: accounts.BasePath() + "/data", AllAccounts: true}), h.Admin.Account.ImportData) + accounts.POST("/data/preview", h.Admin.Account.PreviewImportData) + accounts.POST("/data", h.Admin.Account.ImportData) accounts.POST("/batch-update-credentials", h.Admin.Account.BatchUpdateCredentials) accounts.POST("/batch-refresh-tier", h.Admin.Account.BatchRefreshTier) accounts.POST("/bulk-update", h.Admin.Account.BulkUpdate) @@ -717,11 +680,21 @@ func registerPromoCodeRoutes(admin *gin.RouterGroup, h *handler.Handlers) { } } -func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { +func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAuth middleware.StepUpAuthMiddleware) { adminSettings := admin.Group("/settings") { adminSettings.GET("", h.Admin.Setting.GetSettings) adminSettings.PUT("", h.Admin.Setting.UpdateSettings) + adminSettings.GET("/model-context-catalog", h.Admin.Setting.GetOfficialModelContextCatalog) + adminSettings.GET("/image-tools", h.Admin.Setting.GetImageToolsSettings) + adminSettings.PUT("/image-tools", gin.HandlerFunc(stepUpAuth), h.Admin.Setting.UpdateImageToolsSettings) + adminSettings.GET("/observability", h.Admin.Setting.GetObservabilitySettings) + adminSettings.PUT("/observability", gin.HandlerFunc(stepUpAuth), h.Admin.Setting.UpdateObservabilitySettings) + adminSettings.GET("/cindy-provider", h.Admin.Setting.GetCindyProviderSettings) + adminSettings.PUT("/cindy-provider", gin.HandlerFunc(stepUpAuth), h.Admin.Setting.UpdateCindyProviderSettings) + adminSettings.GET("/cindy-provider/catalog", h.Admin.Setting.GetCindyProviderCatalog) + adminSettings.GET("/codex-runtime", h.Admin.Setting.GetNativeCodexConfiguration) + adminSettings.PUT("/codex-runtime", gin.HandlerFunc(stepUpAuth), h.Admin.Setting.UpdateNativeCodexConfiguration) adminSettings.POST("/openai-codex-ticket/proxy-test", h.Admin.Setting.TestCodexTicketProxy) adminSettings.POST("/test-smtp", h.Admin.Setting.TestSMTPConnection) adminSettings.POST("/send-test-email", h.Admin.Setting.SendTestEmail) @@ -917,22 +890,16 @@ func registerPluginRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAut plugins := admin.Group("/plugins") { plugins.GET("", h.Admin.Plugin.List) - plugins.GET("/contributions", h.Admin.Plugin.Contributions) plugins.GET("/:id", h.Admin.Plugin.Get) plugins.POST("/upload", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Upload) - plugins.POST("/:id/update", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Update) - plugins.POST("/:id/follow-bundled", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.FollowBundledVersion) plugins.POST("/:id/enable", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Enable) plugins.POST("/:id/disable", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Disable) plugins.DELETE("/:id", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Delete) plugins.GET("/:id/config", h.Admin.Plugin.GetConfig) plugins.GET("/:id/status", h.Admin.Plugin.Status) - plugins.GET("/:id/resources", h.Admin.Plugin.Resources) plugins.PUT("/:id/config", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.SaveConfig) plugins.POST("/:id/test", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Test) plugins.POST("/:id/ui-session", h.Admin.Plugin.CreateUISession) - plugins.POST("/:id/actions", h.Admin.Plugin.InvokeAdmin) - plugins.POST("/:id/jobs", h.Admin.Plugin.SubmitJob) } } diff --git a/backend/internal/server/routes/auth.go b/backend/internal/server/routes/auth.go index cb783b1b7d34..18f39ed314dd 100644 --- a/backend/internal/server/routes/auth.go +++ b/backend/internal/server/routes/auth.go @@ -244,8 +244,6 @@ func RegisterAuthRoutes( settings.Use(panelRateLimiter.PublicIP()) { settings.GET("/public", h.Setting.GetPublicSettings) - settings.GET("/plugins", h.Setting.GetPublicPluginContributions) - settings.GET("/plugins/:id/theme/:revision/*path", h.Setting.GetPublicPluginThemeAsset) settings.GET("/email-unsubscribe", h.Setting.UnsubscribeNotificationEmail) } diff --git a/backend/internal/server/routes/gateway_models_pinned_test.go b/backend/internal/server/routes/gateway_models_pinned_test.go index 91d10a31779a..653fc0f5f965 100644 --- a/backend/internal/server/routes/gateway_models_pinned_test.go +++ b/backend/internal/server/routes/gateway_models_pinned_test.go @@ -98,7 +98,7 @@ func (u *pinnedModelsRoutesUpstream) Do(req *http.Request, _ string, _ int64, _ func TestGatewayRoutesPinnedModelsDispatchesOrdinaryAndCodexRequests(t *testing.T) { gin.SetMode(gin.TestMode) testextensions.Install() - t.Cleanup(func() { service.ConfigureProcessExtensionServices(nil, nil) }) + t.Cleanup(func() { service.ConfigureNativePolicyOperations(nil) }) repo := &pinnedModelsRoutesRepository{account: service.Account{ ID: 7, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, Status: service.StatusActive, Schedulable: true, diff --git a/backend/internal/server/routes/plugin_account_tools.go b/backend/internal/server/routes/plugin_account_tools.go deleted file mode 100644 index 6ef473c91bbc..000000000000 --- a/backend/internal/server/routes/plugin_account_tools.go +++ /dev/null @@ -1,40 +0,0 @@ -package routes - -import ( - "github.com/Wei-Shaw/sub2api/internal/handler" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/gin-gonic/gin" -) - -func registerAccountToolResources(accounts *gin.RouterGroup, h *handler.Handlers) { - for _, route := range []struct { - name, method, path, accountParam, accountBody, accountItems, filter string - handler gin.HandlerFunc - }{ - {"taxonomy.folders.list", "GET", "/folders", "", "", "", "", h.Admin.Account.ListAccountFolders}, - {"taxonomy.folders.create", "POST", "/folders", "", "", "", "", h.Admin.Account.CreateAccountFolder}, - {"taxonomy.folders.order", "PUT", "/folders/order", "", "", "", "", h.Admin.Account.ReorderAccountFolders}, - {"taxonomy.folders.update", "PUT", "/folders/:id", "", "", "", "", h.Admin.Account.UpdateAccountFolder}, - {"taxonomy.folders.delete", "DELETE", "/folders/:id", "", "", "", "", h.Admin.Account.DeleteAccountFolder}, - {"taxonomy.tags.list", "GET", "/tags", "", "", "", "", h.Admin.Account.ListAccountTags}, - {"taxonomy.tags.create", "POST", "/tags", "", "", "", "", h.Admin.Account.CreateAccountTag}, - {"taxonomy.tags.order", "PUT", "/tags/order", "", "", "", "", h.Admin.Account.ReorderAccountTags}, - {"taxonomy.tags.update", "PUT", "/tags/:id", "", "", "", "", h.Admin.Account.UpdateAccountTag}, - {"taxonomy.tags.delete", "DELETE", "/tags/:id", "", "", "", "", h.Admin.Account.DeleteAccountTag}, - {"taxonomy.account.update", "PUT", "/:id/taxonomy", "id", "", "", "", h.Admin.Account.SetAccountTaxonomy}, - {"taxonomy.bulk.update", "POST", "/bulk-taxonomy", "", "account_ids", "", "filters", h.Admin.Account.BulkUpdateAccountTaxonomy}, - {"tests.models", "POST", "/batch-test-models", "", "account_ids", "", "", h.Admin.Account.BatchTestModels}, - {"tests.submit", "POST", "/batch-test", "", "account_ids", "items", "", h.Admin.Account.BatchTest}, - } { - descriptor := extensionv1.ResourceDescriptor{ResourceGrant: extensionv1.ResourceGrant{Name: route.name, Capability: extensionv1.CapabilityAdmin, Permission: "admin"}, Method: route.method, Path: accounts.BasePath() + route.path, - AccountParam: route.accountParam, AccountBodyField: route.accountBody, AccountItemsField: route.accountItems, FilterField: route.filter, - AllAccounts: accountToolResourceRequiresAllAccounts(route.name)} - accounts.Handle(route.method, route.path, h.Admin.Plugin.RegisterResource(descriptor), route.handler) - } -} - -func accountToolResourceRequiresAllAccounts(name string) bool { - // Deleting a global classification changes every referring account, including - // those outside a scoped binding or rollout. Do not silently filter a delete. - return name == "taxonomy.folders.delete" || name == "taxonomy.tags.delete" -} diff --git a/backend/internal/server/routes/plugin_account_tools_test.go b/backend/internal/server/routes/plugin_account_tools_test.go deleted file mode 100644 index 247272539975..000000000000 --- a/backend/internal/server/routes/plugin_account_tools_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package routes - -import "testing" - -func TestAccountToolDeletionResourcesRequireFullAccountScope(t *testing.T) { - for _, name := range []string{"taxonomy.folders.delete", "taxonomy.tags.delete"} { - if !accountToolResourceRequiresAllAccounts(name) { - t.Fatalf("global deletion can change accounts outside the binding: %s", name) - } - } - for _, name := range []string{"taxonomy.folders.list", "taxonomy.folders.create", "taxonomy.folders.order", "taxonomy.folders.update", "taxonomy.tags.list", "taxonomy.tags.create", "taxonomy.tags.order", "taxonomy.tags.update", "taxonomy.account.update", "taxonomy.bulk.update", "tests.models", "tests.submit"} { - if accountToolResourceRequiresAllAccounts(name) { - t.Fatalf("unrelated metadata or explicit account operations must retain their original scope: %s", name) - } - } -} diff --git a/backend/internal/server/routes/user.go b/backend/internal/server/routes/user.go index 2aacc55c3169..3e65b93fd53f 100644 --- a/backend/internal/server/routes/user.go +++ b/backend/internal/server/routes/user.go @@ -4,7 +4,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/handler" "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/gin-gonic/gin" ) @@ -26,8 +25,6 @@ func RegisterUserRoutes( // 用户管理面变更类操作入审计(含 TOTP 启用/禁用、step-up 验证、密码修改等安全事件) authenticated.Use(gin.HandlerFunc(auditLog)) { - authenticated.GET("/plugins/:id/resources", h.Admin.Plugin.UserResources) - authenticated.POST("/plugins/:id/ui-session", h.Admin.Plugin.CreateUserUISession) // 用户接口 user := authenticated.Group("/user") { @@ -99,25 +96,15 @@ func RegisterUserRoutes( } imageStudio := authenticated.Group("/image-studio") - for _, route := range []struct { - name, method, path, response string - retained bool - handler gin.HandlerFunc - }{ - {"image.keys", "GET", "/eligible-keys", "", false, h.ImageStudio.EligibleKeys}, - {"image.create", "POST", "/jobs", "", false, h.ImageStudio.Create}, - {"image.jobs", "GET", "/jobs", "", true, h.ImageStudio.List}, - {"image.job", "GET", "/jobs/:id", "", true, h.ImageStudio.Get}, - {"image.items", "GET", "/jobs/:id/items", "", true, h.ImageStudio.Items}, - {"image.cancel", "POST", "/jobs/:id/cancel", "", true, h.ImageStudio.Cancel}, - {"image.retry", "POST", "/jobs/:id/retry", "", false, h.ImageStudio.Retry}, - {"image.artifact", "GET", "/jobs/:id/artifacts/:artifact_id", "blob", true, h.ImageStudio.Artifact}, - } { - descriptor := extensionv1.ResourceDescriptor{ResourceGrant: extensionv1.ResourceGrant{Name: route.name, Capability: extensionv1.CapabilityRequest, Permission: "user"}, Method: route.method, Path: imageStudio.BasePath() + route.path, ResponseKind: route.response, Retained: route.retained} - imageStudio.Handle(route.method, route.path, h.Admin.Plugin.RegisterResource(descriptor), route.handler) - } - for _, name := range []string{"image.history.list", "image.history.save", "image.history.delete", "image.history.clear"} { - h.Admin.Plugin.RegisterResource(extensionv1.ResourceDescriptor{ResourceGrant: extensionv1.ResourceGrant{Name: name, Capability: extensionv1.CapabilityRequest, Permission: "user"}, Method: "LOCAL", Retained: true}) + { + imageStudio.GET("/eligible-keys", h.ImageStudio.EligibleKeys) + imageStudio.POST("/jobs", h.ImageStudio.Create) + imageStudio.GET("/jobs", h.ImageStudio.List) + imageStudio.GET("/jobs/:id", h.ImageStudio.Get) + imageStudio.GET("/jobs/:id/items", h.ImageStudio.Items) + imageStudio.POST("/jobs/:id/cancel", h.ImageStudio.Cancel) + imageStudio.POST("/jobs/:id/retry", h.ImageStudio.Retry) + imageStudio.GET("/jobs/:id/artifacts/:artifact_id", h.ImageStudio.Artifact) } // 使用记录(聚合统计属重查询,叠加更严格的按用户限流) diff --git a/backend/internal/service/account_business_errors.go b/backend/internal/service/account_business_errors.go index 90d1d9aa7ffd..da464e47a24a 100644 --- a/backend/internal/service/account_business_errors.go +++ b/backend/internal/service/account_business_errors.go @@ -1,7 +1,7 @@ package service import ( - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "strings" ) diff --git a/backend/internal/service/account_edit_bulk.go b/backend/internal/service/account_edit_bulk.go index 5784f378b73a..d017e9bf03f6 100644 --- a/backend/internal/service/account_edit_bulk.go +++ b/backend/internal/service/account_edit_bulk.go @@ -56,7 +56,7 @@ func captureNativeAccountJobEdits(ctx context.Context, items []AccountJobItemSee } snapshot := AccountJobEditSnapshot{StateSHA256: AccountEditStateDigest(account)} if edit, present := AccountEditFromContext(bound); present && edit.changed { - snapshot.Profile = accountEditProfile(edit.installation, &edit.contribution) + snapshot.Profile = nativeAccountEditProfile(edit.runtime) } release() result[strconv.FormatInt(account.ID, 10)] = snapshot @@ -64,53 +64,6 @@ func captureNativeAccountJobEdits(ctx context.Context, items []AccountJobItemSee return result, nil } -func (m *PluginManager) CaptureAccountJobEdits(ctx context.Context, items []AccountJobItemSeed, credentials, extra map[string]any) (map[string]AccountJobEditSnapshot, error) { - directory, ok := m.accountDirectory.(accountViewAccountDirectory) - if !ok { - return nil, ErrAccountEditUnavailable - } - result := map[string]AccountJobEditSnapshot{} - for _, item := range items { - if item.TargetAccountID == nil || *item.TargetAccountID <= 0 { - return nil, ErrAccountEditInvalid - } - account, err := directory.ReadAccountViewAccount(ctx, *item.TargetAccountID) - if err != nil || account == nil || account.ID != *item.TargetAccountID { - return nil, ErrAccountEditStateChanged - } - if !hasCanonicalCindyProviderIdentity(account) { - continue - } - // Existing bulk eligibility is unchanged: in particular Cindy cannot - // acquire the ordinary OpenAI-only Responses/capability batch feature. - input := &BulkUpdateAccountsInput{AccountIDs: []int64{account.ID}, Credentials: maps.Clone(credentials), Extra: maps.Clone(extra)} - settings, err := normalizeBulkOpenAISettings(input) - if err != nil { - return nil, err - } - if _, err := validateBulkOpenAISettingsTargets(input, settings, map[int64]*Account{account.ID: account}); err != nil { - return nil, err - } - _, changed, err := accountEditDesired(account, credentials, extra, nil, nil) - if err != nil { - return nil, err - } - snapshot := AccountJobEditSnapshot{StateSHA256: AccountEditStateDigest(account)} - if changed { - current, contribution, err := m.resolveAccountEdit(ctx, account) - if err != nil { - return nil, err - } - if _, ready := m.accountEditReady(current, contribution, account); !ready { - return nil, ErrAccountEditUnavailable - } - snapshot.Profile = accountEditProfile(current, contribution) - } - result[strconv.FormatInt(account.ID, 10)] = snapshot - } - return result, nil -} - func accountJobPayloadWithEdit(ctx context.Context, kind string, payload json.RawMessage, items []AccountJobItemSeed) (json.RawMessage, error) { if kind != AccountJobKindBulkUpdate { return payload, nil @@ -137,18 +90,9 @@ func accountJobPayloadWithEdit(ctx context.Context, kind string, payload json.Ra if accounts, ok := ctx.Value(accountJobEditAccountsKey{}).(map[int64]*Account); ok { snapshots, err = captureNativeAccountJobEdits(ctx, items, accounts, input.Credentials, input.Extra) } else { - provider := processExtensionOperations.Load() - if provider == nil { - return nil, ErrAccountEditUnavailable - } - capture, ok := provider.invoker.(interface { - CaptureAccountJobEdits(context.Context, []AccountJobItemSeed, map[string]any, map[string]any) (map[string]AccountJobEditSnapshot, error) - }) - if !ok { - return nil, ErrAccountEditUnavailable - } - snapshots, err = capture.CaptureAccountJobEdits(ctx, items, input.Credentials, input.Extra) + return nil, ErrAccountEditUnavailable } + if err != nil { return nil, err } @@ -199,12 +143,17 @@ func PrepareAccountJobEdit(ctx context.Context, raw json.RawMessage, account *Ac } edit, _ := AccountEditFromContext(bound) if edit != nil && edit.changed { - current := accountEditProfile(edit.installation, &edit.contribution) - if snapshot.Profile == nil || current.PluginID != snapshot.Profile.PluginID || current.PluginKey != snapshot.Profile.PluginKey || current.ContributionID != snapshot.Profile.ContributionID || - current.PackageSHA256 != snapshot.Profile.PackageSHA256 || current.DefinitionSHA256 != snapshot.Profile.DefinitionSHA256 || current.RuntimeGeneration != snapshot.Profile.RuntimeGeneration { + // Historical encrypted jobs retain their original state digest and + // frozen target. Their retired installation is no longer an executor. + if snapshot.Profile == nil { release() return nil, nil, ErrAccountEditUnavailable } + if snapshot.Profile.Native && snapshot.Profile.PolicySHA256 != nativeAccountEditProfile(edit.runtime).PolicySHA256 { + release() + return nil, nil, ErrAccountEditUnavailable + } + } else if snapshot.Profile != nil { release() return nil, nil, ErrAccountEditStateChanged diff --git a/backend/internal/service/account_edit_codec.go b/backend/internal/service/account_edit_codec.go index 02ff822dfa59..81c4e7795345 100644 --- a/backend/internal/service/account_edit_codec.go +++ b/backend/internal/service/account_edit_codec.go @@ -6,14 +6,17 @@ import ( "encoding/hex" "encoding/json" "maps" + "regexp" "slices" "strings" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) // The deletion vocabulary is private and finite. Neither an HTTP client nor a // plugin can supply arbitrary JSON paths or keys to remove. +var accountEditDigestPattern = regexp.MustCompile(`^[a-f0-9]{64}$`) + var accountEditExtraKeys = []string{ "openai_responses_mode", "openai_compact_mode", "openai_apikey_responses_websockets_v2_mode", "openai_apikey_responses_websockets_v2_enabled", @@ -116,11 +119,10 @@ func validateAccountEditRequest(request *extensionv1.ProviderEditRequestV1) erro if request == nil { return nil } - if !accountViewIDPattern.MatchString(request.ContributionID) || !accountViewDigestPattern.MatchString(request.ExpectedPackageSHA256) || - !accountViewDigestPattern.MatchString(request.ExpectedDefinitionSHA256) || !accountViewDigestPattern.MatchString(request.ExpectedStateSHA256) || - request.ExpectedRuntimeGeneration <= 0 || request.Changes == nil || len(request.Changes) > 5 || len(request.ExpectedCatalogNamespace) > 512 { + if !accountEditDigestPattern.MatchString(request.ExpectedStateSHA256) || request.Changes == nil || len(request.Changes) > 5 || len(request.ExpectedCatalogNamespace) > 512 { return ErrAccountEditInvalid } + for target, change := range request.Changes { if change.Op != "set" && change.Op != "clear" { return ErrAccountEditInvalid diff --git a/backend/internal/service/plugin_account_import.go b/backend/internal/service/account_import_policy.go similarity index 95% rename from backend/internal/service/plugin_account_import.go rename to backend/internal/service/account_import_policy.go index 8ac49c51378b..987f8b51f93d 100644 --- a/backend/internal/service/plugin_account_import.go +++ b/backend/internal/service/account_import_policy.go @@ -4,7 +4,7 @@ import ( "context" "slices" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func PlanAccountImport(ctx context.Context, in extensionv1.AccountImportPlanningRequest) ([]extensionv1.AccountImportItemPlan, error) { @@ -123,7 +123,7 @@ func collectAccountImportPlans(ctx context.Context, in extensionv1.AccountImport const chunkSize = 1000 if len(in.Items) <= chunkSize { var plans []extensionv1.AccountImportItemPlan - err := accountToolsOperation(ctx, "*", "*", "import.plan", in, &plans) + err := accountToolsOperation(ctx, "import.plan", in, &plans) return plans, err } prepared := make([]extensionv1.AccountImportItemPlan, 0, len(in.Items)) @@ -133,7 +133,7 @@ func collectAccountImportPlans(ctx context.Context, in extensionv1.AccountImport chunk.Phase = "prepare" chunk.Items = in.Items[start:end] var plans []extensionv1.AccountImportItemPlan - if err := accountToolsOperation(ctx, "*", "*", "import.plan", chunk, &plans); err != nil { + if err := accountToolsOperation(ctx, "import.plan", chunk, &plans); err != nil { return nil, err } if len(plans) != len(chunk.Items) { @@ -169,7 +169,7 @@ func collectAccountImportPlans(ctx context.Context, in extensionv1.AccountImport } } var plans []extensionv1.AccountImportItemPlan - if err := accountToolsOperation(ctx, "*", "*", "import.plan", chunk, &plans); err != nil { + if err := accountToolsOperation(ctx, "import.plan", chunk, &plans); err != nil { return nil, err } if len(plans) != len(chunk.Items) { diff --git a/backend/internal/service/plugin_account_import_test.go b/backend/internal/service/account_import_policy_test.go similarity index 93% rename from backend/internal/service/plugin_account_import_test.go rename to backend/internal/service/account_import_policy_test.go index 4d766ee620ea..845a3495fe0b 100644 --- a/backend/internal/service/plugin_account_import_test.go +++ b/backend/internal/service/account_import_policy_test.go @@ -3,21 +3,21 @@ package service import ( "context" "encoding/json" + "errors" "testing" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" ) type importPlanFixture struct { - promptPolicyFixture payloadSizes []int requests []extensionv1.AccountImportPlanningRequest corrupt bool mutate func(extensionv1.AccountImportPlanningRequest, []extensionv1.AccountImportItemPlan) } -func (f *importPlanFixture) InvokeOperation(ctx context.Context, platform, kind string, in extensionv1.Invocation) (extensionv1.Result, error) { +func (f *importPlanFixture) invoke(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { var request extensionv1.AccountImportPlanningRequest if in.Operation == "import.plan" { f.payloadSizes = append(f.payloadSizes, len(in.Payload)) @@ -30,7 +30,7 @@ func (f *importPlanFixture) InvokeOperation(ctx context.Context, platform, kind return extensionv1.Result{Payload: raw}, nil } } - result, err := f.promptPolicyFixture.InvokeOperation(ctx, platform, kind, in) + result, err := accountTools().Invoke(ctx, in) if err != nil || f.mutate == nil || in.Operation != "import.plan" { return result, err } @@ -45,9 +45,7 @@ func (f *importPlanFixture) InvokeOperation(ctx context.Context, platform, kind func installImportPlanFixture(t *testing.T, fixture *importPlanFixture) { t.Helper() - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - processExtensionOperations.Store(&extensionOperationProvider{invoker: fixture}) + replaceAccountTools(t, fixture.invoke) } func importPlanRequestWithOrdinaryItems(count int) extensionv1.AccountImportPlanningRequest { @@ -59,10 +57,8 @@ func importPlanRequestWithOrdinaryItems(count int) extensionv1.AccountImportPlan } func TestAccountImportPlanKeepsCrossChunkConflictPriorityAndScope(t *testing.T) { - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) fixture := &importPlanFixture{} - processExtensionOperations.Store(&extensionOperationProvider{invoker: fixture}) + installImportPlanFixture(t, fixture) input := extensionv1.AccountImportPlanningRequest{TargetGroupID: 12, TargetCanonical: true, Items: make([]extensionv1.AccountImportItemFacts, 1002)} for index := range input.Items { input.Items[index].PayloadValid = true @@ -99,10 +95,12 @@ func TestAccountImportPlanKeepsCrossChunkConflictPriorityAndScope(t *testing.T) } fixture.corrupt = true _, err = PlanAccountImport(context.Background(), extensionv1.AccountImportPlanningRequest{Items: []extensionv1.AccountImportItemFacts{{PayloadValid: true, Matches: []int64{37}}}}) - require.ErrorIs(t, err, ErrExtensionOperationUnavailable, "plugin cannot choose another existing account") - processExtensionOperations.Store(nil) + require.ErrorIs(t, err, ErrExtensionOperationUnavailable, "a plan cannot choose another existing account") + replaceAccountTools(t, func(context.Context, extensionv1.Invocation) (extensionv1.Result, error) { + return extensionv1.Result{}, errors.New("policy failed") + }) _, err = PlanAccountImport(context.Background(), input) - require.Error(t, err, "disabled policy must not restore the host decision engine") + require.Error(t, err, "a failed policy must not fall back to a host decision") } func TestAccountImportPlanRejectsForgedCrossChunkTrackingFlags(t *testing.T) { diff --git a/backend/internal/service/account_job.go b/backend/internal/service/account_job.go index 71245f6abaeb..18c39946f26f 100644 --- a/backend/internal/service/account_job.go +++ b/backend/internal/service/account_job.go @@ -18,6 +18,7 @@ const ( AccountJobKindBatchCreate = "account_batch_create" AccountJobKindBatchTest = "account_batch_test" AccountJobKindCodexTicketHarvest = "codex_ticket_harvest" + AccountJobKindCodexTicketStop = "codex_ticket_stop" AccountJobKindBulkUpdate = "account_bulk_update" AccountJobKindBulkTaxonomy = "account_bulk_taxonomy" AccountJobKindBatchDelete = "account_batch_delete" @@ -202,19 +203,13 @@ func (s *AccountJobService) Submit(ctx context.Context, createdBy int64, kind, i if err := ValidateAccountJobMetadata(metadata); err != nil { return nil, false, err } - ctx, metadata, releasePolicy, policyErr := prepareAccountJobSubmission(ctx, metadata, kind) - if policyErr != nil { - return nil, false, policyErr - } - defer releasePolicy() - metadata, err := stampAccountJobView(ctx, metadata) - if err != nil { + if _, err := AccountJobPluginExecution(metadata); err != nil { return nil, false, err } - payload, err = accountJobPayloadWithView(ctx, payload) - if err != nil { + if err := validateRecordedAccountJobPayload(metadata, payload); err != nil { return nil, false, err } + for index := range items { if items[index].Ordinal <= 0 { items[index].Ordinal = index + 1 @@ -233,15 +228,7 @@ func (s *AccountJobService) Submit(ctx context.Context, createdBy int64, kind, i if !errors.Is(err, ErrAccountJobNotFound) { return nil, false, err } - if _, bound := AccountViewFromContext(ctx); bound && !isCindyCleanupAccountJob(kind) { - ids, err := accountViewJobSeedTargets(kind, payload, items) - if err != nil { - return nil, false, err - } - if err := ValidateAccountViewSelection(ctx, ids); err != nil { - return nil, false, err - } - } + // Server-only edit snapshots are excluded from the original operation hash. // Replays above reuse the original frozen encrypted payload and lifetime. payload, err = accountJobPayloadWithEdit(ctx, kind, payload, items) @@ -266,9 +253,7 @@ func (s *AccountJobService) Submit(ctx context.Context, createdBy int64, kind, i func (s *AccountJobService) ReplaySubmission(ctx context.Context, createdBy int64, kind, idempotencyKey string, payload json.RawMessage) (*AccountJob, bool, error) { idempotencyKey = strings.TrimSpace(idempotencyKey) kind = strings.TrimSpace(kind) - if err := validateAccountViewJobKind(ctx, kind, nil); err != nil { - return nil, false, err - } + if idempotencyKey == "" || len(idempotencyKey) > 255 { return nil, false, ErrAccountJobIdempotencyRequired } @@ -276,21 +261,10 @@ func (s *AccountJobService) ReplaySubmission(ctx context.Context, createdBy int6 return nil, false, errors.New("invalid account job submission") } var metadata json.RawMessage - if execution, bound := PluginExecutionFromContext(ctx); bound { - var err error - metadata, err = stampAccountJobPlugin(nil, execution) - if err != nil { - return nil, false, err - } - } - metadata, err := stampAccountJobView(ctx, metadata) - if err != nil { - return nil, false, err - } - payload, err = accountJobPayloadWithView(ctx, payload) - if err != nil { + if err := validateRecordedAccountJobPayload(metadata, payload); err != nil { return nil, false, err } + hash := sha256.Sum256(payload) existing, err := s.findMatchingSubmission(ctx, createdBy, kind, idempotencyKey, hex.EncodeToString(hash[:]), metadata) if errors.Is(err, ErrAccountJobNotFound) { @@ -347,9 +321,7 @@ func (s *AccountJobService) RetryFailed(ctx context.Context, jobID, createdBy in if old == nil || len(seeds) == 0 { return nil, false, ErrAccountJobNotRetryable } - if err := validateAccountViewJobKind(ctx, old.Kind, old.Metadata); err != nil { - return nil, false, err - } + if cipher == "" || time.Now().UTC().After(expires) { return nil, false, ErrAccountJobPayloadExpired } @@ -387,28 +359,10 @@ func (s *AccountJobService) RetryFailed(ctx context.Context, jobID, createdBy in fields["retry_of_job_id"], _ = json.Marshal(old.ID) fields["failed_item_count"], _ = json.Marshal(len(seeds)) fields["target_count"], _ = json.Marshal(len(seeds)) - // An explicit retry admits the saved request to the currently enabled - // version. It must keep the same owner, while receiving a fresh fence. - delete(fields, "plugin_generation") + // The retired source identity remains historical evidence. Retry preserves + // the original encrypted request, expiry and failed target/action records. metadata, _ := json.Marshal(fields) - ctx, metadata, releasePolicy, policyErr := prepareAccountJobSubmission(ctx, metadata, old.Kind) - if policyErr != nil { - return nil, false, policyErr - } - defer releasePolicy() - metadata, err = stampAccountJobView(ctx, metadata) - if err != nil { - return nil, false, err - } - if _, bound := AccountViewFromContext(ctx); bound && !isCindyCleanupAccountJob(old.Kind) { - ids, err := accountViewJobSeedTargets(old.Kind, json.RawMessage(plaintext), seeds) - if err != nil { - return nil, false, err - } - if err := ValidateAccountViewTargets(ctx, ids); err != nil { - return nil, false, err - } - } + return s.repo.Create(ctx, CreateAccountJobParams{ CreatedBy: createdBy, Kind: old.Kind, IdempotencyKey: idempotencyKey, RequestHash: requestHash, PayloadCipher: cipher, PayloadExpires: expires, @@ -519,19 +473,8 @@ func (r *AccountJobRuntime) execute(job *AccountJob) (string, string) { } } defer cleanup() - _, pluginBound := PluginExecutionFromContext(executionCtx) - _, viewBound := AccountViewFromContext(executionCtx) - if pluginBound || viewBound { - // Run before cleanup cancels a normally completed policy context. - defer func() { - if executionCtx.Err() != nil { - cancelCtx, cancel := context.WithTimeout(context.WithoutCancel(r.ctx), 10*time.Second) - defer cancel() - _, _ = r.jobs.repo.Cancel(cancelCtx, job.ID, job.CreatedBy) - } - }() - } - if job.Kind == AccountJobKindBatchTest || job.Kind == AccountJobKindCodexTicketHarvest || job.Kind == AccountJobKindExtensionOperation { + + if job.Kind == AccountJobKindBatchTest || job.Kind == AccountJobKindCodexTicketHarvest || job.Kind == AccountJobKindCodexTicketStop || job.Kind == AccountJobKindExtensionOperation { return r.executeBatchTests(executionCtx, job, payload) } for { @@ -600,7 +543,7 @@ func (r *AccountJobRuntime) cleanup(now time.Time) { func validAccountJobKind(kind string) bool { switch kind { - case AccountJobKindCodexTicketHarvest, AccountJobKindExtensionOperation: + case AccountJobKindCodexTicketHarvest, AccountJobKindCodexTicketStop, AccountJobKindExtensionOperation: return true case AccountJobKindImportData, AccountJobKindImportCodex, AccountJobKindBatchCreate, AccountJobKindBatchTest, AccountJobKindBulkUpdate, AccountJobKindBulkTaxonomy, AccountJobKindBatchDelete, diff --git a/backend/internal/service/account_job_legacy.go b/backend/internal/service/account_job_legacy.go new file mode 100644 index 000000000000..3665916d5119 --- /dev/null +++ b/backend/internal/service/account_job_legacy.go @@ -0,0 +1,295 @@ +package service + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "regexp" + "slices" + "strconv" + "strings" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" +) + +// These identities decode existing encrypted jobs. They never select or start +// an installation, stamp a new request, or authorize a different account set. +type AccountJobLegacyOwner struct { + ID int64 + Generation int64 +} + +const CindyAccountViewPluginKey = "codexrip.cindy-provider" +const CindyAccountViewID = "cindy-accounts" + +var ( + ErrAccountJobPluginUnavailable = errors.New("stored account job operation is unavailable") + ErrAccountViewInvalid = ErrAccountJobInvalidMetadata + ErrAccountViewUnavailable = ErrAccountJobInvalidMetadata + ErrAccountViewScope = ErrAccountJobInvalidMetadata + ErrAccountViewUnsupportedOperation = ErrAccountJobInvalidMetadata + accountViewIDPattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{0,63}$`) + accountViewDigestPattern = regexp.MustCompile(`^[a-f0-9]{64}$`) + legacyPluginIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)+$`) +) + +func AccountJobPluginExecution(metadata json.RawMessage) (AccountJobLegacyOwner, error) { + var owner struct { + ID int64 `json:"plugin_id"` + Generation int64 `json:"plugin_generation"` + } + if len(metadata) > 0 && json.Unmarshal(metadata, &owner) != nil { + return AccountJobLegacyOwner{}, ErrAccountJobInvalidMetadata + } + if owner.ID < 0 || owner.Generation < 0 || (owner.ID == 0 && owner.Generation != 0) { + return AccountJobLegacyOwner{}, ErrAccountJobInvalidMetadata + } + return AccountJobLegacyOwner{ID: owner.ID, Generation: owner.Generation}, nil +} + +type AccountJobViewMetadata struct { + extensionv1.AccountViewIdentityV1 + RuntimeGeneration int64 `json:"runtime_generation"` + PolicyRevision int64 `json:"policy_revision"` + NormalizedQueryDigest string `json:"normalized_query_digest"` +} + +func ValidateAccountViewIdentity(identity extensionv1.AccountViewIdentityV1) error { + if identity.Version != 1 || identity.PluginID <= 0 || !legacyPluginIDPattern.MatchString(identity.PluginKey) || len(identity.PluginKey) > 160 || !accountViewIDPattern.MatchString(identity.ViewID) || !accountViewIDPattern.MatchString(identity.PresetID) || !accountViewDigestPattern.MatchString(identity.PackageSHA256) || !accountViewDigestPattern.MatchString(identity.ViewDefinitionDigest) { + return ErrAccountViewInvalid + } + return nil +} + +func boundedAccountViewStrings(values []string, limit int) error { + if len(values) > limit { + return ErrAccountViewInvalid + } + seen := make(map[string]bool, len(values)) + for _, value := range values { + if value == "" || len(value) > 64 || strings.TrimSpace(value) != value || seen[value] || strings.ContainsAny(value, "\x00\r\n") { + return ErrAccountViewInvalid + } + seen[value] = true + } + return nil +} + +func NormalizeAccountViewQuery(query extensionv1.AccountViewQueryV1) (extensionv1.AccountViewQueryV1, error) { + for _, values := range [][]string{query.Platforms, query.Types, query.Statuses, query.Plans} { + if err := boundedAccountViewStrings(values, 32); err != nil { + return query, err + } + } + if len(query.Search) > 100 || len(query.PrivacyMode) > 64 || query.GroupID < AccountListGroupUngrouped { + return query, ErrAccountViewInvalid + } + query.Search = strings.TrimSpace(query.Search) + if query.SortBy != "" && !slices.Contains([]string{"id", "name", "platform", "type", "status", "schedulable", "priority", "concurrency", "rate_multiplier", "upstream_billing_rate", "last_used_at", "created_at", "updated_at", "expires_at"}, query.SortBy) { + return query, ErrAccountViewInvalid + } + if query.SortOrder != "" && query.SortOrder != "asc" && query.SortOrder != "desc" { + return query, ErrAccountViewInvalid + } + for _, values := range [][]int64{query.Tags, query.AccountIDs} { + if len(values) > 3200 { + return query, ErrAccountViewInvalid + } + for _, id := range values { + if id <= 0 { + return query, ErrAccountViewInvalid + } + } + } + for sentinel, values := range map[string][]string{"direct": query.Proxies, "uncategorized": query.Folders} { + if len(values) > 3200 { + return query, ErrAccountViewInvalid + } + for _, value := range values { + if value == sentinel { + continue + } + id, err := strconv.ParseInt(value, 10, 64) + if err != nil || id <= 0 || strconv.FormatInt(id, 10) != value { + return query, ErrAccountViewInvalid + } + } + } + normalizeStrings := func(values []string) []string { + out := slices.Clone(values) + slices.Sort(out) + return slices.Compact(out) + } + normalizeIDs := func(values []int64) []int64 { + out := slices.Clone(values) + slices.Sort(out) + return slices.Compact(out) + } + query.Platforms, query.Types, query.Statuses, query.Plans = normalizeStrings(query.Platforms), normalizeStrings(query.Types), normalizeStrings(query.Statuses), normalizeStrings(query.Plans) + query.Proxies, query.Folders = normalizeStrings(query.Proxies), normalizeStrings(query.Folders) + query.Tags, query.AccountIDs = normalizeIDs(query.Tags), normalizeIDs(query.AccountIDs) + return query, nil +} + +func AccountJobViewExecution(metadata json.RawMessage) (*AccountJobViewMetadata, error) { + var envelope struct { + View json.RawMessage `json:"account_view"` + } + if len(metadata) > 0 && json.Unmarshal(metadata, &envelope) != nil { + return nil, ErrAccountJobInvalidMetadata + } + if len(envelope.View) == 0 || string(envelope.View) == "null" { + return nil, nil + } + var view AccountJobViewMetadata + decoder := json.NewDecoder(bytes.NewReader(envelope.View)) + decoder.DisallowUnknownFields() + if decoder.Decode(&view) != nil || ValidateAccountViewIdentity(view.AccountViewIdentityV1) != nil || view.RuntimeGeneration <= 0 || view.PolicyRevision < 0 || !accountViewDigestPattern.MatchString(view.NormalizedQueryDigest) { + return nil, ErrAccountJobInvalidMetadata + } + return &view, nil +} + +func accountViewQueryDigest(query extensionv1.AccountViewQueryV1) string { + raw, _ := json.Marshal(query) + digest := sha256.Sum256(raw) + return hex.EncodeToString(digest[:]) +} + +func AccountJobViewIdentityEqual(left, right json.RawMessage) bool { + a, aErr := AccountJobViewExecution(left) + b, bErr := AccountJobViewExecution(right) + if aErr != nil || bErr != nil { + return false + } + if a == nil || b == nil { + return a == nil && b == nil + } + return a.AccountViewIdentityV1 == b.AccountViewIdentityV1 && a.NormalizedQueryDigest == b.NormalizedQueryDigest +} + +func accountJobPayloadView(payload json.RawMessage) (*extensionv1.AccountViewContextV1, error) { + var envelope struct { + View json.RawMessage `json:"view_context"` + } + if json.Unmarshal(payload, &envelope) != nil { + return nil, ErrAccountJobInvalidMetadata + } + if len(envelope.View) == 0 || string(envelope.View) == "null" { + return nil, nil + } + var view extensionv1.AccountViewContextV1 + decoder := json.NewDecoder(bytes.NewReader(envelope.View)) + decoder.DisallowUnknownFields() + if decoder.Decode(&view) != nil { + return nil, ErrAccountJobInvalidMetadata + } + query, err := NormalizeAccountViewQuery(view.Query) + if err != nil { + return nil, err + } + view.Query = query + return &view, nil +} + +func isCindyCleanupAccountJob(kind string) bool { + return kind == AccountJobKindCindyConfirmedCleanup || kind == AccountJobKindCindyBannedCleanup +} + +func accountViewJobTargets(kind string, payload json.RawMessage, targets []*int64) ([]int64, error) { + var ids []int64 + switch kind { + case AccountJobKindDuplicateReview: + var request struct { + AccountIDs []int64 `json:"account_ids"` + } + if json.Unmarshal(payload, &request) != nil || len(request.AccountIDs) < 2 || len(request.AccountIDs) > AccountJobBatchSize { + return nil, ErrAccountViewScope + } + ids = request.AccountIDs + case AccountJobKindDuplicateMerge: + var request struct { + SurvivorAccountID int64 `json:"survivor_account_id"` + LoserAccountIDs []int64 `json:"loser_account_ids"` + } + if json.Unmarshal(payload, &request) != nil || request.SurvivorAccountID <= 0 || len(request.LoserAccountIDs) == 0 || len(request.LoserAccountIDs) >= AccountJobBatchSize || len(targets) != 1 || targets[0] == nil || *targets[0] != request.SurvivorAccountID { + return nil, ErrAccountViewScope + } + ids = append([]int64{request.SurvivorAccountID}, request.LoserAccountIDs...) + default: + for _, target := range targets { + if target == nil { + return nil, ErrAccountViewScope + } + ids = append(ids, *target) + } + } + for _, id := range ids { + if id <= 0 { + return nil, ErrAccountViewScope + } + } + if len(ids) == 0 { + return nil, ErrAccountViewScope + } + return ids, nil +} + +// ValidateRecordedAccountJob verifies original view metadata against its saved +// encrypted query. The query is compared only; execution uses persisted IDs. +func ValidateRecordedAccountJob(job *AccountJob, payload json.RawMessage) error { + if job == nil || !json.Valid(payload) { + return ErrAccountJobInvalidMetadata + } + if _, err := AccountJobPluginExecution(job.Metadata); err != nil { + return err + } + return validateRecordedAccountJobPayload(job.Metadata, payload) +} + +func validateRecordedAccountJobPayload(metadata, payload json.RawMessage) error { + stored, err := AccountJobViewExecution(metadata) + if err != nil { + return err + } + if stored == nil { + var envelope struct { + View json.RawMessage `json:"view_context"` + } + if json.Unmarshal(payload, &envelope) == nil && len(envelope.View) > 0 && string(envelope.View) != "null" { + return ErrAccountJobInvalidMetadata + } + return nil + } + request, err := accountJobPayloadView(payload) + if err != nil || request == nil || stored.AccountViewIdentityV1 != request.AccountViewIdentityV1 || stored.NormalizedQueryDigest != accountViewQueryDigest(request.Query) { + return ErrAccountJobInvalidMetadata + } + return nil +} + +func bindRecordedAccountJobView(ctx context.Context, metadata, payload json.RawMessage, _ bool) (context.Context, func(), error) { + if err := validateRecordedAccountJobPayload(metadata, payload); err != nil { + return nil, nil, err + } + return ctx, func() {}, nil +} + +func ValidateRecordedAccountJobTargets(job *AccountJob, payload json.RawMessage, items []AccountJobItem) error { + if err := ValidateRecordedAccountJob(job, payload); err != nil { + return err + } + stored, _ := AccountJobViewExecution(job.Metadata) + if stored == nil || isCindyCleanupAccountJob(job.Kind) { + return nil + } + targets := make([]*int64, 0, len(items)) + for _, item := range items { + targets = append(targets, item.TargetAccountID) + } + _, err := accountViewJobTargets(job.Kind, payload, targets) + return err +} diff --git a/backend/internal/service/account_job_legacy_operation.go b/backend/internal/service/account_job_legacy_operation.go new file mode 100644 index 000000000000..fbdb03398869 --- /dev/null +++ b/backend/internal/service/account_job_legacy_operation.go @@ -0,0 +1,79 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "strconv" + "strings" +) + +// Historical encrypted operation envelope; no new submissions use this format. +type PluginJobPayload struct { + PluginID int64 `json:"plugin_id"` + Operation string `json:"operation"` + Items map[string]json.RawMessage `json:"items"` +} + +var ErrLegacyAccountJobUnsupported = errors.New("stored account operation has no native executor") + +type LegacyCodexAccountJobIntent struct { + PluginID int64 + AccountID int64 + Operation string + Model string + Force bool +} + +// The old host hashed the frozen account ID, one NUL, and the canonical raw +// operation object before adding operation_id. Preserve that exact 64-byte +// hexadecimal contract; a truncated or mismatched action never selects a task. +func DecodeLegacyCodexAccountJob(metadata, payload json.RawMessage, item AccountJobItem) (*LegacyCodexAccountJobIntent, error) { + var request PluginJobPayload + if json.Unmarshal(payload, &request) != nil || request.PluginID <= 0 { + return nil, ErrAccountJobInvalidMetadata + } + if request.Operation != "harvest" && request.Operation != "stop" { + return nil, ErrLegacyAccountJobUnsupported + } + owner, err := AccountJobPluginExecution(metadata) + if err != nil || owner.ID != request.PluginID { + return nil, ErrAccountJobInvalidMetadata + } + if item.TargetAccountID == nil || *item.TargetAccountID <= 0 || !accountViewDigestPattern.MatchString(item.Action) { + return nil, ErrAccountJobInvalidMetadata + } + raw, ok := request.Items[item.Action] + if !ok { + return nil, ErrAccountJobInvalidMetadata + } + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil || fields == nil { + return nil, ErrAccountJobInvalidMetadata + } + canonical, err := json.Marshal(fields) + if err != nil { + return nil, ErrAccountJobInvalidMetadata + } + id := *item.TargetAccountID + digest := sha256.Sum256(append([]byte(strconv.FormatInt(id, 10)+"\x00"), canonical...)) + if hex.EncodeToString(digest[:]) != item.Action { + return nil, ErrAccountJobInvalidMetadata + } + if rawID, present := fields["account_id"]; present { + var declared int64 + if json.Unmarshal(rawID, &declared) != nil || declared != id { + return nil, ErrAccountJobInvalidMetadata + } + } + var model string + if json.Unmarshal(fields["model"], &model) != nil || strings.TrimSpace(model) == "" { + return nil, ErrAccountJobInvalidMetadata + } + var force bool + if rawForce, present := fields["force"]; present && json.Unmarshal(rawForce, &force) != nil { + return nil, ErrAccountJobInvalidMetadata + } + return &LegacyCodexAccountJobIntent{PluginID: request.PluginID, AccountID: id, Operation: request.Operation, Model: strings.TrimSpace(model), Force: force}, nil +} diff --git a/backend/internal/service/account_job_legacy_test.go b/backend/internal/service/account_job_legacy_test.go new file mode 100644 index 000000000000..38cf090385f3 --- /dev/null +++ b/backend/internal/service/account_job_legacy_test.go @@ -0,0 +1,142 @@ +//go:build unit + +package service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strconv" + "strings" + "testing" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + "github.com/stretchr/testify/require" +) + +func legacyCodexJobFixture(t *testing.T, target int64, fields map[string]json.RawMessage) (json.RawMessage, json.RawMessage, AccountJobItem) { + t.Helper() + canonical, err := json.Marshal(fields) + require.NoError(t, err) + digest := sha256.Sum256(append([]byte(strconv.FormatInt(target, 10)+"\x00"), canonical...)) + action := hex.EncodeToString(digest[:]) + raw, err := json.Marshal(PluginJobPayload{PluginID: 7, Operation: "harvest", Items: map[string]json.RawMessage{action: canonical}}) + require.NoError(t, err) + return json.RawMessage(`{"plugin_id":7,"plugin_generation":3}`), raw, AccountJobItem{ID: 1, Action: action, TargetAccountID: &target} +} + +func TestLegacyCodexAccountJobRequiresCompleteActionAndFrozenTarget(t *testing.T) { + metadata, payload, item := legacyCodexJobFixture(t, 41, map[string]json.RawMessage{"model": json.RawMessage(`"gpt-6-astra"`), "force": json.RawMessage(`true`)}) + intent, err := DecodeLegacyCodexAccountJob(metadata, payload, item) + require.NoError(t, err) + require.Equal(t, 41, int(intent.AccountID)) + require.Equal(t, "gpt-6-astra", intent.Model) + require.True(t, intent.Force) + require.Len(t, item.Action, 64) + t.Run("changed_target", func(t *testing.T) { + other := item + id := int64(99) + other.TargetAccountID = &id + _, err := DecodeLegacyCodexAccountJob(metadata, payload, other) + require.ErrorIs(t, err, ErrAccountJobInvalidMetadata) + }) + t.Run("missing_target", func(t *testing.T) { + other := item + other.TargetAccountID = nil + _, err := DecodeLegacyCodexAccountJob(metadata, payload, other) + require.ErrorIs(t, err, ErrAccountJobInvalidMetadata) + }) + t.Run("truncated_action", func(t *testing.T) { + other := item + other.Action = other.Action[:48] + _, err := DecodeLegacyCodexAccountJob(metadata, payload, other) + require.ErrorIs(t, err, ErrAccountJobInvalidMetadata) + }) + t.Run("changed_payload", func(t *testing.T) { + changed := json.RawMessage(strings.Replace(string(payload), "gpt-6-astra", "gpt-6-sol", 1)) + _, err := DecodeLegacyCodexAccountJob(metadata, changed, item) + require.ErrorIs(t, err, ErrAccountJobInvalidMetadata) + }) + t.Run("different_source", func(t *testing.T) { + _, err := DecodeLegacyCodexAccountJob([]byte(`{"plugin_id":8,"plugin_generation":3}`), payload, item) + require.ErrorIs(t, err, ErrAccountJobInvalidMetadata) + }) + t.Run("contradictory_account", func(t *testing.T) { + meta, raw, entry := legacyCodexJobFixture(t, 41, map[string]json.RawMessage{"model": json.RawMessage(`"gpt-6-astra"`), "account_id": json.RawMessage(`99`)}) + _, err := DecodeLegacyCodexAccountJob(meta, raw, entry) + require.ErrorIs(t, err, ErrAccountJobInvalidMetadata) + }) + t.Run("unknown_operation", func(t *testing.T) { + raw := json.RawMessage(strings.Replace(string(payload), `"harvest"`, `"proxy.test"`, 1)) + _, err := DecodeLegacyCodexAccountJob(metadata, raw, item) + require.ErrorIs(t, err, ErrLegacyAccountJobUnsupported) + }) + t.Run("stop_same_single_model", func(t *testing.T) { + raw := json.RawMessage(strings.Replace(string(payload), `"harvest"`, `"stop"`, 1)) + got, err := DecodeLegacyCodexAccountJob(metadata, raw, item) + require.NoError(t, err) + require.Equal(t, "stop", got.Operation) + require.Equal(t, intent.Model, got.Model) + }) +} + +func TestAccountJobLegacyRetryPreservesActorCipherExpiryAndAction64(t *testing.T) { + repo := newAccountJobTestRepo() + jobs := NewAccountJobService(repo, accountJobTestCipher{}) + metadata, payload, item := legacyCodexJobFixture(t, 41, map[string]json.RawMessage{"model": json.RawMessage(`"gpt-6-astra"`)}) + seed := AccountJobItemSeed{Ordinal: 1, Action: item.Action, TargetAccountID: item.TargetAccountID, Metadata: json.RawMessage(`{}`)} + job, _, err := jobs.Submit(context.Background(), 9, AccountJobKindExtensionOperation, "legacy-original", payload, metadata, []AccountJobItemSeed{seed}) + require.NoError(t, err) + repo.jobs[job.ID].Status = AccountJobStatusFailed + repo.items[job.ID][0].Status = AccountJobItemStatusFailed + original := repo.payloads[job.ID] + _, _, err = jobs.RetryFailed(context.Background(), job.ID, 99, "wrong-actor") + require.ErrorIs(t, err, ErrAccountJobNotFound) + retry, replayed, err := jobs.RetryFailed(context.Background(), job.ID, 9, "native-retry") + require.NoError(t, err) + require.False(t, replayed) + require.Equal(t, original, repo.payloads[retry.ID]) + require.Equal(t, item.Action, repo.items[retry.ID][0].Action) + require.Equal(t, int64(41), *repo.items[retry.ID][0].TargetAccountID) + owner, err := AccountJobPluginExecution(retry.Metadata) + require.NoError(t, err) + require.Equal(t, AccountJobLegacyOwner{ID: 7, Generation: 3}, owner) + same, replayed, err := jobs.RetryFailed(context.Background(), job.ID, 9, "native-retry") + require.NoError(t, err) + require.True(t, replayed) + require.Equal(t, retry.ID, same.ID) + require.Len(t, repo.jobs, 2) +} + +func TestAccountJobLegacyViewComparesSavedDigestWithoutQueryingTargets(t *testing.T) { + identity := extensionv1.AccountViewIdentityV1{Version: 1, PluginID: 7, PluginKey: "codexrip.cindy-provider", ViewID: "cindy-accounts", PresetID: "cindy", PackageSHA256: strings.Repeat("a", 64), ViewDefinitionDigest: strings.Repeat("b", 64)} + query := extensionv1.AccountViewQueryV1{Search: "saved-private-query", AccountIDs: []int64{2, 7}} + record := AccountJobViewMetadata{AccountViewIdentityV1: identity, RuntimeGeneration: 3, PolicyRevision: 4, NormalizedQueryDigest: accountViewQueryDigest(query)} + metadata, err := json.Marshal(map[string]any{"plugin_id": 7, "plugin_generation": 3, "account_view": record}) + require.NoError(t, err) + payload, err := json.Marshal(map[string]any{"account_ids": []int64{2, 7}, "view_context": extensionv1.AccountViewContextV1{AccountViewIdentityV1: identity, Query: query}}) + require.NoError(t, err) + job := &AccountJob{ID: 1, Kind: AccountJobKindBulkUpdate, Metadata: metadata} + require.NoError(t, ValidateRecordedAccountJob(job, payload)) + require.NotContains(t, string(metadata), query.Search) + changed := json.RawMessage(strings.Replace(string(payload), query.Search, "different-query", 1)) + require.ErrorIs(t, ValidateRecordedAccountJob(job, changed), ErrAccountJobInvalidMetadata) + target := int64(2) + require.NoError(t, ValidateRecordedAccountJobTargets(job, payload, []AccountJobItem{{TargetAccountID: &target}})) + require.Error(t, ValidateRecordedAccountJobTargets(job, payload, []AccountJobItem{{}})) +} + +func TestAccountJobLegacyDuplicateTargetsKeepLosers(t *testing.T) { + ids, err := accountViewJobTargets(AccountJobKindDuplicateReview, json.RawMessage(`{"account_ids":[2,3]}`), []*int64{nil}) + require.NoError(t, err) + require.Equal(t, []int64{2, 3}, ids) + id := int64(2) + ids, err = accountViewJobTargets(AccountJobKindDuplicateMerge, json.RawMessage(`{"survivor_account_id":2,"loser_account_ids":[3]}`), []*int64{&id}) + require.NoError(t, err) + require.Equal(t, []int64{2, 3}, ids) + _, err = accountViewJobTargets(AccountJobKindDuplicateReview, json.RawMessage(`{}`), []*int64{nil}) + require.Error(t, err) + _, err = accountViewJobTargets(AccountJobKindDuplicateMerge, json.RawMessage(`{"survivor_account_id":2,"loser_account_ids":[-1]}`), []*int64{&id}) + require.Error(t, err) +} diff --git a/backend/internal/service/account_job_plugin.go b/backend/internal/service/account_job_plugin.go deleted file mode 100644 index 4b0e8c4cfdc3..000000000000 --- a/backend/internal/service/account_job_plugin.go +++ /dev/null @@ -1,482 +0,0 @@ -package service - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "time" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" -) - -var ErrAccountJobPluginUnavailable = errors.New("account job plugin is unavailable or has changed") - -// Account jobs are administrative operations. Image and probe job storage has -// its own execution contract and does not pass through this metadata envelope. -func AccountJobPluginExecution(metadata json.RawMessage) (PluginExecution, error) { - var owner struct { - ID int64 `json:"plugin_id"` - Generation int64 `json:"plugin_generation"` - } - if len(metadata) > 0 && json.Unmarshal(metadata, &owner) != nil { - return PluginExecution{}, ErrAccountJobInvalidMetadata - } - if owner.ID < 0 || owner.Generation < 0 || (owner.ID == 0 && owner.Generation != 0) { - return PluginExecution{}, ErrAccountJobInvalidMetadata - } - return PluginExecution{ID: owner.ID, Generation: owner.Generation}, nil -} - -func stampAccountJobPlugin(metadata json.RawMessage, execution PluginExecution) (json.RawMessage, error) { - owner, err := AccountJobPluginExecution(metadata) - if err != nil { - return nil, err - } - if execution.ID <= 0 || execution.Generation <= 0 || (owner.ID > 0 && owner.ID != execution.ID) { - return nil, ErrAccountJobPluginUnavailable - } - fields := map[string]json.RawMessage{} - if len(metadata) > 0 && json.Unmarshal(metadata, &fields) != nil { - return nil, ErrAccountJobInvalidMetadata - } - if fields == nil { - fields = map[string]json.RawMessage{} - } - fields["plugin_id"], _ = json.Marshal(execution.ID) - fields["plugin_generation"], _ = json.Marshal(execution.Generation) - return json.Marshal(fields) -} - -type accountJobPluginBinder interface { - BindAccountJobExecution(context.Context, int64, int64, ...string) (context.Context, func(), error) -} - -func bindAccountJobPlugin(ctx context.Context, owner PluginExecution, kind string) (context.Context, func(), error) { - provider := processExtensionOperations.Load() - if provider == nil { - return nil, nil, ErrAccountJobPluginUnavailable - } - binder, ok := provider.invoker.(accountJobPluginBinder) - if !ok { - return nil, nil, ErrAccountJobPluginUnavailable - } - return binder.BindAccountJobExecution(ctx, owner.ID, owner.Generation, kind) -} - -// The separate shared database lease lasts until the host task actually exits, -// including its transaction cleanup. Killing only the old plugin process must -// not let an update overtake host IO that is still finishing cancellation. -func (m *PluginManager) BindAccountJobExecution(ctx context.Context, id, expectedGeneration int64, kinds ...string) (context.Context, func(), error) { - if m == nil || m.repo == nil { - return nil, nil, ErrAccountJobPluginUnavailable - } - current, err := m.repo.GetByID(ctx, id) - if err == nil && current != nil && PluginExpectedPackage(ctx) != "" && PluginExpectedPackage(ctx) != current.PackageSHA256 { - return nil, nil, ErrPluginStateChanged - } - if err != nil || current == nil || current.State != PluginStateEnabled || (expectedGeneration > 0 && current.RuntimeGeneration != expectedGeneration) { - return nil, nil, ErrAccountJobPluginUnavailable - } - registry := m.extensions.Load() - if registry == nil || registry.unavailable != "" || !samePluginRuntime(current, registry.installations[id]) { - return nil, nil, ErrAccountJobPluginUnavailable - } - admin := false - for _, binding := range current.Bindings { - admin = admin || (binding.Enabled && binding.Capability == extensionv1.CapabilityAdmin) - } - runtime := registry.runtimes[id] - if !admin || runtime == nil || runtime.client == nil || runtime.client.Exited() || !pluginDependenciesHealthy(current, registry, map[int64]bool{}) { - return nil, nil, ErrAccountJobPluginUnavailable - } - bound, release, err := m.bindHostPolicyContext(ctx, current, runtime) - if err != nil { - return nil, nil, ErrAccountJobPluginUnavailable - } - bound = WithPluginExecution(bound, current) - if len(kinds) > 0 && isCindyCleanupAccountJob(kinds[0]) { - if err := m.ValidateResourcePolicy(bound, CindyCleanupResourcePolicy(), nil, true); err != nil { - release() - return nil, nil, ErrAccountJobPluginUnavailable - } - } - if len(kinds) > 0 && (kinds[0] == AccountJobKindImportData || kinds[0] == AccountJobKindImportCodex || kinds[0] == AccountJobKindBatchCreate) { - if err := m.ValidateResourceAccounts(bound, extensionv1.CapabilityAdmin, nil, true); err != nil { - release() - return nil, nil, ErrAccountJobPluginUnavailable - } - } - return bound, release, nil -} - -func isCindyCleanupAccountJob(kind string) bool { - return kind == AccountJobKindCindyConfirmedCleanup || kind == AccountJobKindCindyBannedCleanup -} - -// The generic RPC back-callback contract has no origin-view proof. Keep that -// existing background channel unchanged and require named host jobs for a view. -func validateAccountViewJobKind(ctx context.Context, kind string, metadata json.RawMessage) error { - if kind != AccountJobKindExtensionOperation { - return nil - } - if _, bound := AccountViewFromContext(ctx); bound { - return ErrAccountViewUnsupportedOperation - } - view, err := AccountJobViewExecution(metadata) - if err != nil { - return err - } - if view != nil { - return ErrAccountViewUnsupportedOperation - } - return nil -} - -// Public metadata contains only identity/fences and a digest. The full query is -// in the existing encrypted payload. Action owner and origin view are separate. -type AccountJobViewMetadata struct { - extensionv1.AccountViewIdentityV1 - RuntimeGeneration int64 `json:"runtime_generation"` - PolicyRevision int64 `json:"policy_revision"` - NormalizedQueryDigest string `json:"normalized_query_digest"` -} - -func AccountJobViewExecution(metadata json.RawMessage) (*AccountJobViewMetadata, error) { - var envelope struct { - View json.RawMessage `json:"account_view"` - } - if len(metadata) > 0 && json.Unmarshal(metadata, &envelope) != nil { - return nil, ErrAccountJobInvalidMetadata - } - if len(envelope.View) == 0 || string(envelope.View) == "null" { - return nil, nil - } - var view AccountJobViewMetadata - decoder := json.NewDecoder(bytes.NewReader(envelope.View)) - decoder.DisallowUnknownFields() - if decoder.Decode(&view) != nil || ValidateAccountViewIdentity(view.AccountViewIdentityV1) != nil || view.RuntimeGeneration <= 0 || view.PolicyRevision < 0 || !accountViewDigestPattern.MatchString(view.NormalizedQueryDigest) { - return nil, ErrAccountJobInvalidMetadata - } - return &view, nil -} - -func accountViewQueryDigest(query extensionv1.AccountViewQueryV1) string { - raw, _ := json.Marshal(query) - digest := sha256.Sum256(raw) - return hex.EncodeToString(digest[:]) -} - -func AccountJobViewIdentityEqual(left, right json.RawMessage) bool { - a, aErr := AccountJobViewExecution(left) - b, bErr := AccountJobViewExecution(right) - if aErr != nil || bErr != nil { - return false - } - if a == nil || b == nil { - return a == nil && b == nil - } - return a.AccountViewIdentityV1 == b.AccountViewIdentityV1 && a.NormalizedQueryDigest == b.NormalizedQueryDigest -} - -func AccountJobExecutionFences(metadata json.RawMessage) ([]PluginExecutionFence, error) { - primary, err := AccountJobPluginExecution(metadata) - if err != nil { - return nil, err - } - view, err := AccountJobViewExecution(metadata) - if err != nil { - return nil, err - } - var fences []PluginExecutionFence - if primary.ID > 0 { - fences = append(fences, PluginExecutionFence{ID: primary.ID, Generation: primary.Generation, Primary: true}) - } - if view != nil { - fences = append(fences, PluginExecutionFence{ID: view.PluginID, Generation: view.RuntimeGeneration, PluginKey: view.PluginKey, PackageSHA256: view.PackageSHA256, PolicyRevision: view.PolicyRevision, OriginView: true}) - } - return mergePluginExecutionFences(fences) -} - -func accountJobPayloadView(payload json.RawMessage) (*extensionv1.AccountViewContextV1, error) { - var envelope struct { - View json.RawMessage `json:"view_context"` - } - if json.Unmarshal(payload, &envelope) != nil { - return nil, ErrAccountJobInvalidMetadata - } - if len(envelope.View) == 0 || string(envelope.View) == "null" { - return nil, nil - } - var view extensionv1.AccountViewContextV1 - decoder := json.NewDecoder(bytes.NewReader(envelope.View)) - decoder.DisallowUnknownFields() - if decoder.Decode(&view) != nil { - return nil, ErrAccountJobInvalidMetadata - } - query, err := NormalizeAccountViewQuery(view.Query) - if err != nil { - return nil, err - } - view.Query = query - return &view, nil -} - -func accountJobPayloadWithView(ctx context.Context, payload json.RawMessage) (json.RawMessage, error) { - view, bound := AccountViewFromContext(ctx) - if !bound { - var probe struct { - View json.RawMessage `json:"view_context"` - } - // Non-object native payloads keep their pre-existing hash/validation path. - if json.Unmarshal(payload, &probe) == nil && len(probe.View) > 0 && string(probe.View) != "null" { - return nil, ErrAccountViewUnavailable - } - return payload, nil - } - provided, err := accountJobPayloadView(payload) - if err != nil { - return nil, err - } - if provided != nil && !accountViewJSONEqual(provided, view.Request) { - return nil, ErrAccountViewInvalid - } - fields := map[string]json.RawMessage{} - if json.Unmarshal(payload, &fields) != nil || fields == nil { - return nil, ErrAccountJobInvalidMetadata - } - fields["view_context"], _ = json.Marshal(view.Request) - return json.Marshal(fields) -} - -func stampAccountJobView(ctx context.Context, metadata json.RawMessage) (json.RawMessage, error) { - old, err := AccountJobViewExecution(metadata) - if err != nil { - return nil, err - } - view, bound := AccountViewFromContext(ctx) - if !bound { - if old != nil { - return nil, ErrAccountViewUnavailable - } - return metadata, nil - } - if err := ValidateAccountViewFresh(ctx); err != nil { - return nil, err - } - current := AccountJobViewMetadata{AccountViewIdentityV1: view.Request.AccountViewIdentityV1, RuntimeGeneration: view.Execution.Generation, PolicyRevision: view.installation.Revision, NormalizedQueryDigest: accountViewQueryDigest(view.Request.Query)} - if old != nil && (old.AccountViewIdentityV1 != current.AccountViewIdentityV1 || old.NormalizedQueryDigest != current.NormalizedQueryDigest) { - return nil, ErrAccountJobIdempotencyConflict - } - fields := map[string]json.RawMessage{} - if len(metadata) > 0 && json.Unmarshal(metadata, &fields) != nil { - return nil, ErrAccountJobInvalidMetadata - } - if fields == nil { - fields = map[string]json.RawMessage{} - } - fields["account_view"], _ = json.Marshal(current) - return json.Marshal(fields) -} - -func (m *PluginManager) BindAccountJobView(ctx context.Context, metadata, payload json.RawMessage, allowNewGeneration bool) (context.Context, func(), error) { - stored, err := AccountJobViewExecution(metadata) - if err != nil { - return nil, nil, err - } - if stored == nil { - if _, err := accountJobPayloadWithView(context.Background(), payload); err != nil { - return nil, nil, err - } - return ctx, func() {}, nil - } - request, err := accountJobPayloadView(payload) - if err != nil { - return nil, nil, err - } - if request == nil || stored.AccountViewIdentityV1 != request.AccountViewIdentityV1 || stored.NormalizedQueryDigest != accountViewQueryDigest(request.Query) { - return nil, nil, ErrAccountViewUnavailable - } - if caller, bound := AccountViewFromContext(ctx); bound && !accountViewJSONEqual(caller.Request, request) { - return nil, nil, ErrAccountJobIdempotencyConflict - } - bound, release, err := m.BindAccountViewRequest(ctx, *request) - if err != nil { - return nil, nil, err - } - view, _ := AccountViewFromContext(bound) - if !allowNewGeneration && stored.RuntimeGeneration != view.Execution.Generation { - release() - return nil, nil, ErrAccountViewUnavailable - } - return bound, release, nil -} - -func bindRecordedAccountJobView(ctx context.Context, metadata, payload json.RawMessage, allowNewGeneration bool) (context.Context, func(), error) { - stored, err := AccountJobViewExecution(metadata) - if err != nil { - return nil, nil, err - } - if stored == nil { - return ctx, func() {}, nil - } - provider := processExtensionOperations.Load() - if provider == nil { - return nil, nil, ErrAccountViewUnavailable - } - binder, ok := provider.invoker.(interface { - BindAccountJobView(context.Context, json.RawMessage, json.RawMessage, bool) (context.Context, func(), error) - }) - if !ok { - return nil, nil, ErrAccountViewUnavailable - } - return binder.BindAccountJobView(ctx, metadata, payload, allowNewGeneration) -} - -func accountViewJobTargets(kind string, payload json.RawMessage, targets []*int64) ([]int64, error) { - var ids []int64 - switch kind { - case AccountJobKindDuplicateReview: - var request struct { - AccountIDs []int64 `json:"account_ids"` - } - if json.Unmarshal(payload, &request) != nil || len(request.AccountIDs) < 2 || len(request.AccountIDs) > AccountJobBatchSize { - return nil, ErrAccountViewScope - } - ids = request.AccountIDs - case AccountJobKindDuplicateMerge: - var request struct { - SurvivorAccountID int64 `json:"survivor_account_id"` - LoserAccountIDs []int64 `json:"loser_account_ids"` - } - if json.Unmarshal(payload, &request) != nil || request.SurvivorAccountID <= 0 || len(request.LoserAccountIDs) == 0 || len(request.LoserAccountIDs) >= AccountJobBatchSize || len(targets) != 1 || targets[0] == nil || *targets[0] != request.SurvivorAccountID { - return nil, ErrAccountViewScope - } - ids = append([]int64{request.SurvivorAccountID}, request.LoserAccountIDs...) - default: - for _, target := range targets { - if target == nil { - return nil, ErrAccountViewScope - } - ids = append(ids, *target) - } - } - for _, id := range ids { - if id <= 0 { - return nil, ErrAccountViewScope - } - } - if len(ids) == 0 { - return nil, ErrAccountViewScope - } - return ids, nil -} - -func accountViewJobSeedTargets(kind string, payload json.RawMessage, seeds []AccountJobItemSeed) ([]int64, error) { - targets := make([]*int64, 0, len(seeds)) - for _, seed := range seeds { - targets = append(targets, seed.TargetAccountID) - } - return accountViewJobTargets(kind, payload, targets) -} - -func validateAccountJobViewExecution(ctx context.Context, job *AccountJob, payload json.RawMessage, items []AccountJobItem) error { - if err := validateAccountViewJobKind(ctx, job.Kind, job.Metadata); err != nil { - return err - } - stored, err := AccountJobViewExecution(job.Metadata) - if err != nil { - return err - } - view, bound := AccountViewFromContext(ctx) - if stored == nil { - if bound { - return ErrAccountViewUnavailable - } - return nil - } - if !bound || stored.AccountViewIdentityV1 != view.Request.AccountViewIdentityV1 || stored.RuntimeGeneration != view.Execution.Generation { - return ErrAccountViewUnavailable - } - if err := ValidateAccountViewFresh(ctx); err != nil { - return err - } - // These two named operations intentionally target their complete fixed domain, - // not the originating view's active preset, visible rows or captured search. - if isCindyCleanupAccountJob(job.Kind) { - return nil - } - targets := make([]*int64, 0, len(items)) - for _, item := range items { - targets = append(targets, item.TargetAccountID) - } - ids, err := accountViewJobTargets(job.Kind, payload, targets) - if err != nil { - return err - } - return ValidateAccountViewTargets(ctx, ids) -} - -// The native merge continuation loads the original review selection/query only -// inside the host. It never exposes the encrypted request in public job metadata. -func (s *AccountJobService) AccountViewFromReviewJob(ctx context.Context, jobID, actorID int64) (*extensionv1.AccountViewContextV1, []int64, error) { - job, err := s.repo.Get(ctx, jobID) - if err != nil { - return nil, nil, err - } - if job == nil || job.Kind != AccountJobKindDuplicateReview || job.CreatedBy != actorID || job.Status != AccountJobStatusSucceeded { - return nil, nil, ErrAccountViewScope - } - cipher, expires, err := s.repo.Payload(ctx, jobID) - if err != nil || cipher == "" || time.Now().UTC().After(expires) { - return nil, nil, ErrAccountJobPayloadExpired - } - plaintext, err := s.encryptor.Decrypt(cipher) - if err != nil { - return nil, nil, ErrAccountJobPayloadExpired - } - payload := json.RawMessage(plaintext) - view, err := accountJobPayloadView(payload) - if err != nil { - return nil, nil, err - } - stored, err := AccountJobViewExecution(job.Metadata) - if err != nil { - return nil, nil, err - } - if (stored == nil) != (view == nil) || (stored != nil && (stored.AccountViewIdentityV1 != view.AccountViewIdentityV1 || stored.NormalizedQueryDigest != accountViewQueryDigest(view.Query))) { - return nil, nil, ErrAccountViewUnavailable - } - ids, err := accountViewJobTargets(job.Kind, payload, nil) - return view, ids, err -} - -func prepareAccountJobSubmission(ctx context.Context, metadata json.RawMessage, kind string) (context.Context, json.RawMessage, func(), error) { - if err := validateAccountViewJobKind(ctx, kind, metadata); err != nil { - return nil, nil, nil, err - } - owner, err := AccountJobPluginExecution(metadata) - if err != nil { - return nil, nil, nil, err - } - if execution, bound := PluginExecutionFromContext(ctx); bound { - raw, err := stampAccountJobPlugin(metadata, execution) - return ctx, raw, func() {}, err - } - if owner.ID == 0 { - return ctx, metadata, func() {}, nil - } - bound, release, err := bindAccountJobPlugin(ctx, owner, kind) - if err != nil { - return nil, nil, nil, err - } - execution, _ := PluginExecutionFromContext(bound) - raw, err := stampAccountJobPlugin(metadata, execution) - if err != nil { - release() - return nil, nil, nil, err - } - return bound, raw, release, nil -} diff --git a/backend/internal/service/account_job_plugin_test.go b/backend/internal/service/account_job_plugin_test.go deleted file mode 100644 index 1e75a6a1d24a..000000000000 --- a/backend/internal/service/account_job_plugin_test.go +++ /dev/null @@ -1,189 +0,0 @@ -//go:build unit - -package service - -import ( - "context" - "encoding/json" - "strings" - "sync/atomic" - "testing" - "time" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - hcplugin "github.com/hashicorp/go-plugin" - "github.com/stretchr/testify/require" -) - -type accountJobLeaseFixture struct { - pluginTokenRepository - held atomic.Int32 -} - -func (r *accountJobLeaseFixture) HoldPluginRuntime(context.Context, *PluginInstallation) (func(), error) { - r.held.Add(1) - return func() { r.held.Add(-1) }, nil -} -func accountJobPolicyManager() (*PluginManager, *pluginRuntime, *accountJobLeaseFixture) { - installation := &PluginInstallation{ID: 7, PluginKey: "fixture.jobs", Version: "1.0.0", RuntimeGeneration: 3, PackageSHA256: strings.Repeat("a", 64), State: PluginStateEnabled, Bindings: []PluginBinding{{Capability: extensionv1.CapabilityAdmin, Platform: "*", AccountType: "*", Enabled: true, RolloutPercent: 100}}} - repo := &accountJobLeaseFixture{pluginTokenRepository: pluginTokenRepository{installation: installation}} - manager := NewPluginManager(repo, pluginTokenEncryptor{}, nil, PluginHostInfo{}, nil) - runtime := &pluginRuntime{installation: installation, client: hcplugin.NewClient(&hcplugin.ClientConfig{}), done: make(chan struct{})} - manager.extensions.Store(&pluginExtensionRegistry{installations: map[int64]*PluginInstallation{7: installation}, runtimes: map[int64]*pluginRuntime{7: runtime}}) - return manager, runtime, repo -} - -type accountJobHostFixture struct{ calls int } - -func (f *accountJobHostFixture) ExecuteAccountJob(context.Context, *AccountJob, json.RawMessage, []AccountJobItem) ([]AccountJobExecutionResult, error) { - f.calls++ - return nil, nil -} - -func TestPluginOwnedHostJobHoldsUpdateLeaseUntilCleanup(t *testing.T) { - manager, runtime, repo := accountJobPolicyManager() - core := &accountJobHostFixture{} - executor := NewPluginJobExecutor(manager, core) - job := &AccountJob{Kind: AccountJobKindImportData, Metadata: []byte(`{"plugin_id":7,"plugin_generation":2}`)} - _, _, err := executor.PrepareAccountJob(context.Background(), job, []byte(`{}`)) - require.ErrorIs(t, err, ErrAccountJobPluginUnavailable) - require.Zero(t, repo.held.Load()) - job.Metadata = []byte(`{"plugin_id":7,"plugin_generation":3}`) - ctx, release, err := executor.PrepareAccountJob(context.Background(), job, []byte(`{}`)) - require.NoError(t, err) - require.EqualValues(t, 1, repo.held.Load()) - runtime.beginDrain() - require.ErrorIs(t, ctx.Err(), context.Canceled) - _, err = executor.ExecuteAccountJob(ctx, job, []byte(`{}`), []AccountJobItem{{ID: 1}}) - require.ErrorIs(t, err, context.Canceled) - require.Zero(t, core.calls) - require.EqualValues(t, 1, repo.held.Load(), "the old host task still owns its fence until cleanup") - release() - require.Zero(t, repo.held.Load()) - require.Zero(t, runtime.inFlight.Load()) -} - -func TestAccountJobRetryPreservesOwnerAndAdmitsCurrentGeneration(t *testing.T) { - manager, _, plugins := accountJobPolicyManager() - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - processExtensionOperations.Store(&extensionOperationProvider{invoker: manager}) - repo := newAccountJobTestRepo() - jobs := NewAccountJobService(repo, accountJobTestCipher{}) - ctx := WithPluginExecution(context.Background(), plugins.installation) - job, _, err := jobs.Submit(ctx, 9, AccountJobKindImportData, "original", []byte(`{"fixture":true}`), []byte(`{"operation":"fixture"}`), []AccountJobItemSeed{{Ordinal: 1}}) - require.NoError(t, err) - owner, err := AccountJobPluginExecution(job.Metadata) - require.NoError(t, err) - require.Equal(t, PluginExecution{ID: 7, Generation: 3}, owner) - repo.jobs[job.ID].Status = AccountJobStatusFailed - repo.items[job.ID][0].Status = AccountJobItemStatusFailed - plugins.installation.RuntimeGeneration = 4 - retry, _, err := jobs.RetryFailed(context.Background(), job.ID, 9, "retry") - require.NoError(t, err) - owner, err = AccountJobPluginExecution(retry.Metadata) - require.NoError(t, err) - require.Equal(t, PluginExecution{ID: 7, Generation: 4}, owner) - require.Contains(t, string(retry.Metadata), `"operation":"fixture"`) - require.Zero(t, plugins.held.Load()) - plugins.installation.Bindings[0].RolloutPercent = 50 - _, _, err = jobs.RetryFailed(context.Background(), job.ID, 9, "narrowed-retry") - require.ErrorIs(t, err, ErrAccountJobPluginUnavailable, "retry cannot expand a narrowed import binding") - plugins.installation.State = PluginStateDisabled - _, _, err = jobs.RetryFailed(context.Background(), job.ID, 9, "disabled-retry") - require.ErrorIs(t, err, ErrAccountJobPluginUnavailable) - require.Len(t, repo.jobs, 2) -} - -func TestPluginAdminSubmissionUsesTheActualAccountScopeAndRollout(t *testing.T) { - manager, _, plugins := accountJobPolicyManager() - plugins.installation.Manifest.Contributions = []extensionv1.Contribution{{ID: "fixture", Slot: "account.actions", Action: "fixture.run", Permission: "admin", AccountFilter: &extensionv1.AccountFilter{Platforms: []string{PlatformOpenAI}, Types: []string{AccountTypeOAuth}, ExcludeShadows: true}}} - manager.accountDirectory = &resourceAccountDirectory{accounts: map[int64]extensionv1.Account{ - 37: {ID: 37, Platform: PlatformOpenAI, Type: AccountTypeOAuth}, - 38: {ID: 38, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, - }} - require.NoError(t, manager.ValidateAdminExtension(context.Background(), 7, 37, "fixture.run")) - require.Error(t, manager.ValidateAdminExtension(context.Background(), 7, 38, "fixture.run")) - plugins.installation.Bindings[0].RolloutPercent = int(stablePluginBucket(37)) - require.Error(t, manager.ValidateAdminExtension(context.Background(), 7, 37, "fixture.run")) -} - -func TestLegacyImportJobUsesCurrentPluginLifetimeWithoutRewritingHistory(t *testing.T) { - manager, runtime, plugins := accountJobPolicyManager() - plugins.installation.Manifest.Operations = map[string][]string{extensionv1.CapabilityAdmin: {"import.plan"}} - executor := NewPluginJobExecutor(manager, &accountJobHostFixture{}) - job := &AccountJob{Kind: AccountJobKindImportData, Metadata: []byte(`{}`)} - ctx, release, err := executor.PrepareAccountJob(context.Background(), job, []byte(`{}`)) - require.NoError(t, err) - owner, bound := PluginExecutionFromContext(ctx) - require.True(t, bound) - require.Equal(t, PluginExecution{ID: 7, Generation: 3}, owner) - require.JSONEq(t, `{}`, string(job.Metadata)) - runtime.beginDrain() - require.ErrorIs(t, ctx.Err(), context.Canceled) - release() - require.Zero(t, plugins.held.Load()) -} - -type pluginJobCancellationRepo struct { - *accountJobTestRepo - cancels atomic.Int32 - badContext atomic.Bool -} - -func (r *pluginJobCancellationRepo) Cancel(ctx context.Context, _ int64, actor int64) (*AccountJob, error) { - if ctx.Err() != nil || actor <= 0 { - r.badContext.Store(true) - } - r.cancels.Add(1) - return nil, nil -} - -type pluginJobCancellationExecutor struct { - wait bool - started chan struct{} -} - -func (e *pluginJobCancellationExecutor) ExecuteAccountJob(ctx context.Context, _ *AccountJob, _ json.RawMessage, items []AccountJobItem) ([]AccountJobExecutionResult, error) { - close(e.started) - if e.wait { - <-ctx.Done() - return nil, ctx.Err() - } - return []AccountJobExecutionResult{{ItemID: items[0].ID, Status: AccountJobItemStatusSucceeded, Metadata: []byte(`{}`)}}, nil -} - -func TestPluginJobCompletionDoesNotCancelButPolicyStopPersistsCancellation(t *testing.T) { - for _, stop := range []bool{false, true} { - manager, plugin, plugins := accountJobPolicyManager() - plugins.installation.Manifest.Operations = map[string][]string{extensionv1.CapabilityAdmin: {"import.plan"}} - repo := &pluginJobCancellationRepo{accountJobTestRepo: newAccountJobTestRepo()} - jobs := NewAccountJobService(repo, accountJobTestCipher{}) - job, _, err := jobs.Submit(context.Background(), 9, AccountJobKindImportData, "fixture", []byte(`{}`), nil, []AccountJobItemSeed{{Ordinal: 1}}) - require.NoError(t, err) - core := &pluginJobCancellationExecutor{wait: stop, started: make(chan struct{})} - runner := &AccountJobRuntime{jobs: jobs, executor: NewPluginJobExecutor(manager, core), ctx: context.Background()} - done := make(chan struct{}) - go func() { defer close(done); runner.execute(job) }() - select { - case <-core.started: - case <-time.After(time.Second): - t.Fatal("host job did not start") - } - if stop { - plugin.beginDrain() - } - select { - case <-done: - case <-time.After(time.Second): - t.Fatal("host job did not finish") - } - if stop { - require.EqualValues(t, 1, repo.cancels.Load()) - } else { - require.Zero(t, repo.cancels.Load()) - } - require.False(t, repo.badContext.Load()) - require.Zero(t, plugins.held.Load()) - } -} diff --git a/backend/internal/service/account_test_prompt.go b/backend/internal/service/account_test_prompt.go index eb31a310092f..94872fbd8574 100644 --- a/backend/internal/service/account_test_prompt.go +++ b/backend/internal/service/account_test_prompt.go @@ -6,7 +6,7 @@ import ( "strings" "unicode/utf8" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/gin-gonic/gin" ) diff --git a/backend/internal/service/account_test_prompt_test.go b/backend/internal/service/account_test_prompt_test.go index ab60a3678271..dc5d8c077b89 100644 --- a/backend/internal/service/account_test_prompt_test.go +++ b/backend/internal/service/account_test_prompt_test.go @@ -11,7 +11,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) @@ -100,18 +100,6 @@ func TestAccountTestPromptAdaptiveAndOpenCodeFinalRequests(t *testing.T) { } } -func TestDisabledTextPromptExtensionDoesNotDispatch(t *testing.T) { - previous := processExtensionOperations.Load() - processExtensionOperations.Store(nil) - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - account := adaptiveCNAccountTestAccount(993, PlatformDeepseek) - svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNChatTestResponse()) - c, _ := newTestContext() - err := svc.TestAccountConnection(c, account.ID, "deepseek-v4-pro", "custom text", "") - require.Error(t, err) - require.Empty(t, upstream.requests) -} - func TestAccountTestPromptOAuthFinalTicketUsesMappedModel(t *testing.T) { cfg := &config.Config{Gateway: config.GatewayConfig{OpenAICodexTicket: config.OpenAICodexTicketConfig{Enabled: true, FailClosed: true}, OpenAICodexRequestZstd: true}} a := ticketTestAccount(41) @@ -125,7 +113,7 @@ func TestAccountTestPromptOAuthFinalTicketUsesMappedModel(t *testing.T) { // Cookie routing replaced the 292 header: the plugin only references a // host-verified qualification, gated like business traffic on the final model. var qualification *extensionv1.CodexRoutingQualification - gateway.pluginManager = ticketTestManager(t, cfg.Gateway.OpenAICodexTicket, func(in extensionv1.Invocation) (extensionv1.Result, error) { + gateway.nativeCodexRuntime = nativeTicketTestRuntime(t, cfg.Gateway.OpenAICodexTicket, func(in extensionv1.Invocation) (extensionv1.Result, error) { if in.Operation == "codex.routing.observe" { return extensionv1.Result{Payload: json.RawMessage(`{}`)}, nil } @@ -138,10 +126,9 @@ func TestAccountTestPromptOAuthFinalTicketUsesMappedModel(t *testing.T) { raw, _ := json.Marshal(extensionv1.CodexRoutingInjection{Headers: map[string]string{}, Qualification: qualification}) return extensionv1.Result{Payload: raw}, nil }) - installation := gateway.pluginManager.extensions.Load().installations[1] - installation.State = PluginStateEnabled - store := &routingMemoryStore{PluginRepository: &pluginTokenRepository{installation: installation}, values: map[string]extensionv1.StateResult{}} - gateway.pluginManager.repo = store + store := &routingMemoryStore{values: map[string]extensionv1.StateResult{}} + gateway.nativeCodexRuntime.repo = store + scope, err := gateway.PrepareCodexRoutingScope(t.Context(), a.ID, "http") require.NoError(t, err) scope.ConnectionLeaseID, scope.RouteEvidence = "account-test-connection", "connection" diff --git a/backend/internal/service/account_test_reasoning.go b/backend/internal/service/account_test_reasoning.go index 134de2c0c5f0..67333c702ec4 100644 --- a/backend/internal/service/account_test_reasoning.go +++ b/backend/internal/service/account_test_reasoning.go @@ -5,8 +5,8 @@ import ( "errors" "strings" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/gin-gonic/gin" ) diff --git a/backend/internal/service/account_tools.go b/backend/internal/service/account_tools.go new file mode 100644 index 000000000000..f80f5c9584b9 --- /dev/null +++ b/backend/internal/service/account_tools.go @@ -0,0 +1,67 @@ +package service + +import ( + "context" + "encoding/json" + "time" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +func accountToolsOperation(ctx context.Context, operation string, input, output any) error { + return accountToolsOperationScoped(ctx, 0, operation, input, output) +} + +func accountToolsOperationForAccount(ctx context.Context, account *Account, operation string, input, output any) error { + if account == nil { + return ErrExtensionOperationUnavailable + } + return accountToolsOperationScoped(ctx, account.ID, operation, input, output) +} + +func accountToolsOperationScoped(ctx context.Context, accountID int64, operation string, input, output any) error { + raw, err := json.Marshal(input) + if err != nil { + return err + } + call, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + result, err := invokeAccountTools(call, extensionv1.Invocation{Capability: extensionv1.CapabilityAdmin, Operation: operation, AccountID: accountID, Payload: raw}) + if err != nil { + return infraerrors.New(503, "ACCOUNT_TOOLS_UNAVAILABLE", "account tools are unavailable") + } + if result.Code != "" { + status := result.HTTPStatus + if status < 400 || status > 499 { + status = 503 + } + return infraerrors.New(status, result.Code, result.Message) + } + if output != nil { + if err := json.Unmarshal(result.Payload, output); err != nil { + return err + } + } + return nil +} + +func PlanBatchAccountTests(ctx context.Context, request extensionv1.BatchTestPlanningRequest) (extensionv1.BatchTestPlan, error) { + var plan extensionv1.BatchTestPlan + err := accountToolsOperation(ctx, "test.batch", request, &plan) + return plan, err +} + +func ValidateAccountTaxonomyPlan(ctx context.Context, plan extensionv1.TaxonomyBulkPlan) error { + return accountToolsOperation(ctx, "taxonomy.bulk", plan, nil) +} + +func normalizeAccountTaxonomyNameContext(ctx context.Context, value string) (string, string, error) { + var normalized extensionv1.TaxonomyName + err := accountToolsOperation(ctx, "taxonomy.name", extensionv1.TaxonomyName{Name: value}, &normalized) + return normalized.Name, normalized.Normalized, err +} + +func validateTaxonomyOrderIDsContext(ctx context.Context, actual, ordered []int64) error { + return accountToolsOperation(ctx, "taxonomy.order", extensionv1.TaxonomyOrder{Actual: actual, Ordered: ordered}, nil) +} diff --git a/backend/internal/service/account_tools_module.go b/backend/internal/service/account_tools_module.go new file mode 100644 index 000000000000..e53dd9307df2 --- /dev/null +++ b/backend/internal/service/account_tools_module.go @@ -0,0 +1,19 @@ +package service + +import ( + "context" + "sync" + + accounttoolspolicy "github.com/Wei-Shaw/sub2api/internal/accounttools/policy" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" +) + +// accountTools answers the account classification, test and import planning +// operations in process. The account-tools plugin served them before. +var accountTools = sync.OnceValue(accounttoolspolicy.New) + +// invokeAccountTools runs one account tools operation. Tests replace it to +// model rejected plans. +var invokeAccountTools = func(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { + return accountTools().Invoke(ctx, in) +} diff --git a/backend/internal/service/account_traffic_observe.go b/backend/internal/service/account_traffic_observe.go index 2537353e34e0..76e04710af8a 100644 --- a/backend/internal/service/account_traffic_observe.go +++ b/backend/internal/service/account_traffic_observe.go @@ -8,8 +8,8 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "go.uber.org/zap" ) @@ -119,22 +119,18 @@ type AccountTrafficObserver struct { cache AccountTrafficObserveCache } -// The legacy gateway flag is imported during plugin bootstrap. Runtime intent -// and classification come from the observability plugin. +// Whether turns are recorded follows the admin_observability_config setting, +// whose default is the legacy gateway flag. func NewAccountTrafficObserver(cache AccountTrafficObserveCache, _ *config.Config) *AccountTrafficObserver { return &AccountTrafficObserver{ cache: cache, } } -// Enabled reports unscoped plugin availability for management displays. It is -// not account admission: Begin and Snapshot resolve the actual account scope. +// Enabled reports whether telemetry is switched on, for management displays. +// Begin and Snapshot additionally require a concrete account identity. func (o *AccountTrafficObserver) Enabled() bool { - if o == nil || o.cache == nil { - return false - } - policy, ok := currentTrafficObservationPolicy(context.Background()) - return ok && policy.Enabled + return o != nil && o.cache != nil && currentAdminObservabilityConfig().TelemetryEnabled } // Begin records one started turn for account/protocol and samples in-flight @@ -147,7 +143,7 @@ func (o *AccountTrafficObserver) Begin(ctx context.Context, account *Account, pr if ctx == nil { ctx = context.Background() } - policy, available := currentAccountTrafficObservationPolicy(ctx, account) + policy, available := currentAccountTrafficObservationPolicy(account) if !available || !policy.Enabled { return nil } @@ -173,7 +169,7 @@ func (o *AccountTrafficObserver) Snapshot(ctx context.Context, account *Account) if ctx == nil { ctx = context.Background() } - policy, available := currentAccountTrafficObservationPolicy(ctx, account) + policy, available := currentAccountTrafficObservationPolicy(account) if !available || !policy.Enabled { return nil, ErrAccountTrafficTelemetryUnavailable } @@ -218,13 +214,9 @@ func (t *AccountTrafficTurn) Finish(result *OpenAIForwardResult, err error, clie } // Compatibility facade for callers without a begun turn. Started observations -// instead evaluate the immutable rule table captured before incrementing. +// instead evaluate the rule table captured before incrementing. func classifyAccountTrafficOutcome(result *OpenAIForwardResult, err error, clientCancelled bool) AccountTrafficOutcome { - policy, ok := currentTrafficObservationPolicy(context.Background()) - if !ok { - return AccountTrafficOutcomeFailedOther - } - return evaluateAccountTrafficOutcome(policy.Classification, result, err, clientCancelled) + return evaluateAccountTrafficOutcome(accountTrafficOutcomeRules, result, err, clientCancelled) } func evaluateAccountTrafficOutcome(table extensionv1.DecisionTable, result *OpenAIForwardResult, err error, clientCancelled bool) AccountTrafficOutcome { diff --git a/backend/internal/service/account_view_facts.go b/backend/internal/service/account_view_facts.go new file mode 100644 index 000000000000..f8375c6de787 --- /dev/null +++ b/backend/internal/service/account_view_facts.go @@ -0,0 +1,26 @@ +package service + +import "time" + +// Native, non-secret scalar facts shared by the full and compact account DTOs. +type AccountViewFactsV1 struct { + Version int `json:"version"` + Status string `json:"status"` + Plan string `json:"plan"` + PrivacyMode string `json:"privacy_mode"` + CanonicalCindy bool `json:"canonical_cindy"` + CindyBalanceInsufficient bool `json:"cindy_balance_insufficient"` + CindyBanned bool `json:"cindy_banned"` +} + +func AccountViewFactsFromAccount(account *Account, now time.Time) *AccountViewFactsV1 { + if account == nil { + return nil + } + privacy, _ := account.Extra["privacy_mode"].(string) + return &AccountViewFactsV1{Version: 1, Status: accountConsoleStatus(account, now), Plan: accountConsolePlan(account), PrivacyMode: privacy, CanonicalCindy: accountViewCanonicalCindy(account), CindyBalanceInsufficient: account.CindyBalanceInsufficientAt != nil, CindyBanned: account.CindyBannedAt != nil} +} + +func accountViewCanonicalCindy(account *Account) bool { + return account != nil && account.Platform == PlatformCindy && account.WirePlatform == WirePlatformOpenAI && account.ProviderProfile == ProviderProfileCindyLaxaV1 && IsCindyAPIKeyAccount(account.Platform, account.Type, account.Credentials) +} diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go index af0fa0668edd..86f8071b139d 100644 --- a/backend/internal/service/admin_account.go +++ b/backend/internal/service/admin_account.go @@ -645,7 +645,7 @@ func (s *adminServiceImpl) createAccount(ctx context.Context, input *CreateAccou } } } - if create, bound := AccountCreateFromContext(ctx); canonicalCreate && bound && len(uniquePositiveIDs(groupIDs)) < create.contribution.AccountCreate.MinimumEffectiveGroups { + if create, bound := AccountCreateFromContext(ctx); canonicalCreate && bound && len(uniquePositiveIDs(groupIDs)) < create.MinimumEffectiveGroups { return nil, ErrAccountCreateGroupsRequired } @@ -719,9 +719,6 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U if input == nil { return nil, ErrAccountEditInvalid } - if err := ValidateAccountViewTargets(ctx, []int64{id}); err != nil { - return nil, err - } current, err := s.accountRepo.GetByID(ctx, id) if err != nil { return nil, err diff --git a/backend/internal/service/admin_account_taxonomy.go b/backend/internal/service/admin_account_taxonomy.go index 74d99127cdac..29ea0920a881 100644 --- a/backend/internal/service/admin_account_taxonomy.go +++ b/backend/internal/service/admin_account_taxonomy.go @@ -14,8 +14,8 @@ import ( dbaccounttag "github.com/Wei-Shaw/sub2api/ent/accounttag" dbaccounttagbinding "github.com/Wei-Shaw/sub2api/ent/accounttagbinding" "github.com/Wei-Shaw/sub2api/ent/schema/mixins" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" entsql "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqljson" @@ -81,7 +81,6 @@ type AccountFacetOption struct { } type AccountConsoleFacets struct { - ViewPresetCounts map[string]int `json:"view_preset_counts,omitempty"` Total int `json:"total"` UncategorizedCount int `json:"uncategorized_count"` Platforms []AccountFacetOption `json:"platforms"` @@ -193,7 +192,7 @@ func (s *adminServiceImpl) UpdateAccountFolder(ctx context.Context, id int64, in } func (s *adminServiceImpl) DeleteAccountFolder(ctx context.Context, id int64, moveAccounts bool) error { - if err := accountToolsOperation(ctx, "*", "*", "taxonomy.delete", map[string]int64{"id": id}, nil); err != nil { + if err := accountToolsOperation(ctx, "taxonomy.delete", map[string]int64{"id": id}, nil); err != nil { return err } tx, err := s.entClient.Tx(ctx) @@ -330,7 +329,7 @@ func (s *adminServiceImpl) UpdateAccountTag(ctx context.Context, id int64, input } func (s *adminServiceImpl) DeleteAccountTag(ctx context.Context, id int64) error { - if err := accountToolsOperation(ctx, "*", "*", "taxonomy.delete", map[string]int64{"id": id}, nil); err != nil { + if err := accountToolsOperation(ctx, "taxonomy.delete", map[string]int64{"id": id}, nil); err != nil { return err } err := s.entClient.AccountTag.DeleteOneID(id).Exec(ctx) @@ -414,7 +413,7 @@ func uniquePositiveIDs(ids []int64) []int64 { func (s *adminServiceImpl) BulkUpdateAccountTaxonomy(ctx context.Context, input BulkAccountTaxonomyInput) (*BulkAccountTaxonomyResult, error) { plan := extensionv1.TaxonomyBulkPlan{AccountIDs: input.AccountIDs, HasFilters: input.Filters != nil, ExpectedMatchCount: input.ExpectedMatchCount, FolderAction: input.FolderAction, FolderID: input.FolderID, TagAddIDs: input.TagAddIDs, TagRemoveIDs: input.TagRemoveIDs} - if err := accountToolsOperation(ctx, "*", "*", "taxonomy.bulk", plan, nil); err != nil { + if err := accountToolsOperation(ctx, "taxonomy.bulk", plan, nil); err != nil { return nil, err } filteredTarget := input.Filters != nil @@ -607,7 +606,7 @@ func (s *adminServiceImpl) hydrateAccountTaxonomy(ctx context.Context, accounts func (s *adminServiceImpl) SetAccountTaxonomy(ctx context.Context, accountID int64, assignment AccountTaxonomyAssignment) (*Account, error) { var plan extensionv1.TaxonomyAssignmentPlan - if err := accountToolsOperation(ctx, "*", "*", "taxonomy.assignment", extensionv1.TaxonomyAssignmentPlan{FolderID: assignment.FolderID, TagIDs: assignment.TagIDs}, &plan); err != nil { + if err := accountToolsOperation(ctx, "taxonomy.assignment", extensionv1.TaxonomyAssignmentPlan{FolderID: assignment.FolderID, TagIDs: assignment.TagIDs}, &plan); err != nil { return nil, err } if err := validateTaxonomyAssignmentIntent(assignment, plan); err != nil { @@ -919,10 +918,6 @@ func (s *adminServiceImpl) listAccountConsoleAll(ctx context.Context, filters Ac if err := s.hydrateAccountTaxonomy(ctx, accounts); err != nil { return nil, err } - accounts, err = filterAccountViewAccounts(ctx, accounts) - if err != nil { - return nil, err - } accounts = filterConsoleAccounts(accounts, filters) if filters.SortBy == "upstream_billing_rate" { now := time.Now() @@ -1163,19 +1158,7 @@ func filterAccountsForFacet(accounts []*Account, matcher accountFacetMatcher, ig } func (s *adminServiceImpl) GetAccountConsoleFacets(ctx context.Context, filters AccountConsoleFilters) (*AccountConsoleFacets, error) { - var viewPresetCounts map[string]int - if view, bound := AccountViewFromContext(ctx); bound { - viewPresetCounts = make(map[string]int, len(view.contribution.AccountView.Presets)) - common := filters - common.CindyOnly, common.CindyBalanceStatus, common.CindyHealthStatus = false, "", "" - for _, preset := range view.contribution.AccountView.Presets { - candidates, err := s.listAccountConsoleAll(accountViewPresetContext(ctx, preset), common) - if err != nil { - return nil, err - } - viewPresetCounts[preset.ID] = accountViewCounter(preset.Counter, candidates) - } - } + baseFilters := filters baseFilters.Platforms = nil baseFilters.Types = nil @@ -1292,7 +1275,6 @@ func (s *adminServiceImpl) GetAccountConsoleFacets(ctx context.Context, filters return strings.ToLower(proxyOptions[i].Label) < strings.ToLower(proxyOptions[j].Label) }) return &AccountConsoleFacets{ - ViewPresetCounts: viewPresetCounts, // Folder navigation always represents the complete result set after all // non-folder filters, so total must use the same population as its counts. Total: len(folderAccounts), UncategorizedCount: uncategorizedCount, diff --git a/backend/internal/service/admin_account_taxonomy_test.go b/backend/internal/service/admin_account_taxonomy_test.go index 165367013054..a39a63f993be 100644 --- a/backend/internal/service/admin_account_taxonomy_test.go +++ b/backend/internal/service/admin_account_taxonomy_test.go @@ -3,6 +3,7 @@ package service import ( "context" "encoding/json" + "errors" "testing" "time" @@ -10,25 +11,18 @@ import ( entsql "entgo.io/ent/dialect/sql" "github.com/DATA-DOG/go-sqlmock" dbent "github.com/Wei-Shaw/sub2api/ent" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - hcplugin "github.com/hashicorp/go-plugin" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" ) -type taxonomyAssignmentPlanFixture struct { - promptPolicyFixture - plan extensionv1.TaxonomyAssignmentPlan +func replaceAccountTools(t *testing.T, invoke func(context.Context, extensionv1.Invocation) (extensionv1.Result, error)) { + t.Helper() + previous := invokeAccountTools + t.Cleanup(func() { invokeAccountTools = previous }) + invokeAccountTools = invoke } -func (f taxonomyAssignmentPlanFixture) InvokeOperation(ctx context.Context, platform, kind string, in extensionv1.Invocation) (extensionv1.Result, error) { - if in.Operation == "taxonomy.assignment" { - raw, err := json.Marshal(f.plan) - return extensionv1.Result{Payload: raw}, err - } - return f.promptPolicyFixture.InvokeOperation(ctx, platform, kind, in) -} - -func TestSetAccountTaxonomyRejectsChangedPluginAssignmentBeforeTransaction(t *testing.T) { +func TestSetAccountTaxonomyRejectsChangedAssignmentPlanBeforeTransaction(t *testing.T) { folder, otherFolder := int64(7), int64(8) for _, tc := range []struct { name string @@ -40,16 +34,17 @@ func TestSetAccountTaxonomyRejectsChangedPluginAssignmentBeforeTransaction(t *te {"omitted-tag", extensionv1.TaxonomyAssignmentPlan{FolderID: &folder, TagIDs: []int64{5}}}, } { t.Run(tc.name, func(t *testing.T) { - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - processExtensionOperations.Store(&extensionOperationProvider{invoker: taxonomyAssignmentPlanFixture{plan: tc.plan}}) + replaceAccountTools(t, func(context.Context, extensionv1.Invocation) (extensionv1.Result, error) { + raw, err := json.Marshal(tc.plan) + return extensionv1.Result{Payload: raw}, err + }) db, mock, err := sqlmock.New() require.NoError(t, err) client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) t.Cleanup(func() { _ = client.Close() }) svc := &adminServiceImpl{entClient: client} account, err := svc.SetAccountTaxonomy(context.Background(), 42, AccountTaxonomyAssignment{FolderID: &folder, TagIDs: []int64{5, 5, 6}}) - require.ErrorIs(t, err, ErrExtensionOperationUnavailable, "a plugin cannot replace or expand the user's assignment") + require.ErrorIs(t, err, ErrExtensionOperationUnavailable, "a plan cannot replace or expand the user's assignment") require.Nil(t, account) require.NoError(t, mock.ExpectationsWereMet(), "no transaction or query should be started for a changed assignment") }) @@ -57,50 +52,25 @@ func TestSetAccountTaxonomyRejectsChangedPluginAssignmentBeforeTransaction(t *te } func TestTaxonomyAssignmentPreservesPolicyNormalizationAndFailsClosed(t *testing.T) { - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - processExtensionOperations.Store(&extensionOperationProvider{invoker: promptPolicyFixture{}}) folder := int64(7) input := AccountTaxonomyAssignment{FolderID: &folder, TagIDs: []int64{6, 5, 6}} var plan extensionv1.TaxonomyAssignmentPlan - require.NoError(t, accountToolsOperation(context.Background(), "*", "*", "taxonomy.assignment", extensionv1.TaxonomyAssignmentPlan{FolderID: input.FolderID, TagIDs: input.TagIDs}, &plan)) - require.Equal(t, []int64{6, 5}, plan.TagIDs, "plugin remains the owner of stable deduplication") + require.NoError(t, accountToolsOperation(context.Background(), "taxonomy.assignment", extensionv1.TaxonomyAssignmentPlan{FolderID: input.FolderID, TagIDs: input.TagIDs}, &plan)) + require.Equal(t, []int64{6, 5}, plan.TagIDs, "the policy owns stable deduplication") require.NoError(t, validateTaxonomyAssignmentIntent(input, plan)) require.NoError(t, validateTaxonomyAssignmentIntent(AccountTaxonomyAssignment{}, extensionv1.TaxonomyAssignmentPlan{})) - processExtensionOperations.Store(nil) + replaceAccountTools(t, func(context.Context, extensionv1.Invocation) (extensionv1.Result, error) { + return extensionv1.Result{}, errors.New("policy failed") + }) svc := &adminServiceImpl{} account, err := svc.SetAccountTaxonomy(context.Background(), 42, input) require.Error(t, err) - require.Nil(t, account, "disabled policy must fail before using the nil database, without a host fallback") + require.Nil(t, account, "a failed policy must stop before using the nil database") result, err := svc.BulkUpdateAccountTaxonomy(context.Background(), BulkAccountTaxonomyInput{AccountIDs: []int64{42}, FolderAction: "clear"}) require.Error(t, err) require.Nil(t, result) } -func TestTaxonomyGlobalDeleteAvailabilityMatchesAccountScopeGate(t *testing.T) { - for _, name := range []string{"taxonomy.folders.delete", "taxonomy.tags.delete"} { - grant := extensionv1.ResourceGrant{Name: name, Capability: extensionv1.CapabilityAdmin, Permission: "admin"} - installation := &PluginInstallation{ID: 7, RuntimeGeneration: 1, State: PluginStateEnabled, - Manifest: PluginManifest{Resources: []extensionv1.ResourceGrant{grant}}, - Bindings: []PluginBinding{{Capability: grant.Capability, Platform: "*", AccountType: "*", Enabled: true, RolloutPercent: 100}}} - manager := NewPluginManager(&pluginTokenRepository{installation: installation}, pluginTokenEncryptor{}, nil, PluginHostInfo{}, nil) - runtime := &pluginRuntime{installation: installation, client: hcplugin.NewClient(&hcplugin.ClientConfig{}), done: make(chan struct{})} - manager.extensions.Store(&pluginExtensionRegistry{installations: map[int64]*PluginInstallation{7: installation}, runtimes: map[int64]*pluginRuntime{7: runtime}}) - for _, scope := range []struct { - platform, kind string - rollout int - allowed bool - }{{"*", "*", 100, true}, {PlatformOpenAI, "*", 100, false}, {"*", AccountTypeOAuth, 100, false}, {"*", "*", 99, false}} { - installation.Bindings[0].Platform, installation.Bindings[0].AccountType, installation.Bindings[0].RolloutPercent = scope.platform, scope.kind, scope.rollout - descriptors, err := manager.ResourceDescriptors(context.Background(), 7, "admin", []extensionv1.ResourceDescriptor{{ResourceGrant: grant, AllAccounts: true, Method: "DELETE", Path: "/fixture"}}) - require.NoError(t, err) - require.Len(t, descriptors, 1) - require.Equal(t, scope.allowed, descriptors[0].Available, "%s: %+v", name, scope) - require.Equal(t, scope.allowed, manager.ValidateResourceAccounts(WithPluginExecution(context.Background(), installation), grant.Capability, nil, true) == nil) - } - } -} - func TestNormalizeAccountTaxonomyNameTrimsAndBuildsCaseInsensitiveKey(t *testing.T) { display, normalized, err := normalizeAccountTaxonomyName(" Production ") require.NoError(t, err) diff --git a/backend/internal/service/admin_cindy_duplicate_inventory.go b/backend/internal/service/admin_cindy_duplicate_inventory.go index 35cf1e2bdbd0..75186ffd01fb 100644 --- a/backend/internal/service/admin_cindy_duplicate_inventory.go +++ b/backend/internal/service/admin_cindy_duplicate_inventory.go @@ -12,21 +12,6 @@ func (s *adminServiceImpl) BuildCindyDuplicateIdentityInventory(ctx context.Cont if err != nil { return nil, err } - if _, bound := AccountViewFromContext(ctx); bound { - candidates := make([]*Account, 0, len(accounts)) - for index := range accounts { - candidates = append(candidates, &accounts[index]) - } - candidates, err = filterAccountViewAccounts(ctx, candidates) - if err != nil { - return nil, err - } - // Aggregate only admitted facts, including the active preset and bucket. - // The native no-view inventory retains its existing candidate semantics. - accounts = make([]Account, 0, len(candidates)) - for _, account := range candidates { - accounts = append(accounts, *account) - } - } + return buildCindyDuplicateIdentityInventory(ctx, accounts) } diff --git a/backend/internal/service/admin_cindy_group_split.go b/backend/internal/service/admin_cindy_group_split.go index 06c2852108d9..08b49e24b735 100644 --- a/backend/internal/service/admin_cindy_group_split.go +++ b/backend/internal/service/admin_cindy_group_split.go @@ -5,8 +5,8 @@ import ( "errors" "fmt" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" ) const ( @@ -168,12 +168,6 @@ func (s *adminServiceImpl) SplitCindyGroup(ctx context.Context, groupID int64, i if groupID <= 0 { return nil, ErrCindyGroupInvalidInput } - bound, release, err := bindProcessExtensionContext(ctx, PlatformCindy, AccountTypeAPIKey, extensionv1.Invocation{Capability: extensionv1.CapabilityProvider, Operation: "cindy.groups.partition"}) - if err != nil { - return nil, ErrCindyGroupAdminUnavailable - } - defer release() - ctx = bound normalized, err := normalizeCindyGroupSplitInputContext(ctx, input, true) if err != nil { return nil, err diff --git a/backend/internal/service/admin_observability.go b/backend/internal/service/admin_observability.go new file mode 100644 index 000000000000..ebbabc08ede9 --- /dev/null +++ b/backend/internal/service/admin_observability.go @@ -0,0 +1,74 @@ +package service + +import ( + "sync/atomic" + + "github.com/Wei-Shaw/sub2api/internal/config" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" +) + +var adminObservabilityConfigOverride atomic.Pointer[extensionv1.AdminObservabilityConfig] + +// LegacyAdminObservabilityConfig is the deploy-time default: account traffic +// telemetry follows the legacy gateway flag and the flat theme is on. +func LegacyAdminObservabilityConfig(cfg *config.Config) extensionv1.AdminObservabilityConfig { + return extensionv1.AdminObservabilityConfig{TelemetryEnabled: cfg == nil || !cfg.Gateway.AccountTrafficTelemetryDisabled, ThemeEnabled: true} +} + +// ConfigureAdminObservability installs the effective telemetry and theme +// switches (startup load, admin update, tests). A nil value restores the +// built-in default. +func ConfigureAdminObservability(config *extensionv1.AdminObservabilityConfig) { + adminObservabilityConfigOverride.Store(config) +} + +func currentAdminObservabilityConfig() extensionv1.AdminObservabilityConfig { + if config := adminObservabilityConfigOverride.Load(); config != nil { + return *config + } + return LegacyAdminObservabilityConfig(nil) +} + +// FlatThemeEnabled reports whether the flat site theme is switched on. +func FlatThemeEnabled() bool { + return currentAdminObservabilityConfig().ThemeEnabled +} + +// currentAccountTrafficObservationPolicy returns the telemetry switch and the +// outcome rules for one account. Accounts without a concrete identity are never +// observed. +func currentAccountTrafficObservationPolicy(account *Account) (extensionv1.TrafficObservationPolicy, bool) { + if account == nil || account.ID <= 0 || account.Platform == "" || account.Platform == "*" || account.Type == "" || account.Type == "*" { + return extensionv1.TrafficObservationPolicy{}, false + } + return extensionv1.TrafficObservationPolicy{Enabled: currentAdminObservabilityConfig().TelemetryEnabled, Classification: accountTrafficOutcomeRules}, true +} + +// accountTrafficOutcomeRules classifies a finished turn. Client cancellation +// outranks errors, and a WebSocket turn counts as completed only with a +// demonstrable terminal event. Every result is an AccountTrafficOutcome. +var accountTrafficOutcomeRules = func() extensionv1.DecisionTable { + eq := func(field, value string) extensionv1.DecisionCondition { + return extensionv1.DecisionCondition{Field: field, Operator: "eq", Value: value} + } + num := func(field, op, value string) extensionv1.DecisionCondition { + return extensionv1.DecisionCondition{Field: field, Operator: op, Value: value} + } + rule := func(result AccountTrafficOutcome, when ...extensionv1.DecisionCondition) extensionv1.DecisionRule { + return extensionv1.DecisionRule{When: when, Result: string(result)} + } + return extensionv1.DecisionTable{Default: string(AccountTrafficOutcomeFailedOther), Rules: []extensionv1.DecisionRule{ + rule(AccountTrafficOutcomeCancelled, eq("client_cancelled", "true")), + rule(AccountTrafficOutcomeCancelled, eq("ws", "true"), eq("terminal", "response.cancelled")), + rule(AccountTrafficOutcomeCancelled, eq("ws", "true"), eq("terminal", "response.incomplete")), + rule(AccountTrafficOutcomeUpstream429, eq("has_error", "true"), eq("error_status", "429")), + rule(AccountTrafficOutcomeUpstream5xx, eq("has_error", "true"), num("error_status", "gte", "500"), num("error_status", "lt", "600")), + rule(AccountTrafficOutcomeFailedOther, eq("has_error", "true")), + rule(AccountTrafficOutcomeFailedOther, eq("has_result", "false")), + rule(AccountTrafficOutcomeCompleted2xx, eq("ws", "false")), + rule(AccountTrafficOutcomeCompleted2xx, eq("terminal", "response.completed")), + rule(AccountTrafficOutcomeCompleted2xx, eq("terminal", "response.done")), + rule(AccountTrafficOutcomeUpstream429, eq("terminal", "response.failed"), eq("terminal_status", "429")), + rule(AccountTrafficOutcomeUpstream5xx, eq("terminal", "response.failed"), num("terminal_status", "gte", "500"), num("terminal_status", "lt", "600")), + }} +}() diff --git a/backend/internal/service/admin_observability_test.go b/backend/internal/service/admin_observability_test.go new file mode 100644 index 000000000000..4039767a5525 --- /dev/null +++ b/backend/internal/service/admin_observability_test.go @@ -0,0 +1,125 @@ +package service + +import ( + "context" + "strconv" + "testing" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + "github.com/stretchr/testify/require" +) + +type trafficScopeCache struct { + beginIDs []int64 + snapshotIDs []int64 + outcomes []AccountTrafficOutcome +} + +func (c *trafficScopeCache) Begin(_ context.Context, id int64, _ AccountTrafficProtocol) error { + c.beginIDs = append(c.beginIDs, id) + return nil +} +func (c *trafficScopeCache) Finish(_ context.Context, _ int64, _ AccountTrafficProtocol, outcome AccountTrafficOutcome) error { + c.outcomes = append(c.outcomes, outcome) + return nil +} +func (c *trafficScopeCache) Snapshot(_ context.Context, id int64) (map[AccountTrafficProtocol]AccountTrafficObserveState, error) { + c.snapshotIDs = append(c.snapshotIDs, id) + return map[AccountTrafficProtocol]AccountTrafficObserveState{AccountTrafficProtocolHTTP: {Started: 1}}, nil +} + +func useAdminObservability(t *testing.T, config extensionv1.AdminObservabilityConfig) { + t.Helper() + previous := adminObservabilityConfigOverride.Load() + t.Cleanup(func() { adminObservabilityConfigOverride.Store(previous) }) + ConfigureAdminObservability(&config) +} + +func TestTrafficObservationFollowsTelemetrySwitch(t *testing.T) { + account := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + for _, enabled := range []bool{false, true} { + t.Run(strconv.FormatBool(enabled), func(t *testing.T) { + useAdminObservability(t, extensionv1.AdminObservabilityConfig{TelemetryEnabled: enabled}) + cache := &trafficScopeCache{} + observer := NewAccountTrafficObserver(cache, nil) + require.Equal(t, enabled, observer.Enabled()) + turn := observer.Begin(context.Background(), account, AccountTrafficProtocolWS) + snapshot, err := observer.Snapshot(context.Background(), account) + if !enabled { + require.Nil(t, turn) + require.ErrorIs(t, err, ErrAccountTrafficTelemetryUnavailable) + require.Empty(t, cache.beginIDs) + require.Empty(t, cache.snapshotIDs) + return + } + require.NotNil(t, turn) + require.NoError(t, err) + require.EqualValues(t, 1, snapshot[AccountTrafficProtocolHTTP].Started) + require.Equal(t, []int64{2}, cache.beginIDs) + require.Equal(t, []int64{2}, cache.snapshotIDs) + }) + } +} + +func TestTrafficObservationRejectsMissingAccountIdentity(t *testing.T) { + useAdminObservability(t, extensionv1.AdminObservabilityConfig{TelemetryEnabled: true}) + cache := &trafficScopeCache{} + observer := NewAccountTrafficObserver(cache, nil) + for _, account := range []*Account{ + nil, + {Platform: PlatformOpenAI, Type: AccountTypeOAuth}, + {ID: 2, Type: AccountTypeOAuth}, + {ID: 2, Platform: PlatformOpenAI}, + {ID: 2, Platform: "*", Type: AccountTypeOAuth}, + {ID: 2, Platform: PlatformOpenAI, Type: "*"}, + } { + require.Nil(t, observer.Begin(context.Background(), account, AccountTrafficProtocolHTTP)) + snapshot, err := observer.Snapshot(context.Background(), account) + require.ErrorIs(t, err, ErrAccountTrafficTelemetryUnavailable) + require.Nil(t, snapshot) + } + require.Empty(t, cache.beginIDs) + require.Empty(t, cache.snapshotIDs) +} + +func TestTrafficObservationFinishesStartedTurnAfterTelemetryIsSwitchedOff(t *testing.T) { + useAdminObservability(t, extensionv1.AdminObservabilityConfig{TelemetryEnabled: true}) + cache := &trafficScopeCache{} + observer := NewAccountTrafficObserver(cache, nil) + account := &Account{ID: 7, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + turn := observer.Begin(context.Background(), account, AccountTrafficProtocolHTTP) + require.NotNil(t, turn) + ConfigureAdminObservability(&extensionv1.AdminObservabilityConfig{}) + turn.Finish(&OpenAIForwardResult{}, nil, false) + turn.Finish(nil, nil, false) + require.Equal(t, []AccountTrafficOutcome{AccountTrafficOutcomeCompleted2xx}, cache.outcomes) + require.Nil(t, observer.Begin(context.Background(), account, AccountTrafficProtocolHTTP)) + require.Len(t, cache.beginIDs, 1) +} + +func TestAccountTrafficOutcomeRulesKeepCancellationAboveErrorsAndRequireWSTerminals(t *testing.T) { + require.NoError(t, accountTrafficOutcomeRules.Validate()) + for _, tc := range []struct { + facts map[string]string + want AccountTrafficOutcome + }{ + {map[string]string{"client_cancelled": "true", "has_error": "true", "error_status": "503"}, AccountTrafficOutcomeCancelled}, + {map[string]string{"ws": "true", "terminal": "response.incomplete", "has_error": "true", "error_status": "429"}, AccountTrafficOutcomeCancelled}, + {map[string]string{"ws": "true", "has_result": "true", "has_error": "false", "terminal": ""}, AccountTrafficOutcomeFailedOther}, + {map[string]string{"ws": "true", "has_result": "true", "has_error": "false", "terminal": "response.failed", "terminal_status": "503"}, AccountTrafficOutcomeUpstream5xx}, + {map[string]string{"ws": "false", "has_result": "true", "has_error": "false"}, AccountTrafficOutcomeCompleted2xx}, + } { + got, err := accountTrafficOutcomeRules.Evaluate(tc.facts) + require.NoError(t, err) + require.Equal(t, string(tc.want), got) + } +} + +func TestDecodeSwitchSettingsRejectsUnknownAndNonBooleanValues(t *testing.T) { + config := extensionv1.AdminObservabilityConfig{TelemetryEnabled: true, ThemeEnabled: true} + require.NoError(t, DecodeSwitchSettings([]byte(`{"theme_enabled":false}`), &config, "telemetry_enabled", "theme_enabled")) + require.Equal(t, extensionv1.AdminObservabilityConfig{TelemetryEnabled: true}, config, "omitted switches keep their defaults") + for _, raw := range []string{`{"theme_enabled":null}`, `{"theme":true}`, `{"theme_enabled":"false"}`, `[]`, `null`} { + require.Error(t, DecodeSwitchSettings([]byte(raw), &config, "telemetry_enabled", "theme_enabled"), raw) + } +} diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 6d0a687b8436..444ebc945a92 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -7,8 +7,8 @@ import ( dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/internal/config" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" ) // AdminService interface defines admin management operations diff --git a/backend/internal/service/business_system_prompt_account_scope_test.go b/backend/internal/service/business_system_prompt_account_scope_test.go index c100a643ddf2..456d12fc3335 100644 --- a/backend/internal/service/business_system_prompt_account_scope_test.go +++ b/backend/internal/service/business_system_prompt_account_scope_test.go @@ -8,8 +8,8 @@ import ( "strings" "testing" - promptpolicy "github.com/HTExplicit/sub2api-plugins/promptskills/policy" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + promptpolicy "github.com/Wei-Shaw/sub2api/internal/promptskills/policy" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" @@ -17,51 +17,36 @@ import ( ) type accountScopedPromptInvoker struct { - manager *PluginManager - installation *PluginInstallation - module *promptpolicy.Module - other extensionv1.OperationInvoker - calls []extensionv1.Invocation + module *promptpolicy.Module + rollout int + calls []extensionv1.Invocation } -func (p *accountScopedPromptInvoker) InvokeOperation(ctx context.Context, platform, kind string, in extensionv1.Invocation) (extensionv1.Result, error) { - if in.Operation != "prompt.plan" && in.Operation != "prompt.availability" { - if p.other != nil { - return p.other.InvokeOperation(ctx, platform, kind, in) - } - return extensionv1.Result{}, ErrExtensionOperationDisabled - } - if in.Operation == "prompt.plan" { - p.calls = append(p.calls, in) +// allows models a per-account prompt decision: 0 denies every account, 100 +// allows every account and anything between allows odd account IDs. +// Accountless previews are always allowed. +func (p *accountScopedPromptInvoker) allows(accountID int64) bool { + switch { + case accountID == 0 || p.rollout >= 100: + return true + case p.rollout <= 0: + return false + default: + return accountID%2 == 1 } - // Use the actual host operation ownership/cohort gate, then run only the - // independent policy module in process. No customer body or model IO exists. - if _, _, err := p.manager.operationOwner(platform, kind, in); err != nil { - return extensionv1.Result{}, err - } - return p.module.Invoke(ctx, in) } -func (p *accountScopedPromptInvoker) InvokeDomainOperation(ctx context.Context, in extensionv1.Invocation, _ bool) (extensionv1.Result, error) { - if in.Operation != "prompt.availability" { - if other, ok := p.other.(domainOperationInvoker); ok { - return other.InvokeDomainOperation(ctx, in, false) - } - return p.InvokeOperation(ctx, "*", "*", in) - } - platform, kind, err := p.manager.domainOperationScope(ctx, in) - if err != nil { - return extensionv1.Result{}, err +// invoke denies prompt plans for accounts outside the modeled scope, which +// covers API-key accounts only. +func (p *accountScopedPromptInvoker) invoke(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { + if in.Operation != "prompt.plan" { + return p.module.Invoke(ctx, in) } - return p.InvokeOperation(ctx, platform, kind, in) -} - -func (p *accountScopedPromptInvoker) BindDomainOperationContext(ctx context.Context, in extensionv1.Invocation) (context.Context, context.CancelFunc, error) { - if _, _, err := p.manager.domainOperationScope(ctx, in); err != nil { - return nil, nil, err + p.calls = append(p.calls, in) + if accountType := gjson.GetBytes(in.Payload, "target.account_type").String(); !p.allows(in.AccountID) || accountType != AccountTypeAPIKey { + return extensionv1.Result{}, ErrExtensionOperationDisabled } - bound, cancel := context.WithCancel(ctx) - return bound, cancel, nil + return p.module.Invoke(ctx, in) } func newAccountScopedPromptGateway(t *testing.T, rollout int) (*OpenAIGatewayService, *accountScopedPromptInvoker, *fakeBusinessSystemPromptStore) { @@ -69,37 +54,16 @@ func newAccountScopedPromptGateway(t *testing.T, rollout int) (*OpenAIGatewaySer store := &fakeBusinessSystemPromptStore{loaded: BusinessSystemPromptSnapshot{Revision: 1, Enabled: true, Body: "account-scoped-server"}} policy := NewBusinessSystemPromptService(store, nil) require.NoError(t, policy.Initialize(context.Background())) - installation := &PluginInstallation{ID: 7, State: PluginStateEnabled, - Manifest: PluginManifest{Operations: map[string][]string{extensionv1.CapabilityRequest: {"prompt.plan", "prompt.availability"}}}, - Bindings: []PluginBinding{{Capability: extensionv1.CapabilityRequest, Platform: PlatformOpenAI, AccountType: AccountTypeAPIKey, Enabled: true, RolloutPercent: rollout}}, - } - manager := NewPluginManager(nil, nil, nil, PluginHostInfo{}, nil) - manager.extensions.Store(&pluginExtensionRegistry{installations: map[int64]*PluginInstallation{installation.ID: installation}, runtimes: map[int64]*pluginRuntime{}}) - invoker := &accountScopedPromptInvoker{manager: manager, installation: installation, module: promptpolicy.New()} - previous := processExtensionOperations.Load() - if previous != nil { - invoker.other = previous.invoker - } - processExtensionOperations.Store(&extensionOperationProvider{invoker: invoker}) - t.Cleanup(func() { processExtensionOperations.Store(previous) }) + invoker := &accountScopedPromptInvoker{module: promptpolicy.New(), rollout: rollout} + previous := invokePromptSkills + invokePromptSkills = invoker.invoke + t.Cleanup(func() { invokePromptSkills = previous }) return &OpenAIGatewayService{businessPromptService: policy}, invoker, store } func promptScopeAccounts(t *testing.T) (*Account, *Account) { t.Helper() - var included, excluded *Account - for id := int64(1); id <= 1000 && (included == nil || excluded == nil); id++ { - account := &Account{ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} - if stablePluginBucket(id) < 50 && included == nil { - included = account - } - if stablePluginBucket(id) >= 50 && excluded == nil { - excluded = account - } - } - require.NotNil(t, included) - require.NotNil(t, excluded) - return included, excluded + return &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} } func TestPromptRealAccountZeroRolloutRejected(t *testing.T) { @@ -140,7 +104,7 @@ func TestPromptRealAccountRolloutAndAccountlessPreview(t *testing.T) { input := []byte(`{"instructions":"client","input":"private-customer-history"}`) _, application, err := gateway.applyBusinessSystemPrompt(input, account, BusinessSystemPromptProtocolResponses, false) require.NoError(t, err) - require.Equal(t, int(stablePluginBucket(account.ID)) < rollout, application.Applied) + require.Equal(t, invoker.allows(account.ID), application.Applied) call := invoker.calls[len(invoker.calls)-1] require.Equal(t, account.ID, call.AccountID) require.Equal(t, account.ID, gjson.GetBytes(call.Payload, "target.account_id").Int()) @@ -219,7 +183,7 @@ func TestPromptAccountChangeRestoresOnlyOwnedCarrier(t *testing.T) { } func TestPromptSameAccountCacheRechecksRevocation(t *testing.T) { - for _, revoke := range []string{"zero_rollout", "disabled"} { + for _, revoke := range []string{"denied"} { for _, form := range []string{"input", "output", "cache_key_rewrite"} { t.Run(revoke+"/"+form, func(t *testing.T) { gateway, invoker, _ := newAccountScopedPromptGateway(t, 100) @@ -236,12 +200,7 @@ func TestPromptSameAccountCacheRechecksRevocation(t *testing.T) { body, err = rewriteBusinessSystemPromptCacheKey(ctx, body, application) require.NoError(t, err) } - if revoke == "disabled" { - invoker.installation.State = PluginStateDisabled - invoker.installation.Bindings[0].Enabled = false - } else { - invoker.installation.Bindings[0].RolloutPercent = 0 - } + invoker.rollout = 0 clean, denied, err := gateway.applyBusinessSystemPromptForRequest(ctx, body, account, BusinessSystemPromptProtocolResponses, false) require.NoError(t, err) require.False(t, denied.Applied) @@ -580,7 +539,7 @@ func TestPromptCarrierTransitionCannotForgetEarlierAppliedOutput(t *testing.T) { other := &Account{ID: secondAccount.ID + 10000, Platform: PlatformGrok, Type: AccountTypeAPIKey} if destination == "denied" { other.Platform = PlatformOpenAI - invoker.installation.Bindings[0].RolloutPercent = 0 + invoker.rollout = 0 } clean, application, err := gateway.applyBusinessSystemPromptForRequest(ctx, olderOutput, other, BusinessSystemPromptProtocolChat, false) if err != nil { diff --git a/backend/internal/service/business_system_prompt_bundle.go b/backend/internal/service/business_system_prompt_bundle.go index 3c5b95277ddd..661df1cc90d5 100644 --- a/backend/internal/service/business_system_prompt_bundle.go +++ b/backend/internal/service/business_system_prompt_bundle.go @@ -13,7 +13,7 @@ import ( "time" "unicode/utf8" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) const ( diff --git a/backend/internal/service/business_system_prompt_composition.go b/backend/internal/service/business_system_prompt_composition.go index d7c289023562..0dbf0f5ba20f 100644 --- a/backend/internal/service/business_system_prompt_composition.go +++ b/backend/internal/service/business_system_prompt_composition.go @@ -5,7 +5,7 @@ import ( "errors" "strings" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) const ( diff --git a/backend/internal/service/business_system_prompt_policy.go b/backend/internal/service/business_system_prompt_policy.go index 48d760f2496a..cc8ab5359121 100644 --- a/backend/internal/service/business_system_prompt_policy.go +++ b/backend/internal/service/business_system_prompt_policy.go @@ -9,7 +9,7 @@ import ( "strings" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -61,14 +61,14 @@ func MergeBusinessSystemPromptInstructions(client, server string) string { } } -// ApplyBusinessSystemPromptToJSON applies the plugin's bounded policy result to -// the original request locally. Large user histories never cross the RPC limit. +// ApplyBusinessSystemPromptToJSON applies the bounded policy result to the +// original request locally. Large user histories never enter the policy input. func ApplyBusinessSystemPromptToJSON(body []byte, snapshot BusinessSystemPromptSnapshot, target BusinessSystemPromptTarget) ([]byte, BusinessSystemPromptApplication, error) { return ApplyBusinessSystemPromptToJSONContext(context.Background(), body, snapshot, target) } func ApplyBusinessSystemPromptToJSONContext(ctx context.Context, body []byte, snapshot BusinessSystemPromptSnapshot, target BusinessSystemPromptTarget) ([]byte, BusinessSystemPromptApplication, error) { - return applyBusinessSystemPromptWithInvoker(ctx, body, snapshot, target, invokeProcessExtension) + return applyBusinessSystemPromptWithInvoker(ctx, body, snapshot, target, promptPlanInvoke) } func applyBusinessSystemPromptWithInvoker(parent context.Context, body []byte, snapshot BusinessSystemPromptSnapshot, target BusinessSystemPromptTarget, invoke func(context.Context, string, string, extensionv1.Invocation) (extensionv1.Result, error)) ([]byte, BusinessSystemPromptApplication, error) { diff --git a/backend/internal/service/business_system_prompt_rules_apply.go b/backend/internal/service/business_system_prompt_rules_apply.go index d23cae22e5e2..0a0aba89f78e 100644 --- a/backend/internal/service/business_system_prompt_rules_apply.go +++ b/backend/internal/service/business_system_prompt_rules_apply.go @@ -10,7 +10,7 @@ import ( "sort" "strings" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" "github.com/tidwall/sjson" diff --git a/backend/internal/service/business_system_prompt_rules_gateway_test.go b/backend/internal/service/business_system_prompt_rules_gateway_test.go index 7e12825c678b..57649fbec08f 100644 --- a/backend/internal/service/business_system_prompt_rules_gateway_test.go +++ b/backend/internal/service/business_system_prompt_rules_gateway_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" coderws "github.com/coder/websocket" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" @@ -297,7 +297,7 @@ func TestPromptRulesExecutionScopeRevocationRestoresWireAndCacheKey(t *testing.T wire, err := gateway.finalizeBusinessPromptForSend(c, account, input, "responses", false) require.NoError(t, err) require.Contains(t, string(wire), "site-rule-content") - invoker.installation.Bindings[0].RolloutPercent = 0 + invoker.rollout = 0 clean, err := gateway.finalizeBusinessPromptForSend(c, account, wire, "responses", false) require.NoError(t, err) require.JSONEq(t, string(input), string(clean)) diff --git a/backend/internal/service/business_system_prompt_rules_preview.go b/backend/internal/service/business_system_prompt_rules_preview.go index 7365e08643f5..3927a849c69b 100644 --- a/backend/internal/service/business_system_prompt_rules_preview.go +++ b/backend/internal/service/business_system_prompt_rules_preview.go @@ -8,8 +8,8 @@ import ( "net/http" "strings" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" "github.com/tidwall/sjson" diff --git a/backend/internal/service/business_system_prompt_rules_service.go b/backend/internal/service/business_system_prompt_rules_service.go index 7f070e582632..fe0ad7a2f40d 100644 --- a/backend/internal/service/business_system_prompt_rules_service.go +++ b/backend/internal/service/business_system_prompt_rules_service.go @@ -8,7 +8,7 @@ import ( "slices" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) const PromptAccountBindingExtraKey = "prompt_skills" diff --git a/backend/internal/service/business_system_prompt_rules_test.go b/backend/internal/service/business_system_prompt_rules_test.go index 0440fe727b76..c77d8b765f81 100644 --- a/backend/internal/service/business_system_prompt_rules_test.go +++ b/backend/internal/service/business_system_prompt_rules_test.go @@ -7,8 +7,8 @@ import ( "strings" "testing" - promptpolicy "github.com/HTExplicit/sub2api-plugins/promptskills/policy" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + promptpolicy "github.com/Wei-Shaw/sub2api/internal/promptskills/policy" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" diff --git a/backend/internal/service/business_system_prompt_rules_ws.go b/backend/internal/service/business_system_prompt_rules_ws.go index 0d5f5a026fca..86bf8c091214 100644 --- a/backend/internal/service/business_system_prompt_rules_ws.go +++ b/backend/internal/service/business_system_prompt_rules_ws.go @@ -21,7 +21,7 @@ func (s *OpenAIGatewayService) prepareBusinessPromptWSIngress(c *gin.Context, bo return s.applyBusinessSystemPromptForRequest(c, body, account, protocol, compact) } target := enrichPromptTarget(c, body, businessSystemPromptTargetForAccount(account, protocol, compact)) - application, err := planBusinessSystemPromptWithInvoker(promptPolicyRequestContext(c), body, snapshot, target, invokeProcessExtension) + application, err := planBusinessSystemPromptWithInvoker(promptPolicyRequestContext(c), body, snapshot, target, promptPlanInvoke) if err != nil { return nil, application, err } diff --git a/backend/internal/service/business_system_prompt_service.go b/backend/internal/service/business_system_prompt_service.go index 29cf0319427e..21f99d0055f1 100644 --- a/backend/internal/service/business_system_prompt_service.go +++ b/backend/internal/service/business_system_prompt_service.go @@ -10,7 +10,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) var ( @@ -207,12 +207,6 @@ func (s *BusinessSystemPromptService) SyncManagedSource( if !ok { return BusinessSystemPromptSourceSyncResult{}, ErrBusinessSystemPromptSourceUnavailable } - bound, release, err := bindProcessDomainExtensionContext(ctx, extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "prompt.source.fetch"}) - if err != nil { - return BusinessSystemPromptSourceSyncResult{}, ErrBusinessSystemPromptSourceUnavailable - } - defer release() - ctx = bound candidate, err := s.source.Fetch(ctx) if err != nil { return BusinessSystemPromptSourceSyncResult{}, err @@ -233,7 +227,7 @@ func (s *BusinessSystemPromptService) Initialize(ctx context.Context) error { return errors.New("business system prompt store unavailable") } var seeds []BusinessSystemPromptSeed - if err := invokePromptManagementPolicy(ctx, "prompt.seeds", struct{}{}, &seeds, true); err != nil { + if err := invokePromptManagement(ctx, "prompt.seeds", struct{}{}, &seeds); err != nil { return err } for i := range seeds { diff --git a/backend/internal/service/business_system_prompt_service_test.go b/backend/internal/service/business_system_prompt_service_test.go index ef4186636e1f..f72c09650f79 100644 --- a/backend/internal/service/business_system_prompt_service_test.go +++ b/backend/internal/service/business_system_prompt_service_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" ) @@ -399,8 +399,8 @@ func (publicationCompositionSubstitutionFixture) InvokeOperation(ctx context.Con } func TestBusinessSystemPromptServiceRejectsPublicationCompositionSubstitution(t *testing.T) { - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) + previous := invokePromptSkills + t.Cleanup(func() { invokePromptSkills = previous }) store := &fakeBusinessSystemPromptStore{ loaded: BusinessSystemPromptSnapshot{Revision: 1, VersionID: 4, Body: "old"}, published: BusinessSystemPromptSnapshot{Revision: 2, VersionID: 5, Enabled: true, Body: "new"}, @@ -411,7 +411,9 @@ func TestBusinessSystemPromptServiceRejectsPublicationCompositionSubstitution(t } svc := NewBusinessSystemPromptService(store, nil) require.NoError(t, svc.Initialize(context.Background())) - processExtensionOperations.Store(&extensionOperationProvider{invoker: publicationCompositionSubstitutionFixture{}}) + invokePromptSkills = func(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { + return (publicationCompositionSubstitutionFixture{}).InvokeOperation(ctx, "", "", in) + } _, err := svc.PublishVersionAction(context.Background(), 3, 4, 1, extensionv1.PublicationActionRollback, 9) require.ErrorIs(t, err, ErrBusinessSystemPromptInvalid) require.Zero(t, store.publishCalls) diff --git a/backend/internal/service/cindy_account_create.go b/backend/internal/service/cindy_account_create.go new file mode 100644 index 000000000000..66f55fe7b3b9 --- /dev/null +++ b/backend/internal/service/cindy_account_create.go @@ -0,0 +1,203 @@ +package service + +import ( + "context" + "maps" + "slices" + "strings" + "sync" + + dbent "github.com/Wei-Shaw/sub2api/ent" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +var ( + ErrAccountCreateInvalid = infraerrors.BadRequest("ACCOUNT_CREATE_INVALID_REQUEST", "invalid provider create input") + ErrAccountCreateUnavailable = infraerrors.Conflict("ACCOUNT_CREATE_UNAVAILABLE", "provider creation is unavailable or has changed") + ErrAccountCreateGroupsRequired = infraerrors.BadRequest("ACCOUNT_CREATE_GROUP_REQUIRED", "provider account requires an effective group") +) +var accountCreateDefaultTargets = []string{"concurrency", "priority", "rate_multiplier", "load_factor", "responses_mode"} + +type accountCreateContextKey struct{} +type BoundAccountCreate struct { + MinimumEffectiveGroups int + runtime *cindyProviderRuntime + request *extensionv1.ProviderCreateRequestV1 +} + +func bindProcessAccountCreate(ctx context.Context, platform, accountType, profile string, request *extensionv1.ProviderCreateRequestV1) (context.Context, func(), error) { + if platform != PlatformCindy || accountType != AccountTypeAPIKey || profile != ProviderProfileCindyLaxaV1 { + return nil, nil, ErrAccountCreateInvalid + } + if _, bound := AccountCreateFromContext(ctx); bound { + return ctx, func() {}, ValidateAccountCreateFresh(ctx) + } + if request != nil { + for key, value := range request.Values { + if key != "device_id" || len(value) > 64 { + return nil, nil, ErrAccountCreateInvalid + } + } + seen := map[string]bool{} + for _, target := range request.InheritDefaults { + if !slices.Contains(accountCreateDefaultTargets, target) || seen[target] { + return nil, nil, ErrAccountCreateInvalid + } + seen[target] = true + } + } + cindyProviderPolicyMu.RLock() + var once sync.Once + release := func() { once.Do(cindyProviderPolicyMu.RUnlock) } + create := &BoundAccountCreate{MinimumEffectiveGroups: 1, runtime: cindyProvider.Load(), request: request} + return context.WithValue(ctx, accountCreateContextKey{}, create), release, nil +} + +func ValidateAccountCreateFresh(ctx context.Context) error { + create, bound := AccountCreateFromContext(ctx) + if !bound { + return nil + } + if ctx.Err() != nil || create.runtime != cindyProvider.Load() { + return ErrAccountCreateUnavailable + } + return nil +} +func AccountCreateFromContext(ctx context.Context) (*BoundAccountCreate, bool) { + if ctx == nil { + return nil, false + } + create, ok := ctx.Value(accountCreateContextKey{}).(*BoundAccountCreate) + return create, ok && create != nil +} + +func retainAccountCreateUntilTransactionEnds(ctx context.Context, tx *dbent.Tx, release func()) { + var once sync.Once + finish := func() { once.Do(release) } + tx.OnCommit(func(next dbent.Committer) dbent.Committer { + return dbent.CommitFunc(func(commitCtx context.Context, transaction *dbent.Tx) error { + if err := ValidateAccountCreateFresh(ctx); err != nil { + return err // rollback owns release when commit is rejected + } + err := next.Commit(commitCtx, transaction) + if err == nil { + finish() + } + return err + }) + }) + tx.OnRollback(func(next dbent.Rollbacker) dbent.Rollbacker { + return dbent.RollbackFunc(func(rollbackCtx context.Context, transaction *dbent.Tx) error { + err := next.Rollback(rollbackCtx, transaction) + finish() + return err + }) + }) +} + +func accountCreateValueExplicit(input *CreateAccountInput, target string) bool { + if input.ExplicitCreateFields != nil { + return input.ExplicitCreateFields[target] + } + // Direct Go callers can supply the presence map for explicit zero/null. + // Existing untagged callers never enter numeric inheritance at all. + switch target { + case "concurrency": + return input.Concurrency != 0 + case "priority": + return input.Priority != 0 + case "rate_multiplier": + return input.RateMultiplier != nil + case "load_factor": + return input.LoadFactor != nil + } + return false +} + +func applyAccountCreateProfile(ctx context.Context, input *CreateAccountInput) error { + create, bound := AccountCreateFromContext(ctx) + if !bound || ValidateAccountCreateFresh(ctx) != nil { + return ErrAccountCreateUnavailable + } + definition := struct { + Defaults struct { + Concurrency int + Priority int + RateMultiplier float64 + LoadFactor *int + ResponsesMode string + } + }{} + definition.Defaults.Concurrency = 3 + definition.Defaults.Priority = 50 + definition.Defaults.RateMultiplier = 1 + definition.Defaults.ResponsesMode = "force_responses" + if input.ProbeEnabled != nil && *input.ProbeEnabled { + return ErrAccountCreateInvalid + } + if len(input.ModelContextOverrides) != 0 { + return ErrAccountCreateInvalid + } + input.Extra = maps.Clone(input.Extra) + if input.Extra == nil { + input.Extra = map[string]any{} + } + request := create.request + if request != nil { + for key, value := range request.Values { + if key != "device_id" { + return ErrAccountCreateInvalid + } + value = strings.TrimSpace(value) + if existing, present := input.Extra[CindyDeviceIDExtraKey]; present { + text, valid := existing.(string) + if !valid || strings.TrimSpace(text) != value { + return ErrAccountCreateInvalid + } + } + if value != "" { + input.Extra[CindyDeviceIDExtraKey] = value + } else { + delete(input.Extra, CindyDeviceIDExtraKey) + } + } + for _, target := range request.InheritDefaults { + if accountCreateValueExplicit(input, target) { + continue + } + switch target { + case "concurrency": + input.Concurrency = definition.Defaults.Concurrency + case "priority": + input.Priority = definition.Defaults.Priority + case "rate_multiplier": + value := definition.Defaults.RateMultiplier + input.RateMultiplier = &value + case "load_factor": + input.LoadFactor = nil + if definition.Defaults.LoadFactor != nil { + value := *definition.Defaults.LoadFactor + input.LoadFactor = &value + } + } + } + } + if mode, present := input.Extra[CindyResponsesModeExtraKey]; present { + text, valid := mode.(string) + if !valid || !slices.Contains([]string{"auto", "force_responses", "force_chat_completions"}, text) { + return ErrAccountCreateInvalid + } + } else { + input.Extra[CindyResponsesModeExtraKey] = definition.Defaults.ResponsesMode + } + // Creation derives provenance from the actual input, not a client assertion. + // Keep the legacy input validator even though valid claims are not trusted. + if source, present := input.Extra[CindyDeviceIDSourceExtraKey]; present { + if _, valid := normalizeCindyDeviceIDSource(source); !valid { + return infraerrors.BadRequest("CINDY_DEVICE_ID_SOURCE_INVALID", "cindy_device_id_source is invalid") + } + } + delete(input.Extra, CindyDeviceIDSourceExtraKey) + return nil +} diff --git a/backend/internal/service/cindy_account_edit.go b/backend/internal/service/cindy_account_edit.go new file mode 100644 index 000000000000..4c5035701646 --- /dev/null +++ b/backend/internal/service/cindy_account_edit.go @@ -0,0 +1,357 @@ +package service + +import ( + "context" + "encoding/json" + "maps" + "slices" + "sync" + "sync/atomic" + + dbent "github.com/Wei-Shaw/sub2api/ent" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" +) + +var ( + ErrAccountEditInvalid = infraerrors.BadRequest("ACCOUNT_EDIT_INVALID_REQUEST", "invalid provider edit input") + ErrAccountEditUnavailable = infraerrors.Conflict("ACCOUNT_EDIT_UNAVAILABLE", "provider editing is unavailable or has changed") + ErrAccountEditStateChanged = infraerrors.Conflict("ACCOUNT_EDIT_STATE_CHANGED", "account edit state has changed") + ErrAccountEditCatalogUnavailable = infraerrors.Conflict("ACCOUNT_EDIT_CATALOG_UNAVAILABLE", "provider edit catalog is unavailable or has changed") +) + +type AccountEditProfileReference struct { + Native bool `json:"native,omitempty"` + PolicySHA256 string `json:"policy_sha256,omitempty"` + PluginID int64 `json:"plugin_id,omitempty"` + PluginKey string `json:"plugin_key,omitempty"` + ContributionID string `json:"contribution_id,omitempty"` + PackageSHA256 string `json:"package_sha256,omitempty"` + DefinitionSHA256 string `json:"definition_sha256,omitempty"` + RuntimeGeneration int64 `json:"runtime_generation,omitempty"` + Available bool `json:"available"` + Reason string `json:"reason,omitempty"` +} + +type AccountEditFieldState struct { + Present bool `json:"present"` + Recognized bool `json:"recognized"` + Value json.RawMessage `json:"value,omitempty"` + Effective string `json:"effective"` +} + +type AccountEditCatalogModel struct { + extensionv1.CindyCatalogModel + Type string `json:"type"` + CreatedAt string `json:"created_at"` +} + +type AccountEditCatalog struct { + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Namespace string `json:"namespace,omitempty"` + Models *[]AccountEditCatalogModel `json:"models,omitempty"` + Aliases *map[string]string `json:"aliases,omitempty"` +} + +type AccountEditContext struct { + SchemaVersion int `json:"schema_version"` + Kind string `json:"kind"` + AccountID int64 `json:"account_id"` + Profile *AccountEditProfileReference `json:"profile,omitempty"` + EditStateSHA256 string `json:"edit_state_sha256,omitempty"` + Values map[string]AccountEditFieldState `json:"values,omitempty"` + Catalog *AccountEditCatalog `json:"catalog,omitempty"` +} + +func accountEditFieldState(account *Account, target, effective string) AccountEditFieldState { + state := AccountEditFieldState{Effective: effective} + for index, key := range accountEditTargetKeys(target) { + raw, present := account.Extra[key] + if !present { + continue + } + state.Present = true + if index > 0 { // legacy WS fallback bool; its native encoding remains stored + if _, ok := raw.(bool); ok { + state.Recognized = true + state.Value, _ = json.Marshal(effective) + return state + } + continue + } + if raw == nil { + state.Recognized = true + state.Value = json.RawMessage("null") + return state + } + if value, ok := raw.(string); ok && (slices.Contains(accountEditModeValues[target], value) || (target == "responses_websocket_mode" && slices.Contains([]string{"shared", "dedicated"}, value))) { + state.Recognized = true + state.Value, _ = json.Marshal(value) + } + return state + } + return state +} + +func newAccountEditContext(account *Account, wsDefault string) *AccountEditContext { + result := &AccountEditContext{SchemaVersion: 1, Kind: "core", AccountID: account.ID} + if !hasCanonicalCindyProviderIdentity(account) { + return result + } + result.Kind, result.EditStateSHA256 = "provider", AccountEditStateDigest(account) + result.Profile = &AccountEditProfileReference{Reason: "account_edit_unavailable"} + result.Catalog = &AccountEditCatalog{Status: "unavailable", Reason: "profile_unavailable"} + responses, _ := account.Extra["openai_responses_mode"].(string) + result.Values = map[string]AccountEditFieldState{ + "responses_mode": accountEditFieldState(account, "responses_mode", string(openai_compat.NormalizeResponsesSupportMode(responses))), + "compact_mode": accountEditFieldState(account, "compact_mode", account.GetOpenAICompactMode()), + "responses_websocket_mode": accountEditFieldState(account, "responses_websocket_mode", account.ResolveOpenAIResponsesWebSocketV2Mode(wsDefault)), + } + return result +} + +func accountEditCatalog(snapshot *CindyCatalogSnapshot) *AccountEditCatalog { + if snapshot == nil { + return &AccountEditCatalog{Status: "unavailable", Reason: "catalog_rpc_failed"} + } + if !snapshot.Config.CatalogEnabled { + return &AccountEditCatalog{Status: "unavailable", Reason: "catalog_disabled"} + } + models := make([]AccountEditCatalogModel, 0, len(snapshot.CatalogModels)) + public := map[string]bool{} + for _, model := range snapshot.CatalogModels { + models = append(models, AccountEditCatalogModel{CindyCatalogModel: model, Type: "model"}) + public[model.ID] = model.PublicModel + } + aliases := map[string]string{} + for alias, target := range snapshot.CompatibilityAliases { + if public[target] { + aliases[alias] = target + } + } + return &AccountEditCatalog{Status: "ready", Namespace: snapshot.Namespace, Models: &models, Aliases: &aliases} +} + +func AccountEditFromContext(ctx context.Context) (*BoundAccountEdit, bool) { + if ctx == nil { + return nil, false + } + edit, ok := ctx.Value(accountEditContextKey{}).(*BoundAccountEdit) + return edit, ok && edit != nil +} + +func AccountEditOwnedForUpdate(ctx context.Context, account *Account, credentials, extra map[string]any, request *extensionv1.ProviderEditRequestV1) (map[string]accountEditRawValue, error) { + if edit, bound := AccountEditFromContext(ctx); bound { + if edit.AccountID != account.ID || edit.StateSHA256 != AccountEditStateDigest(account) || edit.intentSHA256 != accountEditIntentDigest(credentials, extra, request) { + return nil, ErrAccountEditStateChanged + } + if edit.changed && (!edit.fenced || dbent.TxFromContext(ctx) == nil) { + return nil, ErrAccountEditUnavailable + } + if err := ValidateAccountEditFresh(ctx); err != nil { + return nil, err + } + return edit.desired, nil + } + if !hasCanonicalCindyProviderIdentity(account) { + if request != nil { + return nil, ErrAccountEditInvalid + } + // Ordinary OpenAI retains the approved native mode semantics, but its + // mapping object (and every other platform) keeps legacy replacement. + if account.Platform != PlatformOpenAI || account.Type != AccountTypeAPIKey { + return nil, nil + } + desired, _, err := accountEditDesired(account, nil, extra, nil, nil) + for _, key := range accountEditCredentialKeys { + delete(desired, key) + } + return desired, err + } + desired, changed, err := accountEditDesired(account, credentials, extra, request, nil) + if err != nil { + return nil, err + } + if hasCanonicalCindyProviderIdentity(account) && (changed || request != nil) { + return nil, ErrAccountEditUnavailable + } + return desired, nil +} + +func MarkAccountEditFenced(ctx context.Context) { + if edit, bound := AccountEditFromContext(ctx); bound { + edit.fenced = true + } +} + +func retainAccountEditUntilTransactionEnds(ctx context.Context, tx *dbent.Tx, release func()) { + var once sync.Once + finish := func() { once.Do(release) } + tx.OnCommit(func(next dbent.Committer) dbent.Committer { + return dbent.CommitFunc(func(commitCtx context.Context, transaction *dbent.Tx) error { + if err := ValidateAccountEditFresh(ctx); err != nil { + return err + } + err := next.Commit(commitCtx, transaction) + if err == nil { + finish() + } + return err + }) + }) + tx.OnRollback(func(next dbent.Rollbacker) dbent.Rollbacker { + return dbent.RollbackFunc(func(rollbackCtx context.Context, transaction *dbent.Tx) error { + err := next.Rollback(rollbackCtx, transaction) + finish() + return err + }) + }) +} + +func retainBoundAccountEditLease(ctx context.Context, tx *dbent.Tx) { + edit, bound := AccountEditFromContext(ctx) + if !bound || edit.lease == nil || !edit.changed || !edit.lease.transaction.CompareAndSwap(false, true) { + return + } + retainAccountEditUntilTransactionEnds(ctx, tx, edit.lease.finish) +} + +func mergeAccountEditBulkExtra(current, patch map[string]any) map[string]any { + merged := maps.Clone(current) + if merged == nil { + merged = map[string]any{} + } + maps.Copy(merged, patch) + return merged +} + +// A native edit holds the current policy until its account transaction ends. +// No installation, package or plugin execution identity authorizes this edit. +type accountEditContextKey struct{} +type BoundAccountEdit struct { + AccountID int64 + StateSHA256 string + intentSHA256 string + changed bool + fenced bool + desired map[string]accountEditRawValue + runtime *cindyProviderRuntime + catalogNamespace string + account *Account + lease *accountEditLease +} +type accountEditLease struct { + finishOnce sync.Once + transaction atomic.Bool + release func() +} + +func (l *accountEditLease) finish() { l.finishOnce.Do(l.release) } +func (l *accountEditLease) releaseCall() { + if !l.transaction.Load() { + l.finish() + } +} + +func nativeAccountEditProfile(runtimes ...*cindyProviderRuntime) *AccountEditProfileReference { + runtime := cindyProvider.Load() + if len(runtimes) > 0 && runtimes[0] != nil { + runtime = runtimes[0] + } + return &AccountEditProfileReference{Native: true, Available: true, PolicySHA256: runtime.policySHA256} +} + +func LoadAccountEditContext(ctx context.Context, account *Account, wsDefaults ...string) (*AccountEditContext, error) { + if account == nil || account.ID <= 0 { + return nil, ErrAccountEditInvalid + } + wsDefault := "ctx_pool" + if len(wsDefaults) > 0 { + wsDefault = wsDefaults[0] + } + result := newAccountEditContext(account, wsDefault) + if result.Kind == "core" { + return result, nil + } + result.Profile = nativeAccountEditProfile() + snapshot, err := LoadCindyCatalogSnapshot(ctx, account) + if err != nil { + result.Catalog = accountEditCatalog(nil) + } else { + result.Catalog = accountEditCatalog(snapshot) + } + return result, nil +} + +func PrepareAccountEdit(ctx context.Context, account *Account, credentials, extra map[string]any, request *extensionv1.ProviderEditRequestV1) (context.Context, func(), error) { + if account == nil || account.ID <= 0 { + return nil, nil, ErrAccountEditInvalid + } + if existing, bound := AccountEditFromContext(ctx); bound { + if existing.AccountID != account.ID || existing.StateSHA256 != AccountEditStateDigest(account) || existing.intentSHA256 != accountEditIntentDigest(credentials, extra, request) { + return nil, nil, ErrAccountEditStateChanged + } + return ctx, func() {}, nil + } + if !hasCanonicalCindyProviderIdentity(account) { + if request != nil { + return nil, nil, ErrAccountEditInvalid + } + return ctx, func() {}, nil + } + if err := validateAccountEditRequest(request); err != nil { + return nil, nil, err + } + if request != nil && request.ExpectedStateSHA256 != AccountEditStateDigest(account) { + return nil, nil, ErrAccountEditStateChanged + } + cindyProviderPolicyMu.RLock() + lease := &accountEditLease{release: cindyProviderPolicyMu.RUnlock} + runtime := cindyProvider.Load() + var snapshot *CindyCatalogSnapshot + if request != nil { + if _, partition := request.Changes["model_mapping"]; partition { + var err error + snapshot, err = LoadCindyCatalogSnapshot(ctx, account) + if err != nil || snapshot == nil || !snapshot.Config.CatalogEnabled || request.ExpectedCatalogNamespace == "" || snapshot.Namespace != request.ExpectedCatalogNamespace { + lease.finish() + return nil, nil, ErrAccountEditCatalogUnavailable + } + } + } + desired, changed, err := accountEditDesired(account, credentials, extra, request, snapshot) + if err != nil { + lease.finish() + return nil, nil, err + } + edit := &BoundAccountEdit{AccountID: account.ID, StateSHA256: AccountEditStateDigest(account), + desired: desired, changed: changed, intentSHA256: accountEditIntentDigest(credentials, extra, request), account: account} + if changed { + edit.runtime, edit.lease = runtime, lease + if snapshot != nil { + edit.catalogNamespace = snapshot.Namespace + } + return context.WithValue(ctx, accountEditContextKey{}, edit), lease.releaseCall, nil + } + lease.finish() + return context.WithValue(ctx, accountEditContextKey{}, edit), func() {}, nil +} + +func ValidateAccountEditFresh(ctx context.Context) error { + edit, bound := AccountEditFromContext(ctx) + if !bound || !edit.changed { + return nil + } + if ctx.Err() != nil || edit.runtime != cindyProvider.Load() { + return ErrAccountEditUnavailable + } + if edit.catalogNamespace != "" { + snapshot, err := LoadCindyCatalogSnapshot(ctx, edit.account) + if err != nil || snapshot.Namespace != edit.catalogNamespace { + return ErrAccountEditCatalogUnavailable + } + } + return nil +} diff --git a/backend/internal/service/cindy_account_edit_test.go b/backend/internal/service/cindy_account_edit_test.go new file mode 100644 index 000000000000..62c63aaadf01 --- /dev/null +++ b/backend/internal/service/cindy_account_edit_test.go @@ -0,0 +1,244 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "strings" + "testing" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + "github.com/stretchr/testify/require" +) + +func newNativeAccountEditFixture(t *testing.T) (*Account, *extensionv1.ProviderEditRequestV1) { + t.Helper() + original := cindyProvider.Load() + ConfigureCindyProvider(&extensionv1.CindyProviderConfig{BalanceDetection: true, CatalogEnabled: true, SearchEnabled: true}) + t.Cleanup(func() { cindyProvider.Store(original) }) + account := &Account{ID: 2, Platform: PlatformCindy, WirePlatform: WirePlatformOpenAI, ProviderProfile: ProviderProfileCindyLaxaV1, + Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, CindyCredentialGeneration: 6, + Credentials: map[string]any{"base_url": "https://api.laxarouter.ai", "api_key": "synthetic-cindy-only"}, + Extra: map[string]any{CindyDeviceIDExtraKey: strings.Repeat("d", 64), CindyDeviceIDSourceExtraKey: "input-preserved"}} + request := &extensionv1.ProviderEditRequestV1{ExpectedStateSHA256: AccountEditStateDigest(account), Changes: map[string]extensionv1.AccountEditChangeV1{}} + return account, request +} +func editSet(value string) extensionv1.AccountEditChangeV1 { + return extensionv1.AccountEditChangeV1{Op: "set", Value: &value} +} +func editClear() extensionv1.AccountEditChangeV1 { return extensionv1.AccountEditChangeV1{Op: "clear"} } +func TestAccountEditCodecRetainsRawAndFiniteClear(t *testing.T) { + account, request := newNativeAccountEditFixture(t) + account.Extra["openai_responses_mode"] = nil + account.Extra["openai_compact_mode"] = "unknown-retained" + for _, mode := range []string{"shared", "dedicated"} { + account.Extra["openai_apikey_responses_websockets_v2_mode"] = mode + account.Extra["openai_apikey_responses_websockets_v2_enabled"] = false + account.Extra["responses_websockets_v2_enabled"] = true + account.Extra["openai_ws_enabled"] = true + account.Extra["openai_ws_force_http"] = true + account.Extra["openai_passthrough"] = true + account.Extra["openai_oauth_responses_websockets_v2_enabled"] = true + account.Extra["openai_compact_supported"] = false + before := accountEditRawState(account) + values, changed, err := accountEditDesired(account, nil, map[string]any{"unrelated": true}, nil, nil) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, before, values) + request.Changes = map[string]extensionv1.AccountEditChangeV1{"responses_mode": editClear(), "compact_mode": editSet("auto"), "responses_websocket_mode": editClear()} + values, changed, err = accountEditDesired(account, nil, nil, request, nil) + require.NoError(t, err) + require.True(t, changed) + copy := *account + applyAccountEditOwned(©, values) + require.NotContains(t, copy.Extra, "openai_responses_mode") + require.Equal(t, "auto", copy.Extra["openai_compact_mode"]) + for _, key := range accountEditExtraKeys[2:] { + require.NotContains(t, copy.Extra, key) + } + for _, key := range []string{"openai_ws_force_http", "openai_passthrough", "openai_oauth_responses_websockets_v2_enabled", "openai_compact_supported"} { + require.Equal(t, account.Extra[key], copy.Extra[key]) + } + require.Equal(t, before, accountEditRawState(account), "codec may not mutate source") + } + request.Changes = map[string]extensionv1.AccountEditChangeV1{"responses_mode": editSet("auto"), "responses_websocket_mode": editSet("off")} + values, _, err := accountEditDesired(account, nil, nil, request, nil) + require.NoError(t, err) + require.Equal(t, "auto", values["openai_responses_mode"].Value) + require.True(t, values["responses_websockets_v2_enabled"].Present, "set must not clean up fallback keys") + _, _, err = accountEditDesired(account, nil, map[string]any{"openai_responses_mode": "force_responses"}, request, nil) + require.ErrorIs(t, err, ErrAccountEditInvalid) +} + +func TestAccountEditStateDigestIsBoundedAndPresenceSensitive(t *testing.T) { + account, _ := newNativeAccountEditFixture(t) + initial := AccountEditStateDigest(account) + account.Extra["usage"] = 123 + account.Credentials["api_key"] = "not-reflected-in-digest" + require.Equal(t, initial, AccountEditStateDigest(account)) + account.Extra["openai_responses_mode"] = nil + require.NotEqual(t, initial, AccountEditStateDigest(account)) + delete(account.Extra, "openai_responses_mode") + account.Extra["openai_ws_enabled"] = true + require.NotEqual(t, initial, AccountEditStateDigest(account)) + delete(account.Extra, "openai_ws_enabled") + account.CindyCredentialGeneration++ + require.NotEqual(t, initial, AccountEditStateDigest(account)) +} + +func TestAccountEditMappingPartitionAndLegacyReplacement(t *testing.T) { + account, request := newNativeAccountEditFixture(t) + snapshot, err := LoadCindyCatalogSnapshot(context.Background(), account) + require.NoError(t, err) + var managed extensionv1.CindyCatalogModel + for _, model := range snapshot.CatalogModels { + if model.Managed { + managed = model + break + } + } + require.NotEmpty(t, managed.ID) + account.Credentials["model_mapping"] = map[string]any{managed.ID: managed.LiveUpstreamID, "custom": "upstream-custom"} + account.Credentials["compact_model_mapping"] = map[string]any{"compact-custom": "upstream-compact"} + request.ExpectedCatalogNamespace = snapshot.Namespace + request.Changes["model_mapping"] = extensionv1.AccountEditChangeV1{Op: "set"} + values, _, err := accountEditDesired(account, map[string]any{"model_mapping": map[string]any{"another": "upstream-other"}}, nil, request, snapshot) + require.NoError(t, err) + require.Equal(t, map[string]any{managed.ID: managed.LiveUpstreamID, "another": "upstream-other"}, values["model_mapping"].Value) + require.Equal(t, account.Credentials["compact_model_mapping"], values["compact_model_mapping"].Value) + request.Changes["model_mapping"] = editClear() + values, _, err = accountEditDesired(account, nil, nil, request, snapshot) + require.NoError(t, err) + require.Equal(t, map[string]any{managed.ID: managed.LiveUpstreamID}, values["model_mapping"].Value) + request.Changes["compact_model_mapping"] = editClear() + values, _, err = accountEditDesired(account, nil, nil, request, snapshot) + require.NoError(t, err) + require.False(t, values["compact_model_mapping"].Present) + values, _, err = accountEditDesired(account, map[string]any{"model_mapping": map[string]any{managed.ID: "shadowed-but-useful"}}, nil, nil, nil) + require.NoError(t, err) + require.Equal(t, map[string]any{managed.ID: "shadowed-but-useful"}, values["model_mapping"].Value) + _, _, err = accountEditDesired(account, nil, nil, request, nil) + require.ErrorIs(t, err, ErrAccountEditCatalogUnavailable, "unknown partition intent cannot be called a no-op") + account.Credentials["model_mapping"] = map[string]any{"custom": "upstream-custom"} + values, _, err = accountEditDesired(account, nil, nil, request, snapshot) + require.NoError(t, err) + require.False(t, values["model_mapping"].Present) +} + +func TestAccountEditNoopRereadCannotAcquireNewWrite(t *testing.T) { + account, _ := newNativeAccountEditFixture(t) + account.Extra["openai_compact_mode"] = "auto" + ctx, release, err := PrepareAccountEdit(context.Background(), account, nil, map[string]any{"openai_compact_mode": "auto"}, nil) + require.NoError(t, err) + defer release() + account.Extra["openai_compact_mode"] = "force_off" + _, err = AccountEditOwnedForUpdate(ctx, account, nil, map[string]any{"openai_compact_mode": "auto"}, nil) + require.ErrorIs(t, err, ErrAccountEditStateChanged) +} + +func TestAccountEditUnrelatedCoreMappingReplacementAndPayloads(t *testing.T) { + for _, platform := range []string{PlatformOpenAI, PlatformAnthropic, PlatformGemini} { + account := &Account{ID: 71, Name: "core", Platform: platform, Type: AccountTypeAPIKey, Status: StatusActive, + Credentials: map[string]any{"base_url": "https://api.laxarouter.ai", "api_key": "native-secret", "model_mapping": map[string]any{"old": "target"}, "compact_model_mapping": map[string]any{"old": "target"}}, + Extra: map[string]any{"openai_responses_mode": nil, "openai_passthrough": true}} + storage := &accountRepoStubForBulkUpdate{getByIDAccounts: map[int64]*Account{account.ID: account}} + svc := &adminServiceImpl{accountRepo: storage} + _, err := svc.UpdateAccount(context.Background(), account.ID, &UpdateAccountInput{Credentials: map[string]any{"base_url": "https://api.laxarouter.ai"}}) + require.NoError(t, err, platform) + require.NotContains(t, account.Credentials, "model_mapping", platform) + require.NotContains(t, account.Credentials, "compact_model_mapping", platform) + require.Equal(t, "native-secret", account.Credentials["api_key"]) + require.Equal(t, platform, account.Platform) + require.True(t, account.Extra["openai_passthrough"].(bool)) + require.Contains(t, account.Extra, "openai_responses_mode") + require.Nil(t, account.Extra["openai_responses_mode"]) + } + for _, kind := range []string{AccountJobKindImportData, AccountJobKindBatchCreate} { + for _, raw := range []string{`[1,"two",null]`, `null`, `"native"`, `{"_host_account_edit":"native-owned-by-other-kind"}`} { + got, err := accountJobPayloadWithEdit(context.Background(), kind, []byte(raw), nil) + require.NoError(t, err) + require.Equal(t, raw, string(got)) + } + } +} + +func TestAccountEditFrozenJobIdentityCannotBecomeCore(t *testing.T) { + account, _ := newNativeAccountEditFixture(t) + snapshot := AccountJobEditSnapshot{StateSHA256: AccountEditStateDigest(account)} + raw, _ := json.Marshal(map[string]any{accountJobEditSnapshotsKey: map[string]AccountJobEditSnapshot{"2": snapshot}}) + account.Platform, account.ProviderProfile = PlatformOpenAI, "" + _, _, err := PrepareAccountJobEdit(context.Background(), raw, account, nil, map[string]any{"openai_compact_mode": "auto"}) + require.ErrorIs(t, err, ErrAccountEditStateChanged) +} + +func TestAccountEditNativeCoreJobAndNullRemainIndependent(t *testing.T) { + account, _ := newNativeAccountEditFixture(t) + ordinary := *account + ordinary.Platform, ordinary.ProviderProfile = PlatformOpenAI, "" + ctx := WithAccountJobEditAccounts(context.Background(), []*Account{&ordinary}) + payload := json.RawMessage(`{"extra":{"openai_responses_mode":"auto"}}`) + encoded, err := accountJobPayloadWithEdit(ctx, AccountJobKindBulkUpdate, payload, []AccountJobItemSeed{{TargetAccountID: &ordinary.ID}}) + require.NoError(t, err) + require.JSONEq(t, string(payload), string(encoded)) + values, changed, err := accountEditDesired(account, map[string]any{"model_mapping": nil}, map[string]any{"openai_responses_mode": nil}, nil, nil) + require.NoError(t, err) + require.True(t, changed) + require.True(t, values["model_mapping"].Present) + require.Nil(t, values["model_mapping"].Value) + require.True(t, values["openai_responses_mode"].Present) + require.Nil(t, values["openai_responses_mode"].Value) + mode, _, err := normalizeBulkOpenAIResponsesMode("auto") + require.NoError(t, err) + require.Equal(t, "auto", mode) + input := &BulkUpdateAccountsInput{AccountIDs: []int64{account.ID}, Extra: map[string]any{"openai_responses_mode": "auto"}} + settings, err := normalizeBulkOpenAISettings(input) + require.NoError(t, err) + _, err = validateBulkOpenAISettingsTargets(input, settings, map[int64]*Account{account.ID: account}) + require.Error(t, err, "Cindy bulk Responses eligibility must not expand") +} + +func TestAccountEditDuplicatePreservesProtocolAbsenceAndValues(t *testing.T) { + for _, raw := range []any{nil, "auto", "force_chat_completions", "legacy-retained"} { + for _, present := range []bool{false, true} { + repo := newDuplicateAccountRepoStub() + svc := &adminServiceImpl{accountRepo: repo, accountDuplicateRepo: repo} + source := &Account{Name: "source", Platform: PlatformCindy, WirePlatform: WirePlatformOpenAI, ProviderProfile: ProviderProfileCindyLaxaV1, Type: AccountTypeAPIKey, + Credentials: cindyCredentials(), Extra: map[string]any{CindyDeviceIDExtraKey: strings.Repeat("e", 64), CindyDeviceIDSourceExtraKey: "input-preserved", "nested": map[string]any{"kept": true}}} + if present { + source.Extra["openai_responses_mode"] = raw + } + require.NoError(t, repo.Create(context.Background(), source)) + copy, err := svc.DuplicateAccount(context.Background(), source.ID, "admin:9", "unchanged-copy") + require.NoError(t, err) + value, copiedPresence := copy.Extra["openai_responses_mode"] + require.Equal(t, present, copiedPresence) + if present { + require.Equal(t, raw, value) + } + require.False(t, copy.Schedulable) + require.Empty(t, copy.GroupIDs) + require.NotEqual(t, source.Extra[CindyDeviceIDExtraKey], copy.Extra[CindyDeviceIDExtraKey]) + copy.Extra["nested"].(map[string]any)["kept"] = false + require.Equal(t, true, source.Extra["nested"].(map[string]any)["kept"]) + replayed, err := svc.DuplicateAccount(context.Background(), source.ID, "admin:9", "unchanged-copy") + require.NoError(t, err) + require.Equal(t, copy.ID, replayed.ID) + } + } +} + +func TestAccountEditLegacyQueuedNoopDoesNotRequireNewProviderWork(t *testing.T) { + account, _ := newNativeAccountEditFixture(t) + account.Extra["openai_compact_mode"] = nil + ctx, release, err := PrepareAccountJobEdit(context.Background(), []byte(`{"extra":{"openai_compact_mode":null}}`), account, nil, map[string]any{"openai_compact_mode": nil}) + require.NoError(t, err) + defer release() + _, bound := AccountEditFromContext(ctx) + require.True(t, bound) + account.Extra["openai_compact_mode"] = "force_off" + _, err = AccountEditOwnedForUpdate(ctx, account, nil, map[string]any{"openai_compact_mode": nil}, nil) + require.ErrorIs(t, err, ErrAccountEditStateChanged) + _, _, err = PrepareAccountJobEdit(context.Background(), []byte(`{}`), account, nil, map[string]any{"openai_compact_mode": "auto"}) + require.ErrorIs(t, err, ErrAccountEditStateChanged, "an old queued actual delta requires a fresh submission") +} diff --git a/backend/internal/service/cindy_account_test_plan.go b/backend/internal/service/cindy_account_test_plan.go index da8105d70917..877ba030e5fd 100644 --- a/backend/internal/service/cindy_account_test_plan.go +++ b/backend/internal/service/cindy_account_test_plan.go @@ -13,7 +13,7 @@ import ( "strconv" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) type CindyAccountTestPlan struct { @@ -35,13 +35,13 @@ func LoadCindyAccountTestPlan(ctx context.Context, account *Account) (*CindyAcco query, _ := json.Marshal(extensionv1.CindyCatalogQuery{Method: extensionv1.CindyAccountTestPlanMethodV1, Images: images}) call, cancel := context.WithTimeout(ctx, time.Second) defer cancel() - result, err := invokeProcessExtensionCached(call, PlatformCindy, AccountTypeAPIKey, extensionv1.Invocation{ + result, err := invokeCindyProviderCached(call, extensionv1.Invocation{ Capability: extensionv1.CapabilityProvider, Operation: "cindy.catalog", AccountID: account.ID, Payload: query, }) if err != nil { return nil, fmt.Errorf("provider: Cindy account test plan is unavailable: %w", err) } - if result.PluginID <= 0 || result.Code != "" || len(result.Payload) == 0 || len(result.Payload) > extensionv1.MaxPayloadBytes { + if result.Code != "" || len(result.Payload) == 0 || len(result.Payload) > extensionv1.MaxPayloadBytes { return nil, errors.New("provider: Cindy account test plan is unavailable") } var values []json.RawMessage diff --git a/backend/internal/service/cindy_account_view_probe_test.go b/backend/internal/service/cindy_account_view_probe_test.go deleted file mode 100644 index aee8b5fa248a..000000000000 --- a/backend/internal/service/cindy_account_view_probe_test.go +++ /dev/null @@ -1,185 +0,0 @@ -//go:build unit - -package service - -import ( - "context" - "encoding/json" - "errors" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -func TestAccountViewProbeScopeBeforeCountingAndFrozenOrigin(t *testing.T) { - manager, plugins, directory, request := newViewFixture(t) - plugins.installations[1].Bindings[1].RolloutPercent = 50 - applyViewFixture(t, manager, plugins) - view, releaseView, err := manager.BindAccountViewRequest(context.Background(), request) - require.NoError(t, err) - defer releaseView() - ctx, release, err := manager.BindResourceContext(view, 1, request.PackageSHA256, plugins.installations[1].Manifest.Resources[0]) - require.NoError(t, err) - defer release() - ctx = WithCindyProbeOperationKey(ctx, "probe-snapshot") - scope, err := captureCindyProbeOrigin(ctx, CindyBalanceProbeScope{Mode: "all"}, 0.5, 1, "fingerprint") - require.NoError(t, err) - accounts := []Account{*directory.accounts[5], *directory.accounts[3], *directory.accounts[2]} - preview, err := BuildCindyBalanceProbePreviewFromSnapshotContext(ctx, scope, accounts, 0.5, time.Now()) - require.NoError(t, err) - require.Equal(t, 1, preview.CandidateCount) - require.EqualValues(t, 2, preview.Candidates[0].AccountID) - scope.Origin.FrozenAccountIDs = []int64{2} - stored := DecodeCindyBalanceProbeScope(EncodeCindyBalanceProbeScope(scope)) - require.Equal(t, "probe-snapshot", stored.Origin.OperationKey) - require.Equal(t, []int64{2}, stored.Origin.FrozenAccountIDs) - worker, workerRelease, err := manager.BindCindyBalanceProbeOrigin(context.Background(), stored) - require.NoError(t, err) - defer workerRelease() - primary, _ := PluginExecutionFromContext(worker) - origin, _, _ := AccountViewExecutionFromContext(worker) - require.EqualValues(t, 1, primary.ID) - require.EqualValues(t, 1, origin.ID) - plugins.installations[1].Bindings[1].Enabled = false - applyViewFixture(t, manager, plugins) - _, _, err = manager.BindCindyBalanceProbeOrigin(context.Background(), stored) - require.Error(t, err) - bad := DecodeCindyBalanceProbeScope([]byte(`{"mode":"all","origin":"malformed"}`)) - require.NotNil(t, bad.Origin, "malformed origin must not become a legacy job") - _, _, err = manager.BindCindyBalanceProbeOrigin(context.Background(), bad) - require.Error(t, err) -} - -func TestAccountViewProbeKeepsProviderFullScopeAndRejectsExplicitSubset(t *testing.T) { - manager, plugins, directory, request := newViewFixture(t) - ctx, release, err := manager.BindAccountViewRequest(context.Background(), request) - require.NoError(t, err) - defer release() - _, err = BuildCindyBalanceProbePreviewFromSnapshotContext(ctx, CindyBalanceProbeScope{Mode: "selected", AccountIDs: []int64{2, 5}}, []Account{*directory.accounts[2], *directory.accounts[5]}, 0.5, time.Now()) - require.Error(t, err, "explicit outside-view selection must reject the whole preview") - primary, primaryRelease, err := manager.BindResourceContext(ctx, 1, request.PackageSHA256, plugins.installations[1].Manifest.Resources[0]) - require.NoError(t, err) - defer primaryRelease() - scope, err := captureCindyProbeOrigin(primary, CindyBalanceProbeScope{Mode: "all"}, 0.5, 1, "fingerprint") - require.NoError(t, err) - scope.Origin.FrozenAccountIDs = []int64{2} - plugins.installations[1].Bindings[0].RolloutPercent = 50 - applyViewFixture(t, manager, plugins) - _, _, err = manager.BindCindyBalanceProbeOrigin(context.Background(), scope) - require.Error(t, err, "all/filter probe keeps the existing Provider100 gate") -} - -type scopedProbeTerminalFixture struct { - CindyBalanceProbeRepository - committed *CindyHealthEpisode - err error - scopedCalls, legacyCalls int -} - -func (r *scopedProbeTerminalFixture) FinalizeScopedExhausted(context.Context, *CindyBalanceProbeReservation, string, time.Time, time.Duration) (string, *CindyHealthEpisode, error) { - r.scopedCalls++ - return "exhausted", r.committed, r.err -} -func (r *scopedProbeTerminalFixture) FinalizeExhausted(context.Context, *CindyBalanceProbeReservation, string, time.Time, time.Duration) (string, error) { - r.legacyCalls++ - return "exhausted", nil -} - -type committedProbeProjectorFixture struct{ observed, projected int } - -func (p *committedProbeProjectorFixture) ObserveCindyHealthSignal(context.Context, *Account, CindyHealthSignal) { - p.observed++ -} -func (*committedProbeProjectorFixture) ObserveCindyHealthSuccess(context.Context, *Account) {} -func (p *committedProbeProjectorFixture) ApplyCommittedProbeTerminal(context.Context, *Account, CindyHealthEpisode) { - p.projected++ -} - -func TestAccountViewProbeRequiresAtomicTerminalFactWithoutDeferredRetry(t *testing.T) { - manager, plugins, directory, request := newViewFixture(t) - view, releaseView, err := manager.BindAccountViewRequest(context.Background(), request) - require.NoError(t, err) - defer releaseView() - ctx, release, err := manager.BindResourceContext(view, 1, request.PackageSHA256, plugins.installations[1].Manifest.Resources[0]) - require.NoError(t, err) - defer release() - scope, err := captureCindyProbeOrigin(ctx, CindyBalanceProbeScope{Mode: "selected", AccountIDs: []int64{2}}, 0.5, 1, "fingerprint") - require.NoError(t, err) - scope.Origin.FrozenAccountIDs = []int64{2} - worker, workerRelease, err := manager.BindCindyBalanceProbeOrigin(context.Background(), scope) - require.NoError(t, err) - defer workerRelease() - projector := &committedProbeProjectorFixture{} - repo := &scopedProbeTerminalFixture{committed: &CindyHealthEpisode{AccountID: 2, Generation: 1, EpisodeID: "committed", Fingerprint: strings.Repeat("a", 64), Status: CindyHealthStatusBalanceInsufficient, Evidence: CindyHealthEvidenceExactBudget, ObservedAt: time.Now()}} - service := NewCindyBalanceProbeService(repo, nil, &OpenAIGatewayService{cindyHealth: projector}, nil) - defer service.Stop() - require.True(t, service.finalizeExhausted(worker, &CindyBalanceProbeReservation{AccountID: 2}, directory.accounts[2], "lease")) - require.Equal(t, 1, repo.scopedCalls) - require.Zero(t, repo.legacyCalls) - require.Equal(t, 1, projector.projected) - require.Zero(t, projector.observed, "scoped success cannot create an unscoped pending terminal retry") - repo.err = errors.New("terminal transaction rolled back") - require.False(t, service.finalizeExhausted(worker, &CindyBalanceProbeReservation{AccountID: 2}, directory.accounts[2], "lease")) - require.Equal(t, 1, projector.projected) - repo.err, repo.committed = nil, nil - require.False(t, service.finalizeExhausted(worker, &CindyBalanceProbeReservation{AccountID: 2}, directory.accounts[2], "lease"), "marker-only success is not complete success") - require.Equal(t, 1, projector.projected) -} - -func TestAccountViewProbeOriginContainsNoExecutionQueryOrAccountMaps(t *testing.T) { - manager, plugins, _, request := newViewFixture(t) - request.Query.Search = "private-search-not-metadata" - view, releaseView, err := manager.BindAccountViewRequest(context.Background(), request) - require.NoError(t, err) - defer releaseView() - ctx, release, err := manager.BindResourceContext(view, 1, request.PackageSHA256, plugins.installations[1].Manifest.Resources[0]) - require.NoError(t, err) - defer release() - scope, err := captureCindyProbeOrigin(ctx, CindyBalanceProbeScope{Mode: "all"}, 0.5, 1, "fingerprint") - require.NoError(t, err) - raw, err := json.Marshal(scope.Origin) - require.NoError(t, err) - require.NotContains(t, string(raw), "private-search-not-metadata") - require.NotContains(t, string(raw), "credentials") - require.NotContains(t, string(raw), "synthetic-private-key") -} - -func TestAccountViewProbeExplicitResumeReadmitsSameDefinitionOnly(t *testing.T) { - manager, plugins, _, request := newViewFixture(t) - view, releaseView, err := manager.BindAccountViewRequest(context.Background(), request) - require.NoError(t, err) - defer releaseView() - ctx, release, err := manager.BindResourceContext(view, 1, request.PackageSHA256, plugins.installations[1].Manifest.Resources[0]) - require.NoError(t, err) - defer release() - scope, err := captureCindyProbeOrigin(ctx, CindyBalanceProbeScope{Mode: "all"}, 0.5, 2, "fingerprint") - require.NoError(t, err) - scope.Origin.FrozenAccountIDs = []int64{2, 3} - original, _ := json.Marshal(scope.Origin) - plugins.installations[1].RuntimeGeneration++ - plugins.installations[1].Revision++ - applyViewFixture(t, manager, plugins) - _, _, err = manager.BindCindyBalanceProbeOrigin(context.Background(), scope) - require.Error(t, err, "automatic worker cannot adopt a fresh generation") - resumed, resumedRelease, err := manager.RebindCindyBalanceProbeOrigin(context.Background(), scope) - require.NoError(t, err) - next := resumed.Value(cindyProbeOriginContextKey{}).(*CindyBalanceProbeOrigin) - require.Equal(t, scope.Origin.RuntimeGeneration+1, next.RuntimeGeneration) - require.Equal(t, scope.Origin.View.RuntimeGeneration+1, next.View.RuntimeGeneration) - require.Equal(t, scope.Origin.RequestDigest, next.RequestDigest) - require.Equal(t, []int64{2, 3}, next.FrozenAccountIDs) - unchanged, _ := json.Marshal(scope.Origin) - require.Equal(t, original, unchanged, "re-admission must not mutate the saved origin in place") - resumedRelease() - plugins.installations[1].Bindings[1].RolloutPercent = 50 - applyViewFixture(t, manager, plugins) - _, _, err = manager.RebindCindyBalanceProbeOrigin(context.Background(), scope) - require.Error(t, err, "narrowed live scope cannot silently drop a frozen target") - plugins.installations[1].Bindings[1].RolloutPercent = 100 - plugins.installations[1].PackageSHA256 = strings.Repeat("d", 64) - applyViewFixture(t, manager, plugins) - _, _, err = manager.RebindCindyBalanceProbeOrigin(context.Background(), scope) - require.Error(t, err, "explicit resume cannot cross package identity") -} diff --git a/backend/internal/service/cindy_alpha_search_plan.go b/backend/internal/service/cindy_alpha_search_plan.go index f82e357ad591..92b765c7cdb6 100644 --- a/backend/internal/service/cindy_alpha_search_plan.go +++ b/backend/internal/service/cindy_alpha_search_plan.go @@ -9,7 +9,7 @@ import ( "strings" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) const maxCindyAlphaSearchUses = 1 @@ -37,7 +37,7 @@ func resolveCindyAlphaSearchPlanForAccount(ctx context.Context, requestedModel s } callCtx, cancel := context.WithTimeout(ctx, time.Second) defer cancel() - result, err := invokeProcessExtensionCached(callCtx, PlatformCindy, AccountTypeAPIKey, extensionv1.Invocation{ + result, err := invokeCindyProviderCached(callCtx, extensionv1.Invocation{ Capability: extensionv1.CapabilityProvider, Operation: "cindy.search.plan", Payload: payload, @@ -67,7 +67,7 @@ func resolveCindyAlphaSearchPlanForAccount(ctx context.Context, requestedModel s // scope and must be owned by the same plugin; no host model list is copied. model, _ := json.Marshal(requestedModel) query, _ := json.Marshal(extensionv1.CindyCatalogQuery{Method: "CindyAlphaSearchUpstreamModel", Args: []json.RawMessage{model}}) - catalog, err := invokeProcessExtensionCached(callCtx, PlatformCindy, AccountTypeAPIKey, extensionv1.Invocation{ + catalog, err := invokeCindyProviderCached(callCtx, extensionv1.Invocation{ Capability: extensionv1.CapabilityProvider, Operation: "cindy.catalog", AccountID: accountID, Payload: query, }) var upstream string diff --git a/backend/internal/service/cindy_alpha_search_plan_test.go b/backend/internal/service/cindy_alpha_search_plan_test.go index 90fc4d6c504c..b3565c3cb896 100644 --- a/backend/internal/service/cindy_alpha_search_plan_test.go +++ b/backend/internal/service/cindy_alpha_search_plan_test.go @@ -5,7 +5,7 @@ import ( "encoding/json" "testing" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) @@ -50,9 +50,9 @@ func TestResolveCindyAlphaSearchPlanSendsOnlyRequestedModel(t *testing.T) { NativeMessagesModel: CindyWebSearchModel, MaxSearchUses: 1, }} - previous := processExtensionOperations.Load() - processExtensionOperations.Store(&extensionOperationProvider{invoker: fixture}) - t.Cleanup(func() { processExtensionOperations.Store(previous) }) + previous := captureNativeCindyTestInvoker() + setNativeCindyTestInvoker(fixture) + t.Cleanup(func() { restoreNativeCindyTestInvoker(previous) }) plan, err := resolveCindyAlphaSearchPlanForAccount(context.Background(), "gpt-5.6-luna", 42) require.NoError(t, err) @@ -66,8 +66,8 @@ func TestResolveCindyAlphaSearchPlanSendsOnlyRequestedModel(t *testing.T) { } func TestCindyAlphaSearchPlanCannotChangeCatalogIdentityOrReturnTrailingJSON(t *testing.T) { - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) + previous := captureNativeCindyTestInvoker() + t.Cleanup(func() { restoreNativeCindyTestInvoker(previous) }) base := CindyAlphaSearchPlan{Allowed: true, RequestedModel: "gpt-5.6-luna", UpstreamModel: "openai/gpt-5.6-luna", PrimaryProtocol: "responses", ResponsesToolType: "web_search", MaxSearchUses: 1} for _, kind := range []string{"different-model", "different-owner", "trailing-json"} { t.Run(kind, func(t *testing.T) { @@ -82,7 +82,7 @@ func TestCindyAlphaSearchPlanCannotChangeCatalogIdentityOrReturnTrailingJSON(t * require.NoError(t, err) fixture.rawPlan = append(raw, []byte(` {"allowed":false}`)...) } - processExtensionOperations.Store(&extensionOperationProvider{invoker: fixture}) + setNativeCindyTestInvoker(fixture) _, err := resolveCindyAlphaSearchPlanForAccount(context.Background(), base.RequestedModel, 42) require.Error(t, err) if kind == "trailing-json" { diff --git a/backend/internal/service/cindy_balance_probe_service.go b/backend/internal/service/cindy_balance_probe_service.go index 4585b09666a7..79e5ce266207 100644 --- a/backend/internal/service/cindy_balance_probe_service.go +++ b/backend/internal/service/cindy_balance_probe_service.go @@ -2,19 +2,14 @@ package service import ( "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "errors" "log/slog" - "regexp" "slices" "strings" "sync" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/google/uuid" ) @@ -184,11 +179,11 @@ func (s *CindyBalanceProbeService) Resume(ctx context.Context, jobID int64) (*Ci } repository, ok := s.repo.(CindyScopedProbeResumeRepository) if !ok { - return nil, ErrAccountViewUnavailable + return nil, ErrCindyBalanceProbeChanged } next, ok := ctx.Value(cindyProbeOriginContextKey{}).(*CindyBalanceProbeOrigin) if !ok || next == nil { - return nil, ErrAccountViewUnavailable + return nil, ErrCindyBalanceProbeChanged } job, err := repository.ResumeScoped(ctx, jobID, stored.Scope.Origin, next) if err == nil { @@ -282,15 +277,14 @@ func (s *CindyBalanceProbeService) processJob(job *CindyBalanceProbeJob, leaseTo } var jobCtx context.Context var cancel context.CancelFunc - var err error if job.Scope.Origin != nil { + var err error jobCtx, cancel, err = bindCindyProbeOrigin(s.ctx, job.Scope) + if err != nil { + return + } } else { - jobCtx, cancel, err = bindProcessExtensionContext(s.ctx, PlatformCindy, AccountTypeAPIKey, - extensionv1.Invocation{Capability: extensionv1.CapabilityProvider, Operation: "cindy.probe.plan"}) - } - if err != nil { - return + jobCtx, cancel = context.WithCancel(context.WithValue(s.ctx, cindyNativeProbeContextKey{}, true)) } defer cancel() lostLease := make(chan struct{}) @@ -384,14 +378,11 @@ func (s *CindyBalanceProbeService) executeReservation(ctx context.Context, reser return false } if origin, bound := ctx.Value(cindyProbeOriginContextKey{}).(*CindyBalanceProbeOrigin); bound { - if reservation == nil || !slices.Contains(origin.FrozenAccountIDs, reservation.AccountID) || ValidateAccountViewTargets(ctx, []int64{reservation.AccountID}) != nil { - return false - } - view, _ := AccountViewFromContext(ctx) - if view == nil || view.manager.ValidateResourceAccounts(ctx, extensionv1.CapabilityProvider, []int64{reservation.AccountID}, false) != nil { + if origin == nil || reservation == nil || !slices.Contains(origin.FrozenAccountIDs, reservation.AccountID) || ValidateCindyBalanceProbeOriginContext(ctx, origin) != nil { return false } } + account, eligible := s.loadReservationAccount(ctx, reservation) if !eligible || account.ID <= 0 { keepRunning, applied, err := s.repo.CompleteStage(ctx, reservation, account, leaseToken, "stale", "skipped_stale", false) @@ -412,23 +403,6 @@ func (s *CindyBalanceProbeService) executeReservation(ctx context.Context, reser // epoch conservatively recovers the pre-send reservation as unknown. return false } - // Creating a job only admitted its then-current selection. Bind the actual - // reserved account again before any IO, retaining one policy lifetime for - // its plan, HTTP request and completion decision. - bound, release, err := bindProcessExtensionContext(ctx, PlatformCindy, AccountTypeAPIKey, - extensionv1.Invocation{Capability: extensionv1.CapabilityProvider, Operation: "cindy.probe.plan", AccountID: account.ID}) - if err != nil { - if ctx.Err() == nil && errors.Is(err, ErrExtensionOperationDisabled) { - // A known admission rejection invalidates the original selection, - // not the account's health. Finish via the existing claim CAS so this - // reservation is never retried or reported as an upstream failure. - slog.Info("cindy_balance_probe_scope_skipped", "job_id", reservation.JobID, "account_id", account.ID, "stage", reservation.Stage) - return s.completeStage(ctx, reservation, account, leaseToken, "stale", "skipped_stale", false) - } - return false - } - defer release() - ctx = bound plan, err := cindyBalanceProbePlanForAccount(ctx, account.ID) if err != nil { return false @@ -526,8 +500,7 @@ func (s *CindyBalanceProbeService) finalizeRecovery( if recovered { if s.gateway != nil { if store, ok := s.gateway.cindyBalancePendingStore(); ok { - clearBase, release := detachPluginPolicyContext(ctx) - defer release() + clearBase := context.WithoutCancel(ctx) clearCtx, cancel := context.WithTimeout(clearBase, 3*time.Second) if clearErr := store.ClearCindyBalancePendingIfFingerprintMatches(clearCtx, reservation.AccountID, reservation.IdentityFingerprint); clearErr != nil { slog.Error("cindy_balance_probe_recovery_pending_clear_failed", "job_id", reservation.JobID, "error", clearErr) @@ -592,8 +565,7 @@ func (s *CindyBalanceProbeService) finalizeExhausted( if !ok { return true } - clearBase, release := detachPluginPolicyContext(ctx) - defer release() + clearBase := context.WithoutCancel(ctx) clearCtx, clearCancel := context.WithTimeout(clearBase, 3*time.Second) clearErr := store.ClearCindyBalancePendingIfFingerprintMatches(clearCtx, account.ID, reservation.IdentityFingerprint) clearCancel() @@ -620,155 +592,56 @@ func WithCindyProbeOperationKey(ctx context.Context, key string) context.Context return context.WithValue(ctx, cindyProbeOperationKeyContextKey{}, strings.TrimSpace(key)) } -func captureCindyProbeOrigin(ctx context.Context, scope CindyBalanceProbeScope, rate float64, expectedCount int, fingerprint string) (CindyBalanceProbeScope, error) { - scope.Origin = nil // Origin is never accepted from an HTTP/plugin scope copy. - view, bound := AccountViewFromContext(ctx) - if !bound { - return scope, nil - } - if err := ValidateAccountViewFresh(ctx); err != nil { - return scope, err - } - primary, ok := PluginExecutionFromContext(ctx) - if !ok { - return scope, ErrAccountViewUnavailable - } - installation, err := view.manager.repo.GetByID(ctx, primary.ID) - if err != nil || installation == nil || installation.RuntimeGeneration != primary.Generation || installation.State != PluginStateEnabled { - return scope, ErrAccountViewUnavailable - } - metadata, err := stampAccountJobView(ctx, nil) - if err != nil { - return scope, err - } - snapshot, err := AccountJobViewExecution(metadata) - if err != nil || snapshot == nil { - return scope, ErrAccountViewUnavailable - } - key, _ := ctx.Value(cindyProbeOperationKeyContextKey{}).(string) - if key != "" && !regexp.MustCompile(`^[a-zA-Z0-9:_-]{1,160}$`).MatchString(key) { - return scope, ErrAccountViewInvalid - } - request, _ := json.Marshal(struct { - Scope CindyBalanceProbeScope - View extensionv1.AccountViewIdentityV1 - QueryDigest string - Owner int64 - Package string - Rate float64 - ExpectedCount int - Fingerprint string - }{CanonicalizeCindyBalanceProbeScope(scope), snapshot.AccountViewIdentityV1, snapshot.NormalizedQueryDigest, primary.ID, installation.PackageSHA256, rate, expectedCount, strings.TrimSpace(fingerprint)}) - digest := sha256.Sum256(request) - scope.Origin = &CindyBalanceProbeOrigin{Version: 1, PluginID: primary.ID, PluginKey: installation.PluginKey, PackageSHA256: installation.PackageSHA256, RuntimeGeneration: primary.Generation, View: *snapshot, OperationKey: key, RequestDigest: hex.EncodeToString(digest[:])} +// New native requests cannot import a historical origin envelope. Candidate +// IDs and identity fingerprints are still frozen by CreateJob's transaction. +func captureCindyProbeOrigin(_ context.Context, scope CindyBalanceProbeScope, _ float64, _ int, _ string) (CindyBalanceProbeScope, error) { + scope.Origin = nil return scope, nil } -func (m *PluginManager) BindCindyBalanceProbeOrigin(ctx context.Context, scope CindyBalanceProbeScope) (context.Context, context.CancelFunc, error) { - return m.bindCindyBalanceProbeOrigin(ctx, scope, false) -} +type cindyNativeProbeContextKey struct{} -func (m *PluginManager) RebindCindyBalanceProbeOrigin(ctx context.Context, scope CindyBalanceProbeScope) (context.Context, context.CancelFunc, error) { - return m.bindCindyBalanceProbeOrigin(ctx, scope, true) -} - -func (m *PluginManager) bindCindyBalanceProbeOrigin(ctx context.Context, scope CindyBalanceProbeScope, explicitResume bool) (context.Context, context.CancelFunc, error) { +func bindCindyProbeOrigin(ctx context.Context, scope CindyBalanceProbeScope) (context.Context, context.CancelFunc, error) { origin := scope.Origin - if origin == nil || origin.Version != 1 || origin.PluginID <= 0 || origin.RuntimeGeneration <= 0 || origin.View.RuntimeGeneration <= 0 || !accountViewDigestPattern.MatchString(origin.PackageSHA256) || !accountViewDigestPattern.MatchString(origin.RequestDigest) || len(origin.FrozenAccountIDs) == 0 { - return nil, nil, ErrAccountViewUnavailable + if origin == nil || origin.Version != 1 || !accountEditDigestPattern.MatchString(origin.RequestDigest) || len(origin.FrozenAccountIDs) == 0 { + return nil, nil, ErrCindyBalanceProbeChanged } for _, id := range origin.FrozenAccountIDs { if id <= 0 { - return nil, nil, ErrAccountViewScope - } - } - if existing, bound := AccountViewFromContext(ctx); bound && existing.Request.AccountViewIdentityV1 != origin.View.AccountViewIdentityV1 { - return nil, nil, ErrAccountViewUnavailable - } - viewCtx, releaseView, err := m.BindAccountViewRequest(ctx, extensionv1.AccountViewContextV1{AccountViewIdentityV1: origin.View.AccountViewIdentityV1}) - if err != nil { - return nil, nil, err - } - view, _ := AccountViewFromContext(viewCtx) - if !explicitResume && view.Execution.Generation != origin.View.RuntimeGeneration { - releaseView() - return nil, nil, ErrAccountViewUnavailable - } - primary, releasePrimary, err := m.BindResourceContext(viewCtx, origin.PluginID, origin.PackageSHA256, extensionv1.ResourceGrant{Name: "cindy.probe.create", Capability: extensionv1.CapabilityProvider, Permission: "admin"}) - if err != nil { - releaseView() - return nil, nil, err - } - release := func() { releasePrimary(); releaseView() } - execution, _ := PluginExecutionFromContext(primary) - current, err := m.repo.GetByID(primary, origin.PluginID) - if err != nil || current == nil || current.PluginKey != origin.PluginKey || (!explicitResume && execution.Generation != origin.RuntimeGeneration) { - release() - return nil, nil, ErrAccountViewUnavailable - } - if err := m.ValidateResourceAccounts(primary, extensionv1.CapabilityProvider, nil, scope.Mode != "selected", PlatformCindy, AccountTypeAPIKey); err != nil { - release() - return nil, nil, err - } - next := *origin - if explicitResume { - if err := ValidateAccountViewTargets(primary, origin.FrozenAccountIDs); err != nil { - release() - return nil, nil, err + return nil, nil, ErrCindyBalanceProbeChanged } - if err := m.ValidateResourceAccounts(primary, extensionv1.CapabilityProvider, origin.FrozenAccountIDs, false); err != nil { - release() - return nil, nil, err - } - next.RuntimeGeneration, next.View.RuntimeGeneration, next.View.PolicyRevision = execution.Generation, view.Execution.Generation, view.installation.Revision - } - return context.WithValue(primary, cindyProbeOriginContextKey{}, &next), release, nil -} - -func bindCindyProbeOrigin(ctx context.Context, scope CindyBalanceProbeScope) (context.Context, context.CancelFunc, error) { - provider := processExtensionOperations.Load() - if provider == nil { - return nil, nil, ErrAccountViewUnavailable } - binder, ok := provider.invoker.(interface { - BindCindyBalanceProbeOrigin(context.Context, CindyBalanceProbeScope) (context.Context, context.CancelFunc, error) - }) - if !ok { - return nil, nil, ErrAccountViewUnavailable - } - return binder.BindCindyBalanceProbeOrigin(ctx, scope) + // Retain the exact historical scope and frozen IDs. A retired installation + // can no longer widen or replace them during explicit resume or execution. + copy := *origin + copy.FrozenAccountIDs = append([]int64(nil), origin.FrozenAccountIDs...) + copy.View = append(json.RawMessage(nil), origin.View...) + bound := context.WithValue(ctx, cindyProbeOriginContextKey{}, ©) + bound = context.WithValue(bound, cindyNativeProbeContextKey{}, true) + call, cancel := context.WithCancel(bound) + return call, cancel, nil } func rebindCindyProbeOrigin(ctx context.Context, scope CindyBalanceProbeScope) (context.Context, context.CancelFunc, error) { - provider := processExtensionOperations.Load() - if provider == nil { - return nil, nil, ErrAccountViewUnavailable - } - binder, ok := provider.invoker.(interface { - RebindCindyBalanceProbeOrigin(context.Context, CindyBalanceProbeScope) (context.Context, context.CancelFunc, error) - }) - if !ok { - return nil, nil, ErrAccountViewUnavailable - } - return binder.RebindCindyBalanceProbeOrigin(ctx, scope) + return bindCindyProbeOrigin(ctx, scope) } +// Kept for the existing atomic terminal-state repository interface. Native +// workers have no plugin owner, but still use the committed health-episode path. func CindyBalanceProbeOriginOwner(ctx context.Context) (string, bool) { - origin, ok := ctx.Value(cindyProbeOriginContextKey{}).(*CindyBalanceProbeOrigin) - if !ok || origin == nil { - return "", false + if native, _ := ctx.Value(cindyNativeProbeContextKey{}).(bool); native { + return "", true } - return origin.PluginKey, true + return "", false } func ValidateCindyBalanceProbeOriginContext(ctx context.Context, origin *CindyBalanceProbeOrigin) error { if origin == nil { return nil } - view, bound := AccountViewFromContext(ctx) - primary, primaryBound := PluginExecutionFromContext(ctx) - if !bound || !primaryBound || origin.Version != 1 || view.Request.AccountViewIdentityV1 != origin.View.AccountViewIdentityV1 || view.Execution.Generation != origin.View.RuntimeGeneration || primary.ID != origin.PluginID || primary.Generation != origin.RuntimeGeneration { - return ErrAccountViewUnavailable + bound, ok := ctx.Value(cindyProbeOriginContextKey{}).(*CindyBalanceProbeOrigin) + if !ok || bound == nil || ctx.Err() != nil || !accountEditJSONEqual(bound, origin) { + return ErrCindyBalanceProbeChanged } - return ValidateAccountViewFresh(ctx) + return nil } diff --git a/backend/internal/service/cindy_balance_probe_service_test.go b/backend/internal/service/cindy_balance_probe_service_test.go index eb9f76e3b90a..05fc2d39e948 100644 --- a/backend/internal/service/cindy_balance_probe_service_test.go +++ b/backend/internal/service/cindy_balance_probe_service_test.go @@ -52,6 +52,20 @@ type cindyBalanceProbeClaimEpochRepositoryStub struct { claimTokens []string } +type cindyBalanceProbeRecoveryFailureRepositoryStub struct { + CindyBalanceProbeRepository + jobContext context.Context +} + +func (s *cindyBalanceProbeRecoveryFailureRepositoryStub) RecoverInterruptedItems(ctx context.Context, _ int64, _ string) error { + s.jobContext = ctx + return errors.New("synthetic recovery failure") +} + +func (s *cindyBalanceProbeRecoveryFailureRepositoryStub) Heartbeat(context.Context, int64, string, time.Time) (bool, error) { + return true, nil +} + type cindyBalanceProbeCreateRepositoryStub struct { CindyBalanceProbeRepository scope CindyBalanceProbeScope @@ -496,6 +510,32 @@ func TestCindyBalanceProbeMarkedLunaSuccessFinalizesWithPreSendSnapshot(t *testi require.Same(t, account, repo.accountSnapshot) } +func TestCindyBalanceProbeProcessJobCancelsContextOnRecoveryFailure(t *testing.T) { + for _, test := range []struct { + name string + origin *CindyBalanceProbeOrigin + }{ + {name: "native"}, + {name: "historical_scope", origin: &CindyBalanceProbeOrigin{ + Version: 1, RequestDigest: strings.Repeat("a", 64), FrozenAccountIDs: []int64{7}, + }}, + } { + t.Run(test.name, func(t *testing.T) { + repo := &cindyBalanceProbeRecoveryFailureRepositoryStub{} + svc := NewCindyBalanceProbeService(repo, nil, nil, nil) + defer svc.Stop() + svc.processJob(&CindyBalanceProbeJob{ + ID: 1, Scope: CindyBalanceProbeScope{Mode: "selected", AccountIDs: []int64{7}, Origin: test.origin}, + }, "lease") + require.NotNil(t, repo.jobContext) + require.ErrorIs(t, repo.jobContext.Err(), context.Canceled, "early recovery failure must release the worker and heartbeat context") + require.NoError(t, svc.ctx.Err(), "finishing one job must not cancel the service") + _, native := CindyBalanceProbeOriginOwner(repo.jobContext) + require.True(t, native) + }) + } +} + func TestCindyBalanceProbeUsesFreshTokenForEveryClaim(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) repo := &cindyBalanceProbeClaimEpochRepositoryStub{cancel: cancel} diff --git a/backend/internal/service/cindy_balance_probe_types.go b/backend/internal/service/cindy_balance_probe_types.go index e47114919be8..9f73802903a6 100644 --- a/backend/internal/service/cindy_balance_probe_types.go +++ b/backend/internal/service/cindy_balance_probe_types.go @@ -57,15 +57,15 @@ type CindyBalanceProbeScope struct { } type CindyBalanceProbeOrigin struct { - Version int `json:"version"` - PluginID int64 `json:"plugin_id"` - PluginKey string `json:"plugin_key"` - PackageSHA256 string `json:"package_sha256"` - RuntimeGeneration int64 `json:"runtime_generation"` - View AccountJobViewMetadata `json:"view"` - FrozenAccountIDs []int64 `json:"frozen_account_ids,omitempty"` - OperationKey string `json:"operation_key,omitempty"` - RequestDigest string `json:"request_digest"` + Version int `json:"version"` + PluginID int64 `json:"plugin_id"` + PluginKey string `json:"plugin_key"` + PackageSHA256 string `json:"package_sha256"` + RuntimeGeneration int64 `json:"runtime_generation"` + View json.RawMessage `json:"view"` + FrozenAccountIDs []int64 `json:"frozen_account_ids,omitempty"` + OperationKey string `json:"operation_key,omitempty"` + RequestDigest string `json:"request_digest"` } type CindyBalanceProbeCandidate struct { @@ -338,50 +338,10 @@ func BuildCindyBalanceProbePreviewFromSnapshot( // Apply the same host view base/preset/buckets before candidate counting. The // probe's own filter remains an additional intersection, never a replacement. func BuildCindyBalanceProbePreviewFromSnapshotContext(ctx context.Context, scope CindyBalanceProbeScope, accounts []Account, rateRPS float64, now time.Time) (*CindyBalanceProbePreview, error) { - view, bound := AccountViewFromContext(ctx) - if !bound { - return BuildCindyBalanceProbePreviewFromSnapshot(scope, accounts, rateRPS, now) - } - pointers := make([]*Account, 0, len(accounts)) - for index := range accounts { - pointers = append(pointers, &accounts[index]) - } - allowed, err := filterAccountViewAccounts(ctx, pointers) - if err != nil { - return nil, err - } - filtered := make([]Account, 0, len(allowed)) - for _, account := range allowed { - filtered = append(filtered, *account) - } - common, err := BuildCindyBalanceProbePreviewFromSnapshot(CindyBalanceProbeScope{Mode: "filter", Filters: AccountViewQueryFilters(view.Request.Query)}, filtered, rateRPS, now) - if err != nil { + if err := ctx.Err(); err != nil { return nil, err } - ids := make(map[int64]bool, len(common.Candidates)) - for _, candidate := range common.Candidates { - ids[candidate.AccountID] = true - } - filtered = filtered[:0] - for _, account := range allowed { - if ids[account.ID] { - filtered = append(filtered, *account) - } - } - preview, err := BuildCindyBalanceProbePreviewFromSnapshot(scope, filtered, rateRPS, now) - if err != nil { - return nil, err - } - if scope.Mode == "selected" { - selected := CanonicalizeCindyBalanceProbeScope(scope).AccountIDs - if err := ValidateAccountViewTargets(ctx, selected); err != nil { - return nil, err - } - if len(selected) != preview.CandidateCount { - return nil, ErrAccountViewScope - } - } - return preview, nil + return BuildCindyBalanceProbePreviewFromSnapshot(scope, accounts, rateRPS, now) } func cindyBalanceProbeContainsID(values []int64, target int64) bool { diff --git a/backend/internal/service/cindy_balance_recheck.go b/backend/internal/service/cindy_balance_recheck.go index 72106b395fdf..ed8b5ea936a1 100644 --- a/backend/internal/service/cindy_balance_recheck.go +++ b/backend/internal/service/cindy_balance_recheck.go @@ -8,7 +8,7 @@ import ( "net/http" "strings" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/tidwall/gjson" ) diff --git a/backend/internal/service/cindy_capability_catalog.go b/backend/internal/service/cindy_capability_catalog.go index 1688f11ef95b..e91659870fed 100644 --- a/backend/internal/service/cindy_capability_catalog.go +++ b/backend/internal/service/cindy_capability_catalog.go @@ -5,7 +5,7 @@ import ( "encoding/json" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) type CindyModelKind = extensionv1.CindyModelKind @@ -73,7 +73,7 @@ func queryCindyCatalog(method string, args, outputs []any) bool { } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() - result, err := invokeProcessExtensionCached(ctx, PlatformCindy, AccountTypeAPIKey, extensionv1.Invocation{Capability: extensionv1.CapabilityProvider, Operation: "cindy.catalog", Payload: raw}) + result, err := invokeCindyProviderCached(ctx, extensionv1.Invocation{Capability: extensionv1.CapabilityProvider, Operation: "cindy.catalog", Payload: raw}) if err != nil || result.Code != "" { return false } diff --git a/backend/internal/service/cindy_catalog_snapshot.go b/backend/internal/service/cindy_catalog_snapshot.go index ce0a2d500313..c8f24dd16bae 100644 --- a/backend/internal/service/cindy_catalog_snapshot.go +++ b/backend/internal/service/cindy_catalog_snapshot.go @@ -16,7 +16,7 @@ import ( "strings" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) // CindyCatalogSnapshot holds one admitted provider reply. Callers pass this @@ -68,7 +68,7 @@ func LoadCindyCatalogSnapshot(ctx context.Context, account *Account) (*CindyCata query, _ := json.Marshal(extensionv1.CindyCatalogQuery{Method: extensionv1.CindyCatalogSnapshotMethodV1, Images: images}) call, cancel := context.WithTimeout(ctx, time.Second) defer cancel() - result, err := invokeProcessExtensionCached(call, PlatformCindy, AccountTypeAPIKey, extensionv1.Invocation{ + result, err := invokeCindyProviderCached(call, extensionv1.Invocation{ Capability: extensionv1.CapabilityProvider, Operation: "cindy.catalog", AccountID: accountID, Payload: query, }) if err != nil { diff --git a/backend/internal/service/cindy_duplicate_inventory.go b/backend/internal/service/cindy_duplicate_inventory.go index 1456a06d21f9..54796a0f6eed 100644 --- a/backend/internal/service/cindy_duplicate_inventory.go +++ b/backend/internal/service/cindy_duplicate_inventory.go @@ -3,7 +3,7 @@ package service import ( "context" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) type CindyDuplicateIdentityGroup = extensionv1.CindyDuplicateIdentityGroup diff --git a/backend/internal/service/plugin_feature_decode_test.go b/backend/internal/service/cindy_feature_decode_test.go similarity index 54% rename from backend/internal/service/plugin_feature_decode_test.go rename to backend/internal/service/cindy_feature_decode_test.go index f914ffdf3a1e..8019de4bf7e1 100644 --- a/backend/internal/service/plugin_feature_decode_test.go +++ b/backend/internal/service/cindy_feature_decode_test.go @@ -6,24 +6,25 @@ import ( "net/http" "testing" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" ) type featureDecodeFixture struct{ payload json.RawMessage } -func (f featureDecodeFixture) InvokeOperation(context.Context, string, string, extensionv1.Invocation) (extensionv1.Result, error) { +func (f featureDecodeFixture) Invoke(context.Context, extensionv1.Invocation) (extensionv1.Result, error) { return extensionv1.Result{Payload: f.payload}, nil } func withFeatureDecodeFixture(t *testing.T, payload string) { t.Helper() - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - processExtensionOperations.Store(&extensionOperationProvider{invoker: featureDecodeFixture{json.RawMessage(payload)}}) + previous, runtime := invokeCindyProvider, cindyProvider.Load() + ConfigureCindyProvider(nil) + invokeCindyProvider = featureDecodeFixture{json.RawMessage(payload)}.Invoke + t.Cleanup(func() { invokeCindyProvider = previous; cindyProvider.Store(runtime) }) } -func TestPluginFeatureConfigsDiscardPartiallyDecodedFlags(t *testing.T) { +func TestCindyFeatureConfigsDiscardPartiallyDecodedFlags(t *testing.T) { t.Run("cindy", func(t *testing.T) { withFeatureDecodeFixture(t, `{"balance_detection":true,"catalog_enabled":"invalid","search_enabled":true}`) value, ok := currentCindyProviderConfig() @@ -32,19 +33,6 @@ func TestPluginFeatureConfigsDiscardPartiallyDecodedFlags(t *testing.T) { require.False(t, CindyBalanceDetectionFeatureEnabled()) require.False(t, CindySearchFeatureEnabled()) }) - for _, payload := range []string{ - `{"studio_enabled":true,"responses_image_enabled":"invalid"}`, - `{"studio_enabled":"invalid","responses_image_enabled":true}`, - } { - t.Run(payload, func(t *testing.T) { - withFeatureDecodeFixture(t, payload) - value, ok := currentImageToolsConfig() - require.False(t, ok) - require.Equal(t, extensionv1.ImageToolsConfig{}, value) - require.False(t, ImageStudioFeatureEnabled()) - require.False(t, CindyResponsesImageBridgeFeatureEnabled()) - }) - } } func TestCindyResponseDecisionDiscardsPartialDecode(t *testing.T) { @@ -59,17 +47,11 @@ func TestCindyResponseDecisionDiscardsPartialDecode(t *testing.T) { } } -func TestPluginFeatureConfigsKeepValidExplicitFlags(t *testing.T) { +func TestCindyFeatureConfigsKeepValidExplicitFlags(t *testing.T) { t.Run("cindy", func(t *testing.T) { withFeatureDecodeFixture(t, `{"balance_detection":true,"catalog_enabled":false,"search_enabled":true}`) value, ok := currentCindyProviderConfig() require.True(t, ok) require.Equal(t, extensionv1.CindyProviderConfig{BalanceDetection: true, SearchEnabled: true}, value) }) - t.Run("image", func(t *testing.T) { - withFeatureDecodeFixture(t, `{"studio_enabled":true,"responses_image_enabled":false}`) - value, ok := currentImageToolsConfig() - require.True(t, ok) - require.Equal(t, extensionv1.ImageToolsConfig{StudioEnabled: true}, value) - }) } diff --git a/backend/internal/service/cindy_feature_flags.go b/backend/internal/service/cindy_feature_flags.go index b7563bc3e938..16a5992c8c87 100644 --- a/backend/internal/service/cindy_feature_flags.go +++ b/backend/internal/service/cindy_feature_flags.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) const ( diff --git a/backend/internal/service/cindy_health_policy_scope_test.go b/backend/internal/service/cindy_health_policy_scope_test.go index 23224a27e6c3..598468566e9e 100644 --- a/backend/internal/service/cindy_health_policy_scope_test.go +++ b/backend/internal/service/cindy_health_policy_scope_test.go @@ -1,104 +1,71 @@ package service import ( + "context" "encoding/json" - "fmt" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + "github.com/stretchr/testify/require" "net/http" "testing" - - "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/stretchr/testify/require" ) -func cindyHealthScopeFixture(t *testing.T, percent int, calls *[]extensionv1.Invocation) *PluginManager { +func cindyHealthScopeFixture(t *testing.T, calls *[]extensionv1.Invocation) { t.Helper() - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{}, func(in extensionv1.Invocation) (extensionv1.Result, error) { + previous := captureNativeCindyTestInvoker() + t.Cleanup(func() { restoreNativeCindyTestInvoker(previous) }) + setNativeCindyTestInvoker(nativeCindyTestInvoker(func(_ context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { *calls = append(*calls, in) - raw, err := json.Marshal(extensionv1.CindyResponseDecision{ - Health: uint8(CindyHealthSignalExactBudget), Balance: uint8(CindyBalanceSignalHTTP429), - }) + raw, err := json.Marshal(extensionv1.CindyResponseDecision{Health: uint8(CindyHealthSignalExactBudget), Balance: uint8(CindyBalanceSignalHTTP429)}) return extensionv1.Result{Payload: raw}, err - }) - installation := manager.extensions.Load().installations[1] - installation.Bindings = []PluginBinding{{Capability: extensionv1.CapabilityProvider, Platform: PlatformCindy, - AccountType: AccountTypeAPIKey, Enabled: true, RolloutPercent: percent}} - installation.Manifest.Operations = map[string][]string{extensionv1.CapabilityProvider: {"cindy.health"}} - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - processExtensionOperations.Store(&extensionOperationProvider{invoker: manager}) - return manager -} - -func cindyHealthScopeAccount(id int64) *Account { - return &Account{ID: id, Platform: PlatformCindy, Type: AccountTypeAPIKey, - Credentials: map[string]any{"base_url": "https://api.laxarouter.ai", "api_key": "synthetic-account-credential"}} + })) } func TestCindyResponseClassifiersCarryAccountScope(t *testing.T) { - actions := []struct { - name string - call func(*Account, int, []byte) uint8 - }{ - {"health", func(account *Account, status int, body []byte) uint8 { - return uint8(ClassifyCindyHealthSignal(account, status, body)) - }}, - {"balance", func(account *Account, status int, body []byte) uint8 { - return uint8(ClassifyCindyBalanceInsufficient(account, status, body)) - }}, - } - for _, action := range actions { - for _, percent := range []int{0, 50, 100} { - for _, id := range []int64{2, 3} { - t.Run(fmt.Sprintf("%s/rollout_%d/account_%d", action.name, percent, id), func(t *testing.T) { - var calls []extensionv1.Invocation - cindyHealthScopeFixture(t, percent, &calls) - account := cindyHealthScopeAccount(id) - require.True(t, hasCanonicalCindyProviderIdentity(account), "fixture must reach the real classifier") - body := []byte(`{"error":{"type":"insufficient_quota","code":"budget_exceeded","message":"synthetic-upstream-private-detail"},"other":"synthetic-unneeded-field"}`) - got := action.call(account, http.StatusTooManyRequests, body) - if int(stablePluginBucket(id)) >= percent { - require.Zero(t, got, "outside-rollout observations must not acquire a domain policy decision") - require.Empty(t, calls, "unadmitted account facts must not reach the plugin") - return - } - require.Equal(t, uint8(1), got) - require.Len(t, calls, 1) - require.Equal(t, id, calls[0].AccountID) - require.Equal(t, "cindy.health", calls[0].Operation) - require.NotContains(t, string(calls[0].Payload), "synthetic-") - }) - } + for _, id := range []int64{2, 3} { + var calls []extensionv1.Invocation + cindyHealthScopeFixture(t, &calls) + account := cindyHealthScopeAccount(id) + body := []byte(`{"error":{"type":"insufficient_quota","code":"budget_exceeded","message":"synthetic-upstream-private-detail"},"other":"synthetic-unneeded-field"}`) + require.Equal(t, CindyHealthSignalExactBudget, ClassifyCindyHealthSignal(account, http.StatusTooManyRequests, body)) + require.Equal(t, CindyBalanceSignalHTTP429, ClassifyCindyBalanceInsufficient(account, http.StatusTooManyRequests, body)) + require.Len(t, calls, 2) + for _, call := range calls { + require.Equal(t, id, call.AccountID) + require.Equal(t, "cindy.health", call.Operation) + require.NotContains(t, string(call.Payload), "synthetic-") } } } -func TestCindyResponseClassifierDoesNotReusePolicyOutsideCurrentScope(t *testing.T) { - for _, state := range []string{"rollout_revoked", "runtime_unavailable"} { - t.Run(state, func(t *testing.T) { - var calls []extensionv1.Invocation - manager := cindyHealthScopeFixture(t, 100, &calls) - account := cindyHealthScopeAccount(2) - body := []byte(`{"error":{"code":"budget_exceeded"}}`) - require.Equal(t, CindyHealthSignalExactBudget, ClassifyCindyHealthSignal(account, http.StatusTooManyRequests, body)) - require.Len(t, calls, 1) - registry := manager.extensions.Load() - if state == "rollout_revoked" { - registry.installations[1].Bindings[0].RolloutPercent = 0 - } else { - registry.runtimes = map[int64]*pluginRuntime{} - } - manager.extensions.Store(registry) - require.Equal(t, CindyHealthSignalNone, ClassifyCindyHealthSignal(account, http.StatusTooManyRequests, body)) - require.Equal(t, CindyBalanceSignalNone, ClassifyCindyBalanceInsufficient(account, http.StatusTooManyRequests, body)) - require.Len(t, calls, 1, "fresh scope/runtime admission must precede a cache hit") - }) +func TestCindyResponseClassifierDoesNotReusePolicyAfterNativeSwitchChange(t *testing.T) { + previous := captureNativeCindyTestInvoker() + t.Cleanup(func() { restoreNativeCindyTestInvoker(previous) }) + invokeCindyProvider = func(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { + return cindyProvider.Load().module.Invoke(ctx, in) } + ConfigureCindyProvider(&extensionv1.CindyProviderConfig{BalanceDetection: true}) + account := cindyHealthScopeAccount(2) + account.WirePlatform, account.ProviderProfile = WirePlatformOpenAI, ProviderProfileCindyLaxaV1 + require.True(t, hasCanonicalCindyProviderIdentity(account)) + body := []byte(`{"error":{"type":"budget_exceeded","code":"429"}}`) + require.Equal(t, CindyHealthSignalExactBudget, ClassifyCindyHealthSignal(account, http.StatusTooManyRequests, body)) + require.Equal(t, CindyBalanceSignalHTTP429, ClassifyCindyBalanceInsufficient(account, http.StatusTooManyRequests, body)) + ConfigureCindyProvider(&extensionv1.CindyProviderConfig{}) + require.Equal(t, CindyHealthSignalNone, ClassifyCindyHealthSignal(account, http.StatusTooManyRequests, body)) + require.Equal(t, CindyBalanceSignalNone, ClassifyCindyBalanceInsufficient(account, http.StatusTooManyRequests, body)) + ConfigureCindyProvider(&extensionv1.CindyProviderConfig{BalanceDetection: true}) + require.Equal(t, CindyHealthSignalExactBudget, ClassifyCindyHealthSignal(account, http.StatusTooManyRequests, body)) + require.Equal(t, CindyBalanceSignalHTTP429, ClassifyCindyBalanceInsufficient(account, http.StatusTooManyRequests, body)) +} + +func cindyHealthScopeAccount(id int64) *Account { + return &Account{ID: id, Platform: PlatformCindy, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://api.laxarouter.ai", "api_key": "synthetic-account-credential"}} } func TestCindyResponseClassifiersKeepNonAccountAndOrdinaryBoundaries(t *testing.T) { var calls []extensionv1.Invocation - cindyHealthScopeFixture(t, 0, &calls) + cindyHealthScopeFixture(t, &calls) account := cindyHealthScopeAccount(0) body := []byte(`{"error":{"code":"budget_exceeded"}}`) // A pre-create observation has no persisted account identity to invent. diff --git a/backend/internal/service/cindy_independent_snapshot_test.go b/backend/internal/service/cindy_independent_snapshot_test.go index a0fe71b2d3f1..e4ba5a8ce26a 100644 --- a/backend/internal/service/cindy_independent_snapshot_test.go +++ b/backend/internal/service/cindy_independent_snapshot_test.go @@ -14,11 +14,10 @@ import ( "testing" "time" - cindy "github.com/HTExplicit/sub2api-plugins/cindyprovider/catalog" - "github.com/Wei-Shaw/sub2api/internal/config" + cindy "github.com/Wei-Shaw/sub2api/internal/cindyprovider/catalog" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/gin-gonic/gin" gocache "github.com/patrickmn/go-cache" "github.com/stretchr/testify/require" @@ -108,9 +107,9 @@ func independentCindySnapshot(t *testing.T, suffix string, enabled bool) extensi func installIndependentCindyFixture(t *testing.T, f *cindyIndependentSnapshotFixture) { t.Helper() - previous := processExtensionOperations.Load() - processExtensionOperations.Store(&extensionOperationProvider{invoker: f}) - t.Cleanup(func() { processExtensionOperations.Store(previous) }) + previous := captureNativeCindyTestInvoker() + setNativeCindyTestInvoker(f) + t.Cleanup(func() { restoreNativeCindyTestInvoker(previous) }) } func TestCindyPluginOwnedDefaultAndLegacyWire(t *testing.T) { @@ -244,20 +243,7 @@ func TestCindySnapshotOrdinaryAndPinnedPathsHaveNoProviderDependency(t *testing. } func TestCindySnapshotActualAccountAdmissionAndValidation(t *testing.T) { - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - calls := 0 - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{}, func(extensionv1.Invocation) (extensionv1.Result, error) { - calls++ - return extensionv1.Result{}, nil - }) - installation := manager.extensions.Load().installations[1] - installation.Bindings = []PluginBinding{{Capability: extensionv1.CapabilityProvider, Platform: PlatformCindy, AccountType: AccountTypeAPIKey, Enabled: true, RolloutPercent: 0}} - installation.Manifest.Operations = map[string][]string{extensionv1.CapabilityProvider: {"cindy.catalog"}} - processExtensionOperations.Store(&extensionOperationProvider{invoker: manager}) - _, err := LoadCindyCatalogSnapshot(context.Background(), newCindyNativeMessagesAccount()) - require.ErrorIs(t, err, ErrExtensionOperationDisabled) - require.Zero(t, calls, "zero-percent actual accounts must not receive policy content") + var err error for _, enabled := range []bool{false, true} { _, err = validateCindyCatalogSnapshot(independentCindySnapshot(t, "valid", enabled)) require.NoError(t, err, "catalog-off snapshots retain valid descriptive defaults") diff --git a/backend/internal/service/cindy_native_image_snapshot_test.go b/backend/internal/service/cindy_native_image_snapshot_test.go index 3fb7ab024c13..f528ebea0da8 100644 --- a/backend/internal/service/cindy_native_image_snapshot_test.go +++ b/backend/internal/service/cindy_native_image_snapshot_test.go @@ -10,8 +10,8 @@ import ( "strings" "testing" - cindy "github.com/HTExplicit/sub2api-plugins/cindyprovider/catalog" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + cindy "github.com/Wei-Shaw/sub2api/internal/cindyprovider/catalog" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) @@ -26,17 +26,6 @@ type cindyNativeImageSnapshotFixture struct { func (f *cindyNativeImageSnapshotFixture) InvokeOperation(ctx context.Context, platform, accountType string, in extensionv1.Invocation) (extensionv1.Result, error) { f.calls = append(f.calls, in) - if in.Operation == "image.features" { - raw, err := json.Marshal(f.before.Images) - return extensionv1.Result{Payload: raw}, err - } - if in.Operation == "image.native.validate" { - var facts extensionv1.ImageNativeRequest - if err := json.Unmarshal(in.Payload, &facts); err != nil { - return extensionv1.Result{}, err - } - f.nativeFacts = append(f.nativeFacts, facts) - } if in.Operation == "cindy.catalog" && !f.disabled { var query extensionv1.CindyCatalogQuery if err := json.Unmarshal(in.Payload, &query); err != nil { @@ -205,13 +194,20 @@ func newNativeImageSnapshotFixture(t *testing.T, studioEnabled bool) *cindyNativ after := cloneNativeImageCatalog(t, before) publishNativeImageFixture(t, &after, "native-image-b", "fixture-b/native-image", "high", 0.5) pricing, pricingAfter := nativeImagePricingFixture(t, registry, before), nativeImagePricingFixture(t, registry, after) - previous := processExtensionOperations.Load() + previous := captureNativeCindyTestInvoker() require.NotNil(t, previous) fixture := &cindyNativeImageSnapshotFixture{cindySecondReviewSwap: &cindySecondReviewSwap{ before: before, after: after, pricing: pricing, pricingAfter: &pricingAfter, fallback: previous.invoker, }} - processExtensionOperations.Store(&extensionOperationProvider{invoker: fixture}) - t.Cleanup(func() { processExtensionOperations.Store(previous) }) + setNativeCindyTestInvoker(fixture) + images := before.Images + ConfigureImageTools(&images) + observeNativeImageFacts = func(facts extensionv1.ImageNativeRequest) { fixture.nativeFacts = append(fixture.nativeFacts, facts) } + t.Cleanup(func() { + restoreNativeCindyTestInvoker(previous) + ConfigureImageTools(nil) + observeNativeImageFacts = nil + }) return fixture } diff --git a/backend/internal/service/cindy_native_messages_scope_test.go b/backend/internal/service/cindy_native_messages_scope_test.go index d71dc5e583cd..36620a7d98d1 100644 --- a/backend/internal/service/cindy_native_messages_scope_test.go +++ b/backend/internal/service/cindy_native_messages_scope_test.go @@ -10,41 +10,42 @@ import ( "strings" "testing" - cindy "github.com/HTExplicit/sub2api-plugins/cindyprovider/catalog" - "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + cindy "github.com/Wei-Shaw/sub2api/internal/cindyprovider/catalog" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCindyNativeMessagesUsesActualAccountAdmissionAndCapturedPricing(t *testing.T) { + // Image tool switches are host state now; keep them empty so only scope metadata is compared. + ConfigureImageTools(&extensionv1.ImageToolsConfig{}) + t.Cleanup(func() { ConfigureImageTools(nil) }) for _, tc := range []struct { - name string - accountID int64 - rollout int - nonCindy bool - allowed bool + name string + accountID int64 + unavailable bool + nonCindy bool + allowed bool }{ - {name: "zero_rollout", accountID: 2, rollout: 0}, - {name: "partial_excluded", accountID: 3, rollout: 50}, - {name: "partial_included", accountID: 2, rollout: 50, allowed: true}, - {name: "non_cindy_keeps_identity_error", accountID: 2, rollout: 100, nonCindy: true}, + {name: "policy_unavailable", accountID: 2, unavailable: true}, + + {name: "native_account", accountID: 2, allowed: true}, + {name: "non_cindy_keeps_identity_error", accountID: 2, nonCindy: true}, } { t.Run(tc.name, func(t *testing.T) { - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) + previous := captureNativeCindyTestInvoker() + t.Cleanup(func() { restoreNativeCindyTestInvoker(previous) }) module := cindy.New() require.NoError(t, module.ApplyConfig(context.Background(), []byte(`{"catalog_enabled":true}`))) var invocations []extensionv1.Invocation - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{}, func(in extensionv1.Invocation) (extensionv1.Result, error) { + setNativeCindyTestInvoker(nativeCindyTestInvoker(func(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { + if tc.unavailable { + return extensionv1.Result{}, ErrExtensionOperationDisabled + } invocations = append(invocations, in) - return module.Invoke(context.Background(), in) - }) - installation := manager.extensions.Load().installations[1] - installation.Bindings = []PluginBinding{{Capability: extensionv1.CapabilityProvider, Platform: PlatformCindy, AccountType: AccountTypeAPIKey, Enabled: true, RolloutPercent: tc.rollout}} - installation.Manifest.Operations = map[string][]string{extensionv1.CapabilityProvider: {"cindy.features", "cindy.pricing", "cindy.catalog", "cindy.health"}} - processExtensionOperations.Store(&extensionOperationProvider{invoker: manager}) + return module.Invoke(ctx, in) + })) account := newCindyNativeMessagesAccount() account.ID = tc.accountID if tc.nonCindy { diff --git a/backend/internal/service/cindy_native_policy_test.go b/backend/internal/service/cindy_native_policy_test.go new file mode 100644 index 000000000000..bc90c9efd945 --- /dev/null +++ b/backend/internal/service/cindy_native_policy_test.go @@ -0,0 +1,150 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + "github.com/stretchr/testify/require" +) + +func TestNativeCindyCreateDefaultsAndExplicitFields(t *testing.T) { + request := &extensionv1.ProviderCreateRequestV1{Values: map[string]string{"device_id": strings.Repeat("d", 64)}, + InheritDefaults: []string{"concurrency", "priority", "rate_multiplier", "load_factor", "responses_mode"}} + ctx, release, err := bindProcessAccountCreate(context.Background(), PlatformCindy, AccountTypeAPIKey, ProviderProfileCindyLaxaV1, request) + require.NoError(t, err) + defer release() + input := &CreateAccountInput{Platform: PlatformCindy, Type: AccountTypeAPIKey, Priority: 0, + ExplicitCreateFields: map[string]bool{"priority": true}, + Extra: map[string]any{CindyResponsesModeExtraKey: "auto", CindyDeviceIDSourceExtraKey: "input-preserved"}} + require.NoError(t, applyAccountCreateProfile(ctx, input)) + require.Equal(t, 3, input.Concurrency) + require.Zero(t, input.Priority, "an explicitly supplied zero must not inherit priority 50") + require.Equal(t, float64(1), *input.RateMultiplier) + require.Nil(t, input.LoadFactor) + require.Equal(t, "auto", input.Extra[CindyResponsesModeExtraKey]) + require.Equal(t, request.Values["device_id"], input.Extra[CindyDeviceIDExtraKey]) + require.NotContains(t, input.Extra, CindyDeviceIDSourceExtraKey, "provenance is derived by the identity normalizer") + create, bound := AccountCreateFromContext(ctx) + require.True(t, bound) + require.Equal(t, 1, create.MinimumEffectiveGroups) +} + +func TestNativeCindyCreateRejectsWrongIdentityAndUnknownDeviceInput(t *testing.T) { + _, _, err := bindProcessAccountCreate(context.Background(), PlatformOpenAI, AccountTypeAPIKey, ProviderProfileCindyLaxaV1, nil) + require.ErrorIs(t, err, ErrAccountCreateInvalid) + for _, request := range []*extensionv1.ProviderCreateRequestV1{ + {Values: map[string]string{"unknown": "value"}}, + {Values: map[string]string{"device_id": strings.Repeat("d", 65)}}, + {InheritDefaults: []string{"priority", "priority"}}, + {InheritDefaults: []string{"unknown"}}, + } { + _, _, err = bindProcessAccountCreate(context.Background(), PlatformCindy, AccountTypeAPIKey, ProviderProfileCindyLaxaV1, request) + require.ErrorIs(t, err, ErrAccountCreateInvalid) + } +} + +func TestNativeCindyProviderCacheAndPolicyLease(t *testing.T) { + old := cindyProvider.Load() + t.Cleanup(func() { cindyProvider.Store(old) }) + ConfigureCindyProvider(&extensionv1.CindyProviderConfig{BalanceDetection: true, CatalogEnabled: true}) + invocation := extensionv1.Invocation{Capability: extensionv1.CapabilityProvider, Operation: "cindy.features", Payload: json.RawMessage(`{}`)} + first, err := invokeCindyProviderCached(context.Background(), invocation) + require.NoError(t, err) + before := string(first.Payload) + first.Payload[0] = 'x' + next, err := invokeCindyProviderCached(context.Background(), invocation) + require.NoError(t, err) + require.Equal(t, before, string(next.Payload), "caller buffers cannot change the shared cached answer") + canceled, cancel := context.WithCancel(context.Background()) + cancel() + _, err = invokeCindyProviderCached(canceled, invocation) + require.ErrorIs(t, err, context.Canceled) + + _, release, err := bindProcessAccountCreate(context.Background(), PlatformCindy, AccountTypeAPIKey, ProviderProfileCindyLaxaV1, nil) + require.NoError(t, err) + defer release() + started, done := make(chan struct{}), make(chan struct{}) + go func() { + close(started) + ConfigureCindyProvider(&extensionv1.CindyProviderConfig{}) + close(done) + }() + <-started + select { + case <-done: + t.Fatal("configuration replaced while an account transaction still owned the policy") + case <-time.After(20 * time.Millisecond): + } + release() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("configuration update did not resume after the native policy lease ended") + } + updated, err := invokeCindyProviderCached(context.Background(), invocation) + require.NoError(t, err) + var config extensionv1.CindyProviderConfig + require.NoError(t, json.Unmarshal(updated.Payload, &config)) + require.False(t, config.BalanceDetection) + require.False(t, config.CatalogEnabled) +} + +func TestNativeCindyEditContextAndFrozenJobStayInstallationFree(t *testing.T) { + account, _ := newNativeAccountEditFixture(t) + view, err := LoadAccountEditContext(context.Background(), account, "off") + require.NoError(t, err) + require.True(t, view.Profile.Native) + require.True(t, view.Profile.Available) + require.Equal(t, "off", view.Values["responses_websocket_mode"].Effective) + raw, err := json.Marshal(view.Profile) + require.NoError(t, err) + require.NotContains(t, string(raw), "plugin_id") + ctx := WithAccountJobEditAccounts(context.Background(), []*Account{account}) + payload := json.RawMessage(`{"extra":{"openai_compact_mode":"force_off"}}`) + encoded, err := accountJobPayloadWithEdit(ctx, AccountJobKindBulkUpdate, payload, []AccountJobItemSeed{{TargetAccountID: &account.ID}}) + require.NoError(t, err) + bound, release, err := PrepareAccountJobEdit(context.Background(), encoded, account, nil, map[string]any{"openai_compact_mode": "force_off"}) + require.NoError(t, err) + require.NoError(t, ValidateAccountEditFresh(bound)) + release() + ConfigureCindyProvider(&extensionv1.CindyProviderConfig{BalanceDetection: false, CatalogEnabled: true}) + _, _, err = PrepareAccountJobEdit(context.Background(), encoded, account, nil, map[string]any{"openai_compact_mode": "force_off"}) + require.ErrorIs(t, err, ErrAccountEditUnavailable, "a queued native edit must retain its submitted policy") +} + +// Existing canonical-identity service tests share the native fixture. +func useAccountCreateFixture(t *testing.T) { + original := cindyProvider.Load() + ConfigureCindyProvider(&extensionv1.CindyProviderConfig{BalanceDetection: true, CatalogEnabled: true, SearchEnabled: true}) + t.Cleanup(func() { cindyProvider.Store(original) }) +} + +type accountCreateGroupFixture struct { + GroupRepository + groups []Group + defaultReads int +} + +func (f *accountCreateGroupFixture) ListActiveByPlatform(context.Context, string) ([]Group, error) { + f.defaultReads++ + return f.groups, nil +} + +func (f *accountCreateGroupFixture) GetByID(_ context.Context, id int64) (*Group, error) { + for _, group := range f.groups { + if group.ID == id { + return &group, nil + } + } + return nil, ErrGroupNotFound +} + +func accountCreateGroups() *accountCreateGroupFixture { + return &accountCreateGroupFixture{groups: []Group{{ID: 91, Name: "cindy-default", Platform: PlatformCindy, WirePlatform: WirePlatformOpenAI, ProviderProfile: ProviderProfileCindyLaxaV1, Status: StatusActive}}} +} diff --git a/backend/internal/service/cindy_native_probe_scope_test.go b/backend/internal/service/cindy_native_probe_scope_test.go new file mode 100644 index 000000000000..6dceac31dc47 --- /dev/null +++ b/backend/internal/service/cindy_native_probe_scope_test.go @@ -0,0 +1,88 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "errors" + "github.com/stretchr/testify/require" + "strings" + "testing" + "time" +) + +type scopedProbeTerminalFixture struct { + CindyBalanceProbeRepository + committed *CindyHealthEpisode + err error + scopedCalls, legacyCalls int +} + +func (r *scopedProbeTerminalFixture) FinalizeScopedExhausted(context.Context, *CindyBalanceProbeReservation, string, time.Time, time.Duration) (string, *CindyHealthEpisode, error) { + r.scopedCalls++ + return "exhausted", r.committed, r.err +} +func (r *scopedProbeTerminalFixture) FinalizeExhausted(context.Context, *CindyBalanceProbeReservation, string, time.Time, time.Duration) (string, error) { + r.legacyCalls++ + return "exhausted", nil +} + +type committedProbeProjectorFixture struct{ observed, projected int } + +func (p *committedProbeProjectorFixture) ObserveCindyHealthSignal(context.Context, *Account, CindyHealthSignal) { + p.observed++ +} +func (*committedProbeProjectorFixture) ObserveCindyHealthSuccess(context.Context, *Account) {} +func (p *committedProbeProjectorFixture) ApplyCommittedProbeTerminal(context.Context, *Account, CindyHealthEpisode) { + p.projected++ +} + +func TestNativeCindyProbeRequiresAtomicTerminalFactWithoutDeferredRetry(t *testing.T) { + worker := context.WithValue(context.Background(), cindyNativeProbeContextKey{}, true) + account := &Account{ID: 2, Platform: PlatformCindy, Type: AccountTypeAPIKey, Credentials: cindyCredentials()} + projector := &committedProbeProjectorFixture{} + repo := &scopedProbeTerminalFixture{committed: &CindyHealthEpisode{AccountID: 2, Generation: 1, EpisodeID: "committed", Fingerprint: strings.Repeat("a", 64), Status: CindyHealthStatusBalanceInsufficient, Evidence: CindyHealthEvidenceExactBudget, ObservedAt: time.Now()}} + service := NewCindyBalanceProbeService(repo, nil, &OpenAIGatewayService{cindyHealth: projector}, nil) + defer service.Stop() + require.True(t, service.finalizeExhausted(worker, &CindyBalanceProbeReservation{AccountID: 2}, account, "lease")) + require.Equal(t, 1, repo.scopedCalls) + require.Zero(t, repo.legacyCalls) + require.Equal(t, 1, projector.projected) + require.Zero(t, projector.observed, "scoped success cannot create an unscoped pending terminal retry") + repo.err = errors.New("terminal transaction rolled back") + require.False(t, service.finalizeExhausted(worker, &CindyBalanceProbeReservation{AccountID: 2}, account, "lease")) + require.Equal(t, 1, projector.projected) + repo.err, repo.committed = nil, nil + require.False(t, service.finalizeExhausted(worker, &CindyBalanceProbeReservation{AccountID: 2}, account, "lease"), "marker-only success is not complete success") + require.Equal(t, 1, projector.projected) +} + +func TestNativeCindyProbeKeepsHistoricalFrozenScope(t *testing.T) { + origin := &CindyBalanceProbeOrigin{Version: 1, PluginID: 3, PluginKey: "codexrip.cindy-provider", RuntimeGeneration: 4, + PackageSHA256: strings.Repeat("a", 64), RequestDigest: strings.Repeat("b", 64), FrozenAccountIDs: []int64{2, 7}, + View: json.RawMessage(`{"runtime_generation":4,"policy_revision":7,"opaque_future_field":true}`)} + original, err := json.Marshal(origin) + require.NoError(t, err) + scope := CindyBalanceProbeScope{Mode: "all", Origin: origin} + ctx, release, err := bindCindyProbeOrigin(context.Background(), scope) + require.NoError(t, err) + defer release() + saved := ctx.Value(cindyProbeOriginContextKey{}).(*CindyBalanceProbeOrigin) + raw, err := json.Marshal(saved) + require.NoError(t, err) + require.JSONEq(t, string(original), string(raw)) + require.NoError(t, ValidateCindyBalanceProbeOriginContext(ctx, origin)) + changed := *origin + changed.FrozenAccountIDs = []int64{2, 7, 99} + require.ErrorIs(t, ValidateCindyBalanceProbeOriginContext(ctx, &changed), ErrCindyBalanceProbeChanged) + next, done, err := rebindCindyProbeOrigin(context.Background(), scope) + require.NoError(t, err) + defer done() + require.NoError(t, ValidateCindyBalanceProbeOriginContext(next, origin)) + _, _, err = bindCindyProbeOrigin(context.Background(), CindyBalanceProbeScope{Mode: "all", Origin: &CindyBalanceProbeOrigin{Version: 1, RequestDigest: origin.RequestDigest}}) + require.ErrorIs(t, err, ErrCindyBalanceProbeChanged) + native, err := captureCindyProbeOrigin(context.Background(), scope, 0.5, 2, "unused") + require.NoError(t, err) + require.Nil(t, native.Origin) +} diff --git a/backend/internal/service/cindy_native_test_fixture_test.go b/backend/internal/service/cindy_native_test_fixture_test.go new file mode 100644 index 000000000000..639c5c401de5 --- /dev/null +++ b/backend/internal/service/cindy_native_test_fixture_test.go @@ -0,0 +1,40 @@ +package service + +import ( + "context" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" +) + +type nativeCindyTestSnapshot struct { + invoker extensionv1.OperationInvoker + invoke func(context.Context, extensionv1.Invocation) (extensionv1.Result, error) + runtime *cindyProviderRuntime +} +type nativeCindyTestInvoker func(context.Context, extensionv1.Invocation) (extensionv1.Result, error) + +func (f nativeCindyTestInvoker) InvokeOperation(ctx context.Context, _, _ string, in extensionv1.Invocation) (extensionv1.Result, error) { + return f(ctx, in) +} +func captureNativeCindyTestInvoker() nativeCindyTestSnapshot { + return nativeCindyTestSnapshot{invoke: invokeCindyProvider, runtime: cindyProvider.Load(), invoker: nativeCindyTestInvoker(invokeCindyProvider)} +} +func restoreNativeCindyTestInvoker(previous nativeCindyTestSnapshot) { + invokeCindyProvider = previous.invoke + cindyProvider.Store(previous.runtime) +} +func setNativeCindyTestInvoker(fixture interface { + InvokeOperation(context.Context, string, string, extensionv1.Invocation) (extensionv1.Result, error) +}) { + current := cindyProvider.Load() + runtime := &cindyProviderRuntime{module: current.module, policySHA256: current.policySHA256} + // Injected replies deliberately change between calls. Native production + // modules are immutable per configuration; cache behavior has its own test. + runtime.cacheSize.Store(cindyProviderCacheLimit) + cindyProvider.Store(runtime) + invokeCindyProvider = func(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { + if fixture == nil { + return extensionv1.Result{}, ErrExtensionOperationDisabled + } + return fixture.InvokeOperation(ctx, PlatformCindy, AccountTypeAPIKey, in) + } +} diff --git a/backend/internal/service/cindy_pricing_snapshot.go b/backend/internal/service/cindy_pricing_snapshot.go index c381cc3651c6..8fbce84cbbd3 100644 --- a/backend/internal/service/cindy_pricing_snapshot.go +++ b/backend/internal/service/cindy_pricing_snapshot.go @@ -10,7 +10,7 @@ import ( "sync" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/gin-gonic/gin" ) @@ -49,7 +49,7 @@ func CaptureCindyPricingContext(ctx context.Context, c *gin.Context, account *Ac payload, _ := json.Marshal(query) call, cancel := context.WithTimeout(ctx, time.Second) defer cancel() - result, err := invokeProcessExtensionCached(call, PlatformCindy, AccountTypeAPIKey, extensionv1.Invocation{Capability: extensionv1.CapabilityProvider, Operation: "cindy.pricing", AccountID: account.ID, Payload: payload}) + result, err := invokeCindyProviderCached(call, extensionv1.Invocation{Capability: extensionv1.CapabilityProvider, Operation: "cindy.pricing", AccountID: account.ID, Payload: payload}) if err != nil || result.Code != "" || result.PluginID != owner || len(result.Payload) == 0 || len(result.Payload) > extensionv1.MaxPayloadBytes { return ctx, errors.New("provider: Cindy provider policy is unavailable") } @@ -141,7 +141,7 @@ func cindyProviderAdmissionOwner(ctx context.Context, account *Account) (int64, } call, cancel := context.WithTimeout(ctx, time.Second) defer cancel() - result, err := invokeProcessExtensionCached(call, PlatformCindy, AccountTypeAPIKey, extensionv1.Invocation{Capability: extensionv1.CapabilityProvider, Operation: "cindy.features", AccountID: account.ID, Payload: json.RawMessage(`{}`)}) + result, err := invokeCindyProviderCached(call, extensionv1.Invocation{Capability: extensionv1.CapabilityProvider, Operation: "cindy.features", AccountID: account.ID, Payload: json.RawMessage(`{}`)}) if err != nil || result.Code != "" { return 0, errors.New("provider: Cindy provider policy is unavailable") } diff --git a/backend/internal/service/cindy_pricing_snapshot_test.go b/backend/internal/service/cindy_pricing_snapshot_test.go index a14f77347e27..ef2258f20f4a 100644 --- a/backend/internal/service/cindy_pricing_snapshot_test.go +++ b/backend/internal/service/cindy_pricing_snapshot_test.go @@ -7,9 +7,9 @@ import ( "net/http/httptest" "testing" - cindy "github.com/HTExplicit/sub2api-plugins/cindyprovider/catalog" + cindy "github.com/Wei-Shaw/sub2api/internal/cindyprovider/catalog" "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -39,9 +39,9 @@ type cindyPricingFixture struct { } func TestCindyUnavailableBlocksTokenCountingAndEmbeddingsBeforeIO(t *testing.T) { - previous := processExtensionOperations.Load() - processExtensionOperations.Store(nil) - t.Cleanup(func() { processExtensionOperations.Store(previous) }) + previous := captureNativeCindyTestInvoker() + setNativeCindyTestInvoker(nil) + t.Cleanup(func() { restoreNativeCindyTestInvoker(previous) }) account := &Account{ID: 41, Platform: PlatformCindy, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://api.laxarouter.ai"}} service := &OpenAIGatewayService{} for _, endpoint := range []string{"responses-input-tokens", "messages-count-tokens", "embeddings"} { @@ -72,10 +72,10 @@ func (f *cindyPricingFixture) InvokeOperation(ctx context.Context, _, _ string, } func TestCindyNewTurnRefreshesPricingWithoutChangingPendingBill(t *testing.T) { - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) + previous := captureNativeCindyTestInvoker() + t.Cleanup(func() { restoreNativeCindyTestInvoker(previous) }) fixture := &cindyPricingFixture{module: cindy.New()} - processExtensionOperations.Store(&extensionOperationProvider{invoker: fixture}) + setNativeCindyTestInvoker(fixture) account := &Account{ID: 41, Platform: PlatformCindy, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://api.laxarouter.ai"}} require.NoError(t, fixture.module.ApplyConfig(context.Background(), []byte(`{"catalog_enabled":true}`))) first, err := CaptureCindyPricingContext(context.Background(), nil, account) @@ -96,19 +96,18 @@ func TestCindyNewTurnRefreshesPricingWithoutChangingPendingBill(t *testing.T) { } func TestCindyPricingScopeCannotReuseAnExcludedAccountPolicyCache(t *testing.T) { - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) + // Image tool switches are host state now; keep them empty so only scope metadata is compared. + ConfigureImageTools(&extensionv1.ImageToolsConfig{}) + t.Cleanup(func() { ConfigureImageTools(nil) }) + previous := captureNativeCindyTestInvoker() + t.Cleanup(func() { restoreNativeCindyTestInvoker(previous) }) module := cindy.New() require.NoError(t, module.ApplyConfig(context.Background(), []byte(`{"catalog_enabled":true}`))) var calls []extensionv1.Invocation - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{}, func(in extensionv1.Invocation) (extensionv1.Result, error) { + setNativeCindyTestInvoker(nativeCindyTestInvoker(func(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { calls = append(calls, in) - return module.Invoke(context.Background(), in) - }) - installation := manager.extensions.Load().installations[1] - installation.Bindings = []PluginBinding{{Capability: extensionv1.CapabilityProvider, Platform: PlatformCindy, AccountType: AccountTypeAPIKey, Enabled: true, RolloutPercent: 100}} - installation.Manifest.Operations = map[string][]string{extensionv1.CapabilityProvider: {"cindy.features", "cindy.pricing"}} - processExtensionOperations.Store(&extensionOperationProvider{invoker: manager}) + return module.Invoke(ctx, in) + })) account := &Account{ID: 41, Platform: PlatformCindy, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://api.laxarouter.ai", "api_key": "private-fixture-token"}} parent, err := CaptureCindyPricingContext(context.Background(), nil, account) require.NoError(t, err) @@ -118,10 +117,10 @@ func TestCindyPricingScopeCannotReuseAnExcludedAccountPolicyCache(t *testing.T) require.JSONEq(t, `{}`, string(call.Payload), "scope metadata must not carry the account's credentials") } pendingBill := CopyProviderPricingContext(parent, context.Background()) - installation.Bindings[0].RolloutPercent = int(stablePluginBucket(account.ID)) + setNativeCindyTestInvoker(nil) require.Error(t, EnsureCindyProviderAvailable(context.Background(), account)) _, err = CaptureCindyPricingContext(parent, nil, account) require.Error(t, err, "an existing captured value cannot authorize a new excluded request") - require.Len(t, calls, 2, "revoked scope must fail before the cached policy can be returned") + require.Len(t, calls, 2, "a failed current module must not reuse an earlier reply") require.NotNil(t, cindyPricingSnapshotFromContext(pendingBill, account), "a completed request can still settle using its captured reference") } diff --git a/backend/internal/service/cindy_probe_account_scope_test.go b/backend/internal/service/cindy_probe_account_scope_test.go index b0117db6c271..406002d68aa6 100644 --- a/backend/internal/service/cindy_probe_account_scope_test.go +++ b/backend/internal/service/cindy_probe_account_scope_test.go @@ -10,9 +10,8 @@ import ( "testing" "time" - "github.com/Wei-Shaw/sub2api/internal/config" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/stretchr/testify/require" ) @@ -66,18 +65,11 @@ func (u *cindyProbeScopeUpstream) DoWithTLS(req *http.Request, proxy string, acc } func TestCindyProbeReservationHonorsCurrentAccountScope(t *testing.T) { - var included, excluded int64 - for id := int64(1); included == 0 || excluded == 0; id++ { - if stablePluginBucket(id) < 50 { - included = id - } else { - excluded = id - } - } + included, excluded := int64(2), int64(3) for _, tc := range []struct { name string accountID int64 - rollout int + stale bool disabled bool unavailable bool planFailure bool @@ -86,21 +78,26 @@ func TestCindyProbeReservationHonorsCurrentAccountScope(t *testing.T) { wantSend bool wantStale bool }{ - {name: "zero_rollout", accountID: included, rollout: 0, wantStale: true}, - {name: "partial_excluded", accountID: excluded, rollout: 50, wantStale: true}, - {name: "partial_included", accountID: included, rollout: 50, wantSend: true}, - {name: "full_rollout", accountID: excluded, rollout: 100, wantSend: true}, - {name: "capability_disabled", accountID: included, rollout: 100, disabled: true, wantStale: true}, - {name: "runtime_unavailable_is_not_stale", accountID: included, rollout: 100, unavailable: true}, - {name: "plan_failure_is_not_stale", accountID: included, rollout: 100, planFailure: true}, - {name: "cancellation_is_not_stale", accountID: included, rollout: 0, cancel: true}, - {name: "lost_reservation_is_not_stale", accountID: included, rollout: 0, lostLease: true}, + {name: "changed_identity", accountID: included, stale: true, wantStale: true}, + {name: "native_first_account", accountID: included, wantSend: true}, + {name: "native_other_account", accountID: excluded, wantSend: true}, + {name: "capability_disabled", accountID: included, disabled: true}, + {name: "runtime_unavailable_is_not_stale", accountID: included, unavailable: true}, + {name: "plan_failure_is_not_stale", accountID: included, planFailure: true}, + {name: "cancellation_is_not_stale", accountID: included, cancel: true}, + {name: "lost_reservation_is_not_stale", accountID: included, lostLease: true}, } { t.Run(tc.name, func(t *testing.T) { - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) + previous := captureNativeCindyTestInvoker() + t.Cleanup(func() { restoreNativeCindyTestInvoker(previous) }) var invocations []extensionv1.Invocation - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{}, func(in extensionv1.Invocation) (extensionv1.Result, error) { + setNativeCindyTestInvoker(nativeCindyTestInvoker(func(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { + if tc.disabled { + return extensionv1.Result{Code: "disabled"}, nil + } + if tc.unavailable { + return extensionv1.Result{}, ErrExtensionOperationUnavailable + } invocations = append(invocations, in) if tc.planFailure && in.Operation == "cindy.probe.plan" { return extensionv1.Result{}, errors.New("synthetic policy failure") @@ -118,18 +115,15 @@ func TestCindyProbeReservationHonorsCurrentAccountScope(t *testing.T) { } raw, err := json.Marshal(payload) return extensionv1.Result{Payload: raw}, err - }) - registry := manager.extensions.Load() - registry.installations[1].Bindings = []PluginBinding{{Capability: extensionv1.CapabilityProvider, Platform: PlatformCindy, AccountType: AccountTypeAPIKey, Enabled: !tc.disabled, RolloutPercent: tc.rollout}} - registry.installations[1].Manifest.Operations = map[string][]string{extensionv1.CapabilityProvider: {"cindy.features", "cindy.probe.plan", "cindy.probe.decide", "cindy.health"}} - if tc.unavailable { - registry.runtimes[1].beginDrain() - } - processExtensionOperations.Store(&extensionOperationProvider{invoker: manager}) + })) + account := newFirstClassCindyRateLimitAccount(tc.accountID, false) fingerprint, err := CindyAccountIdentityFingerprint(account.Platform, account.Type, account.Credentials) require.NoError(t, err) reservation := &CindyBalanceProbeReservation{JobID: 17, ItemID: 18, AccountID: account.ID, Stage: "luna", LeaseToken: "scope-lease", RequestCount: 1, JobRequestCount: 5, IdentityFingerprint: fingerprint, AccountUpdatedAt: account.UpdatedAt} + if tc.stale { + reservation.IdentityFingerprint = "different-identity" + } repo := &cindyProbeScopeRepository{ready: !tc.lostLease} upstream := &cindyProbeScopeUpstream{} svc := &CindyBalanceProbeService{repo: repo, accountRepo: &cindyBalanceProbeAccountRepositoryStub{account: account}, gateway: &OpenAIGatewayService{httpUpstream: upstream}} @@ -175,20 +169,18 @@ func TestCindyProbeReservationHonorsCurrentAccountScope(t *testing.T) { } func TestCindyProbeReservationPolicyCancellationReachesActualHTTP(t *testing.T) { - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{}, func(in extensionv1.Invocation) (extensionv1.Result, error) { + previous := captureNativeCindyTestInvoker() + t.Cleanup(func() { restoreNativeCindyTestInvoker(previous) }) + setNativeCindyTestInvoker(nativeCindyTestInvoker(func(_ context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { var payload any = extensionv1.CindyProviderConfig{BalanceDetection: true} if in.Operation == "cindy.probe.plan" { payload = extensionv1.CindyProbePlan{Models: [2]string{"tencent/hy3", "z-ai/glm-5.3-flash"}, Input: "Reply OK.", MaxOutputTokens: 1} } raw, err := json.Marshal(payload) return extensionv1.Result{Payload: raw}, err - }) - registry := manager.extensions.Load() - registry.installations[1].Bindings = []PluginBinding{{Capability: extensionv1.CapabilityProvider, Platform: PlatformCindy, AccountType: AccountTypeAPIKey, Enabled: true, RolloutPercent: 100}} - registry.installations[1].Manifest.Operations = map[string][]string{extensionv1.CapabilityProvider: {"cindy.features", "cindy.probe.plan", "cindy.probe.decide", "cindy.health"}} - processExtensionOperations.Store(&extensionOperationProvider{invoker: manager}) + })) + requestContext, cancelRequest := context.WithCancel(context.Background()) + defer cancelRequest() account := newFirstClassCindyRateLimitAccount(37, false) fingerprint, err := CindyAccountIdentityFingerprint(account.Platform, account.Type, account.Credentials) require.NoError(t, err) @@ -196,13 +188,13 @@ func TestCindyProbeReservationPolicyCancellationReachesActualHTTP(t *testing.T) repo := &cindyProbeScopeRepository{ready: true} requestCanceled := false upstream := &cindyProbeScopeUpstream{onSend: func(request *http.Request) { - registry.runtimes[1].beginDrain() + cancelRequest() requestCanceled = errors.Is(request.Context().Err(), context.Canceled) }} svc := &CindyBalanceProbeService{repo: repo, accountRepo: &cindyBalanceProbeAccountRepositoryStub{account: account}, gateway: &OpenAIGatewayService{httpUpstream: upstream}} - require.False(t, svc.executeReservation(context.Background(), reservation, reservation.LeaseToken)) + require.False(t, svc.executeReservation(requestContext, reservation, reservation.LeaseToken)) require.Equal(t, 1, upstream.calls) - require.True(t, requestCanceled, "a replacement/stop must revoke the same generation's actual HTTP context") + require.True(t, requestCanceled, "native cancellation must reach the actual HTTP context") require.Zero(t, repo.completions) require.Zero(t, repo.healthWrites) require.False(t, repo.networkFailure) diff --git a/backend/internal/service/cindy_provider_module.go b/backend/internal/service/cindy_provider_module.go new file mode 100644 index 000000000000..5a62d3eeb4b2 --- /dev/null +++ b/backend/internal/service/cindy_provider_module.go @@ -0,0 +1,91 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "sync" + "sync/atomic" + + cindycatalog "github.com/Wei-Shaw/sub2api/internal/cindyprovider/catalog" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" +) + +// cindyProviderRuntime answers the Cindy catalog, pricing, health, balance probe +// and group planning operations in process. The cindy-provider plugin served +// them before; its switches now live in the cindy_provider_config setting. +type cindyProviderRuntime struct { + module *cindycatalog.Module + policySHA256 string + // cache memoizes configuration-derived answers such as the pricing + // snapshot, which request paths consult on every call. A configuration + // change installs a new runtime and with it an empty cache. + cache sync.Map + cacheSize atomic.Int64 +} + +const cindyProviderCacheLimit = 2048 + +var cindyProvider atomic.Pointer[cindyProviderRuntime] +var cindyProviderPolicyMu sync.RWMutex + +type cindyProviderRuntimeKey struct{} + +func init() { ConfigureCindyProvider(nil) } + +// ConfigureCindyProvider installs the effective switches (startup load, admin +// update, tests). A nil value falls back to the deploy-time rollout flags. +func ConfigureCindyProvider(config *extensionv1.CindyProviderConfig) { + cindyProviderPolicyMu.Lock() + defer cindyProviderPolicyMu.Unlock() + effective := LegacyCindyProviderConfig() + if config != nil { + effective = *config + } + module := cindycatalog.New() + raw, _ := json.Marshal(effective) + if err := module.ApplyConfig(context.Background(), raw); err != nil { + panic(err) // A marshaled CindyProviderConfig always validates. + } + digest := sha256.Sum256(append([]byte("cindy-native-account-policy-v1\x00"), raw...)) + cindyProvider.Store(&cindyProviderRuntime{module: module, policySHA256: hex.EncodeToString(digest[:])}) +} + +// invokeCindyProvider runs one Cindy provider operation. Tests replace it to +// model provider answers. +var invokeCindyProvider = func(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { + runtime, _ := ctx.Value(cindyProviderRuntimeKey{}).(*cindyProviderRuntime) + if runtime == nil { + runtime = cindyProvider.Load() + } + return runtime.module.Invoke(ctx, in) +} + +// invokeCindyProviderCached is for operations whose answer depends only on the +// configuration and the payload. The account does not change the answer. +func invokeCindyProviderCached(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { + if err := ctx.Err(); err != nil { + return extensionv1.Result{}, err + } + runtime := cindyProvider.Load() + key := in.Capability + "\x00" + in.Operation + "\x00" + string(in.Payload) + if cached, ok := runtime.cache.Load(key); ok { + if result, ok := cached.(extensionv1.Result); ok { + result.Payload = append(json.RawMessage(nil), result.Payload...) + return result, nil + } + } + // A concurrent settings update must not cache a new runtime's answer in an + // earlier configuration's cache, or combine two configuration snapshots. + result, err := invokeCindyProvider(context.WithValue(ctx, cindyProviderRuntimeKey{}, runtime), in) + if err != nil || result.Code != "" || runtime.cacheSize.Load() >= cindyProviderCacheLimit { + return result, err + } + stored := result + stored.Payload = append(json.RawMessage(nil), result.Payload...) + if _, loaded := runtime.cache.LoadOrStore(key, stored); !loaded { + runtime.cacheSize.Add(1) + } + return result, nil +} diff --git a/backend/internal/service/cindy_snapshot_pricing_second_review_test.go b/backend/internal/service/cindy_snapshot_pricing_second_review_test.go index 6b58eb2c95d4..4244476232d4 100644 --- a/backend/internal/service/cindy_snapshot_pricing_second_review_test.go +++ b/backend/internal/service/cindy_snapshot_pricing_second_review_test.go @@ -10,9 +10,9 @@ import ( "testing" "time" - cindy "github.com/HTExplicit/sub2api-plugins/cindyprovider/catalog" + cindy "github.com/Wei-Shaw/sub2api/internal/cindyprovider/catalog" "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" coderws "github.com/coder/websocket" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" @@ -141,11 +141,11 @@ func newCindySecondReviewSwap(t *testing.T) *cindySecondReviewSwap { for _, id := range []string{target.PublicID, target.LiveUpstreamID} { pricingAfter.Results[cindy.PricingKey("CindyCompatibilityTextPricingForModel", id)] = pricing.Results[cindy.PricingKey("CindyTextPricingForModel", target.PublicID)] } - previous := processExtensionOperations.Load() + previous := captureNativeCindyTestInvoker() require.NotNil(t, previous) f := &cindySecondReviewSwap{before: before, after: after, pricing: pricing, pricingAfter: &pricingAfter, fallback: previous.invoker} - processExtensionOperations.Store(&extensionOperationProvider{invoker: f}) - t.Cleanup(func() { processExtensionOperations.Store(previous) }) + setNativeCindyTestInvoker(f) + t.Cleanup(func() { restoreNativeCindyTestInvoker(previous) }) return f } @@ -331,11 +331,11 @@ func TestCindySecondReviewForwardKeepsCapturedAliasIdentity(t *testing.T) { require.NoError(t, err, "A is a complete legal snapshot") _, err = validateCindyCatalogSnapshot(second) require.NoError(t, err, "B is a complete legal snapshot, not an invalid-fixture shortcut") - previous := processExtensionOperations.Load() + previous := captureNativeCindyTestInvoker() require.NotNil(t, previous) swap := &cindySecondReviewSwap{before: first, after: second, pricing: registry.PricingSnapshot(), fallback: previous.invoker} - processExtensionOperations.Store(&extensionOperationProvider{invoker: swap}) - t.Cleanup(func() { processExtensionOperations.Store(previous) }) + setNativeCindyTestInvoker(swap) + t.Cleanup(func() { restoreNativeCindyTestInvoker(previous) }) upstream := &httpUpstreamRecorder{resp: &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, diff --git a/backend/internal/service/cindy_text_billing.go b/backend/internal/service/cindy_text_billing.go index 1e196ff1d003..71b66d2aeb54 100644 --- a/backend/internal/service/cindy_text_billing.go +++ b/backend/internal/service/cindy_text_billing.go @@ -2,7 +2,7 @@ package service import ( "fmt" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "math" ) diff --git a/backend/internal/service/plugin_error_diagnostics.go b/backend/internal/service/codex_error_diagnostics.go similarity index 57% rename from backend/internal/service/plugin_error_diagnostics.go rename to backend/internal/service/codex_error_diagnostics.go index 08bdd5104b39..721fc27c5f25 100644 --- a/backend/internal/service/plugin_error_diagnostics.go +++ b/backend/internal/service/codex_error_diagnostics.go @@ -3,21 +3,19 @@ package service import ( "context" "encoding/json" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" ) -// PluginErrorDiagnostic contains only host-sanitized structural facts. Request +// CodexErrorDiagnostic contains only host-sanitized structural facts. Request // bodies, headers, error messages, URLs and account names never enter the UI. -type PluginErrorDiagnostic struct { +type CodexErrorDiagnostic struct { AccountID int64 `json:"account_id"` Attempt int `json:"attempt"` Diagnostic *OpenAIContinuationDiagnostic `json:"diagnostic"` } -func (m *PluginManager) ProjectErrorDiagnostics(ctx context.Context, detail *OpsErrorLogDetail) []PluginErrorDiagnostic { - result := make([]PluginErrorDiagnostic, 0) - if detail == nil || len(detail.UpstreamErrors) > 4<<20 { +func ProjectCodexErrorDiagnostics(ctx context.Context, detail *OpsErrorLogDetail, readAccount func(context.Context, int64) (*Account, error)) []CodexErrorDiagnostic { + result := make([]CodexErrorDiagnostic, 0) + if detail == nil || readAccount == nil || len(detail.UpstreamErrors) > 4<<20 { return result } var events []OpsUpstreamErrorEvent @@ -30,11 +28,12 @@ func (m *PluginManager) ProjectErrorDiagnostics(ctx context.Context, detail *Ops } // Scope comes from the persisted event and current account, never the UI // payload. Deleted accounts remain visible in ordinary host history only. - if m.ValidateResourceAccounts(ctx, extensionv1.CapabilityRecovery, []int64{event.AccountID}, false) != nil { + account, err := readAccount(ctx, event.AccountID) + if err != nil || account == nil || account.ID != event.AccountID || account.Platform != PlatformOpenAI { continue } if safe := sanitizeOpenAIContinuationDiagnostic(event.ContinuationDiagnostic); safe != nil { - result = append(result, PluginErrorDiagnostic{event.AccountID, index + 1, safe}) + result = append(result, CodexErrorDiagnostic{event.AccountID, index + 1, safe}) } } return result diff --git a/backend/internal/service/codex_error_diagnostics_test.go b/backend/internal/service/codex_error_diagnostics_test.go new file mode 100644 index 000000000000..aa932fefaccd --- /dev/null +++ b/backend/internal/service/codex_error_diagnostics_test.go @@ -0,0 +1,38 @@ +package service + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNativeCodexErrorDiagnosticsUseStoredAccountsAndRemovePrivateFields(t *testing.T) { + read := func(_ context.Context, id int64) (*Account, error) { + if id == 1 { + return &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth}, nil + } + if id == 2 { + return &Account{ID: 2, Platform: PlatformCindy, Type: AccountTypeAPIKey}, nil + } + return nil, nil + } + ctx := context.Background() + + detail := &OpsErrorLogDetail{UpstreamErrors: `[ + {"account_id":1,"account_name":"private-name","upstream_response_body":"private-body","continuation_diagnostic":{"classification":"thinking_signature_invalid","upstream_error":{"error_code":{"kind":"string","value":"thinking_signature_invalid"},"message":{"kind":"string","value":"private-message"},"hints":["private-hint","call_id"]},"incoming":{"instructions":{"kind":"string","value":"private-instructions"}}}}, + {"account_id":2,"platform":"openai","continuation_diagnostic":{"classification":"invalid_encrypted_content"}}, + {"account_id":0,"continuation_diagnostic":{"classification":"invalid_encrypted_content"}} + ]`} + projected := ProjectCodexErrorDiagnostics(ctx, detail, read) + require.Len(t, projected, 1) + require.EqualValues(t, 1, projected[0].AccountID) + require.Equal(t, "thinking_signature_invalid", projected[0].Diagnostic.UpstreamError.ErrorCode.Value) + require.Equal(t, []string{"call_id"}, projected[0].Diagnostic.UpstreamError.Hints) + raw, err := json.Marshal(projected) + require.NoError(t, err) + require.NotContains(t, string(raw), "private-") + require.Empty(t, ProjectCodexErrorDiagnostics(ctx, detail, nil), "without an account reader no facts can be projected") + require.Empty(t, ProjectCodexErrorDiagnostics(ctx, detail, func(context.Context, int64) (*Account, error) { return nil, nil }), "deleted accounts are not resurrected from event metadata") +} diff --git a/backend/internal/service/plugin_extension_accounts.go b/backend/internal/service/codex_native_accounts.go similarity index 97% rename from backend/internal/service/plugin_extension_accounts.go rename to backend/internal/service/codex_native_accounts.go index 20cbb3e1567d..2f8f1adc8f44 100644 --- a/backend/internal/service/plugin_extension_accounts.go +++ b/backend/internal/service/codex_native_accounts.go @@ -5,7 +5,7 @@ import ( "fmt" "net/http" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func extensionAccount(a *Account) *extensionv1.Account { diff --git a/backend/internal/service/plugin_extension_accounts_test.go b/backend/internal/service/codex_native_accounts_test.go similarity index 95% rename from backend/internal/service/plugin_extension_accounts_test.go rename to backend/internal/service/codex_native_accounts_test.go index d50e8fd1f605..7a6f408fbef2 100644 --- a/backend/internal/service/plugin_extension_accounts_test.go +++ b/backend/internal/service/codex_native_accounts_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" ) diff --git a/backend/internal/service/codex_native_compatibility_test.go b/backend/internal/service/codex_native_compatibility_test.go new file mode 100644 index 000000000000..112b7594da01 --- /dev/null +++ b/backend/internal/service/codex_native_compatibility_test.go @@ -0,0 +1,105 @@ +package service + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "testing" + "time" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + "github.com/stretchr/testify/require" +) + +type nativeSameConfigRepository struct { + *routingMemoryStore + metadata *NativeCodexMetadata + stored int + failure error +} + +func (r *nativeSameConfigRepository) LoadNativeCodexMetadata(context.Context) (*NativeCodexMetadata, error) { + m := *r.metadata + return &m, nil +} +func (r *nativeSameConfigRepository) StoreNativeCodexConfig(_ context.Context, _ string, hash string) (*NativeCodexMetadata, error) { + if hash != r.metadata.ConfigSHA256 { + return nil, ErrNativeCodexRuntimeChanged + } + r.stored++ + return r.metadata, r.failure +} + +type nativeConfigEncryptorFixture struct{} + +func (nativeConfigEncryptorFixture) Encrypt(value string) (string, error) { + return "fixture-cipher:" + value, nil +} +func (nativeConfigEncryptorFixture) Decrypt(value string) (string, error) { return value, nil } + +func TestNativeCodexSameConfigSaveKeepsActiveEpoch(t *testing.T) { + raw, err := NormalizeNativeCodexConfig(context.Background(), json.RawMessage(`{"enabled":false}`)) + require.NoError(t, err) + sum := sha256.Sum256(raw) + metadata := nativeCodexTestMetadata() + metadata.ConfigSHA256 = hex.EncodeToString(sum[:]) + repo := &nativeSameConfigRepository{routingMemoryStore: &routingMemoryStore{}, metadata: metadata} + epoch, cancel := context.WithCancel(context.Background()) + defer cancel() + snapshot := &nativeCodexSnapshot{metadata: metadata, raw: raw, ctx: epoch, cancel: cancel} + runtime := &NativeCodexRuntime{repo: repo, started: true} + runtime.snapshot.Store(snapshot) + require.NoError(t, runtime.UpdateConfig(context.Background(), raw, nativeConfigEncryptorFixture{})) + require.NoError(t, epoch.Err()) + require.Same(t, snapshot, runtime.current()) + require.Equal(t, 1, repo.stored) + repo.failure = errors.New("fixture write failed") + require.ErrorIs(t, runtime.UpdateConfig(context.Background(), raw, nativeConfigEncryptorFixture{}), repo.failure) + require.NoError(t, epoch.Err()) + require.Same(t, snapshot, runtime.current()) +} + +func TestNativeCodexClosedLedgerReadableWithoutRewritingGeneration(t *testing.T) { + host, _, store := routingHostFixture() + s := &OpenAIGatewayService{nativeCodexRuntime: nativeRoutingFixtureRuntime(host, store), accountRepo: &routingAccountRepositoryFixture{account: ticketTestAccount(7)}} + run := codexQualityRun{RunID: "138bf94a-1bea-4aeb-9e7a-378e9c394bc9", ActorID: 9, AccountID: 7, Status: "closed", MaxSends: 60, UsedSends: 40, RouteRuntimeGeneration: 99, Attempts: make([]CodexQualityAttempt, 40)} + raw, err := json.Marshal(run) + require.NoError(t, err) + key := codexRoutingPrivateNamespace + ":" + codexQualityKey(run.RunID) + store.values[key] = extensionv1.StateResult{Found: true, Revision: 11, Value: raw} + view, err := s.ReadCodexQualityRun(context.Background(), 9, 7, run.RunID) + require.NoError(t, err) + require.Equal(t, "closed", view.Status) + require.Equal(t, 40, view.UsedSends) + require.Equal(t, 60, view.MaxSends) + require.False(t, view.RouteReady) + require.True(t, bytes.Equal(raw, store.values[key].Value), "reading a closed ledger must keep its stored bytes") + require.EqualValues(t, 11, store.values[key].Revision) + require.Len(t, store.values, 1) + run.Status = "open" + run.ExpiresAt = time.Now().Add(time.Minute) + run.Qualification = &extensionv1.CodexRoutingQualification{} + rt, err := s.codexQualityRuntime() + require.NoError(t, err) + _, err = rt.qualification(context.Background(), run) + require.ErrorIs(t, err, ErrCodexQualityUnavailable) + require.True(t, bytes.Equal(raw, store.values[key].Value), "an old active generation is rejected without rewriting its stored bytes") +} + +func TestNativeCodexHostKeepsPrivateStateAndCredentialScope(t *testing.T) { + host, directory, store := routingHostFixture() + raw, _ := json.Marshal(extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: "quality-run.private"}) + _, err := host.Call(context.Background(), extensionv1.HostInvocation{Operation: extensionv1.HostStateRead, Payload: raw}) + require.Error(t, err) + for _, kind := range []string{AccountTypeAPIKey, "other"} { + directory.account.Type = kind + raw, _ = json.Marshal(extensionv1.CodexRoutingQuery{AccountID: directory.account.ID, Transport: "http"}) + _, err = host.Call(context.Background(), extensionv1.HostInvocation{Operation: extensionv1.HostCodexRoutingScope, Payload: raw}) + require.Error(t, err) + } + require.Zero(t, directory.requests) + require.Empty(t, store.values) +} diff --git a/backend/internal/service/codex_native_contract.go b/backend/internal/service/codex_native_contract.go new file mode 100644 index 000000000000..0dfd368e31eb --- /dev/null +++ b/backend/internal/service/codex_native_contract.go @@ -0,0 +1,105 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" +) + +const NativeCodexPluginKey = "codexrip.codex-runtime" +const NativeCodexConfigSettingKey = "codex_native_runtime_config" +const NativeCodexSourceSettingKey = "codex_native_runtime_source" +const NativeCodexRetirementSettingKey = "deplugin_retired_plugins" +const NativeCodexAccountProjectionKey = "plugin_account_projections" + +var ErrNativeCodexRuntimeChanged = errors.New("native Codex runtime metadata changed") +var ErrNativeCodexRuntimeUnavailable = errors.New("native Codex runtime unavailable") + +// The disabled legacy row is a persistence anchor, never a running plugin. +type NativeCodexMetadata struct { + ID int64 + RuntimeGeneration int64 + ConfigVersion int64 + ConfigSHA256 string +} + +type NativeCodexConfigRecord struct { + Version int `json:"version"` + ConfigVersion int64 `json:"config_version"` + ConfigSHA256 string `json:"config_sha256"` +} + +type NativeCodexStateStore interface { + DueExtensionStates(context.Context, string, extensionv1.DueStateRequest) ([]extensionv1.DueState, error) + ReadExtensionState(context.Context, string, extensionv1.StateRequest) (extensionv1.StateResult, error) + CompareSwapExtensionState(context.Context, string, extensionv1.StateRequest) (extensionv1.StateResult, error) + AcquireExtensionLease(context.Context, string, extensionv1.LeaseRequest) (extensionv1.LeaseResult, error) + ReleaseExtensionLease(context.Context, string, extensionv1.LeaseRequest) (extensionv1.LeaseResult, error) +} + +type NativeCodexAccountDirectory interface { + ReadExtensionAccount(context.Context, int64) (*extensionv1.Account, error) + ListExtensionAccounts(context.Context, extensionv1.AccountQuery) ([]extensionv1.Account, error) + ResolveExtensionIdentity(context.Context, extensionv1.AccountQuery) (*extensionv1.OutboundIdentity, error) +} + +type NativeCodexRuntimeLease interface { + Done() <-chan struct{} + Err() error + Release() +} + +type NativeCodexRepository interface { + NativeCodexStateStore + ReadNativeCodexStoredConfig(context.Context) (string, bool, error) + LoadNativeCodexMetadata(context.Context) (*NativeCodexMetadata, error) + SyncNativeCodexConfig(context.Context, string) (*NativeCodexMetadata, error) + StoreNativeCodexConfig(context.Context, string, string) (*NativeCodexMetadata, error) + HoldNativeCodexRuntime(context.Context, *NativeCodexMetadata) (NativeCodexRuntimeLease, error) +} + +type NativeCodexConfigFactory func(context.Context) (json.RawMessage, error) + +type nativeCodexExecutionKey struct{} +type nativeCodexBusinessIOKey struct{} + +func WithNativeCodexExecution(ctx context.Context, metadata *NativeCodexMetadata) context.Context { + if metadata == nil { + return ctx + } + return context.WithValue(ctx, nativeCodexExecutionKey{}, *metadata) +} + +func NativeCodexExecutionFromContext(ctx context.Context) (NativeCodexMetadata, bool) { + metadata, ok := ctx.Value(nativeCodexExecutionKey{}).(NativeCodexMetadata) + return metadata, ok +} + +func WithNativeCodexBusinessIO(ctx context.Context) context.Context { + return context.WithValue(ctx, nativeCodexBusinessIOKey{}, true) +} + +func NativeCodexBusinessIORequired(ctx context.Context) bool { + required, _ := ctx.Value(nativeCodexBusinessIOKey{}).(bool) + return required +} + +type NativeCodexAccountProjection struct { + Identity string `json:"identity"` + Scheduling map[string]extensionv1.SchedulingConstraint `json:"scheduling"` + Observations map[string]extensionv1.AccountObservation `json:"observations,omitempty"` +} + +func nativeCodexAccountProjection(account *Account) NativeCodexAccountProjection { + var projection NativeCodexAccountProjection + if account != nil { + if values, ok := account.Extra[NativeCodexAccountProjectionKey].(map[string]any); ok { + if raw, err := json.Marshal(values[NativeCodexPluginKey]); err == nil { + _ = json.Unmarshal(raw, &projection) + } + } + } + return projection +} diff --git a/backend/internal/service/codex_native_fixture_test.go b/backend/internal/service/codex_native_fixture_test.go new file mode 100644 index 000000000000..822121839c28 --- /dev/null +++ b/backend/internal/service/codex_native_fixture_test.go @@ -0,0 +1,86 @@ +package service + +import ( + "context" + "encoding/json" + "sync" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/codexruntime/tickets" + "github.com/Wei-Shaw/sub2api/internal/config" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" +) + +type nativeCodexTestLease struct { + done chan struct{} + once sync.Once +} + +func (l *nativeCodexTestLease) Done() <-chan struct{} { return l.done } +func (*nativeCodexTestLease) Err() error { return nil } +func (l *nativeCodexTestLease) Release() { l.once.Do(func() { close(l.done) }) } + +func (*routingMemoryStore) ReadNativeCodexStoredConfig(context.Context) (string, bool, error) { + return "", false, nil +} +func (*routingMemoryStore) LoadNativeCodexMetadata(context.Context) (*NativeCodexMetadata, error) { + return nativeCodexTestMetadata(), nil +} +func (*routingMemoryStore) StoreNativeCodexConfig(context.Context, string, string) (*NativeCodexMetadata, error) { + return nativeCodexTestMetadata(), nil +} +func (*routingMemoryStore) SyncNativeCodexConfig(context.Context, string) (*NativeCodexMetadata, error) { + return nativeCodexTestMetadata(), nil +} +func (*routingMemoryStore) HoldNativeCodexRuntime(ctx context.Context, m *NativeCodexMetadata) (NativeCodexRuntimeLease, error) { + if !NativeCodexBusinessIORequired(ctx) || m == nil { + return nil, ErrNativeCodexRuntimeChanged + } + return &nativeCodexTestLease{done: make(chan struct{})}, nil +} +func nativeCodexTestMetadata() *NativeCodexMetadata { + return &NativeCodexMetadata{ID: 1, RuntimeGeneration: 1, ConfigVersion: 1, ConfigSHA256: codexRoutingDigest("fixture-config")} +} + +type nativeTicketModuleFixture struct { + invoke func(extensionv1.Invocation) (extensionv1.Result, error) +} + +func (m nativeTicketModuleFixture) Invoke(_ context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { + return m.invoke(in) +} +func (nativeTicketModuleFixture) ApplyConfig(context.Context, json.RawMessage) error { return nil } +func (nativeTicketModuleFixture) Start(context.Context) error { return nil } +func (nativeTicketModuleFixture) Stop(context.Context) error { return nil } + +type nativeTicketAccountRepository struct{ AccountRepository } + +func (nativeTicketAccountRepository) GetByID(_ context.Context, id int64) (*Account, error) { + return ticketTestAccount(id), nil +} + +func nativeTicketTestRuntime(t *testing.T, cfg config.OpenAICodexTicketConfig, invoke func(extensionv1.Invocation) (extensionv1.Result, error)) *NativeCodexRuntime { + t.Helper() + if len(cfg.Models) == 0 { + cfg.Models = []string{"gpt-6-astra", "gpt-5.6-sol"} + } + if invoke == nil { + invoke = func(extensionv1.Invocation) (extensionv1.Result, error) { + return extensionv1.Result{Code: "ticket_missing"}, nil + } + } + store := &routingMemoryStore{values: map[string]extensionv1.StateResult{}} + r := &NativeCodexRuntime{repo: store, gateway: &OpenAIGatewayService{accountRepo: nativeTicketAccountRepository{}}} + epoch, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + metadata := nativeCodexTestMetadata() + host := &nativeCodexHost{key: NativeCodexPluginKey, state: store, repo: store, directory: r.gateway, metadata: metadata, epoch: epoch} + r.snapshot.Store(&nativeCodexSnapshot{metadata: metadata, config: tickets.Config{Enabled: cfg.Enabled, FailClosed: cfg.FailClosed, Models: cfg.Models, ProxyURL: cfg.HarvestProxyURL}, host: host, module: nativeTicketModuleFixture{invoke: invoke}, ctx: epoch, cancel: cancel}) + return r +} + +func nativeRoutingFixtureRuntime(host *nativeCodexHost, store *routingMemoryStore) *NativeCodexRuntime { + r := &NativeCodexRuntime{repo: store} + r.snapshot.Store(&nativeCodexSnapshot{metadata: host.metadata, host: host, ctx: host.epoch}) + return r +} diff --git a/backend/internal/service/plugin_extension_state.go b/backend/internal/service/codex_native_host.go similarity index 51% rename from backend/internal/service/plugin_extension_state.go rename to backend/internal/service/codex_native_host.go index 1cae4f1df2f1..97ad0d2f4a6f 100644 --- a/backend/internal/service/plugin_extension_state.go +++ b/backend/internal/service/codex_native_host.go @@ -3,100 +3,139 @@ package service import ( "context" "encoding/json" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "sync" ) -type PluginExtensionStateStore interface { - DueExtensionStates(context.Context, string, extensionv1.DueStateRequest) ([]extensionv1.DueState, error) - ReadExtensionState(context.Context, string, extensionv1.StateRequest) (extensionv1.StateResult, error) - CompareSwapExtensionState(context.Context, string, extensionv1.StateRequest) (extensionv1.StateResult, error) - AcquireExtensionLease(context.Context, string, extensionv1.LeaseRequest) (extensionv1.LeaseResult, error) - ReleaseExtensionLease(context.Context, string, extensionv1.LeaseRequest) (extensionv1.LeaseResult, error) +type nativeCodexHost struct { + leaseMu sync.Mutex + leaseWork sync.WaitGroup + closing bool + key string + state NativeCodexStateStore + directory NativeCodexAccountDirectory + metadata *NativeCodexMetadata + repo NativeCodexRepository + activity *QuotaActivityService + epoch context.Context } +type nativeCodexLeaseBindingKey struct{} +type nativeCodexPolicySignalsKey struct{} -type PluginExtensionAccountDirectory interface { - ReadExtensionAccount(context.Context, int64) (*extensionv1.Account, error) - ListExtensionAccounts(context.Context, extensionv1.AccountQuery) ([]extensionv1.Account, error) - ResolveExtensionIdentity(context.Context, extensionv1.AccountQuery) (*extensionv1.OutboundIdentity, error) +func nativeCodexPolicySignals(ctx context.Context) []context.Context { + signals, _ := ctx.Value(nativeCodexPolicySignalsKey{}).([]context.Context) + return signals } -// The owning plugin comes from the authenticated broker connection. No payload -// field can select another plugin namespace or a database table/SQL statement. -type pluginExtensionHost struct { - active func() bool - allows func(string, string, string, int64) bool - accountScope func(context.Context) (*PluginInstallation, error) - traffic AccountTrafficObserveCache - activity *QuotaActivityService - key string - state PluginExtensionStateStore - directory PluginExtensionAccountDirectory - installation *PluginInstallation +type nativeCodexLeaseBinding struct { + metadata NativeCodexMetadata + ctx context.Context } -func (h *pluginExtensionHost) Call(ctx context.Context, in extensionv1.HostInvocation) (extensionv1.Result, error) { - if h.installation != nil && h.installation.RuntimeGeneration > 0 { - ctx = WithPluginExecution(ctx, h.installation) +func (h *nativeCodexHost) bind(parent context.Context) (context.Context, context.CancelFunc, error) { + if h == nil || h.metadata == nil || h.repo == nil || h.epoch == nil || h.epoch.Err() != nil { + return nil, nil, ErrNativeCodexRuntimeUnavailable + } + if wanted, ok := NativeCodexExecutionFromContext(parent); ok && wanted != *h.metadata { + return nil, nil, ErrNativeCodexRuntimeChanged + } + if existing, ok := parent.Value(nativeCodexLeaseBindingKey{}).(nativeCodexLeaseBinding); ok && existing.metadata == *h.metadata && existing.ctx.Err() == nil { + ctx, cancel := context.WithCancel(parent) + return ctx, cancel, nil } - if (in.Operation == extensionv1.HostResolveIdentity || in.Operation == extensionv1.HostLeaseAcquire || in.Operation == extensionv1.HostStateDue || in.Operation == extensionv1.HostJobSubmit || in.Operation == extensionv1.HostMetricsQuery || isCodexRoutingHostOperation(in.Operation)) && h.active != nil && !h.active() { - return extensionv1.Result{}, status.Error(codes.Unavailable, "plugin capability is no longer active") + h.leaseMu.Lock() + if h.closing { + h.leaseMu.Unlock() + return nil, nil, ErrNativeCodexRuntimeUnavailable } - if h.state == nil && in.Operation != extensionv1.HostMetricsQuery { - return extensionv1.Result{}, status.Error(codes.Unavailable, "extension state unavailable") + h.leaseWork.Add(1) + h.leaseMu.Unlock() + bound, cancel := context.WithCancel(WithNativeCodexExecution(parent, h.metadata)) + signal, stopSignal := context.WithCancel(context.Background()) + stopPropagation := context.AfterFunc(signal, cancel) + stopEpoch := context.AfterFunc(h.epoch, stopSignal) + signals := append(append([]context.Context(nil), nativeCodexPolicySignals(parent)...), signal) + bound = context.WithValue(bound, nativeCodexPolicySignalsKey{}, signals) + if h.epoch.Err() != nil { + stopSignal() + cancel() } - // One persisted snapshot per account-bearing call keeps list filtering - // bounded and applies the same live bindings/rollout to every returned row. - // State/lease cleanup and finishing a previously issued observation retain - // their existing ownership/generation checks rather than creating new work. - if h.accountScope != nil && pluginHostOperationUsesAccountScope(in.Operation) { - current, scopeErr := h.accountScope(ctx) - if scopeErr != nil || current == nil { - return extensionv1.Result{}, status.Error(codes.PermissionDenied, "plugin account scope is no longer active") + lease, err := h.repo.HoldNativeCodexRuntime(WithNativeCodexBusinessIO(bound), h.metadata) + if err != nil { + stopEpoch() + stopPropagation() + stopSignal() + cancel() + h.leaseWork.Done() + return nil, nil, err + } + if lease == nil || lease.Done() == nil { + stopEpoch() + stopPropagation() + stopSignal() + cancel() + if lease != nil { + lease.Release() } - scoped := *h - scoped.installation = current - previousAllows := h.allows - scoped.allows = func(capability, platform, accountType string, accountID int64) bool { - return (previousAllows == nil || previousAllows(capability, platform, accountType, accountID)) && - pluginHasInvocationCapability(current, extensionv1.Invocation{Capability: capability, AccountID: accountID}, platform, accountType) + h.leaseWork.Done() + return nil, nil, ErrNativeCodexRuntimeUnavailable + } + go func() { + <-lease.Done() + if lease.Err() != nil { + stopSignal() } - h = &scoped + }() + binding := nativeCodexLeaseBinding{metadata: *h.metadata, ctx: bound} + bound = context.WithValue(bound, nativeCodexLeaseBindingKey{}, binding) + var once sync.Once + return bound, func() { + once.Do(func() { + stopEpoch() + stopPropagation() + stopSignal() + cancel() + lease.Release() + h.leaseWork.Done() + }) + }, nil +} + +func (h *nativeCodexHost) drain(ctx context.Context) error { + h.leaseMu.Lock() + h.closing = true + h.leaseMu.Unlock() + done := make(chan struct{}) + go func() { h.leaseWork.Wait(); close(done) }() + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (h *nativeCodexHost) permitsAccount(platform, accountType string, accountID int64, credentials bool) bool { + return accountID > 0 && platform == PlatformOpenAI && (!credentials || accountType == AccountTypeOAuth || accountType == AccountTypeSetupToken) +} + +func (h *nativeCodexHost) Call(ctx context.Context, in extensionv1.HostInvocation) (extensionv1.Result, error) { + bound, release, err := h.bind(ctx) + if err != nil { + return extensionv1.Result{}, err + } + defer release() + ctx = bound + if h.state == nil { + return extensionv1.Result{}, ErrNativeCodexRuntimeUnavailable } - var value any - var err error if isCodexRoutingHostOperation(in.Operation) { return h.callCodexRouting(ctx, in) } + var value any switch in.Operation { - case extensionv1.HostMetricsQuery: - var request extensionv1.AccountQuery - if json.Unmarshal(in.Payload, &request) != nil || request.AccountID <= 0 { - return extensionv1.Result{}, status.Error(codes.InvalidArgument, "invalid metrics query") - } - if h.directory == nil || h.traffic == nil { - return extensionv1.Result{}, status.Error(codes.Unavailable, "account metrics unavailable") - } - account, queryErr := h.directory.ReadExtensionAccount(ctx, request.AccountID) - if queryErr != nil || account == nil || account.ID != request.AccountID { - return extensionv1.Result{}, status.Error(codes.NotFound, "account unavailable") - } - if !h.permitsCapability(extensionv1.CapabilityObservability, account.Platform, account.Type, account.ID) { - return extensionv1.Result{}, status.Error(codes.PermissionDenied, "metrics are outside plugin capability") - } - counters, queryErr := h.traffic.Snapshot(ctx, request.AccountID) - if queryErr != nil { - return extensionv1.Result{}, status.Error(codes.Unavailable, "account metrics unavailable") - } - snapshot := extensionv1.AccountTrafficSnapshot{AccountID: account.ID, Protocols: map[string]extensionv1.AccountTrafficCounters{}} - for protocol, counter := range counters { - if protocol.Valid() { - snapshot.Protocols[string(protocol)] = counter - } - } - value = snapshot case extensionv1.HostStateDue: var req extensionv1.DueStateRequest if json.Unmarshal(in.Payload, &req) != nil || validatePluginKVNamespace(req.Namespace) != nil || req.Limit < 1 || req.Limit > 100 { @@ -144,12 +183,18 @@ func (h *pluginExtensionHost) Call(ctx context.Context, in extensionv1.HostInvoc case extensionv1.HostAccountRead: value = account case extensionv1.HostFinishObservation: + if h.activity == nil { + return extensionv1.Result{}, ErrNativeCodexRuntimeUnavailable + } err = h.activity.FinishUnbilled(ctx, req.AccountID, h.key, req.ObservationID) value = map[string]bool{"finished": err == nil} default: identity, resolveErr := h.directory.ResolveExtensionIdentity(ctx, req) err = resolveErr if err == nil && identity != nil && req.PrepareCredentials { + if h.activity == nil { + return extensionv1.Result{}, ErrNativeCodexRuntimeUnavailable + } identity.ObservationID, err = h.activity.BeginUnbilled(ctx, req.AccountID, h.key) } value = identity @@ -218,50 +263,3 @@ func (h *pluginExtensionHost) Call(ctx context.Context, in extensionv1.HostInvoc raw, err := json.Marshal(value) return extensionv1.Result{Payload: raw}, err } - -func pluginHostOperationUsesAccountScope(operation extensionv1.HostOperation) bool { - if isCodexRoutingHostOperation(operation) { - return true - } - switch operation { - case extensionv1.HostAccountRead, extensionv1.HostAccountList, extensionv1.HostResolveIdentity, - extensionv1.HostMetricsQuery, extensionv1.HostStateCompareSwap: - return true - default: - return false - } -} - -func (h *pluginExtensionHost) permitsAccount(platform, accountType string, accountID int64, credentials bool) bool { - if h.installation == nil || accountID <= 0 { - return false - } - for _, capability := range h.installation.Manifest.Capabilities { - if !pluginScopeMatches(capability.Platform, platform) || !pluginScopeMatches(capability.AccountType, accountType) { - continue - } - if credentials && capability.ID != extensionv1.CapabilityCredentials { - continue - } - if !credentials && capability.ID == extensionv1.CapabilityUI { - continue - } - if h.allows != nil && !h.allows(capability.ID, platform, accountType, accountID) { - continue - } - return true - } - return false -} - -func (h *pluginExtensionHost) permitsCapability(id, platform, accountType string, accountID int64) bool { - if h.installation == nil || accountID <= 0 { - return false - } - for _, capability := range h.installation.Manifest.Capabilities { - if capability.ID == id && pluginScopeMatches(capability.Platform, platform) && pluginScopeMatches(capability.AccountType, accountType) && (h.allows == nil || h.allows(id, platform, accountType, accountID)) { - return true - } - } - return false -} diff --git a/backend/internal/service/codex_native_policy.go b/backend/internal/service/codex_native_policy.go new file mode 100644 index 000000000000..302e92c8a78a --- /dev/null +++ b/backend/internal/service/codex_native_policy.go @@ -0,0 +1,112 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "slices" + "strings" + "sync" + "time" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + "golang.org/x/net/http/httpguts" +) + +func (r *NativeCodexRuntime) ApplyRequestHeaders(ctx context.Context, account *Account, model string, headers http.Header) error { + if account == nil || headers == nil || !account.IsOpenAIOAuthLike() { + return nil + } + raw, err := json.Marshal(extensionv1.SchedulingRequest{Account: *extensionAccount(account), Model: model, Now: time.Now().UTC()}) + if err != nil { + return err + } + result, err := r.Invoke(ctx, account.Platform, account.Type, extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "inject", AccountID: account.ID, Payload: raw}) + if err != nil { + return err + } + var changes extensionv1.CodexRoutingInjection + if result.Code != "" || json.Unmarshal(result.Payload, &changes) != nil { + return ErrNativeCodexRuntimeUnavailable + } + if changes.Qualification != nil && !changes.Qualification.Valid(time.Now(), account.ID, CodexTicketAccountIdentity(account), model) { + return ErrNativeCodexRuntimeUnavailable + } + projected, seen := headers.Clone(), map[string]bool{} + for name, value := range changes.Headers { + lower := strings.ToLower(name) + blocked := isHeaderOverrideBlockedName(lower) && lower != "x-codex-turn-state" + switch lower { + case "openai-organization", "openai-project", "api-key", "set-cookie": + blocked = true + } + if blocked || seen[lower] || !httpguts.ValidHeaderFieldName(name) || !httpguts.ValidHeaderFieldValue(value) { + return errors.New("invalid native Codex request header") + } + seen[lower] = true + projected.Set(name, value) + } + for name, values := range projected { + headers[name] = values + } + return nil +} + +// Keep scheduling a projection-only operation. No repository IO is added to selection. +func (r *NativeCodexRuntime) SchedulingDecision(account *Account, model string, now time.Time) extensionv1.SchedulingDecision { + allow := extensionv1.SchedulingDecision{Allowed: true} + if account == nil || !isOpenAICodexTicketAccount(account) { + return allow + } + snapshot := r.current() + if snapshot == nil || snapshot.ctx.Err() != nil { + return extensionv1.SchedulingDecision{Reason: "plugin_policy_unavailable", Scope: model} + } + if !snapshot.config.Enabled || !snapshot.config.FailClosed || !slices.Contains(snapshot.config.Models, model) { + return allow + } + projection := nativeCodexAccountProjection(account) + constraint, observed := projection.Scheduling[model] + if !observed { + constraint, observed = projection.Scheduling["*"] + } + if projection.Identity != CodexTicketAccountIdentity(account) || (constraint.Effect == "allow" && constraint.Reason != "routing_verified") || (constraint.Until != nil && !now.Before(*constraint.Until)) { + observed = false + } + if observed { + if constraint.Effect == "deny" { + return extensionv1.SchedulingDecision{Reason: constraint.Reason, Until: constraint.Until, Scope: model} + } + return allow + } + return extensionv1.SchedulingDecision{Reason: "ticket_missing", Scope: model} +} + +func (r *NativeCodexRuntime) bindMetadata(ctx context.Context, metadata *NativeCodexMetadata) (context.Context, context.CancelFunc, error) { + snapshot := r.current() + if snapshot == nil || metadata == nil || *snapshot.metadata != *metadata { + return nil, nil, ErrNativeCodexRuntimeChanged + } + return snapshot.host.bind(ctx) +} + +type nativeCodexLeaseBody struct { + body io.ReadCloser + release context.CancelFunc + once sync.Once +} + +func (b *nativeCodexLeaseBody) Read(p []byte) (int, error) { + n, err := b.body.Read(p) + if err != nil { + b.once.Do(b.release) + } + return n, err +} +func (b *nativeCodexLeaseBody) Close() error { + err := b.body.Close() + b.once.Do(b.release) + return err +} diff --git a/backend/internal/service/codex_native_provider.go b/backend/internal/service/codex_native_provider.go new file mode 100644 index 000000000000..c34aa4e41c88 --- /dev/null +++ b/backend/internal/service/codex_native_provider.go @@ -0,0 +1,79 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + + "github.com/Wei-Shaw/sub2api/internal/config" +) + +func ProvideNativeCodexRuntime(gateway *OpenAIGatewayService, repo NativeCodexRepository, settings SettingRepository, encryptor SecretEncryptor, cfg *config.Config, bootstrap *NativeFeatureBootstrap, activity *QuotaActivityService) *NativeCodexRuntime { + factory := func(ctx context.Context) (json.RawMessage, error) { + _, sourceErr := settings.GetValue(ctx, "codex_native_runtime_source") + if sourceErr != nil && !errors.Is(sourceErr, ErrSettingNotFound) { + return nil, sourceErr + } + cipher, present, err := repo.ReadNativeCodexStoredConfig(ctx) + if err != nil { + return nil, err + } + var raw json.RawMessage + if present { + plain, decryptErr := encryptor.Decrypt(cipher) + if decryptErr != nil { + return nil, errors.New("cannot decrypt saved Codex configuration") + } + raw = json.RawMessage(plain) + } else { + configuration := cfg.Gateway.OpenAICodexTicket + models := configuration.Models + if len(models) == 0 { + models = []string{"gpt-6-astra", "gpt-5.6-sol"} + } + raw, err = json.Marshal(map[string]any{ + "enabled": configuration.Enabled, "fail_closed": configuration.FailClosed, + "proxy_url": configuration.HarvestProxyURL, "models": models, + "request_zstd": cfg.Gateway.OpenAICodexRequestZstd, + }) + if err != nil { + return nil, err + } + } + if errors.Is(sourceErr, ErrSettingNotFound) && bootstrap != nil && bootstrap.Snapshot != nil { + if previous, ok := bootstrap.Snapshot.Plugins[NativeCodexPluginKey]; ok && !previous.NativeCreated && previous.State != "enabled" { + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil || fields == nil { + return nil, errors.New("invalid saved Codex configuration") + } + fields["enabled"] = json.RawMessage(`false`) + raw, err = json.Marshal(fields) + if err != nil { + return nil, err + } + } + } + return NormalizeNativeCodexConfig(ctx, raw) + } + runtime := NewNativeCodexRuntime(gateway, repo, factory) + runtime.SetQuotaActivity(activity) + return runtime +} + +func (s *OpenAIGatewayService) NativeCodexConfiguration() (json.RawMessage, error) { + if s == nil || s.nativeCodexRuntime == nil { + return nil, ErrNativeCodexRuntimeUnavailable + } + snapshot := s.nativeCodexRuntime.snapshot.Load() + if snapshot == nil { + return nil, ErrNativeCodexRuntimeUnavailable + } + return append(json.RawMessage(nil), snapshot.raw...), nil +} + +func (s *OpenAIGatewayService) UpdateNativeCodexConfiguration(ctx context.Context, raw json.RawMessage, encryptor SecretEncryptor) error { + if s == nil || s.nativeCodexRuntime == nil { + return ErrNativeCodexRuntimeUnavailable + } + return s.nativeCodexRuntime.UpdateConfig(ctx, raw, encryptor) +} diff --git a/backend/internal/service/codex_native_runtime.go b/backend/internal/service/codex_native_runtime.go new file mode 100644 index 000000000000..64057519fc0b --- /dev/null +++ b/backend/internal/service/codex_native_runtime.go @@ -0,0 +1,304 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "sync" + "sync/atomic" + "time" + + "github.com/Wei-Shaw/sub2api/internal/codexruntime/tickets" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" +) + +var ErrNativeCodexPolicyDisabled = errors.New("native Codex policy is disabled for this account") + +type nativeCodexModule interface { + Invoke(context.Context, extensionv1.Invocation) (extensionv1.Result, error) + ApplyConfig(context.Context, json.RawMessage) error + Start(context.Context) error + Stop(context.Context) error +} + +type nativeCodexSnapshot struct { + metadata *NativeCodexMetadata + raw json.RawMessage + config tickets.Config + module nativeCodexModule + host *nativeCodexHost + ctx context.Context + cancel context.CancelFunc +} + +type NativeCodexRuntime struct { + mu sync.Mutex + root context.Context + started bool + gateway *OpenAIGatewayService + repo NativeCodexRepository + factory NativeCodexConfigFactory + activity *QuotaActivityService + snapshot atomic.Pointer[nativeCodexSnapshot] + codexDemandMu sync.Mutex + codexDemandAt map[string]time.Time +} + +var nativeCodexPolicyRuntime atomic.Pointer[NativeCodexRuntime] + +func NewNativeCodexRuntime(gateway *OpenAIGatewayService, repo NativeCodexRepository, factory NativeCodexConfigFactory) *NativeCodexRuntime { + r := &NativeCodexRuntime{gateway: gateway, repo: repo, factory: factory} + if gateway != nil { + gateway.SetNativeCodexRuntime(r) + } + nativeCodexPolicyRuntime.Store(r) + return r +} + +func (s *OpenAIGatewayService) SetNativeCodexRuntime(runtime *NativeCodexRuntime) { + s.nativeCodexRuntime = runtime +} + +// Historical extension jobs must name the actual retired Codex installation, +// never an arbitrary third-party plugin exposing a same-named operation. +func (s *OpenAIGatewayService) ValidateLegacyCodexJobSource(ctx context.Context, pluginID int64) error { + if s == nil || s.nativeCodexRuntime == nil || pluginID <= 0 { + return ErrNativeCodexRuntimeUnavailable + } + reader, ok := s.nativeCodexRuntime.repo.(interface { + ValidateLegacyCodexJobSource(context.Context, int64) error + }) + if !ok { + return ErrNativeCodexRuntimeUnavailable + } + return reader.ValidateLegacyCodexJobSource(ctx, pluginID) +} + +func (r *NativeCodexRuntime) SetQuotaActivity(activity *QuotaActivityService) { r.activity = activity } + +func NormalizeNativeCodexConfig(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + return tickets.NewModule().ValidateConfig(ctx, raw) +} + +func (r *NativeCodexRuntime) Start(ctx context.Context) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.started { + return nil + } + r.root = ctx + if err := r.reloadLocked(ctx); err != nil { + return err + } + r.started = true + return nil +} + +func (r *NativeCodexRuntime) Reload(ctx context.Context) error { + r.mu.Lock() + defer r.mu.Unlock() + if !r.started { + return ErrNativeCodexRuntimeUnavailable + } + return r.reloadLocked(ctx) +} + +// UpdateConfig is the sole native settings write path: validate/encrypt first, +// drain the old epoch, then persist source/hash/generation in one transaction. +func (r *NativeCodexRuntime) UpdateConfig(ctx context.Context, raw json.RawMessage, encryptor SecretEncryptor) error { + raw, err := NormalizeNativeCodexConfig(ctx, raw) + if err != nil { + return err + } + if encryptor == nil { + return ErrNativeCodexRuntimeUnavailable + } + cipher, err := encryptor.Encrypt(string(raw)) + if err != nil { + return err + } + sum := sha256.Sum256(raw) + hash := hex.EncodeToString(sum[:]) + r.mu.Lock() + defer r.mu.Unlock() + if !r.started || r.repo == nil { + return ErrNativeCodexRuntimeUnavailable + } + previous := r.snapshot.Load() + if previous != nil && previous.ctx.Err() == nil && previous.metadata.ConfigSHA256 == hash { + current, loadErr := r.repo.LoadNativeCodexMetadata(ctx) + if loadErr != nil { + return loadErr + } + if *current == *previous.metadata { + _, saveErr := r.repo.StoreNativeCodexConfig(ctx, cipher, hash) + return saveErr + } + } + if err = r.stopSnapshot(ctx, previous); err != nil { + return err + } + metadata, err := r.repo.StoreNativeCodexConfig(ctx, cipher, hash) + if err != nil { + r.restoreSnapshot(ctx, previous) + return err + } + return r.publishLocked(ctx, raw, metadata) +} + +func (r *NativeCodexRuntime) stopSnapshot(ctx context.Context, snapshot *nativeCodexSnapshot) error { + if snapshot == nil { + return nil + } + snapshot.cancel() + if err := snapshot.module.Stop(ctx); err != nil { + return err + } + return snapshot.host.drain(ctx) +} + +func (r *NativeCodexRuntime) restoreSnapshot(ctx context.Context, previous *nativeCodexSnapshot) { + if previous != nil { + if current, err := r.repo.LoadNativeCodexMetadata(ctx); err == nil && *current == *previous.metadata { + _ = r.publishLocked(ctx, previous.raw, current) + } + } +} + +func (r *NativeCodexRuntime) reloadLocked(ctx context.Context) error { + if r.repo == nil || r.factory == nil { + return ErrNativeCodexRuntimeUnavailable + } + raw, err := r.factory(ctx) + if err != nil { + return err + } + raw, err = NormalizeNativeCodexConfig(ctx, raw) + if err != nil { + return err + } + sum := sha256.Sum256(raw) + hash := hex.EncodeToString(sum[:]) + previous := r.snapshot.Load() + if previous != nil && previous.ctx.Err() == nil && previous.metadata.ConfigSHA256 == hash { + current, loadErr := r.repo.LoadNativeCodexMetadata(ctx) + if loadErr != nil { + return loadErr + } + if *current == *previous.metadata { + return nil + } + } + if previous != nil { + if err = r.stopSnapshot(ctx, previous); err != nil { + return err + } + } + metadata, err := r.repo.SyncNativeCodexConfig(ctx, hash) + if err != nil { + // A failed transaction may resume the old policy only if its persistent fence is unchanged. + r.restoreSnapshot(ctx, previous) + return err + } + return r.publishLocked(ctx, raw, metadata) +} + +func (r *NativeCodexRuntime) publishLocked(ctx context.Context, raw json.RawMessage, metadata *NativeCodexMetadata) error { + var cfg tickets.Config + if err := json.Unmarshal(raw, &cfg); err != nil { + return err + } + epoch, cancel := context.WithCancel(r.root) + module := tickets.NewModule() + host := &nativeCodexHost{key: NativeCodexPluginKey, state: r.repo, directory: r.gateway, metadata: metadata, repo: r.repo, activity: r.activity, epoch: epoch} + module.SetHost(host) + if err := module.ApplyConfig(ctx, raw); err != nil { + cancel() + return err + } + snapshot := &nativeCodexSnapshot{metadata: metadata, raw: append(json.RawMessage(nil), raw...), config: cfg, module: module, host: host, ctx: epoch, cancel: cancel} + r.snapshot.Store(snapshot) + if err := module.Start(epoch); err != nil { + cancel() + return err + } + return nil +} + +func (r *NativeCodexRuntime) Stop(ctx context.Context) error { + r.mu.Lock() + defer r.mu.Unlock() + r.started = false + if snapshot := r.snapshot.Load(); snapshot != nil { + return r.stopSnapshot(ctx, snapshot) + } + return nil +} + +func (r *NativeCodexRuntime) current() *nativeCodexSnapshot { + if r == nil { + return nil + } + return r.snapshot.Load() +} + +func (r *NativeCodexRuntime) metadata() *NativeCodexMetadata { + if snapshot := r.current(); snapshot != nil { + return snapshot.metadata + } + return nil +} + +func nativeCodexScope(platform, kind string, in extensionv1.Invocation) bool { + if platform != "" && platform != PlatformOpenAI { + return false + } + return in.Capability == extensionv1.CapabilityRecovery || kind == "" || kind == AccountTypeOAuth || kind == AccountTypeSetupToken +} + +func (r *NativeCodexRuntime) Invoke(ctx context.Context, platform, kind string, in extensionv1.Invocation) (extensionv1.Result, error) { + if !nativeCodexScope(platform, kind, in) { + return extensionv1.Result{}, ErrNativeCodexPolicyDisabled + } + snapshot := r.current() + if snapshot == nil || snapshot.ctx.Err() != nil { + return extensionv1.Result{}, ErrNativeCodexRuntimeUnavailable + } + if wanted, ok := NativeCodexExecutionFromContext(ctx); ok && wanted != *snapshot.metadata { + return extensionv1.Result{}, ErrNativeCodexRuntimeChanged + } + if in.AccountID > 0 { + account, err := r.gateway.accountRepo.GetByID(ctx, in.AccountID) + if err != nil || account == nil || account.Platform != PlatformOpenAI || (kind != "" && kind != account.Type) || !nativeCodexScope(account.Platform, account.Type, in) { + return extensionv1.Result{}, ErrNativeCodexPolicyDisabled + } + } + bound, release, err := snapshot.host.bind(ctx) + if err != nil { + return extensionv1.Result{}, err + } + defer release() + return snapshot.module.Invoke(bound, in) +} + +var invokeNativeCodex = func(ctx context.Context, platform, kind string, in extensionv1.Invocation) (extensionv1.Result, error) { + runtime := nativeCodexPolicyRuntime.Load() + if runtime == nil { + return extensionv1.Result{}, ErrNativeCodexPolicyDisabled + } + return runtime.Invoke(ctx, platform, kind, in) +} + +var bindNativeCodexContext = func(ctx context.Context, platform, kind string, in extensionv1.Invocation) (context.Context, context.CancelFunc, error) { + if !nativeCodexScope(platform, kind, in) { + return nil, nil, ErrNativeCodexPolicyDisabled + } + runtime := nativeCodexPolicyRuntime.Load() + snapshot := runtime.current() + if snapshot == nil { + return nil, nil, ErrNativeCodexPolicyDisabled + } + return snapshot.host.bind(ctx) +} diff --git a/backend/internal/service/codex_quality_composition_test.go b/backend/internal/service/codex_quality_composition_test.go index 3db31c544cd2..343f8d2a152d 100644 --- a/backend/internal/service/codex_quality_composition_test.go +++ b/backend/internal/service/codex_quality_composition_test.go @@ -10,7 +10,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" @@ -36,7 +36,7 @@ func TestCodexQualityComposedRawObservationPrecedesGuard(t *testing.T) { t.Run(name, func(t *testing.T) { store, run := qualityStateFixture(t, 3) svc := &OpenAIGatewayService{cfg: &config.Config{}} - rt := &codexQualityRuntime{s: svc, store: store, installation: &PluginInstallation{PluginKey: codexRuntimePluginKey}} + rt := &codexQualityRuntime{s: svc, store: store, installation: &NativeCodexMetadata{}} scope := run.Scope scope.ConnectionLeaseID = "fixture-private-connection" deadline := time.Now().Add(time.Minute) diff --git a/backend/internal/service/codex_quality_contract_test.go b/backend/internal/service/codex_quality_contract_test.go index 8d4fef6160ad..6f7695a86b72 100644 --- a/backend/internal/service/codex_quality_contract_test.go +++ b/backend/internal/service/codex_quality_contract_test.go @@ -12,7 +12,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" diff --git a/backend/internal/service/codex_quality_manual_stop_test.go b/backend/internal/service/codex_quality_manual_stop_test.go index ed4962e2b2e5..27c8def2d53a 100644 --- a/backend/internal/service/codex_quality_manual_stop_test.go +++ b/backend/internal/service/codex_quality_manual_stop_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/google/uuid" "github.com/stretchr/testify/require" ) @@ -38,21 +38,16 @@ func (*qualityManualStopConcurrency) ReleaseAccountSlot(context.Context, int64, func TestCodexQualityManualStopRequiredBeforeCreateRenewAndSend(t *testing.T) { ctx := context.Background() host, baseDirectory, store := routingHostFixture() - installation := host.installation - installation.ID, installation.State, installation.Version = 1, PluginStateEnabled, "0.2.7" - installation.Manifest.Requires.ExtensionAPI = 1 - installation.Bindings = []PluginBinding{{Capability: extensionv1.CapabilityCredentials, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth, Enabled: true, RolloutPercent: 100}} - store.PluginRepository = &pluginTokenRepository{installation: installation} directory := &qualityManualStopDirectory{routingHostDirectoryFixture: baseDirectory} - manager := NewPluginManager(store, nil, nil, PluginHostInfo{}, newFakePluginKVStore()) - manager.accountDirectory = directory - manager.extensions.Store(&pluginExtensionRegistry{installations: map[int64]*PluginInstallation{1: installation}, runtimes: map[int64]*pluginRuntime{}}) + host.directory = directory + manager := nativeRoutingFixtureRuntime(host, store) + group, proxy := int64(53), int64(34) account := &Account{ID: 16380, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: false, Concurrency: 1, GroupIDs: []int64{group}, ProxyID: &proxy, Credentials: map[string]any{"plan_type": "pro", "chatgpt_account_id": "quality-account"}} directory.account = *extensionAccount(account) directory.scope.AccountID, directory.scope.Identity = account.ID, CodexTicketAccountIdentity(account) slots := &qualityManualStopConcurrency{} - s := &OpenAIGatewayService{pluginManager: manager, accountRepo: &routingAccountRepositoryFixture{account: account}, concurrencyService: &ConcurrencyService{cache: slots}} + s := &OpenAIGatewayService{nativeCodexRuntime: manager, accountRepo: &routingAccountRepositoryFixture{account: account}, concurrencyService: &ConcurrencyService{cache: slots}} key := &APIKey{ID: 101, UserID: 9, GroupID: &group, Status: StatusAPIKeyActive} lookup := func(context.Context, int64) (*APIKey, error) { return key, nil } request := CodexQualityCreateRequest{RunID: uuid.NewString(), APIKeyID: key.ID, PromptSHA256: codexQualityHash("question"), MaxSends: 6, TTLSeconds: 7200} diff --git a/backend/internal/service/codex_quality_observation.go b/backend/internal/service/codex_quality_observation.go index 43e257304b2f..514bac6ddc5b 100644 --- a/backend/internal/service/codex_quality_observation.go +++ b/backend/internal/service/codex_quality_observation.go @@ -10,7 +10,7 @@ import ( "sync" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/klauspost/compress/zstd" "github.com/tidwall/gjson" ) diff --git a/backend/internal/service/codex_quality_observation_test.go b/backend/internal/service/codex_quality_observation_test.go index 06ed44dcac38..259915dd6f8e 100644 --- a/backend/internal/service/codex_quality_observation_test.go +++ b/backend/internal/service/codex_quality_observation_test.go @@ -13,7 +13,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) diff --git a/backend/internal/service/codex_quality_route.go b/backend/internal/service/codex_quality_route.go index 24f9ff43379c..31e37813ba73 100644 --- a/backend/internal/service/codex_quality_route.go +++ b/backend/internal/service/codex_quality_route.go @@ -6,7 +6,7 @@ import ( "fmt" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/google/uuid" ) diff --git a/backend/internal/service/codex_quality_runtime.go b/backend/internal/service/codex_quality_runtime.go index 490794d09d0d..acd5534a0afb 100644 --- a/backend/internal/service/codex_quality_runtime.go +++ b/backend/internal/service/codex_quality_runtime.go @@ -10,16 +10,16 @@ import ( "strings" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" ) type codexQualityRuntime struct { s *OpenAIGatewayService - store PluginExtensionStateStore - installation *PluginInstallation - host *pluginExtensionHost + store NativeCodexStateStore + installation *NativeCodexMetadata + host *nativeCodexHost } type codexQualityExecutionKey struct{} @@ -88,30 +88,30 @@ func StageCodexQualityHeaders(c *gin.Context) { } func (s *OpenAIGatewayService) codexQualityRuntime() (*codexQualityRuntime, error) { - if s == nil || s.pluginManager == nil || s.accountRepo == nil { + if s == nil || s.nativeCodexRuntime == nil || s.accountRepo == nil { return nil, ErrCodexQualityUnavailable } - i, _ := s.pluginManager.installedByKey(codexRuntimePluginKey) - store, ok := s.pluginManager.repo.(PluginExtensionStateStore) - if !ok || i == nil { + runtime := s.nativeCodexRuntime + metadata, err := runtime.repo.LoadNativeCodexMetadata(context.Background()) + if err != nil { return nil, ErrCodexQualityUnavailable } - host, ok := s.pluginManager.buildHostServices(i).(*pluginHostServiceServer) - if !ok || host == nil || host.extension == nil { - return nil, ErrCodexQualityUnavailable + snapshot := runtime.current() + var host *nativeCodexHost + if snapshot != nil { + host = snapshot.host } - broker, ok := host.extension.(*pluginExtensionHost) - if !ok || broker == nil { - return nil, ErrCodexQualityUnavailable - } - return &codexQualityRuntime{s: s, store: store, installation: i, host: broker}, nil + return &codexQualityRuntime{s: s, store: runtime.repo, installation: metadata, host: host}, nil } func (rt *codexQualityRuntime) ctx(ctx context.Context) context.Context { - return WithPluginExecution(ctx, rt.installation) + return WithNativeCodexExecution(ctx, rt.installation) } func (rt *codexQualityRuntime) scope(ctx context.Context, id int64) (extensionv1.CodexRoutingScope, error) { + if rt.host == nil { + return extensionv1.CodexRoutingScope{}, ErrCodexQualityUnavailable + } raw, _ := json.Marshal(extensionv1.CodexRoutingQuery{AccountID: id, Transport: "http"}) result, err := rt.host.Call(rt.ctx(ctx), extensionv1.HostInvocation{Operation: extensionv1.HostCodexRoutingScope, Payload: raw}) var scope extensionv1.CodexRoutingScope @@ -414,7 +414,7 @@ func (s *OpenAIGatewayService) SelectCodexQualityAccount(ctx context.Context) (* return attachSelectionProfitGate(ctx, attachSelectionRuntimeBreakerProbe(ctx, &AccountSelectionResult{Account: a, Acquired: true, ReleaseFunc: acquired.ReleaseFunc})), nil } -func codexQualityRequestQualification(ctx context.Context, account *Account, model string) (*extensionv1.CodexRoutingQualification, *PluginInstallation, error) { +func codexQualityRequestQualification(ctx context.Context, account *Account, model string) (*extensionv1.CodexRoutingQualification, *NativeCodexMetadata, error) { e := codexQualityExecutionFromContext(ctx) if e == nil || account == nil || account.ID != e.accountID || model != codexQualityModel { return nil, nil, ErrCodexQualityUnavailable diff --git a/backend/internal/service/codex_quality_state.go b/backend/internal/service/codex_quality_state.go index 7c5f3212e216..d3631173d6a1 100644 --- a/backend/internal/service/codex_quality_state.go +++ b/backend/internal/service/codex_quality_state.go @@ -12,7 +12,7 @@ import ( "strings" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/google/uuid" ) @@ -120,7 +120,7 @@ func codexQualityHash(value string) string { return hex.EncodeToString(sum[:]) } -func readCodexQualityRun(ctx context.Context, store PluginExtensionStateStore, id string) (codexQualityRun, int64, error) { +func readCodexQualityRun(ctx context.Context, store NativeCodexStateStore, id string) (codexQualityRun, int64, error) { var run codexQualityRun canonical, valid := canonicalCodexQualityID(id) if !valid || store == nil { @@ -140,7 +140,7 @@ func readCodexQualityRun(ctx context.Context, store PluginExtensionStateStore, i return run, record.Revision, nil } -func mutateCodexQualityRun(ctx context.Context, store PluginExtensionStateStore, id string, change func(*codexQualityRun) error) (codexQualityRun, error) { +func mutateCodexQualityRun(ctx context.Context, store NativeCodexStateStore, id string, change func(*codexQualityRun) error) (codexQualityRun, error) { canonical, valid := canonicalCodexQualityID(id) if !valid { return codexQualityRun{}, ErrCodexQualityUnavailable @@ -186,7 +186,7 @@ func codexQualityActive(run codexQualityRun, now time.Time) bool { return run.RunID != "" && run.Status == "open" && now.Before(run.ExpiresAt) } -func issueCodexQualityGrant(ctx context.Context, store PluginExtensionStateStore, wanted codexQualityRun, digest string, expiry time.Time) (codexQualityRun, error) { +func issueCodexQualityGrant(ctx context.Context, store NativeCodexStateStore, wanted codexQualityRun, digest string, expiry time.Time) (codexQualityRun, error) { canonical, valid := canonicalCodexQualityID(wanted.RunID) if !valid { return codexQualityRun{}, ErrCodexQualityUnavailable @@ -207,7 +207,7 @@ func sameCodexQualityAttempt(a, b CodexQualityAttempt) bool { return a.Stage == b.Stage && a.TrialID == b.TrialID && a.OperationID == b.OperationID } -func reserveCodexQualityAttempt(ctx context.Context, store PluginExtensionStateStore, id, grantDigest string, attempt CodexQualityAttempt) error { +func reserveCodexQualityAttempt(ctx context.Context, store NativeCodexStateStore, id, grantDigest string, attempt CodexQualityAttempt) error { _, err := mutateCodexQualityRun(ctx, store, id, func(run *codexQualityRun) error { if !codexQualityActive(*run, time.Now()) || (grantDigest != "" && !codexQualityGrantMatches(*run, grantDigest)) || run.AccountID != attempt.AccountID { return ErrCodexQualityUnavailable @@ -230,7 +230,7 @@ func reserveCodexQualityAttempt(ctx context.Context, store PluginExtensionStateS return err } -func finishCodexQualityAttempt(ctx context.Context, store PluginExtensionStateStore, id string, finished CodexQualityAttempt) { +func finishCodexQualityAttempt(ctx context.Context, store NativeCodexStateStore, id string, finished CodexQualityAttempt) { ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second) defer cancel() _, _ = mutateCodexQualityRun(ctx, store, id, func(run *codexQualityRun) error { diff --git a/backend/internal/service/codex_quality_test.go b/backend/internal/service/codex_quality_test.go index b6bc76615c80..fff14495803e 100644 --- a/backend/internal/service/codex_quality_test.go +++ b/backend/internal/service/codex_quality_test.go @@ -12,7 +12,7 @@ import ( "testing" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/stretchr/testify/require" @@ -274,7 +274,7 @@ func TestCodexQualityKeyRevalidationRejectsExpiredQuotaAndDrift(t *testing.T) { change(&other) require.False(t, codexQualityKeyUsable(&other, 9, 101, 53)) store, run := qualityStateFixture(t, 3) - rt := &codexQualityRuntime{store: store, installation: &PluginInstallation{}} + rt := &codexQualityRuntime{store: store, installation: &NativeCodexMetadata{}} e := &codexQualityExecution{runtime: rt, runID: run.RunID, grantDigest: run.GrantDigest, accountID: run.AccountID, stage: "acquire", operationID: uuid.NewString(), keyLookup: func(context.Context, int64) (*APIKey, error) { return &other, nil }} ctx := context.WithValue(context.Background(), codexQualityExecutionKey{}, e) req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://chatgpt.com/backend-api/codex/responses", strings.NewReader(`{"model":"gpt-6-astra"}`)) diff --git a/backend/internal/service/codex_quality_wire_test.go b/backend/internal/service/codex_quality_wire_test.go index 31aaf6668fbc..887a55e789dd 100644 --- a/backend/internal/service/codex_quality_wire_test.go +++ b/backend/internal/service/codex_quality_wire_test.go @@ -15,8 +15,8 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/stretchr/testify/require" @@ -98,21 +98,19 @@ func qualityWireServiceFixture(t *testing.T, compressed, recovery bool) (*OpenAI if !recovery { account.Extra = map[string]any{OpenAIReasoningSignatureRecoveryEnabledExtraKey: false} } - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{Enabled: true, FailClosed: true, Models: []string{codexQualityModel}}, nil) - installation := manager.extensions.Load().installations[1] - installation.State = PluginStateEnabled + manager := nativeTicketTestRuntime(t, config.OpenAICodexTicketConfig{Enabled: true, FailClosed: true, Models: []string{codexQualityModel}}, nil) + installation := manager.metadata() host, directory, store := routingHostFixture() - store.PluginRepository = &pluginTokenRepository{installation: installation} + manager.repo = store upstream := &qualityWireUpstream{accountID: account.ID} - svc := &OpenAIGatewayService{cfg: &config.Config{}, pluginManager: manager, httpUpstream: upstream, + svc := &OpenAIGatewayService{cfg: &config.Config{}, nativeCodexRuntime: manager, httpUpstream: upstream, accountRepo: &routingAccountRepositoryFixture{account: account}} ctx := withCodexTransportFixture(context.Background(), compressed) scope, err := svc.PrepareCodexRoutingScope(ctx, account.ID, "http") require.NoError(t, err) directory.scope, directory.account = scope, *extensionAccount(account) - host.installation = installation - installation.Manifest.Capabilities = []PluginCapability{{ID: extensionv1.CapabilityCredentials, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth}} + host.metadata = installation now := time.Now().UTC() expiry := now.Add(time.Minute) leaseScope := scope diff --git a/backend/internal/service/codex_routing_completion.go b/backend/internal/service/codex_routing_completion.go index d9fad7d2c5d9..fde4346d0b2a 100644 --- a/backend/internal/service/codex_routing_completion.go +++ b/backend/internal/service/codex_routing_completion.go @@ -9,7 +9,7 @@ import ( "net/http" "strings" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) const codexRoutingProbeReadLimit = 128 << 10 diff --git a/backend/internal/service/codex_routing_cookies.go b/backend/internal/service/codex_routing_cookies.go index 729b8ece13ec..d4f636dbdfbc 100644 --- a/backend/internal/service/codex_routing_cookies.go +++ b/backend/internal/service/codex_routing_cookies.go @@ -12,7 +12,7 @@ import ( "strings" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) const codexRoutingPrivateNamespace = "codex-routing-private" @@ -172,7 +172,7 @@ func (clock *codexRoutingCookieClock) apply(changes []codexRoutingCookieChange, // First-seen records are indexed separately from the compact clock. Retaining // only recent digests in a clock must never make an old identical value fresh. -func preserveCodexCookieFirstSeen(ctx context.Context, store PluginExtensionStateStore, plugin string, scope extensionv1.CodexRoutingScope, clock *codexRoutingCookieClock, changes []codexRoutingCookieChange, now time.Time) error { +func preserveCodexCookieFirstSeen(ctx context.Context, store NativeCodexStateStore, plugin string, scope extensionv1.CodexRoutingScope, clock *codexRoutingCookieClock, changes []codexRoutingCookieChange, now time.Time) error { for index := range changes { change := &changes[index] if change.Delete { diff --git a/backend/internal/service/codex_routing_demand.go b/backend/internal/service/codex_routing_demand.go index 7ad15882f52e..3eb40726d94a 100644 --- a/backend/internal/service/codex_routing_demand.go +++ b/backend/internal/service/codex_routing_demand.go @@ -6,20 +6,20 @@ import ( "strconv" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) // Scheduler demand is best-effort and asynchronous. It does not mint a grant, // enroll an untouched account, or wait on a process RPC in candidate selection. -func (m *PluginManager) noteCodexRoutingDemand(account *Account, model string) { +func (m *NativeCodexRuntime) noteCodexRoutingDemand(account *Account, model string) { if m == nil || !isOpenAICodexTicketAccount(account) || model == "" { return } - observation, exists := accountPluginProjection(account, codexRuntimePluginKey).Observations[model] + observation, exists := nativeCodexAccountProjection(account).Observations[model] if !exists || observation.State == "stopped" || observation.State == "idle" { return } - installation, _ := m.installedByKey(codexRuntimePluginKey) + installation := m.metadata() if installation == nil { return } @@ -51,6 +51,6 @@ func (m *PluginManager) noteCodexRoutingDemand(account *Account, model string) { ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() raw, _ := json.Marshal(extensionv1.CodexRoutingDemand{AccountID: id, Model: model, Transport: "http"}) - _, _ = m.InvokeExtension(ctx, installation.ID, platform, kind, extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "codex.routing.demand", AccountID: id, Payload: raw}) + _, _ = m.Invoke(ctx, platform, kind, extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "codex.routing.demand", AccountID: id, Payload: raw}) }() } diff --git a/backend/internal/service/codex_routing_fingerprint.go b/backend/internal/service/codex_routing_fingerprint.go index 5dac3ea9b916..508a08f0792b 100644 --- a/backend/internal/service/codex_routing_fingerprint.go +++ b/backend/internal/service/codex_routing_fingerprint.go @@ -10,7 +10,7 @@ import ( "strings" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" ) @@ -127,11 +127,12 @@ type CodexFingerprintView struct { } func (s *OpenAIGatewayService) observeCodexWire(ctx context.Context, account *Account, request *http.Request, response *http.Response, qualification *extensionv1.CodexRoutingQualification) { - if s == nil || s.pluginManager == nil || account == nil || request == nil || response == nil { + if s == nil || s.nativeCodexRuntime == nil || account == nil || request == nil || response == nil { return } - installation, _ := s.pluginManager.installedByKey(codexRuntimePluginKey) - store, ok := s.pluginManager.repo.(PluginExtensionStateStore) + installation := s.nativeCodexRuntime.metadata() + store := s.nativeCodexRuntime.repo + ok := store != nil if installation == nil || !ok { return } @@ -167,33 +168,34 @@ func (s *OpenAIGatewayService) observeCodexWire(ctx context.Context, account *Ac value.CookieVersions[cookie.Name] = codexRoutingDigest(strconv.FormatInt(account.ID, 10), cookie.Name, cookie.Value)[:16] } } - ctx = WithPluginExecution(context.WithoutCancel(ctx), installation) + ctx = WithNativeCodexExecution(context.WithoutCancel(ctx), installation) ctx, cancel := context.WithTimeout(ctx, time.Second) defer cancel() key := "wire." + strconv.FormatInt(account.ID, 10) - previous, err := store.ReadExtensionState(ctx, installation.PluginKey, extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: key}) + previous, err := store.ReadExtensionState(ctx, NativeCodexPluginKey, extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: key}) if err != nil { return } raw, _ := json.Marshal(value) - _, _ = store.CompareSwapExtensionState(ctx, installation.PluginKey, extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: key, ExpectedRevision: previous.Revision, Value: raw}) + _, _ = store.CompareSwapExtensionState(ctx, NativeCodexPluginKey, extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: key, ExpectedRevision: previous.Revision, Value: raw}) } // CodexFingerprint is read-only: opening an account never refreshes OAuth, // acquires cookies, pings a proxy, or sends a model request. func (s *OpenAIGatewayService) CodexFingerprint(ctx context.Context, id int64) (*CodexFingerprintView, error) { a, err := s.accountRepo.GetByID(ctx, id) - if err != nil || !isOpenAICodexTicketAccount(a) || s.pluginManager == nil { + if err != nil || !isOpenAICodexTicketAccount(a) || s.nativeCodexRuntime == nil { return nil, errCodexRoutingUnavailable } - installation, _ := s.pluginManager.installedByKey(codexRuntimePluginKey) - store, ok := s.pluginManager.repo.(PluginExtensionStateStore) + installation := s.nativeCodexRuntime.metadata() + store := s.nativeCodexRuntime.repo + ok := store != nil if installation == nil || !ok { return nil, errCodexRoutingUnavailable } view := &CodexFingerprintView{AccountID: id, Configured: resolveCodexIdentitySnapshotContext(ctx, a, a, codexAccountIdentityOverrideUA(a)), ReferenceCommit: "4b664e0ef0397f82e68c60088a90fcd035deb796", ReferenceSource: "official_source", Alignment: "application_fields_checked_tls_capture_unknown", CookieMaxAgeSeconds: 120, RefreshLeadSeconds: 20, Models: []CodexRoutingModelStatus{}} currentScope, scopeErr := s.PrepareCodexRoutingScope(ctx, id, "http") - wire, err := store.ReadExtensionState(ctx, installation.PluginKey, extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: "wire." + strconv.FormatInt(id, 10)}) + wire, err := store.ReadExtensionState(ctx, NativeCodexPluginKey, extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: "wire." + strconv.FormatInt(id, 10)}) if err != nil { return nil, err } @@ -205,7 +207,7 @@ func (s *OpenAIGatewayService) CodexFingerprint(ctx context.Context, id int64) ( } for _, model := range s.openAICodexTicketConfig().Models { status := CodexRoutingModelStatus{Model: model, Phase: "idle"} - record, readErr := store.ReadExtensionState(ctx, installation.PluginKey, extensionv1.StateRequest{Namespace: "tickets", Key: strconv.FormatInt(id, 10) + "." + codexRoutingDigest(model)}) + record, readErr := store.ReadExtensionState(ctx, NativeCodexPluginKey, extensionv1.StateRequest{Namespace: "tickets", Key: strconv.FormatInt(id, 10) + "." + codexRoutingDigest(model)}) if readErr != nil { return nil, readErr } diff --git a/backend/internal/service/codex_routing_fingerprint_test.go b/backend/internal/service/codex_routing_fingerprint_test.go index 952c5c2dd9f5..65c1b8734b6f 100644 --- a/backend/internal/service/codex_routing_fingerprint_test.go +++ b/backend/internal/service/codex_routing_fingerprint_test.go @@ -8,16 +8,16 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" ) func TestCodexFingerprintShowsCookieDigestsAndOriginalVerificationTime(t *testing.T) { account := ticketTestAccount(7) - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{Enabled: true, Models: []string{"gpt-6-astra"}}, nil) + manager := nativeTicketTestRuntime(t, config.OpenAICodexTicketConfig{Enabled: true, Models: []string{"gpt-6-astra"}}, nil) store := &routingMemoryStore{values: map[string]extensionv1.StateResult{}} manager.repo = store - service := &OpenAIGatewayService{pluginManager: manager, accountRepo: &routingAccountRepositoryFixture{account: account}} + service := &OpenAIGatewayService{nativeCodexRuntime: manager, accountRepo: &routingAccountRepositoryFixture{account: account}} scope, err := service.PrepareCodexRoutingScope(context.Background(), 7, "http") require.NoError(t, err) verified := time.Now().UTC().Add(-20 * time.Second).Truncate(time.Second) diff --git a/backend/internal/service/codex_routing_host.go b/backend/internal/service/codex_routing_host.go index 587450ec4507..7a4dd6a5eeab 100644 --- a/backend/internal/service/codex_routing_host.go +++ b/backend/internal/service/codex_routing_host.go @@ -12,7 +12,7 @@ import ( "strings" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/google/uuid" ) @@ -29,7 +29,7 @@ func isCodexRoutingHostOperation(operation extensionv1.HostOperation) bool { return operation == extensionv1.HostCodexRoutingScope || operation == extensionv1.HostCodexRoutingProbe || operation == extensionv1.HostCodexRoutingCheck || operation == extensionv1.HostCodexRoutingCleanup } -func (h *pluginExtensionHost) callCodexRouting(ctx context.Context, in extensionv1.HostInvocation) (extensionv1.Result, error) { +func (h *nativeCodexHost) callCodexRouting(ctx context.Context, in extensionv1.HostInvocation) (extensionv1.Result, error) { if in.Operation == extensionv1.HostCodexRoutingCleanup { return h.redactExpiredCodexRoutingMaterial(ctx) } @@ -241,7 +241,7 @@ func (h *pluginExtensionHost) callCodexRouting(ctx context.Context, in extension return marshal(result) } -func readCodexRoutingBundle(ctx context.Context, store PluginExtensionStateStore, key string, ref extensionv1.CodexRoutingBundleRef, scope extensionv1.CodexRoutingScope, qualified bool) (codexRoutingPrivateBundle, error) { +func readCodexRoutingBundle(ctx context.Context, store NativeCodexStateStore, key string, ref extensionv1.CodexRoutingBundleRef, scope extensionv1.CodexRoutingScope, qualified bool) (codexRoutingPrivateBundle, error) { var bundle codexRoutingPrivateBundle if store == nil || !strings.HasPrefix(ref.Key, "bundle.") || len(ref.Key) > 80 || ref.Revision <= 0 || !time.Now().Before(ref.ExpiresAt) { return bundle, errCodexRoutingUnavailable diff --git a/backend/internal/service/codex_routing_infrastructure.go b/backend/internal/service/codex_routing_infrastructure.go index aa111fe0e3e7..542601247aec 100644 --- a/backend/internal/service/codex_routing_infrastructure.go +++ b/backend/internal/service/codex_routing_infrastructure.go @@ -8,7 +8,7 @@ import ( "sync" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "golang.org/x/net/publicsuffix" ) diff --git a/backend/internal/service/codex_routing_native_ws.go b/backend/internal/service/codex_routing_native_ws.go index 5a1dc8ba0153..1b475e9eb9a8 100644 --- a/backend/internal/service/codex_routing_native_ws.go +++ b/backend/internal/service/codex_routing_native_ws.go @@ -6,8 +6,8 @@ import ( "encoding/json" "net/http" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" openaiwsv2 "github.com/Wei-Shaw/sub2api/internal/service/openai_ws_v2" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" coderws "github.com/coder/websocket" "github.com/tidwall/gjson" ) @@ -23,7 +23,7 @@ func (s *OpenAIGatewayService) guardCodexRoutingNativeModel(account *Account, mo } func (s *OpenAIGatewayService) observeNativeCodexWS(ctx context.Context, account *Account, headers, handshake http.Header, payload []byte, connection string) { - if !isOpenAICodexTicketAccount(account) || s.pluginManager == nil { + if !isOpenAICodexTicketAccount(account) || s.nativeCodexRuntime == nil { return } request, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://chatgpt.com/backend-api/codex/responses", bytes.NewReader(payload)) diff --git a/backend/internal/service/codex_routing_profile.go b/backend/internal/service/codex_routing_profile.go index 84f1a1eb4f5a..18965660e3fc 100644 --- a/backend/internal/service/codex_routing_profile.go +++ b/backend/internal/service/codex_routing_profile.go @@ -4,7 +4,7 @@ import ( "context" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) type CodexProfileSelection struct { diff --git a/backend/internal/service/codex_routing_proxy.go b/backend/internal/service/codex_routing_proxy.go index 3ce55371f18b..d1c948acd4e1 100644 --- a/backend/internal/service/codex_routing_proxy.go +++ b/backend/internal/service/codex_routing_proxy.go @@ -9,8 +9,8 @@ import ( "net/url" "time" - proxytransport "github.com/Wei-Shaw/sub2api/pkg/extensionapi/proxy" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + proxytransport "github.com/Wei-Shaw/sub2api/internal/proxytransport" ) // This is the existing explicit proxy.test record. Only acquisition reads it; @@ -21,20 +21,21 @@ type codexRoutingProxyTrust struct { } func (s *OpenAIGatewayService) doCodexRoutingAcquisition(request *http.Request, proxyURL string, accountID int64) (*http.Response, error) { - if request == nil || request.URL == nil || request.Method != http.MethodPost || request.URL.Scheme != "https" || request.URL.Host != "chatgpt.com" || request.URL.Path != "/backend-api/codex/responses" || request.URL.RawQuery != "" || request.URL.User != nil || s.pluginManager == nil { + if request == nil || request.URL == nil || request.Method != http.MethodPost || request.URL.Scheme != "https" || request.URL.Host != "chatgpt.com" || request.URL.Path != "/backend-api/codex/responses" || request.URL.RawQuery != "" || request.URL.User != nil || s.nativeCodexRuntime == nil { return nil, errCodexRoutingUnavailable } normal, err := proxytransport.Normalize(proxyURL) if err != nil || normal == "" { return nil, errCodexRoutingUnavailable } - installation, _ := s.pluginManager.installedByKey(codexRuntimePluginKey) - store, ok := s.pluginManager.repo.(PluginExtensionStateStore) + installation := s.nativeCodexRuntime.metadata() + store := s.nativeCodexRuntime.repo + ok := store != nil if installation == nil || !ok { return nil, errCodexRoutingUnavailable } - ctx := WithPluginExecution(request.Context(), installation) - record, err := store.ReadExtensionState(ctx, installation.PluginKey, extensionv1.StateRequest{Namespace: "proxy-trust", Key: codexRoutingDigest(normal, "chatgpt.com")}) + ctx := WithNativeCodexExecution(request.Context(), installation) + record, err := store.ReadExtensionState(ctx, NativeCodexPluginKey, extensionv1.StateRequest{Namespace: "proxy-trust", Key: codexRoutingDigest(normal, "chatgpt.com")}) if err != nil { return nil, errCodexRoutingUnavailable } diff --git a/backend/internal/service/codex_routing_proxy_test.go b/backend/internal/service/codex_routing_proxy_test.go index 39ef0ce1bf51..6889b9bc9332 100644 --- a/backend/internal/service/codex_routing_proxy_test.go +++ b/backend/internal/service/codex_routing_proxy_test.go @@ -22,7 +22,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" ) @@ -113,9 +113,9 @@ func TestCodexRoutingAcquisitionUsesOnlyExplicitProxyTrust(t *testing.T) { require.NoError(t, err) t.Cleanup(transport.CloseIdleConnections) store := &routingMemoryStore{values: map[string]extensionv1.StateResult{}} - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{}, nil) + manager := nativeTicketTestRuntime(t, config.OpenAICodexTicketConfig{}, nil) manager.repo = store - service := &OpenAIGatewayService{pluginManager: manager, httpUpstream: &routingProxySystemUpstream{client: &http.Client{Transport: transport, Timeout: 3 * time.Second}}} + service := &OpenAIGatewayService{nativeCodexRuntime: manager, httpUpstream: &routingProxySystemUpstream{client: &http.Client{Transport: transport, Timeout: 3 * time.Second}}} send := func() (*http.Response, error) { request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://chatgpt.com/backend-api/codex/responses", strings.NewReader(`{"model":"synthetic-model"}`)) require.NoError(t, err) diff --git a/backend/internal/service/codex_routing_redaction.go b/backend/internal/service/codex_routing_redaction.go index e324f91fff57..06ee8074f1b3 100644 --- a/backend/internal/service/codex_routing_redaction.go +++ b/backend/internal/service/codex_routing_redaction.go @@ -7,7 +7,7 @@ import ( "strings" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func fixedCodexRoutingBundleKey(ctx context.Context, query extensionv1.CodexRoutingQuery, kind string) string { @@ -36,7 +36,7 @@ func codexCookieClockDeadline(clock codexRoutingCookieClock) *time.Time { // Only expired raw values in this domain's private namespace are redacted. // No row, observation, first-seen digest, tombstone or ledger is deleted. -func (h *pluginExtensionHost) redactExpiredCodexRoutingMaterial(ctx context.Context) (extensionv1.Result, error) { +func (h *nativeCodexHost) redactExpiredCodexRoutingMaterial(ctx context.Context) (extensionv1.Result, error) { if h.key != codexRuntimePluginKey || h.state == nil { return extensionv1.Result{}, errCodexRoutingUnavailable } diff --git a/backend/internal/service/codex_routing_test.go b/backend/internal/service/codex_routing_test.go index b22e7da6469f..d88b1637ff23 100644 --- a/backend/internal/service/codex_routing_test.go +++ b/backend/internal/service/codex_routing_test.go @@ -14,7 +14,7 @@ import ( "testing" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -96,16 +96,9 @@ func (r *routingAccountRepositoryFixture) GetByID(context.Context, int64) (*Acco func TestCodexRoutingValidationDoesNotEnrollOrChangeStoppedState(t *testing.T) { host, directory, store := routingHostFixture() - installation := host.installation - installation.ID, installation.State, installation.Version = 1, PluginStateEnabled, "0.2.7" - installation.Manifest.Requires.ExtensionAPI = 1 - installation.Bindings = []PluginBinding{{Capability: extensionv1.CapabilityCredentials, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth, Enabled: true, RolloutPercent: 100}} - store.PluginRepository = &pluginTokenRepository{installation: installation} - manager := NewPluginManager(store, nil, nil, PluginHostInfo{}, newFakePluginKVStore()) - manager.accountDirectory = directory - manager.extensions.Store(&pluginExtensionRegistry{installations: map[int64]*PluginInstallation{1: installation}, runtimes: map[int64]*pluginRuntime{}}) + manager := nativeRoutingFixtureRuntime(host, store) account := &Account{ID: 7, Platform: PlatformOpenAI, Type: AccountTypeOAuth} - service := &OpenAIGatewayService{pluginManager: manager, accountRepo: &routingAccountRepositoryFixture{account: account}} + service := &OpenAIGatewayService{nativeCodexRuntime: manager, accountRepo: &routingAccountRepositoryFixture{account: account}} legacyKey := "tickets:7." + codexRoutingDigest("gpt-6-astra") stopped := json.RawMessage(`{"schema":2,"phase":"stopped","enrolled":true,"failures":2}`) store.values[legacyKey] = extensionv1.StateResult{Found: true, Revision: 8, Value: stopped} @@ -155,11 +148,12 @@ func (d *routingHostDirectoryFixture) ExecuteCodexRoutingProbe(_ context.Context return &http.Response{StatusCode: 200, Header: headers, Body: io.NopCloser(strings.NewReader(d.response))}, scope, nil } -func routingHostFixture() (*pluginExtensionHost, *routingHostDirectoryFixture, *routingMemoryStore) { +func routingHostFixture() (*nativeCodexHost, *routingHostDirectoryFixture, *routingMemoryStore) { directory := &routingHostDirectoryFixture{account: extensionv1.Account{ID: 7, Identity: "owner", Platform: PlatformOpenAI, Type: AccountTypeOAuth}, scope: extensionv1.CodexRoutingScope{AccountID: 7, Identity: "owner", RouteHash: "route", ProfileHash: "profile", Transport: "http"}, response: "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"model\":\"gpt-6-astra\"}}\n\n"} store := &routingMemoryStore{values: map[string]extensionv1.StateResult{}} - installation := &PluginInstallation{PluginKey: codexRuntimePluginKey, Manifest: PluginManifest{Capabilities: []PluginCapability{{ID: extensionv1.CapabilityCredentials, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth}}}} - return &pluginExtensionHost{key: codexRuntimePluginKey, state: store, directory: directory, installation: installation}, directory, store + metadata := nativeCodexTestMetadata() + return &nativeCodexHost{key: codexRuntimePluginKey, state: store, repo: store, directory: directory, metadata: metadata, epoch: context.Background()}, directory, store + } func TestCodexRoutingCookieScopeAgeDeletionAndColdCandidate(t *testing.T) { @@ -326,7 +320,7 @@ func TestCodexRoutingTurnStateCannotCrossLogicalTurn(t *testing.T) { func TestCodexRoutingGenericCookieHeadersStayProtected(t *testing.T) { for _, name := range []string{"Cookie", "Set-Cookie"} { - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{Enabled: true, FailClosed: true}, func(extensionv1.Invocation) (extensionv1.Result, error) { + manager := nativeTicketTestRuntime(t, config.OpenAICodexTicketConfig{Enabled: true, FailClosed: true}, func(extensionv1.Invocation) (extensionv1.Result, error) { raw, _ := json.Marshal(extensionv1.CodexRoutingInjection{Headers: map[string]string{name: "__cflb=not-host-owned"}}) return extensionv1.Result{Payload: raw}, nil }) @@ -375,8 +369,8 @@ func TestCodexRoutingDeviceChangeInvalidatesQualifiedScopeBeforeSend(t *testing. func TestCodexRoutingAnotherModelClockDoesNotBlockOwnCookieRefresh(t *testing.T) { store := &routingMemoryStore{values: map[string]extensionv1.StateResult{}} - installation := &PluginInstallation{PluginKey: codexRuntimePluginKey} - service := &OpenAIGatewayService{pluginManager: &PluginManager{repo: store}} + installation := nativeCodexTestMetadata() + service := &OpenAIGatewayService{nativeCodexRuntime: &NativeCodexRuntime{repo: store}} scope := extensionv1.CodexRoutingScope{AccountID: 7, Identity: "owner", ProfileHash: "profile", RouteHash: "route", ConnectionLeaseID: "connection-astra", Transport: "http"} now := time.Now().Add(-time.Second).UTC() expiry := now.Add(time.Minute) @@ -424,11 +418,11 @@ func TestCodexRoutingAnotherModelClockDoesNotBlockOwnCookieRefresh(t *testing.T) func TestCodexRoutingObservesOrdinaryHTTPAndNativeFramesWithoutCookies(t *testing.T) { account := ticketTestAccount(7) - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{Enabled: false}, nil) + manager := nativeTicketTestRuntime(t, config.OpenAICodexTicketConfig{Enabled: false}, nil) store := &routingMemoryStore{values: map[string]extensionv1.StateResult{}} manager.repo = store upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: 200, Proto: "HTTP/2.0", Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`))}} - service := &OpenAIGatewayService{httpUpstream: upstream, pluginManager: manager} + service := &OpenAIGatewayService{httpUpstream: upstream, nativeCodexRuntime: manager} request, _ := http.NewRequest("POST", "https://chatgpt.com/backend-api/codex/responses", strings.NewReader(`{"model":"gpt-5.5","input":"private-prompt","client_metadata":{"session_id":"session"}}`)) request.Header.Set("Authorization", "Bearer private-token") request.Header.Set("User-Agent", "actual-wire-agent") @@ -480,9 +474,9 @@ func TestCodexRoutingUnchangedCookiesDoNotGrowAndExpiryRedactsValues(t *testing. q := &extensionv1.CodexRoutingQualification{Scope: scope, Model: "gpt-6-astra", VerifiedAt: now, ExpiresAt: expires, Bundle: extensionv1.CodexRoutingBundleRef{Key: bundleKey, Revision: 1, ExpiresAt: expires, ConnectionLeaseID: scope.ConnectionLeaseID}} _, err = readCodexRoutingBundle(context.Background(), store, codexRuntimePluginKey, q.Bundle, scope, true) require.NoError(t, err, "nil refresh must mean unchanged Cookies, not an invalid fixture reference") - service := &OpenAIGatewayService{pluginManager: &PluginManager{repo: store}} + service := &OpenAIGatewayService{nativeCodexRuntime: &NativeCodexRuntime{repo: store}} before := len(store.values) - updated := service.refreshObservedCodexCookies(context.Background(), &PluginInstallation{PluginKey: codexRuntimePluginKey}, q, http.Header{"Set-Cookie": []string{"__cflb=temporary-private-value; Path=/; Secure; Max-Age=100"}}, extensionv1.CodexRoutingObservation{ObservedAt: now}) + updated := service.refreshObservedCodexCookies(context.Background(), nativeCodexTestMetadata(), q, http.Header{"Set-Cookie": []string{"__cflb=temporary-private-value; Path=/; Secure; Max-Age=100"}}, extensionv1.CodexRoutingObservation{ObservedAt: now}) require.Nil(t, updated) require.Len(t, store.values, before) require.EqualValues(t, 1, store.values[codexRoutingPrivateNamespace+":"+clockKey].Revision) @@ -498,7 +492,7 @@ func TestCodexRoutingUnchangedCookiesDoNotGrowAndExpiryRedactsValues(t *testing. require.NoError(t, err) _, err = store.CompareSwapExtensionState(context.Background(), codexRuntimePluginKey, extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: "spent.keep", Value: json.RawMessage(`{"spent":true}`)}) require.NoError(t, err) - host := &pluginExtensionHost{key: codexRuntimePluginKey, state: store} + host := &nativeCodexHost{key: codexRuntimePluginKey, state: store} _, err = host.redactExpiredCodexRoutingMaterial(context.Background()) require.NoError(t, err) require.Len(t, store.values, before+1) diff --git a/backend/internal/service/codex_routing_transport.go b/backend/internal/service/codex_routing_transport.go index f0f44a7249d9..7a6e5c3d20b1 100644 --- a/backend/internal/service/codex_routing_transport.go +++ b/backend/internal/service/codex_routing_transport.go @@ -11,7 +11,7 @@ import ( "sync" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) type codexRoutingModelKey struct{} @@ -28,13 +28,13 @@ func (s *OpenAIGatewayService) codexRoutingApplies(account *Account, model strin return cfg.Enabled && isOpenAICodexTicketAccount(account) && slices.Contains(cfg.Models, model) } -func (m *PluginManager) codexRoutingQualification(ctx context.Context, account *Account, model string) (*extensionv1.CodexRoutingQualification, *PluginInstallation, error) { - installation, _ := m.installedByKey(codexRuntimePluginKey) +func (m *NativeCodexRuntime) codexRoutingQualification(ctx context.Context, account *Account, model string) (*extensionv1.CodexRoutingQualification, *NativeCodexMetadata, error) { + installation := m.metadata() if installation == nil { return nil, nil, errCodexRoutingUnavailable } raw, _ := json.Marshal(extensionv1.SchedulingRequest{Account: *extensionAccount(account), Model: model, Now: time.Now().UTC()}) - result, err := m.InvokeExtension(ctx, installation.ID, account.Platform, account.Type, extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "inject", AccountID: account.ID, Payload: raw}) + result, err := m.Invoke(ctx, account.Platform, account.Type, extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "inject", AccountID: account.ID, Payload: raw}) var injection extensionv1.CodexRoutingInjection if err != nil || result.Code != "" || json.Unmarshal(result.Payload, &injection) != nil || !injection.Qualification.Valid(time.Now(), account.ID, CodexTicketAccountIdentity(account), model) { return nil, installation, errCodexRoutingUnavailable @@ -42,17 +42,17 @@ func (m *PluginManager) codexRoutingQualification(ctx context.Context, account * return injection.Qualification, installation, nil } -func (s *OpenAIGatewayService) prepareQualifiedCodexRequest(request *http.Request, account *Account, model string) (*http.Request, *extensionv1.CodexRoutingQualification, *PluginInstallation, error) { +func (s *OpenAIGatewayService) prepareQualifiedCodexRequest(request *http.Request, account *Account, model string) (*http.Request, *extensionv1.CodexRoutingQualification, *NativeCodexMetadata, error) { if request.URL == nil || request.URL.Scheme != "https" || request.URL.Hostname() != "chatgpt.com" || (request.URL.Path != "/backend-api/codex/responses" && request.URL.Path != "/backend-api/codex/responses/compact") { return request, nil, nil, errCodexRoutingUnavailable } var q *extensionv1.CodexRoutingQualification - var installation *PluginInstallation + var installation *NativeCodexMetadata var err error if IsCodexQualityRequest(request.Context()) { q, installation, err = codexQualityRequestQualification(request.Context(), account, model) } else { - q, installation, err = s.pluginManager.codexRoutingQualification(request.Context(), account, model) + q, installation, err = s.nativeCodexRuntime.codexRoutingQualification(request.Context(), account, model) } if err != nil { return request, nil, installation, err @@ -61,8 +61,8 @@ func (s *OpenAIGatewayService) prepareQualifiedCodexRequest(request *http.Reques if err != nil || !q.Scope.SameOwner(scope) || q.Scope.Transport != "http" || q.Scope.ConnectionLeaseID == "" { return request, q, installation, errCodexRoutingUnavailable } - store, _ := s.pluginManager.repo.(PluginExtensionStateStore) - bundle, err := readCodexRoutingBundle(request.Context(), store, installation.PluginKey, q.Bundle, scope, true) + store := s.nativeCodexRuntime.repo + bundle, err := readCodexRoutingBundle(request.Context(), store, NativeCodexPluginKey, q.Bundle, scope, true) if err != nil || bundle.Model != model || bundle.Scope.ConnectionLeaseID != q.Scope.ConnectionLeaseID || bundle.Scope.Transport != q.Scope.Transport || q.ExpiresAt.After(bundle.ExpiresAt) { return request, q, installation, errCodexRoutingUnavailable } @@ -76,7 +76,7 @@ func (s *OpenAIGatewayService) prepareQualifiedCodexRequest(request *http.Reques return wire, q, installation, nil } -func (s *OpenAIGatewayService) doQualifiedCodexUpstream(request *http.Request, account *Account, proxyURL string) (*http.Response, bool, error) { +func (s *OpenAIGatewayService) doQualifiedCodexUpstream(request *http.Request, account *Account, proxyURL string) (response *http.Response, handled bool, returnErr error) { model, _ := request.Context().Value(codexRoutingModelKey{}).(string) if !s.codexRoutingApplies(account, model) { return nil, false, nil @@ -88,6 +88,18 @@ func (s *OpenAIGatewayService) doQualifiedCodexUpstream(request *http.Request, a } return nil, true, err } + bound, release, bindErr := s.nativeCodexRuntime.bindMetadata(wire.Context(), installation) + if bindErr != nil { + return nil, true, bindErr + } + wire = wire.WithContext(bound) + defer func() { + if response != nil && response.Body != nil && returnErr == nil { + response.Body = &nativeCodexLeaseBody{body: response.Body, release: release} + } else { + release() + } + }() transport, ok := s.httpUpstream.(CodexConnectionLeaseUpstream) if !ok { return nil, true, errCodexRoutingUnavailable @@ -96,7 +108,7 @@ func (s *OpenAIGatewayService) doQualifiedCodexUpstream(request *http.Request, a if err := reserveCodexQualitySend(wire, account, q); err != nil { return nil, true, err } - response, _, err := transport.DoWithCodexConnectionLease(wire, proxyURL, account.ID, codexRoutingScopeKey(q.Scope), q.Scope.ConnectionLeaseID, q.ExpiresAt, nil) + response, _, err = transport.DoWithCodexConnectionLease(wire, proxyURL, account.ID, codexRoutingScopeKey(q.Scope), q.Scope.ConnectionLeaseID, q.ExpiresAt, nil) if IsCodexQualityRequest(request.Context()) { if response != nil { observeCodexInfrastructureCookies(q.Scope, response.Header, q.ExpiresAt) @@ -278,25 +290,26 @@ func (body *codexRoutingObservedBody) Close() error { return err } -func (s *OpenAIGatewayService) publishCodexRoutingObservation(ctx context.Context, account *Account, installation *PluginInstallation, q *extensionv1.CodexRoutingQualification, observation extensionv1.CodexRoutingObservation, replacement *extensionv1.CodexRoutingQualification) { - if s.pluginManager == nil || installation == nil || q == nil { +func (s *OpenAIGatewayService) publishCodexRoutingObservation(ctx context.Context, account *Account, installation *NativeCodexMetadata, q *extensionv1.CodexRoutingQualification, observation extensionv1.CodexRoutingObservation, replacement *extensionv1.CodexRoutingQualification) { + if s.nativeCodexRuntime == nil || installation == nil || q == nil { return } call, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second) defer cancel() raw, _ := json.Marshal(extensionv1.CodexRoutingResponse{AccountID: account.ID, Identity: CodexTicketAccountIdentity(account), Model: q.Model, Qualification: *q, Observation: observation, Replacement: replacement}) - _, _ = s.pluginManager.InvokeExtension(call, installation.ID, account.Platform, account.Type, extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "codex.routing.observe", AccountID: account.ID, Payload: raw}) + _, _ = s.nativeCodexRuntime.Invoke(WithNativeCodexExecution(call, installation), account.Platform, account.Type, extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "codex.routing.observe", AccountID: account.ID, Payload: raw}) } -func (s *OpenAIGatewayService) refreshObservedCodexCookies(ctx context.Context, installation *PluginInstallation, q *extensionv1.CodexRoutingQualification, headers http.Header, observation extensionv1.CodexRoutingObservation) *extensionv1.CodexRoutingQualification { - store, ok := s.pluginManager.repo.(PluginExtensionStateStore) +func (s *OpenAIGatewayService) refreshObservedCodexCookies(ctx context.Context, installation *NativeCodexMetadata, q *extensionv1.CodexRoutingQualification, headers http.Header, observation extensionv1.CodexRoutingObservation) *extensionv1.CodexRoutingQualification { + store := s.nativeCodexRuntime.repo + ok := store != nil if !ok { return nil } - ctx = WithPluginExecution(context.WithoutCancel(ctx), installation) + ctx = WithNativeCodexExecution(context.WithoutCancel(ctx), installation) ctx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() - bundle, err := readCodexRoutingBundle(ctx, store, installation.PluginKey, q.Bundle, q.Scope, true) + bundle, err := readCodexRoutingBundle(ctx, store, NativeCodexPluginKey, q.Bundle, q.Scope, true) if err != nil { return nil } @@ -306,7 +319,7 @@ func (s *OpenAIGatewayService) refreshObservedCodexCookies(ctx context.Context, if len(changes) == 0 { return nil } - record, err := store.ReadExtensionState(ctx, installation.PluginKey, extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: bundle.ClockKey}) + record, err := store.ReadExtensionState(ctx, NativeCodexPluginKey, extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: bundle.ClockKey}) var clock codexRoutingCookieClock if err != nil || !record.Found || json.Unmarshal(record.Value, &clock) != nil || !clock.Scope.SameOwner(q.Scope) { return nil @@ -316,12 +329,12 @@ func (s *OpenAIGatewayService) refreshObservedCodexCookies(ctx context.Context, return nil } } - if err := preserveCodexCookieFirstSeen(ctx, store, installation.PluginKey, q.Scope, &clock, changes, observation.ObservedAt); err != nil { + if err := preserveCodexCookieFirstSeen(ctx, store, NativeCodexPluginKey, q.Scope, &clock, changes, observation.ObservedAt); err != nil { return nil } clock.apply(changes, observation.ObservedAt) raw, _ := json.Marshal(clock) - saved, err := store.CompareSwapExtensionState(ctx, installation.PluginKey, extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: bundle.ClockKey, ExpectedRevision: record.Revision, Value: raw, NextAt: codexCookieClockDeadline(clock)}) + saved, err := store.CompareSwapExtensionState(ctx, NativeCodexPluginKey, extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: bundle.ClockKey, ExpectedRevision: record.Revision, Value: raw, NextAt: codexCookieClockDeadline(clock)}) if err != nil || !saved.Applied { return nil } @@ -335,7 +348,7 @@ func (s *OpenAIGatewayService) refreshObservedCodexCookies(ctx context.Context, bundle.ExpiresAt = earlierCodexTime(expiry, q.ExpiresAt) raw, _ = json.Marshal(bundle) key := q.Bundle.Key - next, err := store.CompareSwapExtensionState(ctx, installation.PluginKey, extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: key, ExpectedRevision: q.Bundle.Revision, Value: raw, NextAt: &bundle.ExpiresAt}) + next, err := store.CompareSwapExtensionState(ctx, NativeCodexPluginKey, extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: key, ExpectedRevision: q.Bundle.Revision, Value: raw, NextAt: &bundle.ExpiresAt}) if err != nil || !next.Applied { return nil } @@ -345,7 +358,7 @@ func (s *OpenAIGatewayService) refreshObservedCodexCookies(ctx context.Context, return &replacement } -func (s *OpenAIGatewayService) recordCodexRoutingDeletions(ctx context.Context, installation *PluginInstallation, q *extensionv1.CodexRoutingQualification, headers http.Header, now time.Time) bool { +func (s *OpenAIGatewayService) recordCodexRoutingDeletions(ctx context.Context, installation *NativeCodexMetadata, q *extensionv1.CodexRoutingQualification, headers http.Header, now time.Time) bool { target, _ := http.NewRequest(http.MethodGet, "https://chatgpt.com/backend-api/codex/responses", nil) var deletions []codexRoutingCookieChange for _, change := range parseCodexRoutingCookies(headers, target.URL, now) { @@ -356,23 +369,24 @@ func (s *OpenAIGatewayService) recordCodexRoutingDeletions(ctx context.Context, if len(deletions) == 0 { return false } - store, ok := s.pluginManager.repo.(PluginExtensionStateStore) + store := s.nativeCodexRuntime.repo + ok := store != nil if !ok { return true } - ctx = WithPluginExecution(context.WithoutCancel(ctx), installation) + ctx = WithNativeCodexExecution(context.WithoutCancel(ctx), installation) ctx, cancel := context.WithTimeout(ctx, time.Second) defer cancel() key := codexQualityClockKey(ctx, "clock."+codexRoutingScopeKey(q.Scope)) for range 3 { - record, err := store.ReadExtensionState(ctx, installation.PluginKey, extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: key}) + record, err := store.ReadExtensionState(ctx, NativeCodexPluginKey, extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: key}) var clock codexRoutingCookieClock if err != nil || !record.Found || json.Unmarshal(record.Value, &clock) != nil { break } clock.apply(deletions, now) raw, _ := json.Marshal(clock) - result, err := store.CompareSwapExtensionState(ctx, installation.PluginKey, extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: key, ExpectedRevision: record.Revision, Value: raw, NextAt: codexCookieClockDeadline(clock)}) + result, err := store.CompareSwapExtensionState(ctx, NativeCodexPluginKey, extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: key, ExpectedRevision: record.Revision, Value: raw, NextAt: codexCookieClockDeadline(clock)}) if err != nil || result.Applied { break } diff --git a/backend/internal/service/codex_routing_validation.go b/backend/internal/service/codex_routing_validation.go index 46401cfe4b2f..43a823eff2b8 100644 --- a/backend/internal/service/codex_routing_validation.go +++ b/backend/internal/service/codex_routing_validation.go @@ -7,7 +7,7 @@ import ( "slices" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/google/uuid" ) @@ -51,7 +51,7 @@ func codexValidationFromContext(ctx context.Context) (codexValidationContext, bo return value, ok } -func consumeCodexValidationBudget(ctx context.Context, store PluginExtensionStateStore, plugin string, query extensionv1.CodexRoutingQuery) error { +func consumeCodexValidationBudget(ctx context.Context, store NativeCodexStateStore, plugin string, query extensionv1.CodexRoutingQuery) error { validation, ok := codexValidationFromContext(ctx) if !ok { return nil @@ -94,28 +94,30 @@ func consumeCodexValidationBudget(ctx context.Context, store PluginExtensionStat } func (s *OpenAIGatewayService) ValidateCodexRouting(ctx context.Context, id int64, request CodexRoutingValidationRequest) (*CodexRoutingValidationResult, error) { - if uuid.Validate(request.ValidationID) != nil || !slices.Contains(codexValidationModels, request.Model) || (request.Transport != "http" && request.Transport != "ws") || s.pluginManager == nil { + if uuid.Validate(request.ValidationID) != nil || !slices.Contains(codexValidationModels, request.Model) || (request.Transport != "http" && request.Transport != "ws") || s.nativeCodexRuntime == nil { return nil, errCodexRoutingUnavailable } account, err := s.accountRepo.GetByID(ctx, id) if err != nil || !isOpenAICodexTicketAccount(account) { return nil, errCodexRoutingUnavailable } - installation, _ := s.pluginManager.installedByKey(codexRuntimePluginKey) - store, ok := s.pluginManager.repo.(PluginExtensionStateStore) + installation := s.nativeCodexRuntime.metadata() + store := s.nativeCodexRuntime.repo + ok := store != nil if installation == nil || !ok { return nil, errCodexRoutingUnavailable } - server, ok := s.pluginManager.buildHostServices(installation).(*pluginHostServiceServer) - if !ok || server.extension == nil { + snapshot := s.nativeCodexRuntime.current() + if snapshot == nil { return nil, errCodexRoutingUnavailable } - ctx = WithPluginExecution(ctx, installation) + + ctx = WithNativeCodexExecution(ctx, installation) ctx = context.WithValue(ctx, codexValidationContextKey{}, codexValidationContext{ID: request.ValidationID, AccountID: id, Model: request.Model, Transport: request.Transport}) ctx, cancel := context.WithTimeout(ctx, 60*time.Second) defer cancel() lease := extensionv1.LeaseRequest{Namespace: "routing-account", Key: fmt.Sprint(id), Owner: uuid.NewString(), TTLSeconds: 75} - locked, err := store.AcquireExtensionLease(ctx, installation.PluginKey, lease) + locked, err := store.AcquireExtensionLease(ctx, NativeCodexPluginKey, lease) if err != nil || !locked.Acquired { return nil, ErrCodexTicketBusy } @@ -123,12 +125,12 @@ func (s *OpenAIGatewayService) ValidateCodexRouting(ctx context.Context, id int6 defer func() { release, stop := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second) defer stop() - _, _ = store.ReleaseExtensionLease(release, installation.PluginKey, lease) + _, _ = store.ReleaseExtensionLease(release, NativeCodexPluginKey, lease) }() result := &CodexRoutingValidationResult{ValidationID: request.ValidationID, AccountID: id, Model: request.Model, Transport: request.Transport, BudgetLimit: 7, Observations: []extensionv1.CodexRoutingObservation{}} call := func(query extensionv1.CodexRoutingQuery) (extensionv1.CodexRoutingProbeResult, error) { raw, _ := json.Marshal(query) - response, err := server.extension.Call(ctx, extensionv1.HostInvocation{Operation: extensionv1.HostCodexRoutingProbe, Payload: raw}) + response, err := snapshot.host.Call(ctx, extensionv1.HostInvocation{Operation: extensionv1.HostCodexRoutingProbe, Payload: raw}) var value extensionv1.CodexRoutingProbeResult if err != nil || response.Code != "" || json.Unmarshal(response.Payload, &value) != nil { return value, errCodexRoutingUnavailable @@ -143,7 +145,7 @@ func (s *OpenAIGatewayService) ValidateCodexRouting(ctx context.Context, id int6 query.Stage = "acquire" acquired, err = call(query) } else { - previous, readErr := store.ReadExtensionState(ctx, installation.PluginKey, extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: storedKey}) + previous, readErr := store.ReadExtensionState(ctx, NativeCodexPluginKey, extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: storedKey}) if readErr != nil || !previous.Found || json.Unmarshal(previous.Value, &acquired) != nil { return nil, errCodexRoutingUnavailable } @@ -155,13 +157,13 @@ func (s *OpenAIGatewayService) ValidateCodexRouting(ctx context.Context, id int6 result.Success = err == nil && verified.Valid if result.Success && request.Transport == "http" { raw, _ := json.Marshal(verified) - _, _ = store.CompareSwapExtensionState(ctx, installation.PluginKey, extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: storedKey, Value: raw}) + _, _ = store.CompareSwapExtensionState(ctx, NativeCodexPluginKey, extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: storedKey, Value: raw}) } } if err != nil { result.Observations = append(result.Observations, extensionv1.CodexRoutingObservation{Stage: query.Stage, Code: "routing_validation_unavailable", RequestedModel: request.Model, Transport: request.Transport, ObservedAt: time.Now().UTC()}) } - budgetRecord, readErr := store.ReadExtensionState(context.WithoutCancel(ctx), installation.PluginKey, extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: "validation." + request.ValidationID}) + budgetRecord, readErr := store.ReadExtensionState(context.WithoutCancel(ctx), NativeCodexPluginKey, extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: "validation." + request.ValidationID}) if readErr == nil { var budget codexValidationBudget if json.Unmarshal(budgetRecord.Value, &budget) == nil { diff --git a/backend/internal/service/codex_routing_ws_bridge_test.go b/backend/internal/service/codex_routing_ws_bridge_test.go index 11cd2ef3936e..d9db455577d6 100644 --- a/backend/internal/service/codex_routing_ws_bridge_test.go +++ b/backend/internal/service/codex_routing_ws_bridge_test.go @@ -13,8 +13,8 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/coder/websocket" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" @@ -55,18 +55,16 @@ func TestCodexRoutingWebSocketIngressUsesVerifiedHTTPConnection(t *testing.T) { cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 3 cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3 var qualification *extensionv1.CodexRoutingQualification - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{Enabled: true, FailClosed: true, Models: []string{"gpt-6-astra"}}, func(in extensionv1.Invocation) (extensionv1.Result, error) { + manager := nativeTicketTestRuntime(t, config.OpenAICodexTicketConfig{Enabled: true, FailClosed: true, Models: []string{"gpt-6-astra"}}, func(in extensionv1.Invocation) (extensionv1.Result, error) { if in.Operation == "codex.routing.observe" || in.Operation == "codex.routing.demand" { return extensionv1.Result{Payload: json.RawMessage(`{}`)}, nil } raw, _ := json.Marshal(extensionv1.CodexRoutingInjection{Headers: map[string]string{}, Qualification: qualification}) return extensionv1.Result{Payload: raw}, nil }) - installation := manager.extensions.Load().installations[1] - installation.State = PluginStateEnabled - store := &routingMemoryStore{PluginRepository: &pluginTokenRepository{installation: installation}, values: map[string]extensionv1.StateResult{}} + store := &routingMemoryStore{values: map[string]extensionv1.StateResult{}} manager.repo = store - service := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream, accountRepo: &routingAccountRepositoryFixture{account: account}, pluginManager: manager, cache: &stubGatewayCache{}, toolCorrector: NewCodexToolCorrector(), openaiWSResolver: NewOpenAIWSProtocolResolver(cfg)} + service := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream, accountRepo: &routingAccountRepositoryFixture{account: account}, nativeCodexRuntime: manager, cache: &stubGatewayCache{}, toolCorrector: NewCodexToolCorrector(), openaiWSResolver: NewOpenAIWSProtocolResolver(cfg)} scope, err := service.PrepareCodexRoutingScope(context.Background(), 7, "http") require.NoError(t, err) scope.ConnectionLeaseID = "verified-connection" @@ -146,7 +144,7 @@ func TestCodexRoutingNativeWebSocketCannotSwitchIntoQualifiedModel(t *testing.T) upstream := &routingNativeConnFixture{newStagedPassthroughConn()} service := newPassthroughLifecycleService(cfg, upstream.stagedPassthroughConn) service.openaiWSPassthroughDialer = &stagedPassthroughDialer{conn: upstream} - service.pluginManager = ticketTestManager(t, config.OpenAICodexTicketConfig{Enabled: true, FailClosed: true, Models: []string{"gpt-6-astra"}}, func(extensionv1.Invocation) (extensionv1.Result, error) { + service.nativeCodexRuntime = nativeTicketTestRuntime(t, config.OpenAICodexTicketConfig{Enabled: true, FailClosed: true, Models: []string{"gpt-6-astra"}}, func(extensionv1.Invocation) (extensionv1.Result, error) { return extensionv1.Result{Payload: json.RawMessage(`{"headers":{}}`)}, nil }) pool := newOpenAIWSConnPool(cfg) diff --git a/backend/internal/service/codex_routing_ws_probe.go b/backend/internal/service/codex_routing_ws_probe.go index 4a86e3dee823..e3d57484cd4b 100644 --- a/backend/internal/service/codex_routing_ws_probe.go +++ b/backend/internal/service/codex_routing_ws_probe.go @@ -8,7 +8,7 @@ import ( "net/http" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/google/uuid" "github.com/klauspost/compress/zstd" ) diff --git a/backend/internal/service/codex_ticket_manual_test.go b/backend/internal/service/codex_ticket_manual_test.go index e1669ae07a0b..ea0ae9eacfcf 100644 --- a/backend/internal/service/codex_ticket_manual_test.go +++ b/backend/internal/service/codex_ticket_manual_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" ) diff --git a/backend/internal/service/codex_ticket_plugin.go b/backend/internal/service/codex_ticket_plugin.go index a82145b94870..42e6e5961ccb 100644 --- a/backend/internal/service/codex_ticket_plugin.go +++ b/backend/internal/service/codex_ticket_plugin.go @@ -9,6 +9,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) // The compatibility facade is the only host location that identifies this @@ -21,27 +22,12 @@ func (s *OpenAIGatewayService) CodexTicketStatuses(account *Account) []OpenAICod func (s *OpenAIGatewayService) openAICodexTicketConfig() config.OpenAICodexTicketConfig { cfg := config.OpenAICodexTicketConfig{FailClosed: true, Models: []string{openAICodexTicketDefaultModel, openAICodexTicketDefaultSolModel}} - if s == nil || s.pluginManager == nil { + if s == nil || s.nativeCodexRuntime == nil { return cfg } - raw, active := s.pluginManager.activeConfig(codexRuntimePluginKey) - if !active { - return cfg - } - var value struct { - Enabled bool `json:"enabled"` - FailClosed bool `json:"fail_closed"` - ProxyURL string `json:"proxy_url"` - Models []string `json:"models"` - } - if json.Unmarshal(raw, &value) != nil { - return cfg - } - cfg.Enabled = value.Enabled - cfg.FailClosed = value.FailClosed - cfg.HarvestProxyURL = value.ProxyURL - if len(value.Models) > 0 { - cfg.Models = value.Models + if snapshot := s.nativeCodexRuntime.current(); snapshot != nil { + cfg.Enabled, cfg.FailClosed, cfg.HarvestProxyURL = snapshot.config.Enabled, snapshot.config.FailClosed, snapshot.config.ProxyURL + cfg.Models = append([]string(nil), snapshot.config.Models...) } return cfg } @@ -55,27 +41,27 @@ func (s *OpenAIGatewayService) applyOpenAICodexTicket(ctx context.Context, accou _, _, err := codexQualityRequestQualification(ctx, account, model) return err } - if s == nil || s.pluginManager == nil { + if s == nil || s.nativeCodexRuntime == nil { return nil } - if err := s.pluginManager.ApplyRequestHeaders(ctx, account, model, headers); err != nil { + if err := s.nativeCodexRuntime.ApplyRequestHeaders(ctx, account, model, headers); err != nil { return fmt.Errorf("%w: extension request prerequisites", ErrOpenAICodexTicketUnavailable) } return nil } func (s *OpenAIGatewayService) openAICodexTicketBlocksAccount(account *Account, model string) bool { - if s != nil && s.pluginManager != nil { - s.pluginManager.noteCodexRoutingDemand(account, model) + if s != nil && s.nativeCodexRuntime != nil { + s.nativeCodexRuntime.noteCodexRoutingDemand(account, model) } - return s != nil && s.pluginManager != nil && !s.pluginManager.SchedulingDecision(account, model, time.Now()).Allowed + return s != nil && s.nativeCodexRuntime != nil && !s.nativeCodexRuntime.SchedulingDecision(account, model, time.Now()).Allowed } func (s *OpenAIGatewayService) HarvestCodexTicket(ctx context.Context, id int64, model, operation string, jobID int64, force bool) CodexTicketResult { - if s == nil || s.pluginManager == nil { + if s == nil || s.nativeCodexRuntime == nil { return CodexTicketFailure("ticket_plugin_unavailable") } - installation, _ := s.pluginManager.installedByKey(codexRuntimePluginKey) + installation := s.nativeCodexRuntime.metadata() if installation == nil { return CodexTicketFailure("ticket_plugin_unavailable") } @@ -83,7 +69,7 @@ func (s *OpenAIGatewayService) HarvestCodexTicket(ctx context.Context, id int64, operation = fmt.Sprintf("job-%d", jobID) } payload, _ := json.Marshal(map[string]any{"account_id": id, "model": model, "operation_id": operation, "force": force}) - response, err := s.pluginManager.InvokeAdminExtension(ctx, installation.ID, id, "harvest", payload) + response, err := s.nativeCodexRuntime.Invoke(ctx, PlatformOpenAI, "", extensionv1.Invocation{Capability: extensionv1.CapabilityAdmin, Operation: "harvest", AccountID: id, Payload: payload}) if err != nil { return CodexTicketFailure("ticket_plugin_unavailable") } @@ -95,16 +81,16 @@ func (s *OpenAIGatewayService) HarvestCodexTicket(ctx context.Context, id int64, } func (s *OpenAIGatewayService) StopCodexTicketRenewal(ctx context.Context, id int64, models []string) error { - if s == nil || s.pluginManager == nil { + if s == nil || s.nativeCodexRuntime == nil { return errors.New("ticket plugin unavailable") } - installation, _ := s.pluginManager.installedByKey(codexRuntimePluginKey) + installation := s.nativeCodexRuntime.metadata() if installation == nil { return errors.New("ticket plugin unavailable") } for _, model := range models { raw, _ := json.Marshal(map[string]any{"account_id": id, "model": model}) - response, err := s.pluginManager.InvokeAdminExtension(ctx, installation.ID, id, "stop", raw) + response, err := s.nativeCodexRuntime.Invoke(ctx, PlatformOpenAI, "", extensionv1.Invocation{Capability: extensionv1.CapabilityAdmin, Operation: "stop", AccountID: id, Payload: raw}) if err != nil { return err } @@ -117,15 +103,19 @@ func (s *OpenAIGatewayService) StopCodexTicketRenewal(ctx context.Context, id in } func (s *OpenAIGatewayService) TestCodexTicketProxy(ctx context.Context, raw string) (*CodexTicketProxyTestResult, error) { - if s == nil || s.pluginManager == nil { + return s.TestCodexTicketProxyWithProtocol(ctx, raw, "") +} + +func (s *OpenAIGatewayService) TestCodexTicketProxyWithProtocol(ctx context.Context, raw, protocol string) (*CodexTicketProxyTestResult, error) { + if s == nil || s.nativeCodexRuntime == nil { return nil, errors.New("ticket plugin unavailable") } - installation, _ := s.pluginManager.installedByKey(codexRuntimePluginKey) + installation := s.nativeCodexRuntime.metadata() if installation == nil { return nil, errors.New("ticket plugin unavailable") } - payload, _ := json.Marshal(map[string]string{"proxy_url": raw}) - response, err := s.pluginManager.InvokeAdminExtension(ctx, installation.ID, 0, "proxy.test", payload) + payload, _ := json.Marshal(map[string]string{"proxy_url": raw, "protocol": protocol}) + response, err := s.nativeCodexRuntime.Invoke(ctx, PlatformOpenAI, "", extensionv1.Invocation{Capability: extensionv1.CapabilityAdmin, Operation: "proxy.test", Payload: payload}) if err != nil { return nil, err } @@ -145,7 +135,7 @@ func OpenAICodexTicketStatuses(account *Account, cfg config.OpenAICodexTicketCon if !cfg.Enabled || !isOpenAICodexTicketAccount(account) { return nil } - projection := accountPluginProjection(account, codexRuntimePluginKey) + projection := nativeCodexAccountProjection(account) out := make([]OpenAICodexTicketStatus, 0, len(cfg.Models)) for _, model := range cfg.Models { entry := OpenAICodexTicketStatus{Model: model, RenewalState: "idle"} diff --git a/backend/internal/service/codex_ticket_proxy.go b/backend/internal/service/codex_ticket_proxy.go index 0b050bd48499..5f07084f2811 100644 --- a/backend/internal/service/codex_ticket_proxy.go +++ b/backend/internal/service/codex_ticket_proxy.go @@ -1,7 +1,7 @@ package service import ( - proxytransport "github.com/Wei-Shaw/sub2api/pkg/extensionapi/proxy" + proxytransport "github.com/Wei-Shaw/sub2api/internal/proxytransport" ) type CodexTicketProxyTrust struct { diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 4f15fc84d0e1..4dbeeebd9c8f 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -567,14 +567,14 @@ func detachStreamUpstreamContext(ctx context.Context, stream bool) (context.Cont if !stream { return ctx, func() {} } - return detachPluginPolicyContext(ctx) + return detachPolicyContext(ctx) } func detachUpstreamContext(ctx context.Context) (context.Context, context.CancelFunc) { if ctx == nil { return context.Background(), func() {} } - return detachPluginPolicyContext(ctx) + return detachPolicyContext(ctx) } // billingDeps 扣费逻辑依赖的服务(由各 gateway service 提供) diff --git a/backend/internal/service/gpt56_prompt_source.go b/backend/internal/service/gpt56_prompt_source.go index 6289e1bf25e2..8e00885986d9 100644 --- a/backend/internal/service/gpt56_prompt_source.go +++ b/backend/internal/service/gpt56_prompt_source.go @@ -5,7 +5,7 @@ import ( "errors" "strings" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) var ( diff --git a/backend/internal/service/image_studio_job.go b/backend/internal/service/image_studio_job.go index 8d62c43fbf39..153ac5b7302e 100644 --- a/backend/internal/service/image_studio_job.go +++ b/backend/internal/service/image_studio_job.go @@ -6,7 +6,7 @@ import ( "io" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) const ( diff --git a/backend/internal/service/image_studio_job_service.go b/backend/internal/service/image_studio_job_service.go index 7292a77cac35..bdd723d1185a 100644 --- a/backend/internal/service/image_studio_job_service.go +++ b/backend/internal/service/image_studio_job_service.go @@ -2,14 +2,11 @@ package service import ( "context" - "encoding/json" "errors" "fmt" "io" "strings" "time" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" ) type ImageStudioUpload struct { @@ -129,15 +126,7 @@ func imageStudioModelCapabilities() []CindyModelCapability { capabilities = append(capabilities, cindyModelCapabilityFromCapability(capability)) } } - raw, _ := json.Marshal(extensionv1.ImageStudioCatalogRequest{Capabilities: capabilities}) - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - out, err := invokeProcessExtensionCached(ctx, "*", "*", extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "image.studio.models", Payload: raw}) - result := make([]CindyModelCapability, 0) - if err == nil && out.Code == "" { - _ = json.Unmarshal(out.Payload, &result) - } - return result + return imageStudioModelChoices(capabilities) } func (s *ImageStudioService) eligibleAPIKey(ctx context.Context, userID, apiKeyID int64) (*APIKey, error) { diff --git a/backend/internal/service/image_studio_runtime.go b/backend/internal/service/image_studio_runtime.go index 709f8fd1b101..97acac073871 100644 --- a/backend/internal/service/image_studio_runtime.go +++ b/backend/internal/service/image_studio_runtime.go @@ -6,8 +6,6 @@ import ( "fmt" "sync" "time" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" ) type ImageStudioRuntimeOptions struct { @@ -155,13 +153,8 @@ func (r *ImageStudioRuntime) processClaim(ctx context.Context, claim *ImageStudi return } job := claim.Job - policyCtx, release, err := bindProcessExtensionContext(ctx, "*", "*", extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "image.studio.plan"}) - if err != nil { - r.failClaim(ctx, job.ID, claim.Item.ID, newImageStudioError(503, "studio_unavailable", "Image Studio is unavailable")) - return - } + ctx, release := bindImageStudioEnabled(ctx) defer release() - ctx = policyCtx if err := EnsureImageStudioAvailable(ctx); err != nil { r.failClaim(ctx, job.ID, claim.Item.ID, err) return diff --git a/backend/internal/service/image_studio_runtime_test.go b/backend/internal/service/image_studio_runtime_test.go index 195627b19c78..f1ad2665d5a8 100644 --- a/backend/internal/service/image_studio_runtime_test.go +++ b/backend/internal/service/image_studio_runtime_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" ) @@ -22,25 +22,9 @@ type imageStudioRuntimeRepoFake struct { failed atomic.Int32 } -type imageStudioBoundPolicy struct { - promptPolicyFixture - runtime *pluginRuntime -} - -func (p imageStudioBoundPolicy) BindOperationContext(ctx context.Context, _, _ string, _ extensionv1.Invocation) (context.Context, context.CancelFunc, error) { - return p.runtime.bindPolicyContext(ctx) -} - func TestImageStudioDisableCancelsHostExecutionAndKeepsSavedInputs(t *testing.T) { - previousFlag := cindyRolloutFeatures.imageStudio - cindyRolloutFeatures.imageStudio = true - previousProvider := processExtensionOperations.Load() - t.Cleanup(func() { - cindyRolloutFeatures.imageStudio = previousFlag - processExtensionOperations.Store(previousProvider) - }) - plugin := &pluginRuntime{} - processExtensionOperations.Store(&extensionOperationProvider{invoker: imageStudioBoundPolicy{runtime: plugin}}) + ConfigureImageTools(&extensionv1.ImageToolsConfig{StudioEnabled: true}) + t.Cleanup(func() { ConfigureImageTools(nil) }) group, key, account := canonicalImageStudioFixture() repo := &imageStudioRuntimeRepoFake{} store := &imageStudioStoreFake{} @@ -55,11 +39,11 @@ func TestImageStudioDisableCancelsHostExecutionAndKeepsSavedInputs(t *testing.T) case <-time.After(time.Second): t.Fatal("execution did not start") } - plugin.beginDrain() + ConfigureImageTools(&extensionv1.ImageToolsConfig{}) select { case <-done: case <-time.After(time.Second): - t.Fatal("plugin disable did not cancel execution") + t.Fatal("disabling Image Studio did not cancel execution") } require.EqualValues(t, 1, repo.failed.Load()) require.Zero(t, repo.completed.Load()) diff --git a/backend/internal/service/image_tools.go b/backend/internal/service/image_tools.go new file mode 100644 index 000000000000..cc8907824fa1 --- /dev/null +++ b/backend/internal/service/image_tools.go @@ -0,0 +1,154 @@ +package service + +import ( + "context" + "errors" + "slices" + "strings" + "sync" + "sync/atomic" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" +) + +var imageToolsConfigOverride atomic.Pointer[extensionv1.ImageToolsConfig] + +// currentImageToolsConfig returns the effective Image Studio and Responses image +// bridge switches: the stored administrator setting once loaded, otherwise the +// deploy-time rollout flags (the value the former image-tools plugin was seeded with). +func currentImageToolsConfig() (extensionv1.ImageToolsConfig, bool) { + if config := imageToolsConfigOverride.Load(); config != nil { + return *config, true + } + return LegacyImageToolsConfig(), true +} + +// imageStudioStop is closed whenever Image Studio is switched off so running +// executions stop, as the former plugin's policy lease did. +var imageStudioStop = struct { + mu sync.Mutex + ch chan struct{} +}{ch: make(chan struct{})} + +// imageStudioStarter starts the Image Studio background runtime. The server +// registers it so that switching Image Studio on after boot needs no restart. +var imageStudioStarter atomic.Pointer[func()] + +// SetImageStudioStarter registers the runtime start used when Image Studio is +// switched on after boot. Starting an already running runtime is a no-op. +func SetImageStudioStarter(start func()) { + imageStudioStarter.Store(&start) +} + +// ConfigureImageTools installs the effective switches (startup load, admin +// update, tests). A nil value falls back to the deploy-time rollout flags. +func ConfigureImageTools(config *extensionv1.ImageToolsConfig) { + imageToolsConfigOverride.Store(config) + current, _ := currentImageToolsConfig() + if current.StudioEnabled { + if start := imageStudioStarter.Load(); start != nil { + (*start)() + } + return + } + imageStudioStop.mu.Lock() + close(imageStudioStop.ch) + imageStudioStop.ch = make(chan struct{}) + imageStudioStop.mu.Unlock() +} + +// bindImageStudioEnabled returns a context that is canceled when Image Studio +// is switched off. The switch is also registered as a policy signal: upstream +// image IO detaches from request cancellation (detachUpstreamContext) but still +// honours policy signals, so switching off stops an in-flight generation +// instead of billing a result that can no longer be saved. +func bindImageStudioEnabled(ctx context.Context) (context.Context, context.CancelFunc) { + imageStudioStop.mu.Lock() + stop := imageStudioStop.ch + imageStudioStop.mu.Unlock() + signal, stopSignal := context.WithCancel(context.Background()) + signals, _ := ctx.Value(policyCancellationSignalsKey{}).([]context.Context) + ctx = context.WithValue(ctx, policyCancellationSignalsKey{}, append(append([]context.Context{}, signals...), signal)) + ctx, cancel := context.WithCancel(ctx) + go func() { + select { + case <-stop: + stopSignal() + cancel() + case <-ctx.Done(): + } + }() + return ctx, func() { + stopSignal() + cancel() + } +} + +// observeNativeImageFacts lets tests inspect the facts the host derives for the +// native image policy. It is nil in production. +var observeNativeImageFacts func(extensionv1.ImageNativeRequest) + +func ValidateCindyImageRequest(model string, request *OpenAIImagesRequest) error { + return ValidateCindyImageRequestForAccount(context.Background(), nil, model, request) +} + +func ValidateCindyImageRequestForAccount(ctx context.Context, account *Account, model string, request *OpenAIImagesRequest) error { + if request == nil { + return errors.New("image request is required") + } + for _, value := range []string{model, request.Size, request.Quality, request.ResponseFormat} { + if len(value) > 256 { + return errors.New("image control exceeds maximum length") + } + } + snapshot, err := LoadCindyCatalogSnapshot(ctx, account) + if err != nil { + return err + } + capability, found := snapshot.Capability(model) + endpoint := CindyEndpointImagesGenerate + if request.IsEdits() { + endpoint = CindyEndpointImagesEdit + } + verified := found && capability.PublicModel && snapshot.Config.CatalogEnabled && snapshot.Images.StudioEnabled && slices.Contains(capability.VerifiedEndpoints, endpoint) + facts := extensionv1.ImageNativeRequest{Model: strings.TrimSpace(model), Capability: capability, Verified: verified, Editing: request.IsEdits(), Stream: request.Stream, Count: request.N, Size: strings.TrimSpace(request.Size), Quality: strings.TrimSpace(request.Quality), ResponseFormat: strings.TrimSpace(request.ResponseFormat), HasReference: request.InputImageCount() > 0, HasMask: request.HasMask, + UnverifiedControls: request.Background != "" || request.OutputFormat != "" || request.Moderation != "" || request.InputFidelity != "" || request.Style != "" || request.OutputCompression != nil || request.PartialImages != nil} + if observeNativeImageFacts != nil { + observeNativeImageFacts(facts) + } + return validateNativeImageRequest(facts) +} + +func EnsureImageStudioAvailable(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return newImageStudioError(503, "studio_unavailable", "Image Studio is unavailable") + } + config, _ := currentImageToolsConfig() + if !config.StudioEnabled { + return newImageStudioError(404, "studio_disabled", "Image Studio is not enabled") + } + return nil +} + +func planImageStudio(ctx context.Context, input ImageStudioCreateInput, hasReference, hasMask, execute bool) (extensionv1.ImageStudioPlan, error) { + request := extensionv1.ImageStudioPlanRequest{APIKeySelected: input.APIKeyID > 0, Mode: string(input.Mode), Model: input.Model, PromptBytes: len(strings.TrimSpace(input.Prompt)), Count: input.Count, Size: input.Size, Quality: input.Quality, HasReference: hasReference, HasMask: hasMask} + if err := ctx.Err(); err != nil { + return extensionv1.ImageStudioPlan{}, newImageStudioError(503, "studio_unavailable", "Image Studio is unavailable") + } + plan, code, message := planImageStudioRequest(request) + if code != "" { + return plan, newImageStudioError(400, code, message) + } + if config, _ := currentImageToolsConfig(); execute && !config.StudioEnabled { + return plan, newImageStudioError(404, "studio_disabled", "Image Studio is not enabled") + } + return plan, nil +} + +func PlanImageStudioExecution(ctx context.Context, request ImageStudioExecutionRequest) (extensionv1.ImageStudioPlan, error) { + keyID := request.Job.APIKeyID + if request.APIKey != nil { + keyID = request.APIKey.ID + } + return planImageStudio(ctx, ImageStudioCreateInput{APIKeyID: keyID, Mode: request.Job.Mode, Model: request.Job.Model, Prompt: request.Job.Prompt, Count: 1, Size: request.Job.Size, Quality: request.Job.Quality}, len(request.Reference) > 0, len(request.Mask) > 0, true) +} diff --git a/backend/internal/service/image_tools_policy.go b/backend/internal/service/image_tools_policy.go new file mode 100644 index 000000000000..b5d1159e71ea --- /dev/null +++ b/backend/internal/service/image_tools_policy.go @@ -0,0 +1,237 @@ +package service + +import ( + "fmt" + "math" + "slices" + "strings" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" +) + +// Image request policy formerly served by the image-tools plugin process. These +// are pure checks over host facts; the switches come from currentImageToolsConfig. + +func validateNativeImageRequest(in extensionv1.ImageNativeRequest) error { + capability := in.Capability + if !capability.PublicModel || capability.Kind != extensionv1.CindyModelKindImage || capability.Controls == nil { + return fmt.Errorf("model %q has no verified Cindy image capability", in.Model) + } + endpoint := extensionv1.CindyEndpointImagesGenerate + controls := capability.Controls.Generation + if in.Editing { + endpoint, controls = extensionv1.CindyEndpointImagesEdit, capability.Controls.Edit + } + if !in.Verified || controls == nil { + return fmt.Errorf("model %q is not verified for %s", capability.PublicID, endpoint) + } + if in.Stream { + return fmt.Errorf("stream is not verified for model %q on %s", capability.PublicID, endpoint) + } + if controls.MaxOutputCount <= 0 || in.Count <= 0 || in.Count > controls.MaxOutputCount { + return fmt.Errorf("n must be between 1 and %d for model %q on %s", controls.MaxOutputCount, capability.PublicID, endpoint) + } + if !imageControlAllows(controls.Sizes, in.Size) { + return fmt.Errorf("size %q is not verified for model %q on %s", in.Size, capability.PublicID, endpoint) + } + if !imageControlAllows(controls.Qualities, in.Quality) { + return fmt.Errorf("quality %q is not verified for model %q on %s", in.Quality, capability.PublicID, endpoint) + } + if in.ResponseFormat != "" && !strings.EqualFold(in.ResponseFormat, "b64_json") { + return fmt.Errorf("response_format must be b64_json for model %q", capability.PublicID) + } + if in.UnverifiedControls { + return fmt.Errorf("request contains an unverified image control for model %q on %s", capability.PublicID, endpoint) + } + if in.Editing { + if !controls.SupportsReferenceImage || !in.HasReference { + return fmt.Errorf("a reference image is required for model %q on %s", capability.PublicID, endpoint) + } + if in.HasMask && !controls.SupportsMask { + return fmt.Errorf("mask is not verified for model %q on %s", capability.PublicID, endpoint) + } + } + return nil +} + +func planResponsesImageBridge(in extensionv1.ImageBridgeRequest, config extensionv1.ImageToolsConfig) (extensionv1.ImageBridgePlan, string, string) { + plan := extensionv1.ImageBridgePlan{} + resolve := func(model string) (extensionv1.CindyCapability, bool) { + if target, ok := in.Aliases[model]; ok { + model = target + } + for _, capability := range in.Capabilities { + if model == capability.PublicID || model == capability.LiveUpstreamID { + return capability, true + } + } + return extensionv1.CindyCapability{}, false + } + available := func(c extensionv1.CindyCapability) bool { + return c.PublicModel && (in.CatalogEnabled || + (c.Kind == extensionv1.CindyModelKindImage && config.StudioEnabled) || + (c.PublicID == "gpt-image-2" && config.ResponsesImageEnabled)) + } + supports := func(c extensionv1.CindyCapability) bool { + return config.ResponsesImageEnabled && c.PublicModel && c.PublicID == "gpt-image-2" && + c.Kind == extensionv1.CindyModelKindImage && slices.Contains(c.VerifiedEndpoints, extensionv1.CindyEndpointImagesGenerate) + } + top, known := resolve(in.Model) + plan.Supported = known && supports(top) + if in.Stage == "supports" { + return plan, "", "" + } + if in.Stage == "map" { + if len(in.Tools) > 0 && !config.ResponsesImageEnabled { + return plan, "image_model_not_found", "Responses image bridge is disabled" + } + if known && available(top) { + plan.Model = top.LiveUpstreamID + } + for _, tool := range in.Tools { + if capability, ok := resolve(tool.Model); ok && available(capability) && capability.Kind == extensionv1.CindyModelKindImage { + plan.Tools = append(plan.Tools, extensionv1.ImageBridgeModelPlan{Index: tool.Index, Model: capability.LiveUpstreamID}) + } + } + return plan, "", "" + } + if in.Stage != "validate" { + return plan, "invalid_bridge_stage", "Invalid image bridge stage" + } + modelError := func(model string) (extensionv1.ImageBridgePlan, string, string) { + return plan, "image_model_not_found", fmt.Sprintf("cindy Responses image tool model is not verified: %q", model) + } + if known && top.Kind == extensionv1.CindyModelKindImage && !plan.Supported { + return modelError(in.Model) + } + if plan.Supported { + if top.Controls == nil || top.Controls.Generation == nil { + return modelError(in.Model) + } + if err := validateImageBridgeControls("request", top.PublicID, top.Controls.Generation, in.Controls); err != nil { + return plan, "invalid_image_control", err.Error() + } + plan.Model, plan.StripCount = top.PublicID, in.Controls.Count.Present + } + for _, tool := range in.Tools { + if tool.InvalidModel { + return plan, "invalid_image_control", fmt.Sprintf("tools[%d].model must be a bounded string", tool.Index) + } + model := tool.Model + if model == "" { + model = "gpt-image-2" + } + capability, ok := resolve(model) + if !ok || !available(capability) || !supports(capability) || capability.Controls == nil || capability.Controls.Generation == nil { + return modelError(model) + } + if err := validateImageBridgeControls(fmt.Sprintf("tools[%d]", tool.Index), capability.PublicID, capability.Controls.Generation, tool.Controls); err != nil { + return plan, "invalid_image_control", err.Error() + } + plan.Tools = append(plan.Tools, extensionv1.ImageBridgeModelPlan{Index: tool.Index, Model: capability.LiveUpstreamID, StripCount: tool.Controls.Count.Present}) + } + return plan, "", "" +} + +func validateImageBridgeControls(location, model string, controls *extensionv1.CindyImageRequestControls, facts extensionv1.ImageBridgeControls) error { + for _, field := range []struct { + name string + value extensionv1.ImageBridgeValue + allowed []string + }{{"size", facts.Size, controls.Sizes}, {"quality", facts.Quality, controls.Qualities}} { + if field.value.Present && (!field.value.Valid || !imageControlAllows(field.allowed, field.value.Text)) { + return fmt.Errorf("%s.%s is not verified for model %q", location, field.name, model) + } + } + count := facts.Count + if count.Present && (!count.Valid || count.Number != math.Trunc(count.Number) || count.Number < 1 || count.Number > float64(controls.MaxOutputCount)) { + return fmt.Errorf("%s.n must be between 1 and %d for model %q", location, controls.MaxOutputCount, model) + } + if len(facts.Present) > 0 { + return fmt.Errorf("%s.%s is not verified for model %q", location, facts.Present[0], model) + } + return nil +} + +func imageControlAllows(allowed []string, value string) bool { + value = strings.TrimSpace(value) + if value == "" { + return false + } + for _, candidate := range allowed { + if strings.EqualFold(strings.TrimSpace(candidate), value) { + return true + } + } + return false +} + +func planImageStudioRequest(in extensionv1.ImageStudioPlanRequest) (extensionv1.ImageStudioPlan, string, string) { + plan := extensionv1.ImageStudioPlan{Model: strings.TrimSpace(in.Model), Mode: in.Mode, Size: strings.TrimSpace(in.Size), Quality: strings.TrimSpace(in.Quality), OutputPerRequest: 1, ResponseFormat: "b64_json", Endpoint: "/v1/images/generations"} + if !in.APIKeySelected { + return plan, "invalid_api_key", "Select an Image Studio API key" + } + if in.PromptBytes <= 0 || in.PromptBytes > 32000 { + return plan, "invalid_prompt", "Prompt must contain between 1 and 32000 bytes" + } + if in.Count < 1 || in.Count > extensionv1.ImageStudioMaxOutputCount { + return plan, "invalid_count", "Image count must be between 1 and 4" + } + if plan.Model != extensionv1.ImageStudioModelGPTImage2 && plan.Model != extensionv1.ImageStudioModelGeminiProImage { + return plan, "unsupported_model", "Image Studio model is not supported" + } + if in.Mode != "generate" && in.Mode != "edit" { + return plan, "unsupported_mode", "Image Studio mode is not supported" + } + if in.Mode == "edit" && plan.Model != extensionv1.ImageStudioModelGeminiProImage { + return plan, "unsupported_mode", "This model does not support image editing" + } + if in.Mode == "edit" && !in.HasReference { + return plan, "reference_required", "A reference image is required for editing" + } + if in.Mode == "generate" && in.HasReference { + return plan, "reference_not_allowed", "Reference images are only supported for editing" + } + if in.HasMask && (in.Mode != "edit" || plan.Model != extensionv1.ImageStudioModelGeminiProImage) { + return plan, "mask_not_allowed", "Masks are not supported for this request" + } + if plan.Size != "" && plan.Size != "1024x1024" { + return plan, "unsupported_size", "Image size is not supported" + } + if plan.Quality != "" && plan.Quality != "low" { + return plan, "unsupported_quality", "Image quality is not supported" + } + if plan.Size == "" { + plan.Size = "1024x1024" + } + if plan.Quality == "" { + plan.Quality = "low" + } + if in.Mode == "edit" { + plan.Endpoint = "/v1/images/edits" + } + return plan, "", "" +} + +func imageStudioModelChoices(capabilities []extensionv1.CindyModelCapability) []extensionv1.CindyModelCapability { + result := make([]extensionv1.CindyModelCapability, 0, 2) + for _, model := range []string{extensionv1.ImageStudioModelGPTImage2, extensionv1.ImageStudioModelGeminiProImage} { + for _, capability := range capabilities { + if capability.ID != model || capability.Kind != extensionv1.CindyModelKindImage { + continue + } + if capability.Controls != nil { + capability.Controls = &extensionv1.CindyCapabilityControls{Generation: extensionv1.CloneCindyImageRequestControls(capability.Controls.Generation), Edit: extensionv1.CloneCindyImageRequestControls(capability.Controls.Edit)} + if capability.Controls.Generation != nil { + capability.Controls.Generation.MaxOutputCount = extensionv1.ImageStudioMaxOutputCount + } + if capability.Controls.Edit != nil { + capability.Controls.Edit.MaxOutputCount = extensionv1.ImageStudioMaxOutputCount + } + } + result = append(result, capability) + break + } + } + return result +} diff --git a/backend/internal/service/image_tools_policy_test.go b/backend/internal/service/image_tools_policy_test.go new file mode 100644 index 000000000000..0b51cf7be35b --- /dev/null +++ b/backend/internal/service/image_tools_policy_test.go @@ -0,0 +1,161 @@ +package service + +import ( + "context" + "testing" + "time" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" +) + +func imageBridgeFixture() extensionv1.ImageBridgeRequest { + return extensionv1.ImageBridgeRequest{Stage: "validate", Model: "luna", CatalogEnabled: true, + Capabilities: []extensionv1.CindyCapability{{PublicID: "gpt-image-2", LiveUpstreamID: "openai/gpt-image-2", PublicModel: true, + Kind: extensionv1.CindyModelKindImage, VerifiedEndpoints: []extensionv1.CindyEndpoint{extensionv1.CindyEndpointImagesGenerate}, + Controls: &extensionv1.CindyCapabilityControls{Generation: &extensionv1.CindyImageRequestControls{Sizes: []string{"1024x1024"}, Qualities: []string{"low"}, MaxOutputCount: 1}}}}, + Tools: []extensionv1.ImageBridgeTool{{Index: 2, Controls: extensionv1.ImageBridgeControls{Count: extensionv1.ImageBridgeValue{Present: true, Valid: true, Number: 1}}}}, + } +} + +func TestImageToolsResponsesBridgePlansOnlyVerifiedModelsAndRequiresEnablement(t *testing.T) { + in := imageBridgeFixture() + enabled := extensionv1.ImageToolsConfig{ResponsesImageEnabled: true} + plan, code, message := planResponsesImageBridge(in, enabled) + if code != "" || len(plan.Tools) != 1 || plan.Tools[0].Index != 2 || plan.Tools[0].Model != "openai/gpt-image-2" || !plan.Tools[0].StripCount { + t.Fatalf("default tool plan: %+v %s %s", plan, code, message) + } + for _, disabled := range []extensionv1.ImageToolsConfig{{}, {StudioEnabled: true}} { + if _, code, _ := planResponsesImageBridge(in, disabled); code != "image_model_not_found" { + t.Fatalf("disabled bridge executed with config %+v: %s", disabled, code) + } + } + in.Model = "openai/gpt-image-2" + in.Tools = nil + plan, code, _ = planResponsesImageBridge(in, enabled) + if code != "" || !plan.Supported || plan.Model != "gpt-image-2" { + t.Fatalf("top-level live ID: %+v %s", plan, code) + } + in.Capabilities[0].PublicModel = false + if _, code, _ := planResponsesImageBridge(in, enabled); code != "image_model_not_found" { + t.Fatalf("hidden model admitted: %s", code) + } +} + +func TestImageToolsResponsesBridgeRejectsUnverifiedControlsBeforeIssuingPlan(t *testing.T) { + for _, count := range []float64{0, 2, 1.5} { + in := imageBridgeFixture() + in.Tools[0].Controls.Count.Number = count + if _, code, _ := planResponsesImageBridge(in, extensionv1.ImageToolsConfig{ResponsesImageEnabled: true}); code != "invalid_image_control" { + t.Fatalf("invalid count %v: %s", count, code) + } + } + for _, field := range []string{"quality", "size", "mask", "model"} { + in := imageBridgeFixture() + switch field { + case "quality": + in.Tools[0].Controls.Quality = extensionv1.ImageBridgeValue{Present: true, Valid: true, Text: "high"} + case "size": + in.Tools[0].Controls.Size = extensionv1.ImageBridgeValue{Present: true, Valid: false} + case "mask": + in.Tools[0].Controls.Present = []string{"mask"} + case "model": + in.Tools[0].InvalidModel = true + } + if _, code, _ := planResponsesImageBridge(in, extensionv1.ImageToolsConfig{ResponsesImageEnabled: true}); code != "invalid_image_control" { + t.Fatalf("invalid %s: %s", field, code) + } + } +} + +func TestImageToolsNativeImageControlsKeepGenerationAndEditingIndependent(t *testing.T) { + request := extensionv1.ImageNativeRequest{Model: "fixture-image", Verified: true, Count: 1, Size: "1024x1024", Quality: "low", ResponseFormat: "b64_json", Capability: extensionv1.CindyCapability{PublicID: "fixture-image", PublicModel: true, Kind: extensionv1.CindyModelKindImage, Controls: &extensionv1.CindyCapabilityControls{Generation: &extensionv1.CindyImageRequestControls{Sizes: []string{"1024x1024"}, Qualities: []string{"low"}, MaxOutputCount: 1}}}} + if err := validateNativeImageRequest(request); err != nil { + t.Fatal(err) + } + for _, change := range []func(*extensionv1.ImageNativeRequest){ + func(r *extensionv1.ImageNativeRequest) { r.Editing = true; r.HasReference = true }, + func(r *extensionv1.ImageNativeRequest) { r.Count = 2 }, + func(r *extensionv1.ImageNativeRequest) { r.Stream = true }, + func(r *extensionv1.ImageNativeRequest) { r.UnverifiedControls = true }, + func(r *extensionv1.ImageNativeRequest) { r.Verified = false }, + } { + copy := request + change(©) + if err := validateNativeImageRequest(copy); err == nil { + t.Fatalf("unverified request admitted: %+v", copy) + } + } + request.Editing = true + request.Capability.Controls.Edit = &extensionv1.CindyImageRequestControls{Sizes: []string{"1024x1024"}, Qualities: []string{"low"}, MaxOutputCount: 1, SupportsReferenceImage: true} + if err := validateNativeImageRequest(request); err == nil { + t.Fatal("missing reference admitted") + } + request.HasReference = true + if err := validateNativeImageRequest(request); err != nil { + t.Fatal(err) + } + request.HasMask = true + if err := validateNativeImageRequest(request); err == nil { + t.Fatal("unverified mask admitted") + } +} + +func TestImageToolsStudioPlanRequiresExplicitEnablementAndKeepsNativeOne(t *testing.T) { + t.Cleanup(func() { ConfigureImageTools(nil) }) + input := ImageStudioCreateInput{APIKeyID: 1, Mode: "generate", Model: "gpt-image-2", Prompt: "fixture text", Count: 4} + ConfigureImageTools(&extensionv1.ImageToolsConfig{}) + if _, err := planImageStudio(context.Background(), input, false, false, true); err == nil { + t.Fatal("default must remain disabled") + } + if _, err := planImageStudio(context.Background(), input, false, false, false); err != nil { + t.Fatalf("validation must not require enablement: %v", err) + } + ConfigureImageTools(&extensionv1.ImageToolsConfig{StudioEnabled: true}) + plan, err := planImageStudio(context.Background(), input, false, false, true) + if err != nil || plan.OutputPerRequest != 1 || plan.Size != "1024x1024" || plan.Endpoint != "/v1/images/generations" { + t.Fatalf("unexpected plan: %+v %v", plan, err) + } + input.Mode = "edit" + if _, err := planImageStudio(context.Background(), input, true, false, true); err == nil { + t.Fatal("unsupported editing must be rejected") + } + input.Model = "gemini-3-pro-image" + plan, err = planImageStudio(context.Background(), input, true, false, true) + if err != nil || plan.Endpoint != "/v1/images/edits" { + t.Fatalf("supported editing plan unavailable: %+v %v", plan, err) + } +} + +func TestSwitchingImageStudioOnStartsTheRuntime(t *testing.T) { + previousConfig, previousStarter := imageToolsConfigOverride.Load(), imageStudioStarter.Load() + t.Cleanup(func() { + imageToolsConfigOverride.Store(previousConfig) + imageStudioStarter.Store(previousStarter) + }) + starts := 0 + SetImageStudioStarter(func() { starts++ }) + ConfigureImageTools(&extensionv1.ImageToolsConfig{}) + if starts != 0 { + t.Fatalf("switching Image Studio off must not start the runtime, starts=%d", starts) + } + ConfigureImageTools(&extensionv1.ImageToolsConfig{StudioEnabled: true}) + if starts != 1 { + t.Fatalf("switching Image Studio on must start the runtime once, starts=%d", starts) + } +} + +func TestSwitchingImageStudioOffStopsDetachedUpstreamIO(t *testing.T) { + previous := imageToolsConfigOverride.Load() + t.Cleanup(func() { imageToolsConfigOverride.Store(previous) }) + ConfigureImageTools(&extensionv1.ImageToolsConfig{StudioEnabled: true}) + bound, release := bindImageStudioEnabled(context.Background()) + defer release() + upstream, releaseUpstream := detachUpstreamContext(bound) + defer releaseUpstream() + ConfigureImageTools(&extensionv1.ImageToolsConfig{}) + select { + case <-upstream.Done(): + case <-time.After(5 * time.Second): + t.Fatal("switching Image Studio off must cancel detached upstream IO") + } +} diff --git a/backend/internal/service/model_context_capacity.go b/backend/internal/service/model_context_capacity.go index 1b30e0ce5c2c..cc751eba05fc 100644 --- a/backend/internal/service/model_context_capacity.go +++ b/backend/internal/service/model_context_capacity.go @@ -8,9 +8,9 @@ import ( "sort" "strings" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" ) const ( diff --git a/backend/internal/service/model_context_capacity_catalog.go b/backend/internal/service/model_context_capacity_catalog.go index fa40c599df43..f02a12f0d8e2 100644 --- a/backend/internal/service/model_context_capacity_catalog.go +++ b/backend/internal/service/model_context_capacity_catalog.go @@ -1,146 +1,13 @@ package service -import ( - "context" - "encoding/json" - "errors" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "sort" - "strconv" - "sync/atomic" - "time" -) - -type extensionCatalogProvider struct{ resolver extensionv1.CatalogResolver } - -var processExtensionCatalog atomic.Pointer[extensionCatalogProvider] +import extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" +// lookupExtensionCatalog resolves the release-pinned official catalog. No +// remaining plugin declares a model catalog, so plugin health cannot hide it. func lookupExtensionCatalog(query extensionv1.CatalogQuery) *OfficialModelContextCapacity { - provider := processExtensionCatalog.Load() - if provider == nil { - return nil - } - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - result, err := provider.resolver.ResolveCatalog(ctx, query) + official, err := resolveOfficialModelCatalog(query) if err != nil { return nil } - return result.Entry -} -func (m *PluginManager) ResolveCatalog(ctx context.Context, query extensionv1.CatalogQuery) (extensionv1.CatalogMatch, error) { - if err := ctx.Err(); err != nil { - return extensionv1.CatalogMatch{}, err - } - if query.AccountID < 0 { - return extensionv1.CatalogMatch{}, errors.New("invalid model catalog account") - } - registry := m.extensions.Load() - if registry == nil || registry.unavailable != "" { - return extensionv1.CatalogMatch{}, errors.New("model catalog unavailable") - } - invocation := extensionv1.Invocation{Capability: extensionv1.CapabilityCatalog, Operation: "resolve", AccountID: query.AccountID} - var ids []int64 - for id, installation := range registry.installations { - if pluginHasInvocationCapability(installation, invocation, query.Platform, query.AccountType) { - ids = append(ids, id) - } - } - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) - raw, err := json.Marshal(query) - if err != nil { - return extensionv1.CatalogMatch{}, err - } - invocation.Payload = raw - var resolved extensionv1.CatalogMatch - type catalogLease struct { - ctx context.Context - runtime *pluginRuntime - revision uint64 - } - var leases []catalogLease - for _, id := range ids { - runtime := registry.runtimes[id] - if runtime == nil || runtime.client == nil || runtime.extension == nil || runtime.draining.Load() || runtime.configuring.Load() || runtime.client.Exited() || m.repo == nil { - return extensionv1.CatalogMatch{}, errors.New("enabled model catalog unavailable") - } - // Recheck just this selected installation before consulting cached policy. - // A stale local registry cannot authorize persisted disable/scope changes. - current, err := m.repo.GetByID(ctx, id) - if err != nil || !samePluginRuntime(current, registry.installations[id]) { - return extensionv1.CatalogMatch{}, errors.New("enabled model catalog unavailable") - } - if current.State == PluginStateDisabled || !pluginHasInvocationCapability(current, invocation, query.Platform, query.AccountType) { - continue - } - if current.State != PluginStateEnabled || !pluginDependenciesHealthy(current, registry, map[int64]bool{}) { - return extensionv1.CatalogMatch{}, errors.New("enabled model catalog unavailable") - } - m.mu.Lock() - configCurrent := samePluginRuntime(current, runtime.installation) && current.ConfigEncrypted == runtime.installation.ConfigEncrypted - m.mu.Unlock() - if !configCurrent { - return extensionv1.CatalogMatch{}, errors.New("enabled model catalog unavailable") - } - bound, release, err := m.bindHostPolicyContext(ctx, current, runtime) - if err != nil { - return extensionv1.CatalogMatch{}, err - } - defer release() - revision := runtime.configRevision.Load() - leases = append(leases, catalogLease{bound, runtime, revision}) - cacheKey := strconv.FormatUint(revision, 10) + ":" + string(raw) - var payload json.RawMessage - if cached, ok := runtime.catalogCache.Load(cacheKey); ok { - var valid bool - payload, valid = cached.(json.RawMessage) - if !valid { - return extensionv1.CatalogMatch{}, errors.New("invalid model catalog cache") - } - } else { - // This resolver already read the selected installation and holds its - // exact business lease. Invoke that same runtime without a second - // database read or a nested lifetime admission. - admission := &pluginInvocationAdmission{ctx: bound, runtime: runtime, revision: revision, owner: id} - out, callErr := admission.invoke(invocation) - if callErr != nil || out.Code != "" { - return extensionv1.CatalogMatch{}, errors.New("enabled model catalog unavailable") - } - payload = out.Payload - } - if err := bound.Err(); err != nil { - return extensionv1.CatalogMatch{}, err - } - if runtime.configuring.Load() || runtime.configRevision.Load() != revision { - return extensionv1.CatalogMatch{}, errors.New("model catalog configuration changed") - } - var match extensionv1.CatalogMatch - if json.Unmarshal(payload, &match) != nil { - return extensionv1.CatalogMatch{}, errors.New("invalid model catalog response") - } - if match.Entry != nil && runtime.catalogCacheSize.Load() < 2048 { - if _, loaded := runtime.catalogCache.LoadOrStore(cacheKey, payload); !loaded { - runtime.catalogCacheSize.Add(1) - } - } - if !match.Matched { - continue - } - if match.Entry == nil { - return match, nil - } - if resolved.Entry != nil && resolved.Entry.ModelContextCapacity != match.Entry.ModelContextCapacity { - return extensionv1.CatalogMatch{Matched: true}, nil - } - resolved = match - } - for _, lease := range leases { - if err := lease.ctx.Err(); err != nil { - return extensionv1.CatalogMatch{}, err - } - if lease.runtime.configuring.Load() || lease.runtime.configRevision.Load() != lease.revision { - return extensionv1.CatalogMatch{}, errors.New("model catalog configuration changed") - } - } - return resolved, nil + return official.Entry } diff --git a/backend/internal/service/model_context_capacity_catalog_test_fixture_test.go b/backend/internal/service/model_context_capacity_catalog_test_fixture_test.go deleted file mode 100644 index 2b1e975ae06e..000000000000 --- a/backend/internal/service/model_context_capacity_catalog_test_fixture_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package service - -// Contract tests use the actual independent catalog module as their in-process -// fixture. Production reaches it only through the enabled plugin process. -import catalog "github.com/HTExplicit/sub2api-plugins/modelpolicy/catalog" - -var officialModelContextCapacityCatalog = catalog.Snapshot() - -const GPTContextCapacityReferenceRelease = catalog.ReferenceRelease -const gptContextCapacityReferenceSource = catalog.ReferenceSource -const gptContextCapacityReferenceVerifiedAt = catalog.ReferenceVerifiedAt - -func init() { processExtensionCatalog.Store(&extensionCatalogProvider{resolver: catalog.New()}) } diff --git a/backend/internal/service/model_context_official_catalog.go b/backend/internal/service/model_context_official_catalog.go new file mode 100644 index 000000000000..b716e0f89ed8 --- /dev/null +++ b/backend/internal/service/model_context_official_catalog.go @@ -0,0 +1,108 @@ +package service + +import ( + "errors" + "slices" + "strings" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" +) + +// resolveOfficialModelCatalog matches candidates against the release-pinned +// official catalog in process (formerly the model-policy plugin). Product +// scoping keeps a self-hosted look-alike ID from inheriting official capacity. +func resolveOfficialModelCatalog(query extensionv1.CatalogQuery) (extensionv1.CatalogMatch, error) { + if len(query.Candidates) == 0 || len(query.Candidates) > 16 { + return extensionv1.CatalogMatch{}, errors.New("invalid model candidates") + } + for _, candidate := range query.Candidates { + // Keep the host's 512-byte model ID bound: an original namespace may + // precede a short catalog leaf without invalidating the whole query. + if len(candidate) > 512 || strings.TrimSpace(candidate) == "" { + return extensionv1.CatalogMatch{}, errors.New("invalid model candidate") + } + var found *OfficialModelContextCapacity + for _, entry := range officialModelContextCapacityCatalog { + if !strings.EqualFold(entry.ModelID, candidate) && !officialCatalogContainsFold(entry.Aliases, candidate) { + continue + } + if !officialCatalogApplies(query, entry) { + continue + } + if !validOfficialCatalogCapacity(entry.ContextWindow) && !validOfficialCatalogCapacity(entry.MaxInputTokens) && !validOfficialCatalogCapacity(entry.MaxContextWindow) { + continue + } + if found != nil && found.ModelContextCapacity != entry.ModelContextCapacity { + return extensionv1.CatalogMatch{Matched: true}, nil + } + copy := entry + copy.Aliases = slices.Clone(entry.Aliases) + copy.SourceURLs = slices.Clone(entry.SourceURLs) + if entry.Reference != nil { + reference := *entry.Reference + copy.Reference = &reference + } + found = © + } + if found != nil { + return extensionv1.CatalogMatch{Matched: true, Entry: found}, nil + } + } + return extensionv1.CatalogMatch{}, nil +} + +// OfficialModelCatalogSnapshot returns a deep copy of the official catalog for +// the admin catalog browser. +func OfficialModelCatalogSnapshot() []OfficialModelContextCapacity { + result := make([]OfficialModelContextCapacity, len(officialModelContextCapacityCatalog)) + for i, entry := range officialModelContextCapacityCatalog { + entry.Aliases = slices.Clone(entry.Aliases) + entry.SourceURLs = slices.Clone(entry.SourceURLs) + entry.MatchHosts = slices.Clone(entry.MatchHosts) + entry.MatchAccountModes = slices.Clone(entry.MatchAccountModes) + if entry.Reference != nil { + reference := *entry.Reference + entry.Reference = &reference + } + result[i] = entry + } + return result +} + +func validOfficialCatalogCapacity(value int64) bool { + return value > 0 && value <= extensionv1.MaxModelContextTokens +} + +func officialCatalogContainsFold(values []string, target string) bool { + for _, value := range values { + if strings.EqualFold(value, target) { + return true + } + } + return false +} + +func officialCatalogApplies(query extensionv1.CatalogQuery, entry OfficialModelContextCapacity) bool { + secure := query.Scheme == "https" && !query.HasURLCredentials && (query.Port == "" || query.Port == "443") + kimiCoding := secure && strings.EqualFold(query.Host, "api.kimi.com") && (query.Path == "/coding" || strings.HasPrefix(query.Path, "/coding/")) + if entry.Provider == "kimi" && entry.Product == "coding" && query.Platform != "kimi" && !kimiCoding { + return false + } + if len(entry.MatchHosts) > 0 && (!secure || !officialCatalogContainsFold(entry.MatchHosts, query.Host)) { + return false + } + if len(entry.MatchAccountModes) > 0 { + mode := query.AccountMode + if mode == "" && query.Scheme != "" { + if kimiCoding { + mode = AccountModeCoding + } else { + mode = AccountModePayG + } + } + if !slices.Contains(entry.MatchAccountModes, mode) { + return false + } + } + return true +} diff --git a/plugins/model-policy/catalog/catalog_data.go b/backend/internal/service/model_context_official_catalog_data.go similarity index 99% rename from plugins/model-policy/catalog/catalog_data.go rename to backend/internal/service/model_context_official_catalog_data.go index 9ac27f8072e7..d51a2c5e0070 100644 --- a/plugins/model-policy/catalog/catalog_data.go +++ b/backend/internal/service/model_context_official_catalog_data.go @@ -1,18 +1,4 @@ -package catalog - -import extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - -const ModelContextCapacityBasisMaximum = extensionv1.ModelContextCapacityBasisMaximum -const ModelContextCapacityBasisTotal = extensionv1.ModelContextCapacityBasisTotal - -type ModelContextCapacity = extensionv1.ModelContextCapacity -type ModelContextCapacityReference = extensionv1.ModelContextCapacityReference -type OfficialModelContextCapacity = extensionv1.OfficialModelContextCapacity - -const ( - AccountModeCoding = "coding" - AccountModePayG = "payg" -) +package service // This release-pinned catalog describes capacity, not model availability or routing. // Stored IDs/aliases are exact; matching-only namespace and GPT spelling variants diff --git a/plugins/model-policy/catalog/module_test.go b/backend/internal/service/model_context_official_catalog_test.go similarity index 60% rename from plugins/model-policy/catalog/module_test.go rename to backend/internal/service/model_context_official_catalog_test.go index 16bf21a4f776..4e7638488892 100644 --- a/plugins/model-policy/catalog/module_test.go +++ b/backend/internal/service/model_context_official_catalog_test.go @@ -1,15 +1,12 @@ -package catalog +package service import ( - "context" - "encoding/json" "testing" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) -func TestCatalogProcessContractPreservesProductScope(t *testing.T) { - module := New() +func TestOfficialModelCatalogPreservesProductScope(t *testing.T) { for _, tc := range []struct { name, model, host string want bool @@ -21,35 +18,27 @@ func TestCatalogProcessContractPreservesProductScope(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { query := extensionv1.CatalogQuery{Candidates: []string{tc.model}, Platform: "openai", AccountType: "apikey", Scheme: "https", Host: tc.host} - raw, _ := json.Marshal(query) - out, err := module.Invoke(context.Background(), extensionv1.Invocation{Capability: extensionv1.CapabilityCatalog, Operation: "resolve", Payload: raw}) - if err != nil { - t.Fatal(err) - } - var match extensionv1.CatalogMatch - if json.Unmarshal(out.Payload, &match) != nil || (match.Entry != nil) != tc.want { - t.Fatalf("unexpected reference: %s", out.Payload) + match, err := resolveOfficialModelCatalog(query) + if err != nil || (match.Entry != nil) != tc.want { + t.Fatalf("unexpected reference: %+v, %v", match, err) } }) } } -func TestCatalogSnapshotCannotMutateActiveReferences(t *testing.T) { - before := Snapshot() - after := Snapshot() +func TestOfficialModelCatalogSnapshotCannotMutateActiveReferences(t *testing.T) { + before := OfficialModelCatalogSnapshot() + after := OfficialModelCatalogSnapshot() after[0].Aliases[0] = "not-a-real-alias" - if Snapshot()[0].Aliases[0] != before[0].Aliases[0] { + if OfficialModelCatalogSnapshot()[0].Aliases[0] != before[0].Aliases[0] { t.Fatal("catalog exposed mutable alias storage") } - if _, err := New().ValidateConfig(context.Background(), json.RawMessage(`{"arbitrary_host_command":"ignored"}`)); err == nil { - t.Fatal("unknown configuration accepted") - } } -func TestGPT6NamedModelReferencesSeparateAPIAndCodex(t *testing.T) { +func TestOfficialModelCatalogGPT6SeparatesAPIAndCodex(t *testing.T) { for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} { query := extensionv1.CatalogQuery{Candidates: []string{model}, Platform: "openai", AccountType: "apikey", Scheme: "https", Host: "api.openai.com"} - match, err := New().ResolveCatalog(context.Background(), query) + match, err := resolveOfficialModelCatalog(query) if err != nil || match.Entry == nil { t.Fatalf("%s: reference unavailable: %v", model, err) } @@ -62,7 +51,7 @@ func TestGPT6NamedModelReferencesSeparateAPIAndCodex(t *testing.T) { t.Fatalf("%s: incorrect subscription reference: %+v", model, ref) } query.Candidates = []string{model + "-2099-01-01"} - unknown, err := New().ResolveCatalog(context.Background(), query) + unknown, err := resolveOfficialModelCatalog(query) if err != nil || unknown.Entry != nil { t.Fatal("unpublished dated snapshots must not inherit capacity") } diff --git a/backend/internal/service/native_feature_bootstrap.go b/backend/internal/service/native_feature_bootstrap.go new file mode 100644 index 000000000000..2ea59e507730 --- /dev/null +++ b/backend/internal/service/native_feature_bootstrap.go @@ -0,0 +1,258 @@ +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 remain in their original columns. +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:"-"` + NativeCreated bool `json:"native_created,omitempty"` +} + +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 +} + +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}, 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.image-tools": + if err := validateNativeUnmappedBindings(plugin, "extensions.request.v1"); err != nil { + return nil, err + } + var value extensionv1.ImageToolsConfig + if err := DecodeSwitchSettings(raw, &value, "studio_enabled", "responses_image_enabled"); err != nil { + return nil, fmt.Errorf("invalid saved image tool configuration: %w", err) + } + enabled, err := nativeRetirementBindingEnabled(plugin, "extensions.request.v1", "*", "*") + if err != nil { + return nil, err + } + value.StudioEnabled = value.StudioEnabled && enabled + value.ResponsesImageEnabled = value.ResponsesImageEnabled && enabled + return settings, put(SettingKeyImageToolsConfig, value) + 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": + if err := validateNativeUnmappedBindings(plugin, "extensions.provider.v1"); err != nil { + return nil, err + } + value := extensionv1.CindyProviderConfig{BalanceDetection: true} + if err := DecodeSwitchSettings(raw, &value, "balance_detection", "catalog_enabled", "search_enabled"); err != nil { + return nil, fmt.Errorf("invalid saved Cindy configuration: %w", err) + } + enabled, err := nativeRetirementBindingEnabled(plugin, "extensions.provider.v1", PlatformCindy, AccountTypeAPIKey) + if err != nil { + return nil, err + } + value.BalanceDetection = value.BalanceDetection && enabled + value.CatalogEnabled = value.CatalogEnabled && enabled + value.SearchEnabled = value.SearchEnabled && enabled + return settings, put(SettingKeyCindyProviderConfig, value) + 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.image-tools": + return global && (capability == "extensions.admin.v1" || capability == "extensions.request.v1") + case "codexrip.cindy-provider": + return (global && capability == "extensions.admin.v1") || (capability == "extensions.provider.v1" && platform == PlatformCindy && accountType == AccountTypeAPIKey) + case "codexrip.prompt-skills": + return (global && capability == "extensions.admin.v1") || (capability == "extensions.request.v1" && platform == PlatformOpenAI && accountType == "*") + case "codexrip.codex-runtime": + if platform != PlatformOpenAI { + return false + } + if capability == "extensions.recovery.v1" { + return accountType == "*" + } + if accountType != AccountTypeOAuth && accountType != AccountTypeSetupToken { + return false + } + return capability == "extensions.admin.v1" || capability == "extensions.credentials.v1" || capability == "extensions.jobs.v1" || capability == "extensions.request.v1" || capability == "extensions.scheduling.v1" + } + 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 SettingKeyImageToolsConfig: + return DecodeSwitchSettings(raw, &extensionv1.ImageToolsConfig{}, "studio_enabled", "responses_image_enabled") + case SettingKeyAdminObservabilityConfig: + return DecodeSwitchSettings(raw, &extensionv1.AdminObservabilityConfig{}, "telemetry_enabled", "theme_enabled") + case SettingKeyCindyProviderConfig: + return DecodeSwitchSettings(raw, &extensionv1.CindyProviderConfig{}, "balance_detection", "catalog_enabled", "search_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 new file mode 100644 index 000000000000..55dcc6516a8b --- /dev/null +++ b/backend/internal/service/native_feature_bootstrap_test.go @@ -0,0 +1,100 @@ +package service + +import ( + "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 image installation stays off", + plugin: NativeRetirementPlugin{Key: "codexrip.image-tools", State: "disabled", ConfigEncrypted: `{"studio_enabled":true,"responses_image_enabled":true}`}, + key: SettingKeyImageToolsConfig, expected: `{"studio_enabled":false,"responses_image_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: "Cindy missing balance field keeps module default", + 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: PlatformCindy, AccountType: AccountTypeAPIKey, Enabled: true, RolloutPercent: 100}, + }}, key: SettingKeyCindyProviderConfig, expected: `{"balance_detection":true,"catalog_enabled":true,"search_enabled":false}`, + }, + { + 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: "Codex separately bound OAuth and setup tokens stay equivalent", + plugin: NativeRetirementPlugin{Key: "codexrip.codex-runtime", State: "enabled", Manifest: json.RawMessage(`{"capabilities":[{"id":"extensions.request.v1","platform":"openai","account_type":"oauth"},{"id":"extensions.request.v1","platform":"openai","account_type":"setup-token"}]}`), Bindings: []NativeRetirementBinding{ + {Capability: "extensions.request.v1", Platform: "openai", AccountType: "oauth", Enabled: true, RolloutPercent: 100}, + {Capability: "extensions.request.v1", Platform: "openai", AccountType: "setup-token", Enabled: true, RolloutPercent: 100}, + }}, + }, + { + name: "unfinished update cannot be retired", + plugin: NativeRetirementPlugin{Key: "codexrip.codex-runtime", State: "updating"}, wantErr: true, + }, + { + name: "disabled whole Codex domain is not a routing-only switch", + plugin: NativeRetirementPlugin{Key: "codexrip.codex-runtime", 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":"cindy","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.image-tools", State: "disabled", ConfigEncrypted: "private"}, retirementTestEncryptor{fail: true}) + require.Error(t, err) + require.NotContains(t, err.Error(), "sensitive ciphertext") + require.Error(t, ValidateNativeFeatureSetting(SettingKeyImageToolsConfig, []byte(`{"studio_enabled":"false"}`))) + require.Error(t, ValidateNativeFeatureSetting(SettingKeyImageToolsConfig, nil)) +} diff --git a/backend/internal/service/native_policy_errors.go b/backend/internal/service/native_policy_errors.go new file mode 100644 index 000000000000..a97036dcdd81 --- /dev/null +++ b/backend/internal/service/native_policy_errors.go @@ -0,0 +1,7 @@ +package service + +import "errors" + +// Retain existing error identities for the built-in policy callers and API contracts. +var ErrExtensionOperationDisabled = errors.New("extension operation is not enabled") +var ErrExtensionOperationUnavailable = errors.New("enabled extension operation is unavailable") diff --git a/backend/internal/service/native_policy_fixtures.go b/backend/internal/service/native_policy_fixtures.go new file mode 100644 index 000000000000..d6de5d767007 --- /dev/null +++ b/backend/internal/service/native_policy_fixtures.go @@ -0,0 +1,42 @@ +package service + +import ( + "context" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" +) + +// ConfigureNativePolicyOperations is the in-process composition seam used by +// cross-package contract fixtures. The server constructs NativeCodexRuntime. +// It has no plugin registry, process transport, capabilities or lifecycle side effects. +func ConfigureNativePolicyOperations(invoker extensionv1.OperationInvoker) { + // Cross-package fixtures can change replies between phases. Start with an + // empty Cindy runtime and disable writes to its immutable-answer cache; + // the production cache is covered separately with the real native module. + ConfigureCindyProvider(nil) + cindyProvider.Load().cacheSize.Store(cindyProviderCacheLimit) + invokeCindyProvider = func(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { + if invoker == nil { + return extensionv1.Result{}, ErrExtensionOperationDisabled + } + return invoker.InvokeOperation(ctx, "", "", in) + } + invokeAccountTools = func(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { + if invoker == nil { + return extensionv1.Result{}, ErrExtensionOperationDisabled + } + return invoker.InvokeOperation(ctx, "", "", in) + } + invokeNativeCodex = func(ctx context.Context, platform, kind string, in extensionv1.Invocation) (extensionv1.Result, error) { + if invoker == nil { + return extensionv1.Result{}, ErrNativeCodexPolicyDisabled + } + return invoker.InvokeOperation(ctx, platform, kind, in) + } + bindNativeCodexContext = func(ctx context.Context, _ string, _ string, _ extensionv1.Invocation) (context.Context, context.CancelFunc, error) { + if invoker == nil { + return nil, nil, ErrNativeCodexPolicyDisabled + } + bound, cancel := context.WithCancel(ctx) + return bound, cancel, nil + } +} diff --git a/backend/internal/service/native_setting_load_test.go b/backend/internal/service/native_setting_load_test.go new file mode 100644 index 000000000000..4494899f1df1 --- /dev/null +++ b/backend/internal/service/native_setting_load_test.go @@ -0,0 +1,60 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "testing" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + "github.com/stretchr/testify/require" +) + +type nativeSwitchReadRepository struct { + SettingRepository + value string + err error +} + +func (r *nativeSwitchReadRepository) GetValue(context.Context, string) (string, error) { + return r.value, r.err +} + +func TestNativeSettingLoadFailureNeverReenablesSavedSwitches(t *testing.T) { + oldImage := EffectiveImageToolsConfig() + oldCindy := EffectiveCindyProviderConfig() + oldObservability := EffectiveAdminObservabilityConfig() + t.Cleanup(func() { + ConfigureImageTools(&oldImage) + ConfigureCindyProvider(&oldCindy) + ConfigureAdminObservability(&oldObservability) + }) + ConfigureImageTools(&extensionv1.ImageToolsConfig{}) + ConfigureCindyProvider(&extensionv1.CindyProviderConfig{}) + ConfigureAdminObservability(&extensionv1.AdminObservabilityConfig{}) + for _, test := range []struct { + name string + value string + err error + }{ + {name: "database error", err: errors.New("fixture database unavailable")}, + {name: "invalid JSON", value: `{"broken"`}, + {name: "invalid value type", value: `{"studio_enabled":"false"}`}, + {name: "empty existing key", value: ""}, + } { + t.Run(test.name, func(t *testing.T) { + svc := NewSettingService(&nativeSwitchReadRepository{value: test.value, err: test.err}, nil) + for _, load := range []func(context.Context) error{svc.LoadImageToolsConfig, svc.LoadCindyProviderConfig, svc.LoadAdminObservabilityConfig} { + require.Error(t, load(context.Background())) + } + require.Equal(t, extensionv1.ImageToolsConfig{}, EffectiveImageToolsConfig()) + require.Equal(t, extensionv1.CindyProviderConfig{}, EffectiveCindyProviderConfig()) + require.Equal(t, extensionv1.AdminObservabilityConfig{}, EffectiveAdminObservabilityConfig()) + }) + } + svc := NewSettingService(&nativeSwitchReadRepository{err: ErrSettingNotFound}, nil) + var value map[string]json.RawMessage + found, err := svc.readNativeSwitchSetting(context.Background(), SettingKeyImageToolsConfig, &value, "studio_enabled", "responses_image_enabled") + require.NoError(t, err) + require.False(t, found) +} diff --git a/backend/internal/service/openai_business_system_prompt.go b/backend/internal/service/openai_business_system_prompt.go index e6de2c6b0565..588dc903247a 100644 --- a/backend/internal/service/openai_business_system_prompt.go +++ b/backend/internal/service/openai_business_system_prompt.go @@ -480,7 +480,7 @@ func (s *OpenAIGatewayService) applyBusinessSystemPromptForRequest( if frozen.Revision < 1 && state.application.Applied { return nil, BusinessSystemPromptApplication{}, ErrBusinessSystemPromptUnavailable } - application, err := planBusinessSystemPromptWithInvoker(promptPolicyRequestContext(ctx), body, frozen, target, invokeProcessExtension) + application, err := planBusinessSystemPromptWithInvoker(promptPolicyRequestContext(ctx), body, frozen, target, promptPlanInvoke) if err != nil { return nil, BusinessSystemPromptApplication{}, err } diff --git a/backend/internal/service/openai_chat_reasoning_replay.go b/backend/internal/service/openai_chat_reasoning_replay.go index 65be9698d1ca..75953180e69d 100644 --- a/backend/internal/service/openai_chat_reasoning_replay.go +++ b/backend/internal/service/openai_chat_reasoning_replay.go @@ -12,8 +12,8 @@ import ( "slices" "strings" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" "github.com/tidwall/sjson" diff --git a/backend/internal/service/openai_chat_reasoning_replay_test.go b/backend/internal/service/openai_chat_reasoning_replay_test.go index 55dbd7747e95..2e4f06f2ca12 100644 --- a/backend/internal/service/openai_chat_reasoning_replay_test.go +++ b/backend/internal/service/openai_chat_reasoning_replay_test.go @@ -14,9 +14,9 @@ import ( "testing" "time" - codexrecovery "github.com/HTExplicit/sub2api-plugins/codexruntime/recovery" + codexrecovery "github.com/Wei-Shaw/sub2api/internal/codexruntime/recovery" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" diff --git a/backend/internal/service/openai_codex_client_identity.go b/backend/internal/service/openai_codex_client_identity.go index 4afdf533c3eb..333113fba446 100644 --- a/backend/internal/service/openai_codex_client_identity.go +++ b/backend/internal/service/openai_codex_client_identity.go @@ -9,7 +9,7 @@ import ( "sync" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) // CodexClientIdentityExtraKey 持久化在 accounts.extra 中的每账号 Codex 客户端身份。 @@ -285,10 +285,10 @@ func (s *CodexClientIdentityBackfillService) RunOnce(ctx context.Context) (int, // The background scan may skip inapplicable accounts, but its policy // lease and write must stay scoped to each actual applicable account. if err := func() error { - bound, release, err := bindProcessExtensionContext(ctx, account.Platform, account.Type, extensionv1.Invocation{ + bound, release, err := bindNativeCodexContext(ctx, account.Platform, account.Type, extensionv1.Invocation{ Capability: extensionv1.CapabilityRequest, Operation: "codex.identity.derive", AccountID: account.ID, }) - if errors.Is(err, ErrExtensionOperationDisabled) { + if errors.Is(err, ErrNativeCodexPolicyDisabled) { return nil } if err != nil { diff --git a/backend/internal/service/openai_codex_client_identity_test.go b/backend/internal/service/openai_codex_client_identity_test.go index b4e89ec7672d..953d4b38b2b6 100644 --- a/backend/internal/service/openai_codex_client_identity_test.go +++ b/backend/internal/service/openai_codex_client_identity_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/Wei-Shaw/sub2api/internal/config" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/stretchr/testify/require" diff --git a/backend/internal/service/openai_codex_fingerprint.go b/backend/internal/service/openai_codex_fingerprint.go index 55d3531acfda..a2d3d2f53218 100644 --- a/backend/internal/service/openai_codex_fingerprint.go +++ b/backend/internal/service/openai_codex_fingerprint.go @@ -11,7 +11,7 @@ import ( "strings" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/tidwall/gjson" diff --git a/backend/internal/service/openai_codex_identity_snapshot.go b/backend/internal/service/openai_codex_identity_snapshot.go index 24c2c6fc8653..7302c8039482 100644 --- a/backend/internal/service/openai_codex_identity_snapshot.go +++ b/backend/internal/service/openai_codex_identity_snapshot.go @@ -8,8 +8,8 @@ import ( "strings" "time" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" ) // codexIdentitySnapshot is the secret-free execution state reported by an diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 857da90f8b38..d32ec26de08c 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -17,12 +17,12 @@ import ( "sync" "time" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "golang.org/x/net/http2" "golang.org/x/sync/singleflight" ) diff --git a/backend/internal/service/openai_codex_ticket_scheduler_test.go b/backend/internal/service/openai_codex_ticket_scheduler_test.go index aa69de765481..3cc202e9648b 100644 --- a/backend/internal/service/openai_codex_ticket_scheduler_test.go +++ b/backend/internal/service/openai_codex_ticket_scheduler_test.go @@ -127,7 +127,7 @@ func TestSelectAccountWithSchedulerDefersTicketGateToAuthoritativeAccount(t *tes if tc.advanced { svc.rateLimitService = newOpenAIAdvancedSchedulerRateLimitService("true") } - svc.pluginManager = ticketTestManager(t, svc.cfg.Gateway.OpenAICodexTicket, nil) + svc.nativeCodexRuntime = nativeTicketTestRuntime(t, svc.cfg.Gateway.OpenAICodexTicket, nil) selection, decision, err := svc.SelectAccountWithScheduler( ctx, &groupID, "", "", "gpt-6-astra", nil, OpenAIUpstreamTransportAny, false, @@ -138,7 +138,7 @@ func TestSelectAccountWithSchedulerDefersTicketGateToAuthoritativeAccount(t *tes require.NotNil(t, selection.Account) require.Equal(t, full.ID, selection.Account.ID) require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) - require.NotNil(t, selection.Account.Extra[PluginAccountProjectionKey]) + require.NotNil(t, selection.Account.Extra[NativeCodexAccountProjectionKey]) }) } } @@ -159,7 +159,7 @@ func TestSelectAccountWithSchedulerKeepsFailClosedForTicketlessAccounts(t *testi schedulerSnapshot: &SchedulerSnapshotService{cache: snapshotCache}, concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}), } - svc.pluginManager = ticketTestManager(t, svc.cfg.Gateway.OpenAICodexTicket, nil) + svc.nativeCodexRuntime = nativeTicketTestRuntime(t, svc.cfg.Gateway.OpenAICodexTicket, nil) selection, _, err := svc.SelectAccountWithScheduler( ctx, &groupID, "", "", "gpt-6-astra", nil, OpenAIUpstreamTransportAny, false, diff --git a/backend/internal/service/openai_codex_ticket_test.go b/backend/internal/service/openai_codex_ticket_test.go index 77e968414da3..9f76da379d50 100644 --- a/backend/internal/service/openai_codex_ticket_test.go +++ b/backend/internal/service/openai_codex_ticket_test.go @@ -10,11 +10,8 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - hcplugin "github.com/hashicorp/go-plugin" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" - "google.golang.org/grpc" - "google.golang.org/protobuf/types/known/wrapperspb" ) func fakeCodexTicketState(n int) string { @@ -27,110 +24,54 @@ func ticketTestAccount(id int64) *Account { return &Account{ID: id, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"access_token": "tok", "chatgpt_account_id": "acc-1"}} } -// Host tests stub only the process boundary. Ticket validation, acquisition and -// renewal are tested against the independent module in plugins/codex-runtime. -type ticketExtensionTestConn struct { - grpc.ClientConnInterface - invoke func(extensionv1.Invocation) (extensionv1.Result, error) -} - -func (c *ticketExtensionTestConn) Invoke(_ context.Context, _ string, args, reply any, _ ...grpc.CallOption) error { - input, ok := args.(*wrapperspb.BytesValue) - if !ok || input == nil { - return ErrExtensionOperationUnavailable - } - output, ok := reply.(*wrapperspb.BytesValue) - if !ok || output == nil { - return ErrExtensionOperationUnavailable - } - var request extensionv1.Invocation - if err := json.Unmarshal(input.Value, &request); err != nil { - return err - } - result, err := c.invoke(request) - if err != nil { - return err - } - output.Value, err = json.Marshal(result) - return err -} -func ticketTestManager(t *testing.T, cfg config.OpenAICodexTicketConfig, invoke func(extensionv1.Invocation) (extensionv1.Result, error)) *PluginManager { - t.Helper() - if len(cfg.Models) == 0 { - cfg.Models = []string{"gpt-6-astra", "gpt-5.6-sol"} - } - installation := &PluginInstallation{ID: 1, PluginKey: codexRuntimePluginKey} - for _, capability := range []string{extensionv1.CapabilityScheduling, extensionv1.CapabilityRequest, extensionv1.CapabilityAdmin} { - for _, kind := range []string{AccountTypeOAuth, AccountTypeSetupToken} { - installation.Bindings = append(installation.Bindings, PluginBinding{Capability: capability, Platform: PlatformOpenAI, AccountType: kind, Enabled: true, RolloutPercent: 100}) - } - } - if invoke == nil { - invoke = func(extensionv1.Invocation) (extensionv1.Result, error) { - return extensionv1.Result{Code: "ticket_missing"}, nil - } - } - runtime := &pluginRuntime{installation: installation, client: &hcplugin.Client{}, extension: extensionv1.NewClient(&ticketExtensionTestConn{invoke: invoke}), done: make(chan struct{})} - raw, _ := json.Marshal(map[string]any{"enabled": cfg.Enabled, "fail_closed": cfg.FailClosed, "models": cfg.Models, "proxy_url": cfg.HarvestProxyURL}) - snapshot := json.RawMessage(raw) - runtime.configSnapshot.Store(&snapshot) - rules := []extensionv1.SchedulingRule{} - if cfg.Enabled && cfg.FailClosed { - rules = append(rules, extensionv1.SchedulingRule{Models: cfg.Models, Default: "deny", Reason: "ticket_missing", ExcludeShadows: true}) - } - runtime.scheduling.Store(&rules) - manager := &PluginManager{} - manager.extensions.Store(&pluginExtensionRegistry{installations: map[int64]*PluginInstallation{1: installation}, runtimes: map[int64]*pluginRuntime{1: runtime}}) - return manager -} func ticketTestService(t *testing.T, cfg config.OpenAICodexTicketConfig, upstream HTTPUpstream) *OpenAIGatewayService { - return &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICodexTicket: cfg}}, httpUpstream: upstream, pluginManager: ticketTestManager(t, cfg, nil)} + return &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICodexTicket: cfg}}, httpUpstream: upstream, nativeCodexRuntime: nativeTicketTestRuntime(t, cfg, nil)} } + func setTicketTestProjection(account *Account, model string, expiry time.Time) { - projection := PluginAccountProjection{Identity: CodexTicketAccountIdentity(account), Scheduling: map[string]extensionv1.SchedulingConstraint{model: {Model: model, Effect: "allow", Until: &expiry, Reason: "routing_verified"}}} + projection := NativeCodexAccountProjection{Identity: CodexTicketAccountIdentity(account), Scheduling: map[string]extensionv1.SchedulingConstraint{model: {Model: model, Effect: "allow", Until: &expiry, Reason: "routing_verified"}}} raw, _ := json.Marshal(projection) var value map[string]any _ = json.Unmarshal(raw, &value) - account.Extra = map[string]any{PluginAccountProjectionKey: map[string]any{codexRuntimePluginKey: value}} + account.Extra = map[string]any{NativeCodexAccountProjectionKey: map[string]any{codexRuntimePluginKey: value}} } -func TestCodexTicketFacadeUsesPluginWithoutLegacyFallback(t *testing.T) { + +func TestCodexTicketFacadeUsesNativeWithoutLegacyFallback(t *testing.T) { cfg := config.OpenAICodexTicketConfig{Enabled: true, FailClosed: true} account := ticketTestAccount(41) account.Extra = map[string]any{openAICodexTicketExtraKey("gpt-6-astra"): map[string]any{"state": fakeCodexTicketState(292), "expires_at": time.Now().Add(time.Hour)}} var observed extensionv1.SchedulingRequest - manager := ticketTestManager(t, cfg, func(in extensionv1.Invocation) (extensionv1.Result, error) { + manager := nativeTicketTestRuntime(t, cfg, func(in extensionv1.Invocation) (extensionv1.Result, error) { require.Equal(t, extensionv1.CapabilityRequest, in.Capability) require.Equal(t, "inject", in.Operation) require.NoError(t, json.Unmarshal(in.Payload, &observed)) raw, _ := json.Marshal(map[string]any{"headers": map[string]string{openAICodexTurnStateHeader: "plugin-owned-ticket"}}) return extensionv1.Result{Payload: raw}, nil }) - service := &OpenAIGatewayService{pluginManager: manager} + service := &OpenAIGatewayService{nativeCodexRuntime: manager} headers := http.Header{http.CanonicalHeaderKey(openAICodexTurnStateHeader): []string{"caller-ticket"}} require.NoError(t, service.applyOpenAICodexTicket(context.Background(), account, "gpt-6-astra", headers)) require.Equal(t, "plugin-owned-ticket", headers.Get(openAICodexTurnStateHeader)) require.Equal(t, account.ID, observed.Account.ID) require.Equal(t, CodexTicketAccountIdentity(account), observed.Account.Identity) require.Equal(t, "gpt-6-astra", observed.Model) - registry := manager.extensions.Load() - registry.runtimes = map[int64]*pluginRuntime{} - manager.extensions.Store(registry) + manager.current().cancel() require.ErrorIs(t, service.applyOpenAICodexTicket(context.Background(), account, "gpt-6-astra", http.Header{}), ErrOpenAICodexTicketUnavailable) - require.True(t, service.openAICodexTicketBlocksAccount(account, "gpt-6-astra"), "legacy Extra must not bypass an unavailable plugin") - for i := range registry.installations[1].Bindings { - registry.installations[1].Bindings[i].Enabled = false - } - manager.extensions.Store(registry) + require.True(t, service.openAICodexTicketBlocksAccount(account, "gpt-6-astra"), "legacy Extra cannot bypass a stopped native runtime") + service.nativeCodexRuntime = nativeTicketTestRuntime(t, config.OpenAICodexTicketConfig{Enabled: false}, func(extensionv1.Invocation) (extensionv1.Result, error) { + return extensionv1.Result{Payload: json.RawMessage(`{"headers":{}}`)}, nil + }) + headers = http.Header{} require.NoError(t, service.applyOpenAICodexTicket(context.Background(), account, "gpt-6-astra", headers)) require.Empty(t, headers) require.False(t, service.openAICodexTicketBlocksAccount(account, "gpt-6-astra")) } -func TestCodexTicketPluginRPCFailureDoesNotFallBack(t *testing.T) { - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{Enabled: true, FailClosed: true}, func(extensionv1.Invocation) (extensionv1.Result, error) { +func TestCodexTicketNativePolicyFailureDoesNotFallBack(t *testing.T) { + manager := nativeTicketTestRuntime(t, config.OpenAICodexTicketConfig{Enabled: true, FailClosed: true}, func(extensionv1.Invocation) (extensionv1.Result, error) { return extensionv1.Result{}, errors.New("offline fixture") }) - service := &OpenAIGatewayService{pluginManager: manager} + service := &OpenAIGatewayService{nativeCodexRuntime: manager} require.ErrorIs(t, service.applyOpenAICodexTicket(context.Background(), ticketTestAccount(41), "gpt-6-astra", http.Header{}), ErrOpenAICodexTicketUnavailable) } func TestOpenAICodexTicketStatusesUsesSanitizedProjection(t *testing.T) { diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 521f04c7422f..d3bc6f389bc0 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -13,7 +13,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" ) @@ -23,13 +22,6 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco rememberPromptRequestedModel(c, body) stageCodexRoutingTurn(c, body) if account != nil && IsCindyAPIKeyAccount(account.Platform, account.Type, account.Credentials) && IsImageGenerationIntent(openAIResponsesEndpoint, gjson.GetBytes(body, "model").String(), body) { - bound, release, err := bindProcessExtensionContext(ctx, PlatformCindy, AccountTypeAPIKey, extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "image.responses.plan", AccountID: account.ID}) - if err != nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{"type": "service_unavailable", "message": "Image bridge is unavailable"}}) - return nil, err - } - defer release() - ctx = bound resolved, err := ResolveCindyResponsesImageToolsForAccount(ctx, account, body) if err != nil { status, code, message := http.StatusBadRequest, "invalid_request_error", "Invalid image bridge request" diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 117fb48eb165..632a4a4c7e07 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -474,6 +474,7 @@ type OpenAIGatewayService struct { userGroupRateResolver *userGroupRateResolver httpUpstream HTTPUpstream pluginManager *PluginManager + nativeCodexRuntime *NativeCodexRuntime deferredService *DeferredService openAITokenProvider *OpenAITokenProvider grokTokenProvider *GrokTokenProvider diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go index ab3317e15912..f824cbf05467 100644 --- a/backend/internal/service/openai_images.go +++ b/backend/internal/service/openai_images.go @@ -22,7 +22,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/gin-gonic/gin" "github.com/imroc/req/v3" "github.com/tidwall/gjson" @@ -687,14 +686,6 @@ func (s *OpenAIGatewayService) ForwardImages( parsed *OpenAIImagesRequest, channelMappedModel string, ) (*OpenAIForwardResult, error) { - if parsed != nil && account != nil && IsCindyAPIKeyAccount(account.Platform, account.Type, account.Credentials) { - bound, release, err := bindProcessExtensionContext(ctx, PlatformCindy, AccountTypeAPIKey, extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "image.native.validate", AccountID: account.ID}) - if err != nil { - return nil, err - } - defer release() - ctx = bound - } pricingContext, pricingErr := CaptureCindyPricingContext(ctx, c, account) if pricingErr != nil { return nil, pricingErr diff --git a/backend/internal/service/openai_reasoning_recovery.go b/backend/internal/service/openai_reasoning_recovery.go index 32f18fe1935d..12fc2ad2177e 100644 --- a/backend/internal/service/openai_reasoning_recovery.go +++ b/backend/internal/service/openai_reasoning_recovery.go @@ -14,8 +14,8 @@ import ( "strings" "time" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" "github.com/tidwall/sjson" diff --git a/backend/internal/service/plugin_account_create.go b/backend/internal/service/plugin_account_create.go deleted file mode 100644 index 14f9393c2a96..000000000000 --- a/backend/internal/service/plugin_account_create.go +++ /dev/null @@ -1,439 +0,0 @@ -package service - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "maps" - "math" - "slices" - "strings" - "sync" - - dbent "github.com/Wei-Shaw/sub2api/ent" - infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" -) - -var ( - ErrAccountCreateInvalid = infraerrors.BadRequest("ACCOUNT_CREATE_INVALID_REQUEST", "invalid provider create input") - ErrAccountCreateUnavailable = infraerrors.Conflict("ACCOUNT_CREATE_UNAVAILABLE", "provider creation is unavailable or has changed") - ErrAccountCreateGroupsRequired = infraerrors.BadRequest("ACCOUNT_CREATE_GROUP_REQUIRED", "provider account requires an effective group") -) - -var accountCreateDefaultTargets = []string{"concurrency", "priority", "rate_multiplier", "load_factor", "responses_mode"} - -func validateAccountCreateContributions(manifest PluginManifest) error { - seen := map[string]bool{} - for _, contribution := range manifest.Contributions { - definition := contribution.AccountCreate - if contribution.Slot != extensionv1.AccountCreateSlot { - if definition != nil { - return ErrAccountCreateInvalid - } - continue - } - if definition == nil || manifest.ID != CindyAccountViewPluginKey || !accountViewIDPattern.MatchString(contribution.ID) || - contribution.Capability != extensionv1.CapabilityProvider || contribution.Permission != "admin" || - contribution.Entrypoint != "" || contribution.Action != "" || contribution.AccountFilter != nil || - contribution.AccountView != nil || contribution.ResourceAction != nil || contribution.AllAccounts || contribution.RetainedControls || - len(contribution.DisplayFields) != 0 || len(contribution.ValueBindings) != 0 || len(contribution.Events) != 0 || - !accountViewLabelValid(contribution.Label) { - return ErrAccountCreateInvalid - } - adminDeclared := false - for _, capability := range manifest.Capabilities { - adminDeclared = adminDeclared || capability.ID == extensionv1.CapabilityAdmin - } - if !adminDeclared || definition.Version != 1 || definition.Platform != PlatformCindy || definition.AccountType != AccountTypeAPIKey || definition.CredentialProfile != ProviderProfileCindyLaxaV1 || seen[definition.CredentialProfile] { - return ErrAccountCreateInvalid - } - seen[definition.CredentialProfile] = true - ui := definition.CredentialUI - endpoint, err := NormalizeCredentialIdentityBaseURL(definition.CredentialProfile, ui.BaseURL) - if err != nil || endpoint != "https://api.laxarouter.ai" || ui.BaseURL != endpoint || !ui.BaseURLReadonly || len(ui.APIKeyPlaceholder) < 1 || len(ui.APIKeyPlaceholder) > 128 || !accountViewLabelValid(ui.Hint) { - return ErrAccountCreateInvalid - } - for _, label := range []map[string]string{ui.BaseURLHint, ui.AccountTypeHint, ui.CatalogLabel, ui.CatalogHint} { - if label != nil && !accountViewLabelValid(label) { - return ErrAccountCreateInvalid - } - } - defaults := definition.Defaults - if defaults.Concurrency < 1 || defaults.Priority < 0 || defaults.RateMultiplier < 0 || math.IsNaN(defaults.RateMultiplier) || math.IsInf(defaults.RateMultiplier, 0) || - (defaults.LoadFactor != nil && (*defaults.LoadFactor < 1 || *defaults.LoadFactor > 10000)) || - !slices.Contains([]string{"auto", "force_responses", "force_chat_completions"}, defaults.ResponsesMode) || - definition.MinimumEffectiveGroups != 1 || definition.UpstreamBillingProbe != "unsupported" || definition.ModelEditing != "provider_managed" || definition.CatalogSource != "group_model_candidates" { - return ErrAccountCreateInvalid - } - if len(contribution.Fields) > 8 || len(definition.FieldBindings) != len(contribution.Fields) { - return ErrAccountCreateInvalid - } - keys, targets := map[string]bool{}, map[string]bool{} - for _, field := range contribution.Fields { - target, exists := definition.FieldBindings[field.Key] - if !exists || !accountViewIDPattern.MatchString(field.Key) || keys[field.Key] || targets[target] || target != "provider_device_identity_input" || - field.Kind != "text" || field.MaxLength < 1 || field.MaxLength > 64 || field.Rows != 0 || field.OptionsSource != "" || field.DefaultSource != "" || len(field.DefaultLabel) != 0 || - !accountViewLabelValid(field.Label) || (field.Hint != nil && !accountViewLabelValid(field.Hint)) { - return ErrAccountCreateInvalid - } - keys[field.Key], targets[target] = true, true - } - } - return nil -} - -func AccountCreateDefinitionDigest(contribution *extensionv1.Contribution) string { - if contribution == nil || contribution.AccountCreate == nil { - return "" - } - raw, err := json.Marshal(contribution) - if err != nil { - return "" - } - digest := sha256.Sum256(raw) - return hex.EncodeToString(digest[:]) -} - -func validateAccountCreateRegistry(installations []*PluginInstallation) error { - owners := map[string]int64{} - for _, installation := range installations { - if installation == nil { - continue - } - for _, contribution := range installation.Manifest.Contributions { - if contribution.Slot != extensionv1.AccountCreateSlot || contribution.AccountCreate == nil { - continue - } - profile := contribution.AccountCreate.CredentialProfile - if owner, exists := owners[profile]; exists && owner != installation.ID { - return ErrAccountCreateUnavailable - } - owners[profile] = installation.ID - } - } - return nil -} - -func accountCreateScopeAllowed(installation *PluginInstallation, contribution *extensionv1.Contribution) bool { - if contribution == nil || contribution.AccountCreate == nil { - return false - } - definition := contribution.AccountCreate - for _, binding := range contributionEffectiveBindings(installation, contribution) { - if binding.RolloutPercent == 100 && pluginScopeMatches(binding.Platform, definition.Platform) && pluginScopeMatches(binding.AccountType, definition.AccountType) { - return true - } - } - return false -} - -type accountCreateContextKey struct{} - -// BoundAccountCreate is host-private and independent of the existing job owner. -// No credentials, native account object or generated device value enter the SDK. -type BoundAccountCreate struct { - Execution PluginExecution - PrimaryPluginKey string - manager *PluginManager - installation *PluginInstallation - contribution extensionv1.Contribution - request *extensionv1.ProviderCreateRequestV1 -} - -func AccountCreateFromContext(ctx context.Context) (*BoundAccountCreate, bool) { - if ctx == nil { - return nil, false - } - create, ok := ctx.Value(accountCreateContextKey{}).(*BoundAccountCreate) - return create, ok && create != nil -} - -func accountCreateContribution(installation *PluginInstallation, id string) *extensionv1.Contribution { - var found *extensionv1.Contribution - if installation == nil { - return nil - } - for _, contribution := range installation.Manifest.Contributions { - if contribution.ID == id && contribution.Slot == extensionv1.AccountCreateSlot && contribution.AccountCreate != nil { - if found != nil { - return nil - } - copy := contribution - found = © - } - } - return found -} - -func (m *PluginManager) accountCreateReady(current *PluginInstallation, contribution *extensionv1.Contribution) (*pluginRuntime, bool) { - registry := m.extensions.Load() - if registry == nil || registry.unavailable != "" || current == nil || current.State != PluginStateEnabled || current.RuntimeGeneration <= 0 || - current.PluginKey != CindyAccountViewPluginKey || current.Manifest.ID != current.PluginKey || !samePluginRuntime(current, registry.installations[current.ID]) || - contribution == nil || validateAccountCreateContributions(current.Manifest) != nil || !accountCreateScopeAllowed(current, contribution) { - return nil, false - } - runtime := registry.runtimes[current.ID] - if runtime == nil || runtime.client == nil || runtime.draining.Load() || runtime.client.Exited() || !pluginDependenciesHealthy(current, registry, map[int64]bool{}) { - return nil, false - } - if _, applied := m.contributionAppliedRevision(current, runtime); !applied { - return nil, false - } - if enabled, known := m.contributionConfigured(current, runtime, contribution.ConfigFlag); !enabled || !known { - return nil, false - } - return runtime, true -} - -func (m *PluginManager) BindAccountCreate(ctx context.Context, platform, accountType, profile string, request *extensionv1.ProviderCreateRequestV1) (context.Context, func(), error) { - if platform != PlatformCindy || accountType != AccountTypeAPIKey || profile != ProviderProfileCindyLaxaV1 || m == nil || m.repo == nil { - return nil, nil, ErrAccountCreateUnavailable - } - registry := m.extensions.Load() - if registry == nil || registry.unavailable != "" { - return nil, nil, ErrAccountCreateUnavailable - } - var ownerID int64 - var contributionID string - for id, installation := range registry.installations { - for _, contribution := range installation.Manifest.Contributions { - if contribution.Slot != extensionv1.AccountCreateSlot || contribution.AccountCreate == nil || contribution.AccountCreate.CredentialProfile != profile { - continue - } - if ownerID != 0 || installation.PluginKey != CindyAccountViewPluginKey { - return nil, nil, ErrAccountCreateUnavailable - } - ownerID, contributionID = id, contribution.ID - } - } - if ownerID == 0 { - return nil, nil, ErrAccountCreateUnavailable - } - current, err := m.repo.GetByID(ctx, ownerID) - if err != nil { - return nil, nil, ErrAccountCreateUnavailable - } - contribution := accountCreateContribution(current, contributionID) - runtime, ready := m.accountCreateReady(current, contribution) - if !ready { - return nil, nil, ErrAccountCreateUnavailable - } - if request != nil { - if !accountViewIDPattern.MatchString(request.ContributionID) || !accountViewDigestPattern.MatchString(request.ExpectedPackageSHA256) || !accountViewDigestPattern.MatchString(request.ExpectedDefinitionSHA256) || request.ExpectedRuntimeGeneration <= 0 || len(request.Values) > 8 || len(request.InheritDefaults) > 5 { - return nil, nil, ErrAccountCreateInvalid - } - if request.ContributionID != contribution.ID || request.ExpectedPackageSHA256 != current.PackageSHA256 || request.ExpectedDefinitionSHA256 != AccountCreateDefinitionDigest(contribution) || request.ExpectedRuntimeGeneration != current.RuntimeGeneration { - return nil, nil, ErrAccountCreateUnavailable - } - seen := map[string]bool{} - for _, target := range request.InheritDefaults { - if !slices.Contains(accountCreateDefaultTargets, target) || seen[target] { - return nil, nil, ErrAccountCreateInvalid - } - seen[target] = true - } - for key, value := range request.Values { - valid := false - for _, field := range contribution.Fields { - valid = valid || (key == field.Key && len([]rune(value)) <= field.MaxLength) - } - if !valid { - return nil, nil, ErrAccountCreateInvalid - } - } - copy := *request - copy.Values, copy.InheritDefaults = maps.Clone(request.Values), slices.Clone(request.InheritDefaults) - request = © - } - primaryKey := "" - if execution, bound := PluginExecutionFromContext(ctx); bound { - primary, err := m.repo.GetByID(ctx, execution.ID) - if err != nil || primary == nil || primary.RuntimeGeneration != execution.Generation || primary.State != PluginStateEnabled { - return nil, nil, ErrAccountCreateUnavailable - } - primaryKey = primary.PluginKey - } - bound, release, err := m.bindHostPolicyContext(ctx, current, runtime) - if err != nil { - return nil, nil, ErrAccountCreateUnavailable - } - create := &BoundAccountCreate{Execution: PluginExecution{current.ID, current.RuntimeGeneration}, PrimaryPluginKey: primaryKey, manager: m, installation: current, contribution: *contribution, request: request} - return context.WithValue(bound, accountCreateContextKey{}, create), release, nil -} - -func bindProcessAccountCreate(ctx context.Context, platform, accountType, profile string, request *extensionv1.ProviderCreateRequestV1) (context.Context, func(), error) { - provider := processExtensionOperations.Load() - if provider == nil { - return nil, nil, ErrAccountCreateUnavailable - } - binder, ok := provider.invoker.(interface { - BindAccountCreate(context.Context, string, string, string, *extensionv1.ProviderCreateRequestV1) (context.Context, func(), error) - }) - if !ok { - return nil, nil, ErrAccountCreateUnavailable - } - return binder.BindAccountCreate(ctx, platform, accountType, profile, request) -} - -func ValidateAccountCreateFresh(ctx context.Context) error { - create, bound := AccountCreateFromContext(ctx) - if !bound { - return nil - } - if ctx.Err() != nil { - return ErrAccountCreateUnavailable - } - current, err := create.manager.repo.GetByID(ctx, create.Execution.ID) - if err != nil || current == nil || current.Revision != create.installation.Revision || current.ConfigEncrypted != create.installation.ConfigEncrypted || !samePluginRuntime(current, create.installation) { - return ErrAccountCreateUnavailable - } - contribution := accountCreateContribution(current, create.contribution.ID) - if _, ready := create.manager.accountCreateReady(current, contribution); !ready || AccountCreateDefinitionDigest(contribution) != AccountCreateDefinitionDigest(&create.contribution) { - return ErrAccountCreateUnavailable - } - return nil -} - -func accountCreateConfigDigest(encrypted string) string { - digest := sha256.Sum256([]byte(encrypted)) - return hex.EncodeToString(digest[:]) -} - -// ValidateAccountCreateFenceData validates data read under the shared installation -// row lock in the actual account transaction. No secret is returned or logged. -func ValidateAccountCreateFenceData(fence PluginExecutionFence, manifestJSON []byte, encryptedConfig string, fullProviderAdmin bool) error { - if !fullProviderAdmin || accountCreateConfigDigest(encryptedConfig) != fence.ConfigSHA256 { - return ErrAccountCreateUnavailable - } - var manifest PluginManifest - if json.Unmarshal(manifestJSON, &manifest) != nil || manifest.ID != fence.PluginKey || validateAccountCreateContributions(manifest) != nil { - return ErrAccountCreateUnavailable - } - contribution := accountCreateContribution(&PluginInstallation{Manifest: manifest}, fence.CreateContributionID) - if AccountCreateDefinitionDigest(contribution) != fence.CreateDefinitionSHA256 { - return ErrAccountCreateUnavailable - } - return nil -} - -// An outer batch/import transaction owns the create lease until actual commit or -// rollback. Returning from the nested service call must not release it early. -func retainAccountCreateUntilTransactionEnds(ctx context.Context, tx *dbent.Tx, release func()) { - var once sync.Once - finish := func() { once.Do(release) } - tx.OnCommit(func(next dbent.Committer) dbent.Committer { - return dbent.CommitFunc(func(commitCtx context.Context, transaction *dbent.Tx) error { - if err := ValidateAccountCreateFresh(ctx); err != nil { - return err // rollback owns release when commit is rejected - } - err := next.Commit(commitCtx, transaction) - if err == nil { - finish() - } - return err - }) - }) - tx.OnRollback(func(next dbent.Rollbacker) dbent.Rollbacker { - return dbent.RollbackFunc(func(rollbackCtx context.Context, transaction *dbent.Tx) error { - err := next.Rollback(rollbackCtx, transaction) - finish() - return err - }) - }) -} - -func applyAccountCreateProfile(ctx context.Context, input *CreateAccountInput) error { - create, bound := AccountCreateFromContext(ctx) - if !bound || ValidateAccountCreateFresh(ctx) != nil { - return ErrAccountCreateUnavailable - } - definition := create.contribution.AccountCreate - if input.ProbeEnabled != nil && *input.ProbeEnabled { - return ErrAccountCreateInvalid - } - if len(input.ModelContextOverrides) != 0 { - return ErrAccountCreateInvalid - } - input.Extra = maps.Clone(input.Extra) - if input.Extra == nil { - input.Extra = map[string]any{} - } - request := create.request - if request != nil { - for key, value := range request.Values { - if create.contribution.AccountCreate.FieldBindings[key] != "provider_device_identity_input" { - return ErrAccountCreateInvalid - } - value = strings.TrimSpace(value) - if existing, present := input.Extra[CindyDeviceIDExtraKey]; present { - text, valid := existing.(string) - if !valid || strings.TrimSpace(text) != value { - return ErrAccountCreateInvalid - } - } - if value != "" { - input.Extra[CindyDeviceIDExtraKey] = value - } else { - delete(input.Extra, CindyDeviceIDExtraKey) - } - } - for _, target := range request.InheritDefaults { - if accountCreateValueExplicit(input, target) { - continue - } - switch target { - case "concurrency": - input.Concurrency = definition.Defaults.Concurrency - case "priority": - input.Priority = definition.Defaults.Priority - case "rate_multiplier": - value := definition.Defaults.RateMultiplier - input.RateMultiplier = &value - case "load_factor": - input.LoadFactor = nil - if definition.Defaults.LoadFactor != nil { - value := *definition.Defaults.LoadFactor - input.LoadFactor = &value - } - } - } - } - if mode, present := input.Extra[CindyResponsesModeExtraKey]; present { - text, valid := mode.(string) - if !valid || !slices.Contains([]string{"auto", "force_responses", "force_chat_completions"}, text) { - return ErrAccountCreateInvalid - } - } else { - input.Extra[CindyResponsesModeExtraKey] = definition.Defaults.ResponsesMode - } - // Creation derives provenance from the actual input, not a client assertion. - // Keep the legacy input validator even though valid claims are not trusted. - if source, present := input.Extra[CindyDeviceIDSourceExtraKey]; present { - if _, valid := normalizeCindyDeviceIDSource(source); !valid { - return infraerrors.BadRequest("CINDY_DEVICE_ID_SOURCE_INVALID", "cindy_device_id_source is invalid") - } - } - delete(input.Extra, CindyDeviceIDSourceExtraKey) - return nil -} - -func accountCreateValueExplicit(input *CreateAccountInput, target string) bool { - if input.ExplicitCreateFields != nil { - return input.ExplicitCreateFields[target] - } - // Direct Go callers can supply the presence map for explicit zero/null. - // Existing untagged callers never enter numeric inheritance at all. - switch target { - case "concurrency": - return input.Concurrency != 0 - case "priority": - return input.Priority != 0 - case "rate_multiplier": - return input.RateMultiplier != nil - case "load_factor": - return input.LoadFactor != nil - } - return false -} diff --git a/backend/internal/service/plugin_account_create_test.go b/backend/internal/service/plugin_account_create_test.go deleted file mode 100644 index 71c6b4c95354..000000000000 --- a/backend/internal/service/plugin_account_create_test.go +++ /dev/null @@ -1,362 +0,0 @@ -//go:build unit - -package service - -import ( - "context" - "encoding/json" - "os" - "strings" - "testing" - - "entgo.io/ent/dialect" - entsql "entgo.io/ent/dialect/sql" - "github.com/DATA-DOG/go-sqlmock" - dbent "github.com/Wei-Shaw/sub2api/ent" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/stretchr/testify/require" -) - -type accountCreateFixtureInvoker struct { - promptPolicyFixture - manager *PluginManager -} - -func (f *accountCreateFixtureInvoker) BindAccountCreate(ctx context.Context, platform, kind, profile string, request *extensionv1.ProviderCreateRequestV1) (context.Context, func(), error) { - return f.manager.BindAccountCreate(ctx, platform, kind, profile, request) -} - -func newAccountCreateFixture(t *testing.T) (*PluginManager, *viewFixtureRepo, *extensionv1.ProviderCreateRequestV1) { - t.Helper() - manager, repo, _, _ := newViewFixture(t) - raw, err := os.ReadFile("../../../plugins/cindy-provider/manifest.source.json") - require.NoError(t, err) - var manifest PluginManifest - require.NoError(t, json.Unmarshal(raw, &manifest)) - var create *extensionv1.Contribution - for _, contribution := range manifest.Contributions { - if contribution.Slot == extensionv1.AccountCreateSlot { - copy := contribution - create = © - } - } - require.NotNil(t, create) - repo.installations[1].Manifest.Contributions = append(repo.installations[1].Manifest.Contributions, *create) - applyViewFixture(t, manager, repo) - request := &extensionv1.ProviderCreateRequestV1{ContributionID: create.ID, - ExpectedPackageSHA256: repo.installations[1].PackageSHA256, ExpectedDefinitionSHA256: AccountCreateDefinitionDigest(create), - ExpectedRuntimeGeneration: repo.installations[1].RuntimeGeneration, Values: map[string]string{}, InheritDefaults: []string{}} - return manager, repo, request -} - -func useAccountCreateFixture(t *testing.T) (*PluginManager, *viewFixtureRepo, *extensionv1.ProviderCreateRequestV1) { - t.Helper() - manager, repo, request := newAccountCreateFixture(t) - previous := processExtensionOperations.Load() - processExtensionOperations.Store(&extensionOperationProvider{invoker: &accountCreateFixtureInvoker{manager: manager}}) - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - return manager, repo, request -} - -type accountCreateGroupFixture struct { - GroupRepository - groups []Group - defaultReads int -} - -func (f *accountCreateGroupFixture) ListActiveByPlatform(context.Context, string) ([]Group, error) { - f.defaultReads++ - return f.groups, nil -} - -func (f *accountCreateGroupFixture) GetByID(_ context.Context, id int64) (*Group, error) { - for _, group := range f.groups { - if group.ID == id { - return &group, nil - } - } - return nil, ErrGroupNotFound -} - -func accountCreateGroups() *accountCreateGroupFixture { - return &accountCreateGroupFixture{groups: []Group{{ID: 91, Name: "cindy-default", Platform: PlatformCindy, WirePlatform: WirePlatformOpenAI, ProviderProfile: ProviderProfileCindyLaxaV1, Status: StatusActive}}} -} - -func TestAccountCreateDeclarationAndNullableDefaults(t *testing.T) { - _, repo, request := newAccountCreateFixture(t) - baseline := repo.installations[1].Manifest - require.NoError(t, validateAccountCreateContributions(baseline)) - create := accountCreateContribution(repo.installations[1], request.ContributionID) - require.Nil(t, create.AccountCreate.Defaults.LoadFactor) - for _, mutate := range []func(*PluginManifest){ - func(m *PluginManifest) { m.ID = "other.owner" }, - func(m *PluginManifest) { - m.Contributions[1].AccountCreate.CredentialUI.BaseURL = "https://other.invalid" - }, - func(m *PluginManifest) { - m.Contributions[1].AccountCreate.FieldBindings["device_id"] = "credentials.api_key" - }, - func(m *PluginManifest) { m.Contributions[1].AccountCreate.MinimumEffectiveGroups = 0 }, - func(m *PluginManifest) { m.Contributions[1].AccountCreate.Defaults.LoadFactor = ptrAccountCreateInt(0) }, - func(m *PluginManifest) { m.Contributions = append(m.Contributions, m.Contributions[1]) }, - } { - raw, err := json.Marshal(baseline) - require.NoError(t, err) - var changed PluginManifest - require.NoError(t, json.Unmarshal(raw, &changed)) - mutate(&changed) - require.Error(t, validateAccountCreateContributions(changed)) - } - raw, err := json.Marshal(create.AccountCreate) - require.NoError(t, err) - var decoded extensionv1.AccountCreateDefinitionV1 - require.NoError(t, json.Unmarshal(raw, &decoded), "load_factor:null is part of the contract") - for _, invalid := range []string{ - strings.Replace(string(raw), `"priority":50`, `"priority":null`, 1), - strings.Replace(string(raw), `"version":1`, `"version":1,"callback":"arbitrary"`, 1), - } { - require.Error(t, json.Unmarshal([]byte(invalid), &decoded)) - } -} - -func ptrAccountCreateInt(value int) *int { return &value } - -func TestAccountCreateIDlessScopeAndFreshPreconditions(t *testing.T) { - for _, percentages := range [][2]int{{100, 100}, {99, 100}, {100, 99}, {0, 100}} { - manager, repo, request := newAccountCreateFixture(t) - repo.installations[1].Bindings[0].RolloutPercent = percentages[0] - repo.installations[1].Bindings[1].RolloutPercent = percentages[1] - applyViewFixture(t, manager, repo) - _, release, err := manager.BindAccountCreate(context.Background(), PlatformCindy, AccountTypeAPIKey, ProviderProfileCindyLaxaV1, request) - if percentages == [2]int{100, 100} { - require.NoError(t, err) - release() - } else { - require.ErrorIs(t, err, ErrAccountCreateUnavailable) - } - require.Zero(t, manager.accountDirectory.(*viewFixtureDirectory).reads, "IDless admission never invents an account bucket") - } - manager, repo, request := newAccountCreateFixture(t) - for _, mutate := range []func(*extensionv1.ProviderCreateRequestV1){ - func(r *extensionv1.ProviderCreateRequestV1) { r.ExpectedPackageSHA256 = strings.Repeat("d", 64) }, - func(r *extensionv1.ProviderCreateRequestV1) { r.ExpectedDefinitionSHA256 = strings.Repeat("e", 64) }, - func(r *extensionv1.ProviderCreateRequestV1) { r.ExpectedRuntimeGeneration++ }, - func(r *extensionv1.ProviderCreateRequestV1) { r.ContributionID = "other-create" }, - } { - copy := *request - mutate(©) - _, _, err := manager.BindAccountCreate(context.Background(), PlatformCindy, AccountTypeAPIKey, ProviderProfileCindyLaxaV1, ©) - require.ErrorIs(t, err, ErrAccountCreateUnavailable) - } - repo.installations[1].ConfigEncrypted = "changed-but-not-applied" - _, _, err := manager.BindAccountCreate(context.Background(), PlatformCindy, AccountTypeAPIKey, ProviderProfileCindyLaxaV1, nil) - require.ErrorIs(t, err, ErrAccountCreateUnavailable, "legacy omission cannot use a stale applied config") -} - -func TestAccountCreateCentralDefaultsGroupsAndExplicitAuto(t *testing.T) { - _, plugins, request := useAccountCreateFixture(t) - accounts := &accountRepoStubForBulkUpdate{createID: 71} - groups := accountCreateGroups() - svc := &adminServiceImpl{accountRepo: accounts, groupRepo: groups, cindyAccountMutations: &recordingAdminCindyMutationRunner{}} - input := &CreateAccountInput{Name: "synthetic", Platform: PlatformCindy, Type: AccountTypeAPIKey, - Credentials: cindyCredentials(), SkipMixedChannelCheck: true} - legacy, err := svc.CreateAccount(context.Background(), input) - require.NoError(t, err) - require.Equal(t, []int64{91}, accounts.bindGroupsByAccount[71]) - require.Equal(t, 1, groups.defaultReads, "minimum is checked after the server fallback") - require.Zero(t, legacy.Concurrency) - require.Zero(t, legacy.Priority) - require.Nil(t, legacy.RateMultiplier, "legacy untagged numeric semantics remain unchanged") - require.Equal(t, "force_responses", legacy.Extra[CindyResponsesModeExtraKey]) - request.InheritDefaults = append([]string(nil), accountCreateDefaultTargets...) - input.ProviderCreate = request - input.ExplicitCreateFields = map[string]bool{} - inherited, err := svc.CreateAccount(context.Background(), input) - require.NoError(t, err) - require.Equal(t, 3, inherited.Concurrency) - require.Equal(t, 50, inherited.Priority) - require.Equal(t, 1.0, *inherited.RateMultiplier) - require.Nil(t, inherited.LoadFactor) - zero := 0.0 - input.RateMultiplier = &zero - input.ExplicitCreateFields = map[string]bool{"concurrency": true, "priority": true, "rate_multiplier": true, "load_factor": true} - input.Extra = map[string]any{CindyResponsesModeExtraKey: "auto", CindyDeviceIDSourceExtraKey: "registration-record"} - explicit, err := svc.CreateAccount(context.Background(), input) - require.NoError(t, err) - require.Zero(t, explicit.Concurrency) - require.Zero(t, explicit.Priority) - require.Zero(t, *explicit.RateMultiplier) - require.Nil(t, explicit.LoadFactor) - require.Equal(t, "auto", explicit.Extra[CindyResponsesModeExtraKey]) - require.Equal(t, "generated-production-v1", explicit.Extra[CindyDeviceIDSourceExtraKey]) - accounts.createAccount = nil - groups.groups = nil - _, err = svc.CreateAccount(context.Background(), input) - require.ErrorIs(t, err, ErrAccountCreateGroupsRequired) - require.Nil(t, accounts.createAccount) - plugins.installations[1].State = PluginStateDisabled - _, err = svc.CreateAccount(context.Background(), &CreateAccountInput{Platform: PlatformCindy, Type: AccountTypeAPIKey, Credentials: cindyCredentials(), GroupIDs: []int64{91}}) - require.ErrorIs(t, err, ErrAccountCreateUnavailable, "omitting provider_create cannot bypass disable") -} - -func TestAccountCreateDeclaredInputsStayInHost(t *testing.T) { - manager, _, request := newAccountCreateFixture(t) - request.Values["device_id"] = strings.Repeat("a", 64) - ctx, release, err := manager.BindAccountCreate(context.Background(), PlatformCindy, AccountTypeAPIKey, ProviderProfileCindyLaxaV1, request) - require.NoError(t, err) - defer release() - input := &CreateAccountInput{Credentials: cindyCredentials(), Extra: map[string]any{CindyDeviceIDSourceExtraKey: "registration-record"}} - require.NoError(t, applyAccountCreateProfile(ctx, input)) - extra, err := normalizeCindyDeviceIdentityForCreate(PlatformCindy, AccountTypeAPIKey, input.Credentials, input.Extra) - require.NoError(t, err) - require.Equal(t, request.Values["device_id"], extra[CindyDeviceIDExtraKey]) - require.Equal(t, "input-preserved", extra[CindyDeviceIDSourceExtraKey]) - input.Extra = map[string]any{CindyDeviceIDExtraKey: strings.Repeat("b", 64)} - require.ErrorIs(t, applyAccountCreateProfile(ctx, input), ErrAccountCreateInvalid) - request.Values = map[string]string{"credentials": "not allowed"} - _, _, err = manager.BindAccountCreate(context.Background(), PlatformCindy, AccountTypeAPIKey, ProviderProfileCindyLaxaV1, request) - require.ErrorIs(t, err, ErrAccountCreateInvalid) -} - -func TestAccountCreateLegacyImportMappingIsPreserved(t *testing.T) { - useAccountCreateFixture(t) - accounts := &accountRepoStubForBulkUpdate{createID: 83} - svc := &adminServiceImpl{accountRepo: accounts, groupRepo: accountCreateGroups(), cindyAccountMutations: &recordingAdminCindyMutationRunner{}} - mapping := map[string]any{"client-alias": "provider/model-one", "custom-route": "upstream-model-two"} - credentials := cindyCredentials() - credentials["model_mapping"] = mapping - // Data import passes the ordinary credential map through this central input - // without provider_create; a hidden UI editor is not a storage prohibition. - account, err := svc.CreateAccount(context.Background(), &CreateAccountInput{ - Name: "legacy-import", Platform: PlatformCindy, Type: AccountTypeAPIKey, - Credentials: credentials, GroupIDs: []int64{91}, SkipMixedChannelCheck: true, SkipDefaultGroupBind: true, - }) - require.NoError(t, err) - require.Equal(t, mapping, account.Credentials["model_mapping"]) - require.Equal(t, mapping, accounts.createAccount.Credentials["model_mapping"]) - require.Equal(t, mapping, credentials["model_mapping"], "normalization must not rewrite imported mapping values") -} - -func TestAccountCreateLegacyProvenanceRejectsInvalidSource(t *testing.T) { - useAccountCreateFixture(t) - accounts := &accountRepoStubForBulkUpdate{createID: 84} - svc := &adminServiceImpl{accountRepo: accounts, groupRepo: accountCreateGroups(), cindyAccountMutations: &recordingAdminCindyMutationRunner{}} - for _, source := range []any{nil, 7, false, "unknown-provenance"} { - _, err := svc.CreateAccount(context.Background(), &CreateAccountInput{ - Platform: PlatformCindy, Type: AccountTypeAPIKey, Credentials: cindyCredentials(), - Extra: map[string]any{CindyDeviceIDSourceExtraKey: source}, GroupIDs: []int64{91}, SkipMixedChannelCheck: true, - }) - requireApplicationErrorReason(t, err, "CINDY_DEVICE_ID_SOURCE_INVALID") - require.Nil(t, accounts.createAccount) - } - account, err := svc.CreateAccount(context.Background(), &CreateAccountInput{ - Platform: PlatformCindy, Type: AccountTypeAPIKey, Credentials: cindyCredentials(), - Extra: map[string]any{CindyDeviceIDExtraKey: strings.Repeat("f", 64), CindyDeviceIDSourceExtraKey: "registration-record"}, - GroupIDs: []int64{91}, SkipMixedChannelCheck: true, - }) - require.NoError(t, err) - require.Equal(t, "input-preserved", account.Extra[CindyDeviceIDSourceExtraKey], "valid legacy input is checked but actual provenance is still host-derived") -} - -func TestAccountCreateOrdinaryOpenAILaxaRemainsIndependent(t *testing.T) { - previous := processExtensionOperations.Load() - processExtensionOperations.Store(nil) - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - accounts := &accountRepoStubForBulkUpdate{createID: 81} - svc := &adminServiceImpl{accountRepo: accounts} - input := &CreateAccountInput{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: cindyCredentials(), SkipDefaultGroupBind: true} - account, err := svc.CreateAccount(context.Background(), input) - require.NoError(t, err) - require.Equal(t, PlatformOpenAI, account.Platform) - require.NotEqual(t, ProviderProfileCindyLaxaV1, account.ProviderProfile) - require.NotContains(t, account.Extra, CindyResponsesModeExtraKey) - require.NotContains(t, account.Extra, CindyDeviceIDExtraKey) - input.Extra = map[string]any{CindyResponsesModeExtraKey: "force_chat_completions"} - account, err = svc.CreateAccount(context.Background(), input) - require.NoError(t, err) - require.Equal(t, "force_chat_completions", account.Extra[CindyResponsesModeExtraKey]) -} - -func TestAccountCreateOriginContextDoesNotCaptureOrdinaryAccount(t *testing.T) { - manager, _, request := newAccountCreateFixture(t) - ctx, release, err := manager.BindAccountCreate(context.Background(), PlatformCindy, AccountTypeAPIKey, ProviderProfileCindyLaxaV1, request) - require.NoError(t, err) - defer release() - svc := &adminServiceImpl{accountRepo: &accountRepoStubForBulkUpdate{createID: 82}} - account, err := svc.CreateAccount(ctx, &CreateAccountInput{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, - Credentials: cindyCredentials(), SkipDefaultGroupBind: true}) - require.NoError(t, err, "an inherited host context must not impose another profile's minimum groups on ordinary OpenAI") - require.NotContains(t, account.Extra, CindyResponsesModeExtraKey) - require.NotContains(t, account.Extra, CindyDeviceIDExtraKey) -} - -func TestAccountCreateTransactionOwnsLeaseAndCommitAdmission(t *testing.T) { - for _, canceled := range []bool{false, true} { - manager, plugins, request := newAccountCreateFixture(t) - ctx, release, err := manager.BindAccountCreate(context.Background(), PlatformCindy, AccountTypeAPIKey, ProviderProfileCindyLaxaV1, request) - require.NoError(t, err) - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) - mock.ExpectBegin() - tx, err := client.Tx(context.Background()) - require.NoError(t, err) - retainAccountCreateUntilTransactionEnds(ctx, tx, release) - require.Empty(t, plugins.released, "returning from an inner create does not finish its outer transaction") - if canceled { - manager.extensions.Load().runtimes[1].beginDrain() - require.ErrorIs(t, tx.Commit(), ErrAccountCreateUnavailable) - require.Empty(t, plugins.released, "rejected commit retains the lease until rollback finishes") - mock.ExpectRollback() - require.NoError(t, tx.Rollback()) - } else { - mock.ExpectCommit() - require.NoError(t, tx.Commit()) - } - require.Equal(t, []int64{1}, plugins.released) - require.NoError(t, mock.ExpectationsWereMet()) - } -} - -func TestAccountCreateProjectionAndSameOwnerFenceIntersection(t *testing.T) { - manager, plugins, request := newAccountCreateFixture(t) - var projected *PluginContribution - for _, contribution := range manager.Contributions() { - if contribution.Slot == extensionv1.AccountCreateSlot { - copy := contribution - projected = © - } - } - require.NotNil(t, projected) - require.True(t, projected.Available) - require.Equal(t, request.ExpectedDefinitionSHA256, projected.CreateDefinitionDigest) - require.Equal(t, request.ExpectedRuntimeGeneration, projected.RuntimeGeneration) - raw, err := json.Marshal(projected) - require.NoError(t, err) - require.NotContains(t, string(raw), `"credentials"`) - require.NotContains(t, string(raw), `"extra"`) - require.NotContains(t, string(raw), "test-key") - view := plugins.installations[1].Manifest.Contributions[0] - viewCtx, viewRelease, err := manager.BindAccountViewRequest(context.Background(), extensionv1.AccountViewContextV1{AccountViewIdentityV1: extensionv1.AccountViewIdentityV1{ - Version: 1, PluginID: 1, PluginKey: CindyAccountViewPluginKey, PackageSHA256: request.ExpectedPackageSHA256, - ViewID: view.ID, PresetID: "cindy", ViewDefinitionDigest: AccountViewDefinitionDigest(&view), - }}) - require.NoError(t, err) - defer viewRelease() - ctx, release, err := manager.BindAccountCreate(WithPluginExecution(viewCtx, plugins.installations[2]), PlatformCindy, AccountTypeAPIKey, ProviderProfileCindyLaxaV1, request) - require.NoError(t, err) - defer release() - primary, _ := PluginExecutionFromContext(ctx) - require.EqualValues(t, 2, primary.ID) - fences, err := PluginExecutionFences(ctx, "") - require.NoError(t, err) - require.Len(t, fences, 2) - require.True(t, fences[0].OriginView && fences[0].OriginCreate) - require.EqualValues(t, 1, fences[0].ID) - require.True(t, fences[1].Primary) - create, _ := AccountCreateFromContext(ctx) - create.installation.Revision++ - _, err = PluginExecutionFences(ctx, "") - require.Error(t, err, "two policies for one installation cannot overwrite conflicting revisions") -} diff --git a/backend/internal/service/plugin_account_edit.go b/backend/internal/service/plugin_account_edit.go deleted file mode 100644 index 262ffaa03b25..000000000000 --- a/backend/internal/service/plugin_account_edit.go +++ /dev/null @@ -1,692 +0,0 @@ -package service - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "maps" - "slices" - "sync" - "sync/atomic" - - dbent "github.com/Wei-Shaw/sub2api/ent" - infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" - "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" -) - -var ( - ErrAccountEditInvalid = infraerrors.BadRequest("ACCOUNT_EDIT_INVALID_REQUEST", "invalid provider edit input") - ErrAccountEditUnavailable = infraerrors.Conflict("ACCOUNT_EDIT_UNAVAILABLE", "provider editing is unavailable or has changed") - ErrAccountEditStateChanged = infraerrors.Conflict("ACCOUNT_EDIT_STATE_CHANGED", "account edit state has changed") - ErrAccountEditCatalogUnavailable = infraerrors.Conflict("ACCOUNT_EDIT_CATALOG_UNAVAILABLE", "provider edit catalog is unavailable or has changed") -) - -func validateAccountEditContributions(manifest PluginManifest) error { - seen := false - for _, contribution := range manifest.Contributions { - definition := contribution.AccountEdit - if contribution.Slot != extensionv1.AccountEditSlot { - if definition != nil { - return ErrAccountEditInvalid - } - continue - } - if definition == nil || seen || manifest.ID != CindyAccountViewPluginKey || !accountViewIDPattern.MatchString(contribution.ID) || - contribution.Capability != extensionv1.CapabilityProvider || contribution.Permission != "admin" || !accountViewLabelValid(contribution.Label) || - contribution.Entrypoint != "" || contribution.Action != "" || contribution.AccountFilter != nil || contribution.AccountView != nil || contribution.AccountCreate != nil || - contribution.ResourceAction != nil || contribution.AllAccounts || contribution.RetainedControls || len(contribution.Fields) != 0 || len(contribution.Assets) != 0 || - len(contribution.DisplayFields) != 0 || len(contribution.ValueBindings) != 0 || len(contribution.Events) != 0 { - return ErrAccountEditInvalid - } - seen = true - admin := false - for _, capability := range manifest.Capabilities { - admin = admin || capability.ID == extensionv1.CapabilityAdmin - } - if !admin || definition.Version != 1 || definition.Platform != PlatformCindy || definition.AccountType != AccountTypeAPIKey || definition.CredentialProfile != ProviderProfileCindyLaxaV1 || - definition.CredentialUI.BaseURL != "https://api.laxarouter.ai" || !definition.CredentialUI.BaseURLReadonly || !accountViewLabelValid(definition.CredentialUI.Hint) || - definition.CatalogSource != "provider_catalog_snapshot_v1" || definition.MappingPolicy != "managed_catalog_v1" || definition.UnlistedFields != "preserve" || len(definition.WireControls) > 3 || - !accountViewLabelValid(definition.Labels.ManagedCatalog) || !accountViewLabelValid(definition.Labels.ManagedAliases) || !accountViewLabelValid(definition.Labels.CustomMappings) { - return ErrAccountEditInvalid - } - targets := map[string]bool{} - for _, control := range definition.WireControls { - allowed, valid := accountEditModeValues[control.Target] - if !valid || targets[control.Target] || len(control.Values) == 0 || len(control.Values) > len(allowed) { - return ErrAccountEditInvalid - } - targets[control.Target] = true - values := map[string]bool{} - for _, value := range control.Values { - if !slices.Contains(allowed, value) || values[value] { - return ErrAccountEditInvalid - } - values[value] = true - } - } - } - return nil -} - -func AccountEditDefinitionDigest(contribution *extensionv1.Contribution) string { - if contribution == nil || contribution.AccountEdit == nil { - return "" - } - raw, err := json.Marshal(contribution) - if err != nil { - return "" - } - digest := sha256.Sum256(raw) - return hex.EncodeToString(digest[:]) -} - -func validateAccountEditRegistry(installations []*PluginInstallation) error { - owner := int64(0) - for _, installation := range installations { - if installation == nil { - continue - } - for _, contribution := range installation.Manifest.Contributions { - if contribution.Slot == extensionv1.AccountEditSlot && contribution.AccountEdit != nil { - if owner != 0 { - return ErrAccountEditUnavailable - } - owner = installation.ID - } - } - } - return nil -} - -func accountEditContribution(installation *PluginInstallation, id string) *extensionv1.Contribution { - if installation == nil { - return nil - } - for _, contribution := range installation.Manifest.Contributions { - if contribution.ID == id && contribution.Slot == extensionv1.AccountEditSlot && contribution.AccountEdit != nil { - copy := contribution - return © - } - } - return nil -} - -// Resolve from persisted identity/registry, including disabled owners. This -// read does not acquire a runtime/business lease: deliberate typed no-ops still -// reject stale references without depending on process health. -func (m *PluginManager) resolveAccountEdit(ctx context.Context, account *Account) (*PluginInstallation, *extensionv1.Contribution, error) { - if m == nil || m.repo == nil || !hasCanonicalCindyProviderIdentity(account) || account.ID <= 0 { - return nil, nil, ErrAccountEditUnavailable - } - installations, err := m.repo.List(ctx) - if err != nil || validateAccountEditRegistry(installations) != nil { - return nil, nil, ErrAccountEditUnavailable - } - for _, installation := range installations { - if installation == nil || installation.PluginKey != CindyAccountViewPluginKey || installation.Manifest.ID != installation.PluginKey { - continue - } - if validateAccountEditContributions(installation.Manifest) != nil { - return nil, nil, ErrAccountEditUnavailable - } - for _, contribution := range installation.Manifest.Contributions { - if contribution.Slot == extensionv1.AccountEditSlot && contribution.AccountEdit != nil && contribution.AccountEdit.CredentialProfile == account.EffectiveProviderProfile() { - frozen := *installation - frozen.Bindings = slices.Clone(installation.Bindings) - raw, err := json.Marshal(installation.Manifest) - if err != nil { - return nil, nil, ErrAccountEditUnavailable - } - var manifest PluginManifest - if json.Unmarshal(raw, &manifest) != nil { - return nil, nil, ErrAccountEditUnavailable - } - frozen.Manifest = manifest - return &frozen, accountEditContribution(&frozen, contribution.ID), nil - } - } - } - return nil, nil, ErrAccountEditUnavailable -} - -func (m *PluginManager) accountEditReady(current *PluginInstallation, contribution *extensionv1.Contribution, account *Account) (*pluginRuntime, bool) { - registry := m.extensions.Load() - if registry == nil || registry.unavailable != "" || current == nil || current.State != PluginStateEnabled || current.RuntimeGeneration <= 0 || - !samePluginRuntime(current, registry.installations[current.ID]) || contribution == nil || validateAccountEditContributions(current.Manifest) != nil { - return nil, false - } - if account != nil && (!hasCanonicalCindyProviderIdentity(account) || !contributionAccountAllowed(current, contribution, extensionv1.Account{ID: account.ID, Platform: account.Platform, Type: account.Type})) { - return nil, false - } - runtime := registry.runtimes[current.ID] - if runtime == nil || runtime.client == nil || runtime.draining.Load() || runtime.client.Exited() || !pluginDependenciesHealthy(current, registry, map[int64]bool{}) { - return nil, false - } - if _, applied := m.contributionAppliedRevision(current, runtime); !applied { - return nil, false - } - if enabled, known := m.contributionConfigured(current, runtime, contribution.ConfigFlag); !enabled || !known { - return nil, false - } - return runtime, true -} - -type AccountEditProfileReference struct { - PluginID int64 `json:"plugin_id,omitempty"` - PluginKey string `json:"plugin_key,omitempty"` - ContributionID string `json:"contribution_id,omitempty"` - PackageSHA256 string `json:"package_sha256,omitempty"` - DefinitionSHA256 string `json:"definition_sha256,omitempty"` - RuntimeGeneration int64 `json:"runtime_generation,omitempty"` - Available bool `json:"available"` - Reason string `json:"reason,omitempty"` -} - -type AccountEditFieldState struct { - Present bool `json:"present"` - Recognized bool `json:"recognized"` - Value json.RawMessage `json:"value,omitempty"` - Effective string `json:"effective"` -} - -type AccountEditCatalogModel struct { - extensionv1.CindyCatalogModel - Type string `json:"type"` - CreatedAt string `json:"created_at"` -} - -type AccountEditCatalog struct { - Status string `json:"status"` - Reason string `json:"reason,omitempty"` - Namespace string `json:"namespace,omitempty"` - Models *[]AccountEditCatalogModel `json:"models,omitempty"` - Aliases *map[string]string `json:"aliases,omitempty"` -} - -type AccountEditContext struct { - SchemaVersion int `json:"schema_version"` - Kind string `json:"kind"` - AccountID int64 `json:"account_id"` - Profile *AccountEditProfileReference `json:"profile,omitempty"` - EditStateSHA256 string `json:"edit_state_sha256,omitempty"` - Values map[string]AccountEditFieldState `json:"values,omitempty"` - Catalog *AccountEditCatalog `json:"catalog,omitempty"` -} - -func accountEditFieldState(account *Account, target, effective string) AccountEditFieldState { - state := AccountEditFieldState{Effective: effective} - for index, key := range accountEditTargetKeys(target) { - raw, present := account.Extra[key] - if !present { - continue - } - state.Present = true - if index > 0 { // legacy WS fallback bool; its native encoding remains stored - if _, ok := raw.(bool); ok { - state.Recognized = true - state.Value, _ = json.Marshal(effective) - return state - } - continue - } - if raw == nil { - state.Recognized = true - state.Value = json.RawMessage("null") - return state - } - if value, ok := raw.(string); ok && (slices.Contains(accountEditModeValues[target], value) || (target == "responses_websocket_mode" && slices.Contains([]string{"shared", "dedicated"}, value))) { - state.Recognized = true - state.Value, _ = json.Marshal(value) - } - return state - } - return state -} - -func newAccountEditContext(account *Account, wsDefault string) *AccountEditContext { - result := &AccountEditContext{SchemaVersion: 1, Kind: "core", AccountID: account.ID} - if !hasCanonicalCindyProviderIdentity(account) { - return result - } - result.Kind, result.EditStateSHA256 = "provider", AccountEditStateDigest(account) - result.Profile = &AccountEditProfileReference{Reason: "account_edit_unavailable"} - result.Catalog = &AccountEditCatalog{Status: "unavailable", Reason: "profile_unavailable"} - responses, _ := account.Extra["openai_responses_mode"].(string) - result.Values = map[string]AccountEditFieldState{ - "responses_mode": accountEditFieldState(account, "responses_mode", string(openai_compat.NormalizeResponsesSupportMode(responses))), - "compact_mode": accountEditFieldState(account, "compact_mode", account.GetOpenAICompactMode()), - "responses_websocket_mode": accountEditFieldState(account, "responses_websocket_mode", account.ResolveOpenAIResponsesWebSocketV2Mode(wsDefault)), - } - return result -} - -func accountEditProfile(current *PluginInstallation, contribution *extensionv1.Contribution) *AccountEditProfileReference { - return &AccountEditProfileReference{PluginID: current.ID, PluginKey: current.PluginKey, ContributionID: contribution.ID, - PackageSHA256: current.PackageSHA256, DefinitionSHA256: AccountEditDefinitionDigest(contribution), RuntimeGeneration: current.RuntimeGeneration} -} - -func accountEditCatalog(snapshot *CindyCatalogSnapshot) *AccountEditCatalog { - if snapshot == nil { - return &AccountEditCatalog{Status: "unavailable", Reason: "catalog_rpc_failed"} - } - if !snapshot.Config.CatalogEnabled { - return &AccountEditCatalog{Status: "unavailable", Reason: "catalog_disabled"} - } - models := make([]AccountEditCatalogModel, 0, len(snapshot.CatalogModels)) - public := map[string]bool{} - for _, model := range snapshot.CatalogModels { - models = append(models, AccountEditCatalogModel{CindyCatalogModel: model, Type: "model"}) - public[model.ID] = model.PublicModel - } - aliases := map[string]string{} - for alias, target := range snapshot.CompatibilityAliases { - if public[target] { - aliases[alias] = target - } - } - return &AccountEditCatalog{Status: "ready", Namespace: snapshot.Namespace, Models: &models, Aliases: &aliases} -} - -func (m *PluginManager) AccountEditContext(ctx context.Context, account *Account) (*AccountEditContext, error) { - if account == nil || account.ID <= 0 { - return nil, ErrAccountEditInvalid - } - if err := ValidateAccountViewTargets(ctx, []int64{account.ID}); err != nil { - return nil, err - } - defaultMode := "ctx_pool" - if m != nil && m.cfg != nil { - defaultMode = m.cfg.Gateway.OpenAIWS.IngressModeDefault - } - result := newAccountEditContext(account, defaultMode) - if result.Kind == "core" { - return result, nil - } - current, contribution, err := m.resolveAccountEdit(ctx, account) - if err != nil { - return result, nil - } - result.Profile = accountEditProfile(current, contribution) - bound, release, err := m.bindAccountEditPolicy(ctx, account, current, contribution) - if err != nil { - result.Profile.Reason = "account_edit_unavailable" - return result, nil - } - defer release() - result.Profile.Available = true - snapshot, err := LoadCindyCatalogSnapshot(bound, account) - if err != nil { - result.Catalog = accountEditCatalog(nil) - return result, nil - } - if snapshot.PluginID != current.ID || ValidateAccountEditFresh(bound) != nil { - result.Profile.Available, result.Profile.Reason = false, "account_edit_unavailable" - return result, nil - } - result.Catalog = accountEditCatalog(snapshot) - return result, nil -} - -func LoadAccountEditContext(ctx context.Context, account *Account) (*AccountEditContext, error) { - if account == nil || account.ID <= 0 { - return nil, ErrAccountEditInvalid - } - if err := ValidateAccountViewTargets(ctx, []int64{account.ID}); err != nil { - return nil, err - } - provider := processExtensionOperations.Load() - if provider != nil { - if resolver, ok := provider.invoker.(interface { - AccountEditContext(context.Context, *Account) (*AccountEditContext, error) - }); ok { - return resolver.AccountEditContext(ctx, account) - } - } - return newAccountEditContext(account, "ctx_pool"), nil -} - -type accountEditContextKey struct{} - -// A host-private plan is bound to a single actual row. The primary job and view -// identities remain separate; the edit owner never replaces either one. -type BoundAccountEdit struct { - Execution PluginExecution - PrimaryPluginKey string - AccountID int64 - StateSHA256 string - intentSHA256 string - changed bool - fenced bool - desired map[string]accountEditRawValue - manager *PluginManager - installation *PluginInstallation - contribution extensionv1.Contribution - account *Account - lease *accountEditLease -} - -type accountEditLease struct { - finishOnce sync.Once - transaction atomic.Bool - release func() -} - -func (l *accountEditLease) finish() { l.finishOnce.Do(l.release) } -func (l *accountEditLease) releaseCall() { - if !l.transaction.Load() { - l.finish() - } -} - -func AccountEditFromContext(ctx context.Context) (*BoundAccountEdit, bool) { - if ctx == nil { - return nil, false - } - edit, ok := ctx.Value(accountEditContextKey{}).(*BoundAccountEdit) - return edit, ok && edit != nil -} - -func (m *PluginManager) bindAccountEditPolicy(ctx context.Context, account *Account, current *PluginInstallation, contribution *extensionv1.Contribution) (context.Context, func(), error) { - runtime, ready := m.accountEditReady(current, contribution, account) - if !ready { - return nil, nil, ErrAccountEditUnavailable - } - primaryKey := "" - if execution, bound := PluginExecutionFromContext(ctx); bound { - primary, err := m.repo.GetByID(ctx, execution.ID) - if err != nil || primary == nil || primary.RuntimeGeneration != execution.Generation || primary.State != PluginStateEnabled { - return nil, nil, ErrAccountEditUnavailable - } - primaryKey = primary.PluginKey - } - bound, release, err := m.bindHostPolicyContext(ctx, current, runtime) - if err != nil { - return nil, nil, ErrAccountEditUnavailable - } - copy := *account - edit := &BoundAccountEdit{Execution: PluginExecution{current.ID, current.RuntimeGeneration}, PrimaryPluginKey: primaryKey, - AccountID: account.ID, StateSHA256: AccountEditStateDigest(account), manager: m, installation: current, contribution: *contribution, account: ©} - edit.lease = &accountEditLease{release: release} - return context.WithValue(bound, accountEditContextKey{}, edit), edit.lease.releaseCall, nil -} - -func validateAccountEditReference(account *Account, current *PluginInstallation, contribution *extensionv1.Contribution, request *extensionv1.ProviderEditRequestV1) error { - if request == nil { - return nil - } - if err := validateAccountEditRequest(request); err != nil { - return err - } - if request.ExpectedStateSHA256 != AccountEditStateDigest(account) { - return ErrAccountEditStateChanged - } - if contribution == nil || current == nil || request.ContributionID != contribution.ID || request.ExpectedPackageSHA256 != current.PackageSHA256 || - request.ExpectedDefinitionSHA256 != AccountEditDefinitionDigest(contribution) || request.ExpectedRuntimeGeneration != current.RuntimeGeneration { - return ErrAccountEditUnavailable - } - for target, change := range request.Changes { - if target == "model_mapping" { - continue - } - if target == "compact_model_mapping" { - if !contribution.AccountEdit.CompactMappingEditable { - return ErrAccountEditInvalid - } - continue - } - allowed := false - for _, control := range contribution.AccountEdit.WireControls { - if control.Target == target && ((change.Op == "clear" && control.AllowClear) || (change.Op == "set" && change.Value != nil && slices.Contains(control.Values, *change.Value))) { - allowed = true - } - } - if !allowed { - return ErrAccountEditInvalid - } - } - return nil -} - -func (m *PluginManager) PrepareAccountEdit(ctx context.Context, account *Account, credentials, extra map[string]any, request *extensionv1.ProviderEditRequestV1) (context.Context, func(), error) { - if err := validateAccountEditRequest(request); err != nil { - return nil, nil, err - } - if !hasCanonicalCindyProviderIdentity(account) || account.ID <= 0 { - return nil, nil, ErrAccountEditInvalid - } - var current *PluginInstallation - var contribution *extensionv1.Contribution - var err error - if request != nil { - current, contribution, err = m.resolveAccountEdit(ctx, account) - if err != nil { - return nil, nil, err - } - if err = validateAccountEditReference(account, current, contribution, request); err != nil { - return nil, nil, err - } - } - var snapshot *CindyCatalogSnapshot - release := func() {} - bound := ctx - if request != nil { - if _, partition := request.Changes["model_mapping"]; partition { - if request.ExpectedCatalogNamespace == "" { - return nil, nil, ErrAccountEditInvalid - } - bound, release, err = m.bindAccountEditPolicy(ctx, account, current, contribution) - if err != nil { - return nil, nil, err - } - snapshot, err = LoadCindyCatalogSnapshot(bound, account) - if err != nil || snapshot == nil || snapshot.PluginID != current.ID || !snapshot.Config.CatalogEnabled || snapshot.Namespace != request.ExpectedCatalogNamespace { - release() - return nil, nil, ErrAccountEditCatalogUnavailable - } - } - } - desired, changed, err := accountEditDesired(account, credentials, extra, request, snapshot) - if err != nil { - release() - return nil, nil, err - } - if changed { - if current == nil { - current, contribution, err = m.resolveAccountEdit(ctx, account) - if err != nil { - release() - return nil, nil, err - } - } - if _, already := AccountEditFromContext(bound); !already { - bound, release, err = m.bindAccountEditPolicy(ctx, account, current, contribution) - if err != nil { - return nil, nil, err - } - } - edit, _ := AccountEditFromContext(bound) - edit.changed, edit.desired, edit.intentSHA256 = true, desired, accountEditIntentDigest(credentials, extra, request) - return bound, release, nil - } - // A catalog-dependent intent had to prove its partition. Release that read - // lease now; the remaining unchanged write does not need runtime health. - release() - edit := &BoundAccountEdit{AccountID: account.ID, StateSHA256: AccountEditStateDigest(account), desired: desired, intentSHA256: accountEditIntentDigest(credentials, extra, request), - manager: m, installation: current, account: account} - if contribution != nil { - edit.contribution = *contribution - } - return context.WithValue(ctx, accountEditContextKey{}, edit), func() {}, nil -} - -func PrepareAccountEdit(ctx context.Context, account *Account, credentials, extra map[string]any, request *extensionv1.ProviderEditRequestV1) (context.Context, func(), error) { - if account == nil || account.ID <= 0 { - return nil, nil, ErrAccountEditInvalid - } - if existing, bound := AccountEditFromContext(ctx); bound { - if existing.AccountID != account.ID || existing.StateSHA256 != AccountEditStateDigest(account) || existing.intentSHA256 != accountEditIntentDigest(credentials, extra, request) { - return nil, nil, ErrAccountEditStateChanged - } - return ctx, func() {}, nil - } - if !hasCanonicalCindyProviderIdentity(account) { - if request != nil { - return nil, nil, ErrAccountEditInvalid - } - return ctx, func() {}, nil - } - if request == nil { - desired, changed, err := accountEditDesired(account, credentials, extra, nil, nil) - if err != nil { - return nil, nil, err - } - if !changed { - if !HasAccountEditOwnedInput(credentials, extra) { - return ctx, func() {}, nil - } - return context.WithValue(ctx, accountEditContextKey{}, &BoundAccountEdit{AccountID: account.ID, StateSHA256: AccountEditStateDigest(account), desired: desired, intentSHA256: accountEditIntentDigest(credentials, extra, request)}), func() {}, nil - } - } - provider := processExtensionOperations.Load() - if provider != nil { - if binder, ok := provider.invoker.(interface { - PrepareAccountEdit(context.Context, *Account, map[string]any, map[string]any, *extensionv1.ProviderEditRequestV1) (context.Context, func(), error) - }); ok { - return binder.PrepareAccountEdit(ctx, account, credentials, extra, request) - } - } - return nil, nil, ErrAccountEditUnavailable -} - -// Called only after ordered installation fences and the account row lock have -// both been acquired. A new delta discovered after re-read must fail closed. -func AccountEditOwnedForUpdate(ctx context.Context, account *Account, credentials, extra map[string]any, request *extensionv1.ProviderEditRequestV1) (map[string]accountEditRawValue, error) { - if edit, bound := AccountEditFromContext(ctx); bound { - if edit.AccountID != account.ID || edit.StateSHA256 != AccountEditStateDigest(account) || edit.intentSHA256 != accountEditIntentDigest(credentials, extra, request) { - return nil, ErrAccountEditStateChanged - } - if edit.changed && (!edit.fenced || dbent.TxFromContext(ctx) == nil) { - return nil, ErrAccountEditUnavailable - } - if err := ValidateAccountEditFresh(ctx); err != nil { - return nil, err - } - return edit.desired, nil - } - if !hasCanonicalCindyProviderIdentity(account) { - if request != nil { - return nil, ErrAccountEditInvalid - } - // Ordinary OpenAI retains the approved native mode semantics, but its - // mapping object (and every other platform) keeps legacy replacement. - if account.Platform != PlatformOpenAI || account.Type != AccountTypeAPIKey { - return nil, nil - } - desired, _, err := accountEditDesired(account, nil, extra, nil, nil) - for _, key := range accountEditCredentialKeys { - delete(desired, key) - } - return desired, err - } - desired, changed, err := accountEditDesired(account, credentials, extra, request, nil) - if err != nil { - return nil, err - } - if hasCanonicalCindyProviderIdentity(account) && (changed || request != nil) { - return nil, ErrAccountEditUnavailable - } - return desired, nil -} - -func MarkAccountEditFenced(ctx context.Context) { - if edit, bound := AccountEditFromContext(ctx); bound { - edit.fenced = true - } -} - -func ValidateAccountEditFresh(ctx context.Context) error { - edit, bound := AccountEditFromContext(ctx) - if !bound || edit.installation == nil { - return nil - } - if ctx.Err() != nil { - return ErrAccountEditUnavailable - } - current, err := edit.manager.repo.GetByID(ctx, edit.installation.ID) - if err != nil || current == nil || !samePluginRuntime(current, edit.installation) || current.Revision != edit.installation.Revision || current.ConfigEncrypted != edit.installation.ConfigEncrypted { - return ErrAccountEditUnavailable - } - contribution := accountEditContribution(current, edit.contribution.ID) - if AccountEditDefinitionDigest(contribution) != AccountEditDefinitionDigest(&edit.contribution) { - return ErrAccountEditUnavailable - } - if edit.changed || edit.Execution.ID > 0 { - if _, ready := edit.manager.accountEditReady(current, contribution, edit.account); !ready { - return ErrAccountEditUnavailable - } - } - return nil -} - -// Database fence validation uses bindings read while holding the same owner row -// lock as config/package changes. Unlike Create it uses the real stable bucket. -func ValidateAccountEditFenceData(fence PluginExecutionFence, manifestJSON []byte, encryptedConfig string, bindings []PluginBinding) error { - if accountCreateConfigDigest(encryptedConfig) != fence.ConfigSHA256 || fence.EditAccountID <= 0 { - return ErrAccountEditUnavailable - } - var manifest PluginManifest - if json.Unmarshal(manifestJSON, &manifest) != nil || manifest.ID != fence.PluginKey || validateAccountEditContributions(manifest) != nil { - return ErrAccountEditUnavailable - } - installation := &PluginInstallation{ID: fence.ID, PluginKey: fence.PluginKey, Manifest: manifest, Bindings: bindings} - contribution := accountEditContribution(installation, fence.EditContributionID) - if contribution == nil || AccountEditDefinitionDigest(contribution) != fence.EditDefinitionSHA256 || !contributionAccountAllowed(installation, contribution, extensionv1.Account{ID: fence.EditAccountID, Platform: PlatformCindy, Type: AccountTypeAPIKey}) { - return ErrAccountEditUnavailable - } - return nil -} - -func retainAccountEditUntilTransactionEnds(ctx context.Context, tx *dbent.Tx, release func()) { - var once sync.Once - finish := func() { once.Do(release) } - tx.OnCommit(func(next dbent.Committer) dbent.Committer { - return dbent.CommitFunc(func(commitCtx context.Context, transaction *dbent.Tx) error { - if err := ValidateAccountEditFresh(ctx); err != nil { - return err - } - err := next.Commit(commitCtx, transaction) - if err == nil { - finish() - } - return err - }) - }) - tx.OnRollback(func(next dbent.Rollbacker) dbent.Rollbacker { - return dbent.RollbackFunc(func(rollbackCtx context.Context, transaction *dbent.Tx) error { - err := next.Rollback(rollbackCtx, transaction) - finish() - return err - }) - }) -} - -func retainBoundAccountEditLease(ctx context.Context, tx *dbent.Tx) { - edit, bound := AccountEditFromContext(ctx) - if !bound || edit.lease == nil || !edit.changed || !edit.lease.transaction.CompareAndSwap(false, true) { - return - } - retainAccountEditUntilTransactionEnds(ctx, tx, edit.lease.finish) -} - -// Used by a legacy bulk merge: codec keys are native full replacements here. -// No public bulk intent or clear capability is introduced. -func mergeAccountEditBulkExtra(current, patch map[string]any) map[string]any { - merged := maps.Clone(current) - if merged == nil { - merged = map[string]any{} - } - maps.Copy(merged, patch) - return merged -} diff --git a/backend/internal/service/plugin_account_edit_test.go b/backend/internal/service/plugin_account_edit_test.go deleted file mode 100644 index f402282cfba6..000000000000 --- a/backend/internal/service/plugin_account_edit_test.go +++ /dev/null @@ -1,658 +0,0 @@ -//go:build unit - -package service - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "os" - "strings" - "testing" - - "entgo.io/ent/dialect" - entsql "entgo.io/ent/dialect/sql" - "github.com/DATA-DOG/go-sqlmock" - dbent "github.com/Wei-Shaw/sub2api/ent" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/stretchr/testify/require" -) - -type accountEditFixtureInvoker struct { - *PluginManager - catalog cindyIndependentSnapshotFixture - owner int64 -} - -func (f *accountEditFixtureInvoker) InvokeOperation(ctx context.Context, platform, kind string, input extensionv1.Invocation) (extensionv1.Result, error) { - if input.Operation != "cindy.catalog" { - return promptPolicyFixture{}.InvokeOperation(ctx, platform, kind, input) - } - result, err := f.catalog.InvokeOperation(ctx, platform, kind, input) - result.PluginID = f.owner - return result, err -} - -func (f *accountEditFixtureInvoker) InvokeCachedOperation(ctx context.Context, platform, kind string, input extensionv1.Invocation) (extensionv1.Result, error) { - return f.InvokeOperation(ctx, platform, kind, input) -} - -func newAccountEditFixture(t *testing.T) (*accountEditFixtureInvoker, *viewFixtureRepo, *Account, *extensionv1.ProviderEditRequestV1, extensionv1.AccountViewContextV1) { - t.Helper() - manager, repo, directory, view := newViewFixture(t) - raw, err := os.ReadFile("../../../plugins/cindy-provider/manifest.source.json") - require.NoError(t, err) - var manifest PluginManifest - require.NoError(t, json.Unmarshal(raw, &manifest)) - var edit extensionv1.Contribution - for _, contribution := range manifest.Contributions { - if contribution.Slot == extensionv1.AccountEditSlot { - edit = contribution - } - } - require.NotNil(t, edit.AccountEdit) - repo.installations[1].Manifest.Contributions = append(repo.installations[1].Manifest.Contributions, edit) - applyViewFixture(t, manager, repo) - account := directory.accounts[2] - account.Extra[CindyDeviceIDExtraKey], account.Extra[CindyDeviceIDSourceExtraKey] = strings.Repeat("d", 64), "input-preserved" - account.CindyCredentialGeneration = 6 - request := &extensionv1.ProviderEditRequestV1{ContributionID: edit.ID, ExpectedPackageSHA256: repo.installations[1].PackageSHA256, - ExpectedDefinitionSHA256: AccountEditDefinitionDigest(&edit), ExpectedRuntimeGeneration: repo.installations[1].RuntimeGeneration, - ExpectedStateSHA256: AccountEditStateDigest(account), Changes: map[string]extensionv1.AccountEditChangeV1{}} - invoker := &accountEditFixtureInvoker{PluginManager: manager, catalog: cindyIndependentSnapshotFixture{snapshot: independentCindySnapshot(t, "edit", true)}, owner: 1} - previous := processExtensionOperations.Load() - processExtensionOperations.Store(&extensionOperationProvider{invoker: invoker}) - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - return invoker, repo, account, request, view -} - -func editSet(value string) extensionv1.AccountEditChangeV1 { - return extensionv1.AccountEditChangeV1{Op: "set", Value: &value} -} -func editClear() extensionv1.AccountEditChangeV1 { return extensionv1.AccountEditChangeV1{Op: "clear"} } - -func TestAccountEditDeclarationAndStrictRequest(t *testing.T) { - f, repo, _, request, _ := newAccountEditFixture(t) - base := repo.installations[1].Manifest - require.NoError(t, validateAccountEditContributions(base)) - for name, mutate := range map[string]func(*PluginManifest){ - "owner": func(m *PluginManifest) { m.ID = "other.owner" }, - "endpoint": func(m *PluginManifest) { m.Contributions[1].AccountEdit.CredentialUI.BaseURL = "https://other.invalid" }, - "ws_auto": func(m *PluginManifest) { m.Contributions[1].AccountEdit.WireControls[2].Values = []string{"auto"} }, - "duplicate": func(m *PluginManifest) { m.Contributions = append(m.Contributions, m.Contributions[1]) }, - "retained": func(m *PluginManifest) { m.Contributions[1].RetainedControls = true }, - "defaults": func(m *PluginManifest) { m.Contributions[1].AccountCreate = &extensionv1.AccountCreateDefinitionV1{} }, - "admin": func(m *PluginManifest) { m.Capabilities = m.Capabilities[:1] }, - "path": func(m *PluginManifest) { m.Contributions[1].AccountEdit.WireControls[0].Target = "extra.api_key" }, - } { - t.Run(name, func(t *testing.T) { - raw, _ := json.Marshal(base) - var copy PluginManifest - require.NoError(t, json.Unmarshal(raw, ©)) - mutate(©) - require.Error(t, validateAccountEditContributions(copy)) - }) - } - raw, _ := json.Marshal(request) - for _, invalid := range []string{ - strings.Replace(string(raw), `"changes":{}`, `"changes":{"credentials.api_key":{"op":"clear"}}`, 1), - strings.Replace(string(raw), `"changes":{}`, `"changes":{"responses_websocket_mode":{"op":"set","value":"auto"}}`, 1), - strings.Replace(string(raw), `"changes":{}`, `"changes":{"compact_mode":{"op":"clear","value":"auto"}}`, 1), - strings.Replace(string(raw), `"changes":{}`, `"changes":{},"inherit_defaults":[]`, 1), - strings.Replace(string(raw), `"changes":{}`, `"changes":{"compact_mode":{"op":"clear","path":"extra"}}`, 1), - strings.Replace(string(raw), `"changes":{}`, `"changes":null`, 1), - } { - var copy extensionv1.ProviderEditRequestV1 - err := json.Unmarshal([]byte(invalid), ©) - if err == nil { - err = validateAccountEditRequest(©) - } - require.Error(t, err) - } - for _, contribution := range f.Contributions() { - if contribution.Slot == extensionv1.AccountEditSlot { - require.Equal(t, request.ExpectedDefinitionSHA256, contribution.EditDefinitionDigest) - require.Equal(t, request.ExpectedRuntimeGeneration, contribution.RuntimeGeneration) - require.True(t, contribution.Available) - require.Equal(t, []string{extensionv1.CapabilityProvider, extensionv1.CapabilityAdmin}, contributionRequiredCapabilities(&contribution.Contribution)) - return - } - } - t.Fatal("missing edit projection") -} - -func TestAccountEditCodecRetainsRawAndFiniteClear(t *testing.T) { - _, _, account, request, _ := newAccountEditFixture(t) - account.Extra["openai_responses_mode"] = nil - account.Extra["openai_compact_mode"] = "unknown-retained" - for _, mode := range []string{"shared", "dedicated"} { - account.Extra["openai_apikey_responses_websockets_v2_mode"] = mode - account.Extra["openai_apikey_responses_websockets_v2_enabled"] = false - account.Extra["responses_websockets_v2_enabled"] = true - account.Extra["openai_ws_enabled"] = true - account.Extra["openai_ws_force_http"] = true - account.Extra["openai_passthrough"] = true - account.Extra["openai_oauth_responses_websockets_v2_enabled"] = true - account.Extra["openai_compact_supported"] = false - before := accountEditRawState(account) - values, changed, err := accountEditDesired(account, nil, map[string]any{"unrelated": true}, nil, nil) - require.NoError(t, err) - require.False(t, changed) - require.Equal(t, before, values) - request.Changes = map[string]extensionv1.AccountEditChangeV1{"responses_mode": editClear(), "compact_mode": editSet("auto"), "responses_websocket_mode": editClear()} - values, changed, err = accountEditDesired(account, nil, nil, request, nil) - require.NoError(t, err) - require.True(t, changed) - copy := *account - applyAccountEditOwned(©, values) - require.NotContains(t, copy.Extra, "openai_responses_mode") - require.Equal(t, "auto", copy.Extra["openai_compact_mode"]) - for _, key := range accountEditExtraKeys[2:] { - require.NotContains(t, copy.Extra, key) - } - for _, key := range []string{"openai_ws_force_http", "openai_passthrough", "openai_oauth_responses_websockets_v2_enabled", "openai_compact_supported"} { - require.Equal(t, account.Extra[key], copy.Extra[key]) - } - require.Equal(t, before, accountEditRawState(account), "codec may not mutate source") - } - request.Changes = map[string]extensionv1.AccountEditChangeV1{"responses_mode": editSet("auto"), "responses_websocket_mode": editSet("off")} - values, _, err := accountEditDesired(account, nil, nil, request, nil) - require.NoError(t, err) - require.Equal(t, "auto", values["openai_responses_mode"].Value) - require.True(t, values["responses_websockets_v2_enabled"].Present, "set must not clean up fallback keys") - _, _, err = accountEditDesired(account, nil, map[string]any{"openai_responses_mode": "force_responses"}, request, nil) - require.ErrorIs(t, err, ErrAccountEditInvalid) -} - -func TestAccountEditStateDigestIsBoundedAndPresenceSensitive(t *testing.T) { - _, _, account, _, _ := newAccountEditFixture(t) - initial := AccountEditStateDigest(account) - account.Extra["usage"] = 123 - account.Credentials["api_key"] = "not-reflected-in-digest" - require.Equal(t, initial, AccountEditStateDigest(account)) - account.Extra["openai_responses_mode"] = nil - require.NotEqual(t, initial, AccountEditStateDigest(account)) - delete(account.Extra, "openai_responses_mode") - account.Extra["openai_ws_enabled"] = true - require.NotEqual(t, initial, AccountEditStateDigest(account)) - delete(account.Extra, "openai_ws_enabled") - account.CindyCredentialGeneration++ - require.NotEqual(t, initial, AccountEditStateDigest(account)) -} - -func TestAccountEditNoopAndBasicAreIndependentOfProviderHealth(t *testing.T) { - f, repo, account, request, _ := newAccountEditFixture(t) - repo.installations[1].State = PluginStateDisabled - f.extensions.Load().runtimes[1].beginDrain() - for _, typed := range []*extensionv1.ProviderEditRequestV1{nil, request} { - ctx, release, err := PrepareAccountEdit(context.Background(), account, nil, nil, typed) - require.NoError(t, err) - require.NoError(t, ValidateAccountEditFresh(ctx)) - release() - } - require.Empty(t, repo.held, "no-op must not acquire a business runtime lease") - stale := *request - stale.ExpectedStateSHA256 = strings.Repeat("0", 64) - _, _, err := PrepareAccountEdit(context.Background(), account, nil, nil, &stale) - require.ErrorIs(t, err, ErrAccountEditStateChanged) - stale = *request - stale.ExpectedRuntimeGeneration++ - _, _, err = PrepareAccountEdit(context.Background(), account, nil, nil, &stale) - require.ErrorIs(t, err, ErrAccountEditUnavailable) - _, _, err = PrepareAccountEdit(context.Background(), account, nil, map[string]any{"openai_compact_mode": "force_off"}, nil) - require.ErrorIs(t, err, ErrAccountEditUnavailable) - ordinary := *account - ordinary.Platform, ordinary.ProviderProfile = PlatformOpenAI, "" - _, release, err := PrepareAccountEdit(context.Background(), &ordinary, nil, map[string]any{"openai_compact_mode": "force_off"}, nil) - require.NoError(t, err) - release() - context, err := LoadAccountEditContext(context.Background(), &ordinary) - require.NoError(t, err) - require.Equal(t, "core", context.Kind) -} - -func TestAccountEditRealIDAdmissionAndIndependentOwnerFences(t *testing.T) { - f, repo, account, request, viewRequest := newAccountEditFixture(t) - request.Changes["compact_mode"] = editSet("force_off") - for _, capability := range []string{extensionv1.CapabilityProvider, extensionv1.CapabilityAdmin} { - for index := range repo.installations[1].Bindings { - if repo.installations[1].Bindings[index].Capability == capability { - repo.installations[1].Bindings[index].RolloutPercent = int(stablePluginBucket(account.ID)) - } - } - applyViewFixture(t, f.PluginManager, repo) - _, _, err := PrepareAccountEdit(context.Background(), account, nil, nil, request) - require.ErrorIs(t, err, ErrAccountEditUnavailable) - for index := range repo.installations[1].Bindings { - repo.installations[1].Bindings[index].RolloutPercent = 100 - } - } - applyViewFixture(t, f.PluginManager, repo) - viewCtx, releaseView, err := f.BindAccountViewRequest(context.Background(), viewRequest) - require.NoError(t, err) - defer releaseView() - ctx, release, err := PrepareAccountEdit(WithPluginExecution(viewCtx, repo.installations[2]), account, nil, nil, request) - require.NoError(t, err) - defer release() - execution, _ := PluginExecutionFromContext(ctx) - require.EqualValues(t, 2, execution.ID) - fences, err := PluginExecutionFences(ctx, "") - require.NoError(t, err) - require.Len(t, fences, 2) - require.EqualValues(t, 1, fences[0].ID) - require.True(t, fences[0].OriginView) - require.True(t, fences[0].OriginEdit) - require.False(t, fences[0].OriginCreate) - require.Equal(t, account.ID, fences[0].EditAccountID) - require.True(t, fences[1].Primary) - _, err = AccountEditOwnedForUpdate(ctx, account, nil, nil, request) - require.ErrorIs(t, err, ErrAccountEditUnavailable, "new owned work is forbidden before the ordered transaction fence") - bad := fences[0] - bad.EditDefinitionSHA256 = strings.Repeat("0", 64) - _, err = mergePluginExecutionFences([]PluginExecutionFence{fences[0], bad}) - require.ErrorIs(t, err, ErrAccountEditUnavailable) -} - -func TestAccountEditCatalogContextUsesOneActualAccountSnapshot(t *testing.T) { - f, repo, account, _, _ := newAccountEditFixture(t) - account.Extra["openai_responses_mode"] = "not-public" - account.Extra["openai_compact_mode"] = nil - account.Extra["openai_apikey_responses_websockets_v2_mode"] = "dedicated" - result, err := LoadAccountEditContext(context.Background(), account) - require.NoError(t, err) - require.True(t, result.Profile.Available) - require.Equal(t, "ready", result.Catalog.Status) - require.Len(t, f.catalog.calls, 1) - require.Equal(t, account.ID, f.catalog.calls[0].AccountID) - require.Len(t, *result.Catalog.Models, len(f.catalog.snapshot.CatalogModels)) - raw, _ := json.Marshal(result) - for _, forbidden := range []string{"not-public", "synthetic-private-key", "private_secret", strings.Repeat("d", 64)} { - require.NotContains(t, string(raw), forbidden) - } - require.Equal(t, json.RawMessage("null"), result.Values["compact_mode"].Value) - require.Equal(t, json.RawMessage(`"dedicated"`), result.Values["responses_websocket_mode"].Value) - require.False(t, result.Values["responses_mode"].Recognized) - require.Equal(t, repo.held, repo.released) - f.catalog.snapshot.Config.CatalogEnabled = false - result, err = LoadAccountEditContext(context.Background(), account) - require.NoError(t, err) - require.Equal(t, "catalog_disabled", result.Catalog.Reason) - require.Nil(t, result.Catalog.Models) - require.Nil(t, result.Catalog.Aliases) - f.catalog.disabled = true - result, err = LoadAccountEditContext(context.Background(), account) - require.NoError(t, err) - require.Equal(t, "catalog_rpc_failed", result.Catalog.Reason) - require.True(t, result.Profile.Available) - f.catalog.disabled = false - f.catalog.snapshot.Config.CatalogEnabled = true - f.owner = 9 - result, err = LoadAccountEditContext(context.Background(), account) - require.NoError(t, err) - require.False(t, result.Profile.Available) - require.Nil(t, result.Catalog.Models) -} - -func TestAccountEditMappingPartitionAndLegacyReplacement(t *testing.T) { - f, _, account, request, _ := newAccountEditFixture(t) - snapshot, err := newCindyCatalogSnapshot(f.catalog.snapshot, 1, f.catalog.snapshot.Images) - require.NoError(t, err) - var managed extensionv1.CindyCatalogModel - for _, model := range snapshot.CatalogModels { - if model.Managed { - managed = model - break - } - } - require.NotEmpty(t, managed.ID) - account.Credentials["model_mapping"] = map[string]any{managed.ID: managed.LiveUpstreamID, "custom": "upstream-custom"} - account.Credentials["compact_model_mapping"] = map[string]any{"compact-custom": "upstream-compact"} - request.ExpectedCatalogNamespace = snapshot.Namespace - request.Changes["model_mapping"] = extensionv1.AccountEditChangeV1{Op: "set"} - values, _, err := accountEditDesired(account, map[string]any{"model_mapping": map[string]any{"another": "upstream-other"}}, nil, request, snapshot) - require.NoError(t, err) - require.Equal(t, map[string]any{managed.ID: managed.LiveUpstreamID, "another": "upstream-other"}, values["model_mapping"].Value) - require.Equal(t, account.Credentials["compact_model_mapping"], values["compact_model_mapping"].Value) - request.Changes["model_mapping"] = editClear() - values, _, err = accountEditDesired(account, nil, nil, request, snapshot) - require.NoError(t, err) - require.Equal(t, map[string]any{managed.ID: managed.LiveUpstreamID}, values["model_mapping"].Value) - request.Changes["compact_model_mapping"] = editClear() - values, _, err = accountEditDesired(account, nil, nil, request, snapshot) - require.NoError(t, err) - require.False(t, values["compact_model_mapping"].Present) - values, _, err = accountEditDesired(account, map[string]any{"model_mapping": map[string]any{managed.ID: "shadowed-but-useful"}}, nil, nil, nil) - require.NoError(t, err) - require.Equal(t, map[string]any{managed.ID: "shadowed-but-useful"}, values["model_mapping"].Value) - _, _, err = accountEditDesired(account, nil, nil, request, nil) - require.ErrorIs(t, err, ErrAccountEditCatalogUnavailable, "unknown partition intent cannot be called a no-op") - account.Credentials["model_mapping"] = map[string]any{"custom": "upstream-custom"} - values, _, err = accountEditDesired(account, nil, nil, request, snapshot) - require.NoError(t, err) - require.False(t, values["model_mapping"].Present) -} - -func TestAccountEditCatalogFailureDoesNotBlockStaticWire(t *testing.T) { - f, _, account, request, _ := newAccountEditFixture(t) - f.catalog.disabled = true - request.Changes["compact_mode"] = editSet("auto") - _, release, err := PrepareAccountEdit(context.Background(), account, nil, nil, request) - require.NoError(t, err) - release() - require.Empty(t, f.catalog.calls) - request.Changes = map[string]extensionv1.AccountEditChangeV1{"model_mapping": editClear()} - request.ExpectedCatalogNamespace = "1:unknown" - _, _, err = PrepareAccountEdit(context.Background(), account, nil, nil, request) - require.ErrorIs(t, err, ErrAccountEditCatalogUnavailable) -} - -func TestAccountEditNoopRereadCannotAcquireNewWrite(t *testing.T) { - _, _, account, _, _ := newAccountEditFixture(t) - account.Extra["openai_compact_mode"] = "auto" - ctx, release, err := PrepareAccountEdit(context.Background(), account, nil, map[string]any{"openai_compact_mode": "auto"}, nil) - require.NoError(t, err) - defer release() - account.Extra["openai_compact_mode"] = "force_off" - _, err = AccountEditOwnedForUpdate(ctx, account, nil, map[string]any{"openai_compact_mode": "auto"}, nil) - require.ErrorIs(t, err, ErrAccountEditStateChanged) -} - -type accountEditMutationFixture struct { - t *testing.T - before func() - calls int -} - -func (r *accountEditMutationFixture) Run(ctx context.Context, _ int64, mutate func(context.Context) (*Account, error)) (*Account, error) { - r.calls++ - db, mock, err := sqlmock.New() - require.NoError(r.t, err) - defer db.Close() - client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) - mock.ExpectBegin() - tx, err := client.Tx(ctx) - require.NoError(r.t, err) - if r.before != nil { - r.before() - } - MarkAccountEditFenced(ctx) - account, mutationErr := mutate(dbent.NewTxContext(ctx, tx)) - mock.ExpectRollback() - require.NoError(r.t, tx.Rollback()) - require.NoError(r.t, mock.ExpectationsWereMet()) - return account, mutationErr -} - -func TestAccountEditCentralUpdateRetainClearAndMixedFailure(t *testing.T) { - f, repo, account, request, _ := newAccountEditFixture(t) - storage := &accountRepoStubForBulkUpdate{getByIDAccounts: map[int64]*Account{account.ID: account}} - runner := &accountEditMutationFixture{t: t} - svc := &adminServiceImpl{accountRepo: storage, cindyAccountMutations: runner} - account.Extra["openai_responses_mode"] = nil - account.Extra["openai_apikey_responses_websockets_v2_mode"] = "shared" - account.Credentials["model_mapping"] = map[string]any{"saved": "saved-target"} - repo.installations[1].State = PluginStateDisabled - _, err := svc.UpdateAccount(context.Background(), account.ID, &UpdateAccountInput{Name: "basic", Extra: map[string]any{"ordinary": true}}) - require.NoError(t, err) - require.Nil(t, account.Extra["openai_responses_mode"]) - require.Contains(t, account.Extra, "openai_responses_mode") - require.Equal(t, "shared", account.Extra["openai_apikey_responses_websockets_v2_mode"]) - require.Equal(t, "basic", account.Name) - require.Empty(t, repo.held) - _, err = svc.UpdateAccount(context.Background(), account.ID, &UpdateAccountInput{Name: "must-not-change", Extra: map[string]any{"openai_compact_mode": "force_off"}}) - require.ErrorIs(t, err, ErrAccountEditUnavailable) - require.Equal(t, "basic", account.Name) - require.Len(t, storage.updatedAccounts, 1) - repo.installations[1].State = PluginStateEnabled - applyViewFixture(t, f.PluginManager, repo) - request.ExpectedStateSHA256 = AccountEditStateDigest(account) - request.Changes = map[string]extensionv1.AccountEditChangeV1{"responses_mode": editClear(), "responses_websocket_mode": editClear()} - _, err = svc.UpdateAccount(context.Background(), account.ID, &UpdateAccountInput{Name: "clear", ProviderEdit: request}) - require.NoError(t, err) - require.NotContains(t, account.Extra, "openai_responses_mode") - require.NotContains(t, account.Extra, "openai_apikey_responses_websockets_v2_mode") - require.Equal(t, map[string]any{"saved": "saved-target"}, account.Credentials["model_mapping"]) - require.Equal(t, repo.held, repo.released) -} - -func TestAccountEditOuterTransactionKeepsLeaseThroughRollback(t *testing.T) { - f, repo, account, request, _ := newAccountEditFixture(t) - request.Changes["compact_mode"] = editSet("force_off") - ctx, release, err := PrepareAccountEdit(context.Background(), account, nil, nil, request) - require.NoError(t, err) - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) - mock.ExpectBegin() - tx, err := client.Tx(context.Background()) - require.NoError(t, err) - retainBoundAccountEditLease(ctx, tx) - release() - require.Empty(t, repo.released) - f.extensions.Load().runtimes[1].beginDrain() - require.ErrorIs(t, tx.Commit(), ErrAccountEditUnavailable) - require.Empty(t, repo.released) - mock.ExpectRollback() - require.NoError(t, tx.Rollback()) - require.Equal(t, []int64{1}, repo.released) - require.NoError(t, mock.ExpectationsWereMet()) -} - -func TestAccountEditUnrelatedCoreMappingReplacementAndPayloads(t *testing.T) { - for _, platform := range []string{PlatformOpenAI, PlatformAnthropic, PlatformGemini} { - account := &Account{ID: 71, Name: "core", Platform: platform, Type: AccountTypeAPIKey, Status: StatusActive, - Credentials: map[string]any{"base_url": "https://api.laxarouter.ai", "api_key": "native-secret", "model_mapping": map[string]any{"old": "target"}, "compact_model_mapping": map[string]any{"old": "target"}}, - Extra: map[string]any{"openai_responses_mode": nil, "openai_passthrough": true}} - storage := &accountRepoStubForBulkUpdate{getByIDAccounts: map[int64]*Account{account.ID: account}} - svc := &adminServiceImpl{accountRepo: storage} - _, err := svc.UpdateAccount(context.Background(), account.ID, &UpdateAccountInput{Credentials: map[string]any{"base_url": "https://api.laxarouter.ai"}}) - require.NoError(t, err, platform) - require.NotContains(t, account.Credentials, "model_mapping", platform) - require.NotContains(t, account.Credentials, "compact_model_mapping", platform) - require.Equal(t, "native-secret", account.Credentials["api_key"]) - require.Equal(t, platform, account.Platform) - require.True(t, account.Extra["openai_passthrough"].(bool)) - require.Contains(t, account.Extra, "openai_responses_mode") - require.Nil(t, account.Extra["openai_responses_mode"]) - } - for _, kind := range []string{AccountJobKindImportData, AccountJobKindBatchCreate, AccountJobKindExtensionOperation} { - for _, raw := range []string{`[1,"two",null]`, `null`, `"native"`, `{"_host_account_edit":"native-owned-by-other-kind"}`} { - got, err := accountJobPayloadWithEdit(context.Background(), kind, []byte(raw), nil) - require.NoError(t, err) - require.Equal(t, raw, string(got)) - } - } -} - -func TestAccountEditFrozenJobIdentityCannotBecomeCore(t *testing.T) { - _, _, account, _, _ := newAccountEditFixture(t) - snapshot := AccountJobEditSnapshot{StateSHA256: AccountEditStateDigest(account)} - raw, _ := json.Marshal(map[string]any{accountJobEditSnapshotsKey: map[string]AccountJobEditSnapshot{"2": snapshot}}) - account.Platform, account.ProviderProfile = PlatformOpenAI, "" - _, _, err := PrepareAccountJobEdit(context.Background(), raw, account, nil, map[string]any{"openai_compact_mode": "auto"}) - require.ErrorIs(t, err, ErrAccountEditStateChanged) -} - -func TestAccountEditJobSnapshotKeepsOriginalHashOwnerAndTTL(t *testing.T) { - f, plugins, account, _, _ := newAccountEditFixture(t) - repo := newAccountJobTestRepo() - jobs := NewAccountJobService(repo, accountJobTestCipher{}) - payload := json.RawMessage(`{"account_ids":[2],"extra":{"openai_compact_mode":"force_off"}}`) - ctx := WithAccountJobEditAccounts(WithPluginExecution(context.Background(), plugins.installations[2]), []*Account{account}) - job, replayed, err := jobs.Submit(ctx, 9, AccountJobKindBulkUpdate, "edit-frozen", payload, nil, []AccountJobItemSeed{{TargetAccountID: &account.ID}}) - require.NoError(t, err) - require.False(t, replayed) - hash := sha256.Sum256(payload) - require.Equal(t, hex.EncodeToString(hash[:]), job.RequestHash) - owner, err := AccountJobPluginExecution(job.Metadata) - require.NoError(t, err) - require.EqualValues(t, 2, owner.ID) - frozen := repo.payloads[job.ID] - plaintext, err := (accountJobTestCipher{}).Decrypt(frozen.cipher) - require.NoError(t, err) - require.Contains(t, plaintext, accountJobEditSnapshotsKey) - require.NotContains(t, plaintext, "synthetic-private-key") - bound, release, err := PrepareAccountJobEdit(ctx, []byte(plaintext), account, nil, map[string]any{"openai_compact_mode": "force_off"}) - require.NoError(t, err) - inherited, _ := PluginExecutionFromContext(bound) - require.Equal(t, owner, inherited) - release() - plugins.installations[1].RuntimeGeneration++ - applyViewFixture(t, f.PluginManager, plugins) - _, _, err = PrepareAccountJobEdit(ctx, []byte(plaintext), account, nil, map[string]any{"openai_compact_mode": "force_off"}) - require.ErrorIs(t, err, ErrAccountEditUnavailable) - replayedJob, replayed, err := jobs.Submit(ctx, 9, AccountJobKindBulkUpdate, "edit-frozen", payload, nil, []AccountJobItemSeed{{TargetAccountID: &account.ID}}) - require.NoError(t, err) - require.True(t, replayed) - require.Equal(t, job.ID, replayedJob.ID) - require.Equal(t, frozen, repo.payloads[job.ID]) - _, _, err = jobs.Submit(ctx, 9, AccountJobKindBulkUpdate, "forged", []byte(`{"_host_account_edit":{},"extra":{"openai_compact_mode":"force_off"}}`), nil, []AccountJobItemSeed{{TargetAccountID: &account.ID}}) - require.ErrorIs(t, err, ErrAccountEditInvalid) -} - -func TestAccountEditNativeCoreJobAndNullRemainIndependent(t *testing.T) { - _, _, account, _, _ := newAccountEditFixture(t) - processExtensionOperations.Store(nil) - ordinary := *account - ordinary.Platform, ordinary.ProviderProfile = PlatformOpenAI, "" - ctx := WithAccountJobEditAccounts(context.Background(), []*Account{&ordinary}) - payload := json.RawMessage(`{"extra":{"openai_responses_mode":"auto"}}`) - encoded, err := accountJobPayloadWithEdit(ctx, AccountJobKindBulkUpdate, payload, []AccountJobItemSeed{{TargetAccountID: &ordinary.ID}}) - require.NoError(t, err) - require.JSONEq(t, string(payload), string(encoded)) - values, changed, err := accountEditDesired(account, map[string]any{"model_mapping": nil}, map[string]any{"openai_responses_mode": nil}, nil, nil) - require.NoError(t, err) - require.True(t, changed) - require.True(t, values["model_mapping"].Present) - require.Nil(t, values["model_mapping"].Value) - require.True(t, values["openai_responses_mode"].Present) - require.Nil(t, values["openai_responses_mode"].Value) - mode, _, err := normalizeBulkOpenAIResponsesMode("auto") - require.NoError(t, err) - require.Equal(t, "auto", mode) - input := &BulkUpdateAccountsInput{AccountIDs: []int64{account.ID}, Extra: map[string]any{"openai_responses_mode": "auto"}} - settings, err := normalizeBulkOpenAISettings(input) - require.NoError(t, err) - _, err = validateBulkOpenAISettingsTargets(input, settings, map[int64]*Account{account.ID: account}) - require.Error(t, err, "Cindy bulk Responses eligibility must not expand") -} - -func TestAccountEditDuplicatePreservesProtocolAbsenceAndValues(t *testing.T) { - for _, raw := range []any{nil, "auto", "force_chat_completions", "legacy-retained"} { - for _, present := range []bool{false, true} { - repo := newDuplicateAccountRepoStub() - svc := &adminServiceImpl{accountRepo: repo, accountDuplicateRepo: repo} - source := &Account{Name: "source", Platform: PlatformCindy, WirePlatform: WirePlatformOpenAI, ProviderProfile: ProviderProfileCindyLaxaV1, Type: AccountTypeAPIKey, - Credentials: cindyCredentials(), Extra: map[string]any{CindyDeviceIDExtraKey: strings.Repeat("e", 64), CindyDeviceIDSourceExtraKey: "input-preserved", "nested": map[string]any{"kept": true}}} - if present { - source.Extra["openai_responses_mode"] = raw - } - require.NoError(t, repo.Create(context.Background(), source)) - copy, err := svc.DuplicateAccount(context.Background(), source.ID, "admin:9", "unchanged-copy") - require.NoError(t, err) - value, copiedPresence := copy.Extra["openai_responses_mode"] - require.Equal(t, present, copiedPresence) - if present { - require.Equal(t, raw, value) - } - require.False(t, copy.Schedulable) - require.Empty(t, copy.GroupIDs) - require.NotEqual(t, source.Extra[CindyDeviceIDExtraKey], copy.Extra[CindyDeviceIDExtraKey]) - copy.Extra["nested"].(map[string]any)["kept"] = false - require.Equal(t, true, source.Extra["nested"].(map[string]any)["kept"]) - replayed, err := svc.DuplicateAccount(context.Background(), source.ID, "admin:9", "unchanged-copy") - require.NoError(t, err) - require.Equal(t, copy.ID, replayed.ID) - } - } -} - -func TestAccountEditFenceDataUsesActualBucketAndFreshConfig(t *testing.T) { - _, repo, account, request, _ := newAccountEditFixture(t) - installation := repo.installations[1] - manifest, _ := json.Marshal(installation.Manifest) - fence := PluginExecutionFence{ID: 1, OriginEdit: true, EditAccountID: account.ID, PluginKey: installation.PluginKey, EditContributionID: request.ContributionID, - EditDefinitionSHA256: request.ExpectedDefinitionSHA256, ConfigSHA256: accountCreateConfigDigest(installation.ConfigEncrypted)} - require.NoError(t, ValidateAccountEditFenceData(fence, manifest, installation.ConfigEncrypted, installation.Bindings)) - for _, capability := range []string{extensionv1.CapabilityProvider, extensionv1.CapabilityAdmin} { - bindings := append([]PluginBinding(nil), installation.Bindings...) - for index := range bindings { - if bindings[index].Capability == capability { - bindings[index].RolloutPercent = int(stablePluginBucket(account.ID)) - } - } - require.ErrorIs(t, ValidateAccountEditFenceData(fence, manifest, installation.ConfigEncrypted, bindings), ErrAccountEditUnavailable) - } - require.ErrorIs(t, ValidateAccountEditFenceData(fence, manifest, "changed", installation.Bindings), ErrAccountEditUnavailable) -} - -func TestAccountEditUpdateExtraAndBulkCannotBypassAdmission(t *testing.T) { - _, plugins, account, _, _ := newAccountEditFixture(t) - storage := &accountRepoStubForBulkUpdate{getByIDAccounts: map[int64]*Account{account.ID: account}, getByIDsAccounts: []*Account{account}} - runner := &accountEditMutationFixture{t: t} - svc := &adminServiceImpl{accountRepo: storage, cindyAccountMutations: runner} - plugins.installations[1].State = PluginStateDisabled - err := svc.UpdateAccountExtra(context.Background(), account.ID, map[string]any{"openai_compact_mode": "force_off"}) - require.ErrorIs(t, err, ErrAccountEditUnavailable) - require.Empty(t, storage.updatedAccounts) - _, err = svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{AccountIDs: []int64{account.ID}, Name: "not-written", Credentials: map[string]any{"model_mapping": map[string]any{"custom": "target"}}}) - require.ErrorIs(t, err, ErrAccountEditUnavailable) - require.Zero(t, storage.bulkUpdateCalls) - account.Extra["openai_compact_mode"] = "force_off" - _, err = svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{AccountIDs: []int64{account.ID}, Extra: map[string]any{"openai_compact_mode": "force_off"}}) - require.NoError(t, err) - require.Equal(t, 1, storage.bulkUpdateCalls) - require.Equal(t, "force_off", storage.lastBulkUpdate.Extra["openai_compact_mode"]) -} - -func TestAccountEditBasicSavePreservesIndependentViewScope(t *testing.T) { - f, plugins, account, _, request := newAccountEditFixture(t) - ctx, releaseView, err := f.BindAccountViewRequest(context.Background(), request) - require.NoError(t, err) - defer releaseView() - storage := &accountRepoStubForBulkUpdate{getByIDAccounts: map[int64]*Account{account.ID: account}} - svc := &adminServiceImpl{accountRepo: storage, cindyAccountMutations: &accountEditMutationFixture{t: t}} - _, err = svc.UpdateAccount(ctx, 5, &UpdateAccountInput{Name: "outside-view"}) - require.ErrorIs(t, err, ErrAccountViewScope) - require.Empty(t, storage.updatedAccounts) - held := len(plugins.held) - _, err = svc.UpdateAccount(ctx, account.ID, &UpdateAccountInput{Name: "basic-view-save"}) - require.NoError(t, err) - require.Equal(t, held, len(plugins.held), "basic-only save does not acquire edit ownership") - plugins.installations[1].State = PluginStateDisabled - _, err = svc.UpdateAccount(ctx, account.ID, &UpdateAccountInput{Name: "must-not-save"}) - require.ErrorIs(t, err, ErrAccountViewUnavailable) - require.Equal(t, "basic-view-save", account.Name) -} - -func TestAccountEditBulkLockedIdentityChangeCannotRestoreUnderOldPlan(t *testing.T) { - _, _, account, _, _ := newAccountEditFixture(t) - storage := &accountRepoStubForBulkUpdate{getByIDAccounts: map[int64]*Account{account.ID: account}, getByIDsAccounts: []*Account{account}} - runner := &accountEditMutationFixture{t: t, before: func() { account.Credentials["base_url"] = "https://changed.invalid" }} - svc := &adminServiceImpl{accountRepo: storage, cindyAccountMutations: runner} - _, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{AccountIDs: []int64{account.ID}, Credentials: map[string]any{ - "base_url": "https://api.laxarouter.ai", "model_mapping": map[string]any{"new-custom": "upstream"}, - }}) - require.ErrorIs(t, err, ErrAccountEditStateChanged) - require.Equal(t, 1, runner.calls) - require.Zero(t, storage.bulkUpdateCalls) -} - -func TestAccountEditLegacyQueuedNoopDoesNotRequireNewProviderWork(t *testing.T) { - _, plugins, account, _, _ := newAccountEditFixture(t) - plugins.installations[1].State = PluginStateDisabled - account.Extra["openai_compact_mode"] = nil - ctx, release, err := PrepareAccountJobEdit(context.Background(), []byte(`{"extra":{"openai_compact_mode":null}}`), account, nil, map[string]any{"openai_compact_mode": nil}) - require.NoError(t, err) - defer release() - _, bound := AccountEditFromContext(ctx) - require.True(t, bound) - require.Empty(t, plugins.held) - account.Extra["openai_compact_mode"] = "force_off" - _, err = AccountEditOwnedForUpdate(ctx, account, nil, map[string]any{"openai_compact_mode": nil}, nil) - require.ErrorIs(t, err, ErrAccountEditStateChanged) - _, _, err = PrepareAccountJobEdit(context.Background(), []byte(`{}`), account, nil, map[string]any{"openai_compact_mode": "auto"}) - require.ErrorIs(t, err, ErrAccountEditStateChanged, "an old queued actual delta requires a fresh submission") -} diff --git a/backend/internal/service/plugin_account_tools.go b/backend/internal/service/plugin_account_tools.go deleted file mode 100644 index c1141049d84f..000000000000 --- a/backend/internal/service/plugin_account_tools.go +++ /dev/null @@ -1,79 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "time" - - infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" -) - -func accountToolsOperation(ctx context.Context, platform, accountType, operation string, input, output any, owner ...*int64) error { - return accountToolsOperationScoped(ctx, platform, accountType, 0, operation, input, output, owner...) -} - -func accountToolsOperationForAccount(ctx context.Context, account *Account, operation string, input, output any) error { - if account == nil { - return ErrExtensionOperationUnavailable - } - return accountToolsOperationScoped(ctx, account.Platform, account.Type, account.ID, operation, input, output) -} - -func accountToolsOperationScoped(ctx context.Context, platform, accountType string, accountID int64, operation string, input, output any, owner ...*int64) error { - raw, err := json.Marshal(input) - if err != nil { - return err - } - call, cancel := context.WithTimeout(ctx, 2*time.Second) - defer cancel() - invocation := extensionv1.Invocation{Capability: extensionv1.CapabilityAdmin, Operation: operation, AccountID: accountID, Payload: raw} - var result extensionv1.Result - if platform == "*" && accountType == "*" { - result, err = invokeProcessDomainExtension(call, invocation, false) - } else { - result, err = invokeProcessExtension(call, platform, accountType, invocation) - } - if err != nil { - return infraerrors.New(503, "ACCOUNT_TOOLS_UNAVAILABLE", "account tools plugin is disabled or unavailable") - } - if result.Code != "" { - status := result.HTTPStatus - if status < 400 || status > 499 { - status = 503 - } - return infraerrors.New(status, result.Code, result.Message) - } - if output != nil { - if err := json.Unmarshal(result.Payload, output); err != nil { - return err - } - } - if len(owner) > 0 && owner[0] != nil { - *owner[0] = result.PluginID - } - return nil -} - -func PlanBatchAccountTests(ctx context.Context, request extensionv1.BatchTestPlanningRequest) (extensionv1.BatchTestPlan, int64, error) { - var plan extensionv1.BatchTestPlan - var owner int64 - err := accountToolsOperation(ctx, "*", "*", "test.batch", request, &plan, &owner) - return plan, owner, err -} - -func ValidateAccountTaxonomyPlan(ctx context.Context, plan extensionv1.TaxonomyBulkPlan) (int64, error) { - var owner int64 - err := accountToolsOperation(ctx, "*", "*", "taxonomy.bulk", plan, nil, &owner) - return owner, err -} - -func normalizeAccountTaxonomyNameContext(ctx context.Context, value string) (string, string, error) { - var normalized extensionv1.TaxonomyName - err := accountToolsOperation(ctx, "*", "*", "taxonomy.name", extensionv1.TaxonomyName{Name: value}, &normalized) - return normalized.Name, normalized.Normalized, err -} - -func validateTaxonomyOrderIDsContext(ctx context.Context, actual, ordered []int64) error { - return accountToolsOperation(ctx, "*", "*", "taxonomy.order", extensionv1.TaxonomyOrder{Actual: actual, Ordered: ordered}, nil) -} diff --git a/backend/internal/service/plugin_account_view_jobs_test.go b/backend/internal/service/plugin_account_view_jobs_test.go deleted file mode 100644 index 18769cdff592..000000000000 --- a/backend/internal/service/plugin_account_view_jobs_test.go +++ /dev/null @@ -1,235 +0,0 @@ -//go:build unit - -package service - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "testing" - "time" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/stretchr/testify/require" -) - -type viewCountingExecutor struct{ calls int } - -func TestAccountViewGenericRPCJobsAreUnsupported(t *testing.T) { - manager, plugins, _, request := newViewFixture(t) - view, release, err := manager.BindAccountViewRequest(context.Background(), request) - require.NoError(t, err) - defer release() - repo := newAccountJobTestRepo() - jobs := NewAccountJobService(repo, accountJobTestCipher{}) - id := int64(2) - seeds := []AccountJobItemSeed{{Ordinal: 1, TargetAccountID: &id}} - payload := json.RawMessage(`{"plugin_id":2,"operation":"fixture","items":{}}`) - _, _, err = jobs.Submit(view, 42, AccountJobKindExtensionOperation, "view-rpc", payload, nil, seeds) - require.ErrorIs(t, err, ErrAccountViewUnsupportedOperation) - _, _, err = jobs.ReplaySubmission(view, 42, AccountJobKindExtensionOperation, "view-rpc", payload) - require.ErrorIs(t, err, ErrAccountViewUnsupportedOperation) - metadata, err := stampAccountJobView(view, nil) - require.NoError(t, err) - executor := NewPluginJobExecutor(manager, nil) - _, _, err = executor.PrepareAccountJob(context.Background(), &AccountJob{Kind: AccountJobKindExtensionOperation, Metadata: metadata}, payload) - require.ErrorIs(t, err, ErrAccountViewUnsupportedOperation) - _, err = executor.ExecuteAccountJob(view, &AccountJob{Kind: AccountJobKindExtensionOperation, Metadata: metadata}, payload, []AccountJobItem{{ID: 1, TargetAccountID: &id}}) - require.ErrorIs(t, err, ErrAccountViewUnsupportedOperation) - - background := WithPluginExecution(context.Background(), plugins.installations[2]) - job, _, err := jobs.Submit(background, 42, AccountJobKindExtensionOperation, "background-rpc", payload, nil, seeds) - require.NoError(t, err) - storedView, err := AccountJobViewExecution(job.Metadata) - require.NoError(t, err) - require.Nil(t, storedView) - require.Equal(t, string(payload), mustViewPayload(t, jobs, repo, job.ID)) -} - -func (e *viewCountingExecutor) ExecuteAccountJob(_ context.Context, _ *AccountJob, _ json.RawMessage, items []AccountJobItem) ([]AccountJobExecutionResult, error) { - e.calls++ - return []AccountJobExecutionResult{{ItemID: items[0].ID, Status: AccountJobItemStatusSucceeded}}, nil -} - -type viewCancelJobRepo struct { - *accountJobTestRepo - cancels int -} - -func (r *viewCancelJobRepo) Cancel(ctx context.Context, id, actor int64) (*AccountJob, error) { - r.cancels++ - return r.accountJobTestRepo.Get(ctx, id) -} - -type viewCancelExecutor struct{ cancel func() } - -func (e *viewCancelExecutor) ExecuteAccountJob(ctx context.Context, _ *AccountJob, _ json.RawMessage, _ []AccountJobItem) ([]AccountJobExecutionResult, error) { - e.cancel() - return nil, ctx.Err() -} - -func TestAccountViewNativeJobCancellationKeepsHostProgress(t *testing.T) { - manager, _, _, request := newViewFixture(t) - ctx, release, err := manager.BindAccountViewRequest(context.Background(), request) - require.NoError(t, err) - defer release() - repo := &viewCancelJobRepo{accountJobTestRepo: newAccountJobTestRepo()} - jobs := NewAccountJobService(repo, accountJobTestCipher{}) - id := int64(2) - job, _, err := jobs.Submit(ctx, 42, AccountJobKindBatchDelete, "view-core-cancel", json.RawMessage(`{"account_ids":[2]}`), nil, []AccountJobItemSeed{{Ordinal: 1, TargetAccountID: &id}}) - require.NoError(t, err) - primary, _ := AccountJobPluginExecution(job.Metadata) - require.Zero(t, primary.ID, "native core action ownership remains native") - executor := NewPluginJobExecutor(manager, &viewCancelExecutor{cancel: func() { manager.extensions.Load().runtimes[1].beginDrain() }}) - runtime := &AccountJobRuntime{jobs: jobs, executor: executor, ctx: context.Background()} - runtime.execute(job) - require.Equal(t, 1, repo.cancels, "origin-only cancellation still settles the host job") -} - -func TestAccountViewReplayIdentityAndFrozenRetry(t *testing.T) { - manager, plugins, directory, request := newViewFixture(t) - request.Query.Search = "captured-private-search" - view, releaseView, err := manager.BindAccountViewRequest(context.Background(), request) - require.NoError(t, err) - defer releaseView() - ctx, release, err := manager.BindResourceContext(view, 2, plugins.installations[2].PackageSHA256, plugins.installations[2].Manifest.Resources[0]) - require.NoError(t, err) - defer release() - ctx = manager.WithResourcePolicy(ctx, extensionv1.ResourceDescriptor{ResourceGrant: plugins.installations[2].Manifest.Resources[0]}) - repo := newAccountJobTestRepo() - jobs := NewAccountJobService(repo, accountJobTestCipher{}) - id := int64(2) - payload := json.RawMessage(`{"name":"renamed","filters":{"search":"captured-private-search"}}`) - job, replayed, err := jobs.Submit(ctx, 42, AccountJobKindBulkUpdate, "view-key", payload, json.RawMessage(`{"plugin_id":2}`), []AccountJobItemSeed{{Ordinal: 1, TargetAccountID: &id}}) - require.NoError(t, err) - require.False(t, replayed) - owner, err := AccountJobPluginExecution(job.Metadata) - require.NoError(t, err) - require.EqualValues(t, 2, owner.ID) - origin, err := AccountJobViewExecution(job.Metadata) - require.NoError(t, err) - require.EqualValues(t, 1, origin.PluginID) - require.NotContains(t, string(job.Metadata), "captured-private-search") - account := directory.accounts[2] - delete(directory.accounts, 2) - directory.reads = 0 - old, replayed, err := jobs.ReplaySubmission(ctx, 42, AccountJobKindBulkUpdate, "view-key", payload) - require.NoError(t, err) - require.True(t, replayed) - require.Equal(t, job.ID, old.ID) - require.Zero(t, directory.reads, "replay must precede mutable account selection") - directory.accounts[2] = account - other := request - other.PresetID = "insufficient" - otherCtx, otherRelease, err := manager.BindAccountViewRequest(context.Background(), other) - require.NoError(t, err) - defer otherRelease() - otherCtx = WithPluginExecution(otherCtx, plugins.installations[2]) - _, _, err = jobs.ReplaySubmission(otherCtx, 42, AccountJobKindBulkUpdate, "view-key", payload) - require.ErrorIs(t, err, ErrAccountJobIdempotencyConflict) - beforePayload := repo.payloads[job.ID] - repo.items[job.ID][0].Status = AccountJobItemStatusFailed - previous := processExtensionOperations.Load() - processExtensionOperations.Store(&extensionOperationProvider{invoker: manager}) - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - plugins.installations[1].RuntimeGeneration++ - applyViewFixture(t, manager, plugins) - retried, replayed, err := jobs.RetryFailed(context.Background(), job.ID, 42, "retry-view-key") - require.NoError(t, err) - require.False(t, replayed) - retryOrigin, err := AccountJobViewExecution(retried.Metadata) - require.NoError(t, err) - require.Equal(t, origin.RuntimeGeneration+1, retryOrigin.RuntimeGeneration) - require.Equal(t, beforePayload, repo.payloads[retried.ID], "retry preserves original encrypted request and expiry") - require.EqualValues(t, 2, *repo.items[retried.ID][0].TargetAccountID) - _, _, err = manager.BindAccountJobView(context.Background(), job.Metadata, json.RawMessage(mustViewPayload(t, jobs, repo, job.ID)), false) - require.Error(t, err, "queued work cannot silently adopt a different generation") -} - -func mustViewPayload(t *testing.T, jobs *AccountJobService, repo *accountJobTestRepo, id int64) string { - t.Helper() - plain, err := jobs.encryptor.Decrypt(repo.payloads[id].cipher) - require.NoError(t, err) - return plain -} - -func TestAccountViewCoreJobHashAndTTLUnchanged(t *testing.T) { - repo := newAccountJobTestRepo() - jobs := NewAccountJobService(repo, accountJobTestCipher{}) - payload := json.RawMessage("{ \"account_ids\" : [2] }") - id := int64(2) - job, _, err := jobs.Submit(context.Background(), 42, AccountJobKindBatchDelete, "core-key", payload, nil, []AccountJobItemSeed{{Ordinal: 1, TargetAccountID: &id}}) - require.NoError(t, err) - digest := sha256.Sum256(payload) - require.Equal(t, hex.EncodeToString(digest[:]), job.RequestHash) - require.Equal(t, string(payload), mustViewPayload(t, jobs, repo, job.ID)) - view, err := AccountJobViewExecution(job.Metadata) - require.NoError(t, err) - require.Nil(t, view) - require.Equal(t, 24*time.Hour, AccountJobPayloadTTL) - require.WithinDuration(t, time.Now().UTC().Add(24*time.Hour), repo.payloads[job.ID].expiresAt, time.Second) -} - -func TestCindyCleanupBindAndExecuteAdmission(t *testing.T) { - manager, plugins, _, request := newViewFixture(t) - request.PresetID = "banned" - request.Query.Search = "nothing matches" - view, releaseView, err := manager.BindAccountViewRequest(context.Background(), request) - require.NoError(t, err) - defer releaseView() - ctx, release, err := manager.BindAccountJobExecution(view, 1, 3, AccountJobKindCindyConfirmedCleanup) - require.NoError(t, err) - defer release() - metadata, err := stampAccountJobPlugin(nil, PluginExecution{1, 3}) - require.NoError(t, err) - metadata, err = stampAccountJobView(ctx, metadata) - require.NoError(t, err) - core := &viewCountingExecutor{} - executor := NewPluginJobExecutor(manager, core) - job := &AccountJob{Kind: AccountJobKindCindyConfirmedCleanup, Metadata: metadata} - _, err = executor.ExecuteAccountJob(ctx, job, json.RawMessage(`{"expected_count":3,"fingerprint":"fixed"}`), []AccountJobItem{{ID: 1}}) - require.NoError(t, err) - require.Equal(t, 1, core.calls, "ordinal cleanup ignores active preset/search; it checks the fixed full domain") - plugins.installations[1].Bindings[0].RolloutPercent = 99 - applyViewFixture(t, manager, plugins) - _, _, err = manager.BindAccountJobExecution(context.Background(), 1, 3, AccountJobKindCindyConfirmedCleanup) - require.Error(t, err) - _, err = executor.ExecuteAccountJob(ctx, job, json.RawMessage(`{}`), []AccountJobItem{{ID: 2}}) - require.Error(t, err) - require.Equal(t, 1, core.calls) -} - -func TestLegacyCindyCleanupExecutionOwner(t *testing.T) { - manager, plugins, _, _ := newViewFixture(t) - core := &viewCountingExecutor{} - executor := NewPluginJobExecutor(manager, core) - job := &AccountJob{Kind: AccountJobKindCindyBannedCleanup} - ctx, release, err := executor.PrepareAccountJob(context.Background(), job, json.RawMessage(`{"expected_count":1,"fingerprint":"fixed"}`)) - require.NoError(t, err) - owner, bound := PluginExecutionFromContext(ctx) - require.True(t, bound) - require.EqualValues(t, 1, owner.ID) - _, err = executor.ExecuteAccountJob(ctx, job, json.RawMessage(`{}`), []AccountJobItem{{ID: 1}}) - require.NoError(t, err) - release() - require.Empty(t, job.Metadata, "old records must not be rewritten") - plugins.installations[1].PluginKey = "another.provider" - applyViewFixture(t, manager, plugins) - _, _, err = executor.PrepareAccountJob(context.Background(), job, json.RawMessage(`{}`)) - require.Error(t, err) -} - -func TestAccountViewDuplicateJobTargetsDoNotLoseLosers(t *testing.T) { - ids, err := accountViewJobTargets(AccountJobKindDuplicateReview, json.RawMessage(`{"account_ids":[2,3]}`), []*int64{nil}) - require.NoError(t, err) - require.Equal(t, []int64{2, 3}, ids) - id := int64(2) - ids, err = accountViewJobTargets(AccountJobKindDuplicateMerge, json.RawMessage(`{"survivor_account_id":2,"loser_account_ids":[3]}`), []*int64{&id}) - require.NoError(t, err) - require.Equal(t, []int64{2, 3}, ids) - _, err = accountViewJobTargets(AccountJobKindDuplicateReview, json.RawMessage(`{}`), []*int64{nil}) - require.Error(t, err) - _, err = accountViewJobTargets(AccountJobKindDuplicateMerge, json.RawMessage(`{"survivor_account_id":2,"loser_account_ids":[-1]}`), []*int64{&id}) - require.Error(t, err) -} diff --git a/backend/internal/service/plugin_account_view_resources_test.go b/backend/internal/service/plugin_account_view_resources_test.go deleted file mode 100644 index e383951c966d..000000000000 --- a/backend/internal/service/plugin_account_view_resources_test.go +++ /dev/null @@ -1,96 +0,0 @@ -//go:build unit - -package service - -import ( - "context" - "encoding/json" - "testing" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/stretchr/testify/require" -) - -func TestCindyCleanupFixedScopeResourceAdmission(t *testing.T) { - for _, tc := range []struct { - provider, admin int - allowed bool - }{{100, 100, true}, {99, 100, false}, {100, 99, false}, {0, 100, false}, {100, 0, false}} { - manager, repo, _, request := newViewFixture(t) - repo.installations[1].Bindings[0].RolloutPercent, repo.installations[1].Bindings[1].RolloutPercent = tc.provider, tc.admin - applyViewFixture(t, manager, repo) - request.PresetID = "banned" - request.Query = extensionv1.AccountViewQueryV1{Search: "no visible candidates", AccountIDs: []int64{999}} - view, releaseView, err := manager.BindAccountViewRequest(context.Background(), request) - require.NoError(t, err) - policy := CindyCleanupResourcePolicy() - policy.Name = "cindy.cleanup.insufficient.submit" - ctx, release, err := manager.BindResourceContext(view, 1, request.PackageSHA256, policy.ResourceGrant) - require.NoError(t, err) - require.Equal(t, tc.allowed, manager.ValidateResourcePolicy(ctx, policy, nil, true) == nil, "full-domain cleanup must not depend on view preset/search/selection") - release() - releaseView() - } -} - -func TestCindyCleanupResourceAvailabilityCache(t *testing.T) { - manager, repo, _, _ := newViewFixture(t) - first := CindyCleanupResourcePolicy() - first.Name = "cindy.cleanup.insufficient.preview" - second := CindyCleanupResourcePolicy() - second.Name = "cindy.cleanup.banned.preview" - second.FilterPlatform, second.FilterAccountType = "*", "*" - for _, descriptors := range [][]extensionv1.ResourceDescriptor{{first, second}, {second, first}} { - items, err := manager.ResourceDescriptors(context.Background(), 1, "admin", descriptors) - require.NoError(t, err) - require.Len(t, items, 2) - for _, item := range items { - require.Equal(t, item.Name == first.Name, item.Available, "different fixed domains cannot share a cache result") - } - } - repo.installations[1].Bindings[1].Enabled = false - applyViewFixture(t, manager, repo) - require.Empty(t, manager.Contributions(), "disabled Admin also withdraws the account view") -} - -func TestAccountViewRetainedCatalogDoesNotAuthorizeNewIO(t *testing.T) { - manager, repo, _, request := newViewFixture(t) - read := extensionv1.ResourceDescriptor{ResourceGrant: extensionv1.ResourceGrant{Name: "cindy.probe.get", Capability: extensionv1.CapabilityProvider, Permission: "admin"}, Retained: true} - repo.installations[1].Manifest.Resources = append(repo.installations[1].Manifest.Resources, read.ResourceGrant) - repo.installations[1].State = PluginStateDisabled - for index := range repo.installations[1].Bindings { - repo.installations[1].Bindings[index].Enabled = false - } - applyViewFixture(t, manager, repo) - ctx, err := manager.BindRetainedAccountView(context.Background(), request) - require.NoError(t, err) - _, business := AccountViewFromContext(ctx) - require.False(t, business) - create := CindyCleanupResourcePolicy() - create.Name = "cindy.cleanup.insufficient.submit" - items, err := manager.ResourceDescriptors(ctx, 1, "admin", []extensionv1.ResourceDescriptor{read, create}) - require.NoError(t, err) - require.Len(t, items, 2) - require.True(t, items[0].Available) - require.False(t, items[1].Available) - _, _, err = manager.BindAccountViewRequest(ctx, request) - require.Error(t, err) - raw, err := json.Marshal(items[0]) - require.NoError(t, err) - require.Contains(t, string(raw), `"retained":true`) -} - -func TestAccountViewResourceActionRequiresAdminAndRowPredicate(t *testing.T) { - manager, repo, _, _ := newViewFixture(t) - yes := true - action := extensionv1.Contribution{ID: "recover", Slot: "account.actions", Permission: "admin", Capability: extensionv1.CapabilityProvider, Label: map[string]string{"en": "Recover"}, ResourceAction: &extensionv1.AccountResourceActionV1{Version: 1, Resource: "cindy.balance.recover", AccountParameter: "id", AccountSource: "row.id", Effect: "refresh_current_account_view", RowPredicate: &extensionv1.AccountViewPredicate{CindyOnly: &yes, CindyBalanceStatus: "insufficient"}}} - repo.installations[1].Manifest.Contributions = append(repo.installations[1].Manifest.Contributions, action) - applyViewFixture(t, manager, repo) - require.Equal(t, []string{extensionv1.CapabilityProvider, extensionv1.CapabilityAdmin}, contributionRequiredCapabilities(&action)) - descriptor := extensionv1.ResourceDescriptor{ResourceGrant: extensionv1.ResourceGrant{Name: "cindy.balance.recover", Capability: extensionv1.CapabilityProvider, Permission: "admin"}, RequiredCapabilities: []string{extensionv1.CapabilityAdmin}} - ctx, release, err := manager.BindResourceContext(context.Background(), 1, repo.installations[1].PackageSHA256, descriptor.ResourceGrant) - require.NoError(t, err) - defer release() - require.NoError(t, manager.ValidateResourcePolicy(ctx, descriptor, []int64{3}, false)) - require.Error(t, manager.ValidateResourcePolicy(ctx, descriptor, []int64{2}, false), "row predicate is rechecked, not just hidden in the UI") -} diff --git a/backend/internal/service/plugin_account_views.go b/backend/internal/service/plugin_account_views.go deleted file mode 100644 index 0433d32107a1..000000000000 --- a/backend/internal/service/plugin_account_views.go +++ /dev/null @@ -1,811 +0,0 @@ -package service - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "regexp" - "slices" - "strconv" - "strings" - "time" - - infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" -) - -const CindyAccountViewPluginKey = "codexrip.cindy-provider" -const CindyAccountViewID = "cindy-accounts" - -var ( - ErrAccountViewInvalid = infraerrors.BadRequest("ACCOUNT_VIEW_INVALID_REQUEST", "invalid account view context") - ErrAccountViewUnavailable = infraerrors.Conflict("ACCOUNT_VIEW_UNAVAILABLE", "account view is unavailable or has changed") - ErrAccountViewScope = infraerrors.Forbidden("ACCOUNT_VIEW_ACCOUNT_OUTSIDE_SCOPE", "an account is outside the current account view") - ErrAccountViewUnsupportedOperation = infraerrors.BadRequest("ACCOUNT_VIEW_UNSUPPORTED_OPERATION", "account view operations must use a named host resource") - accountViewIDPattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{0,63}$`) - accountViewDigestPattern = regexp.MustCompile(`^[a-f0-9]{64}$`) -) - -func validateAccountViewPredicate(p extensionv1.AccountViewPredicate) error { - for _, values := range [][]string{p.Platforms, p.Types, p.Statuses, p.Plans} { - if err := boundedAccountViewStrings(values, 32); err != nil { - return err - } - } - if len(p.PrivacyMode) > 64 || strings.TrimSpace(p.PrivacyMode) != p.PrivacyMode || - (p.CindyBalanceStatus != "" && p.CindyBalanceStatus != "insufficient") || - (p.CindyHealthStatus != "" && p.CindyHealthStatus != "banned") { - return ErrAccountViewInvalid - } - return nil -} - -func boundedAccountViewStrings(values []string, limit int) error { - if len(values) > limit { - return ErrAccountViewInvalid - } - seen := make(map[string]bool, len(values)) - for _, value := range values { - if value == "" || len(value) > 64 || strings.TrimSpace(value) != value || seen[value] || strings.ContainsAny(value, "\x00\r\n") { - return ErrAccountViewInvalid - } - seen[value] = true - } - return nil -} - -func accountViewLabelValid(label map[string]string) bool { - if len(label) == 0 || len(label) > 16 { - return false - } - for key, value := range label { - if key == "" || len(key) > 32 || strings.TrimSpace(value) == "" || len([]rune(value)) > 256 { - return false - } - } - return true -} - -func validateAccountViewContributions(manifest PluginManifest) error { - byID := make(map[string]extensionv1.Contribution, len(manifest.Contributions)) - resourceActions := map[string]bool{} - for _, contribution := range manifest.Contributions { - if _, exists := byID[contribution.ID]; exists { - return fmt.Errorf("duplicate contribution: %s", contribution.ID) - } - byID[contribution.ID] = contribution - } - for _, contribution := range manifest.Contributions { - if contribution.AccountView != nil && contribution.Slot != extensionv1.AccountViewSlot { - return ErrAccountViewInvalid - } - if len(contribution.ValueBindings) > 0 { - if contribution.Slot != "account.columns" || contribution.Permission != "admin" || len(contribution.ValueBindings) > 16 { - return ErrAccountViewInvalid - } - fields := make(map[string]bool, len(contribution.DisplayFields)) - for _, field := range contribution.DisplayFields { - fields[field.Key] = true - } - for key, source := range contribution.ValueBindings { - if !fields[key] || !slices.Contains([]string{"cindy_balance_probe_job_id", "cindy_balance_probe_outcome", "cindy_balance_probe_checked_at"}, source) { - return ErrAccountViewInvalid - } - } - } - if action := contribution.ResourceAction; action != nil { - if resourceActions[action.Resource] { - return ErrAccountViewInvalid - } - resourceActions[action.Resource] = true - if contribution.Slot != "account.actions" || contribution.Permission != "admin" || contribution.Capability == "" || contribution.Action != "" || contribution.Entrypoint != "" || action.Version != 1 || action.AccountParameter != "id" || action.AccountSource != "row.id" || action.Effect != "refresh_current_account_view" { - return ErrAccountViewInvalid - } - declared := false - for _, resource := range manifest.Resources { - declared = declared || (resource.Name == action.Resource && resource.Permission == "admin" && resource.Capability == contribution.Capability) - } - if !declared { - return ErrAccountViewInvalid - } - if action.RowPredicate != nil { - if err := validateAccountViewPredicate(*action.RowPredicate); err != nil { - return err - } - } - } - if contribution.Slot != extensionv1.AccountViewSlot { - continue - } - view := contribution.AccountView - if view == nil || !accountViewIDPattern.MatchString(contribution.ID) || contribution.Permission != "admin" || contribution.Capability == "" || contribution.AllAccounts || contribution.RetainedControls || contribution.Entrypoint != "" || contribution.Action != "" || contribution.ResourceAction != nil || len(contribution.Fields) != 0 || len(contribution.DisplayFields) != 0 || view.Version != 1 || view.Source != extensionv1.AccountConsoleSource { - return ErrAccountViewInvalid - } - declaredAdmin := false - for _, capability := range manifest.Capabilities { - declaredAdmin = declaredAdmin || capability.ID == extensionv1.CapabilityAdmin - } - if !declaredAdmin || !accountViewLabelValid(contribution.Label) { - return ErrAccountViewInvalid - } - if err := validateAccountViewPredicate(view.BaseQuery); err != nil { - return err - } - if len(view.Presets) < 1 || len(view.Presets) > 16 || len(view.Layout) < 1 || len(view.Layout) > 16 { - return ErrAccountViewInvalid - } - presets := make(map[string]bool, len(view.Presets)) - for _, preset := range view.Presets { - if !accountViewIDPattern.MatchString(preset.ID) || presets[preset.ID] || !accountViewLabelValid(preset.Label) || !slices.Contains([]string{"cindy_total", "cindy_insufficient_count", "cindy_banned_count"}, preset.Counter) { - return ErrAccountViewInvalid - } - if err := validateAccountViewPredicate(preset.Query); err != nil { - return err - } - presets[preset.ID] = true - } - if !presets[view.DefaultPreset] { - return ErrAccountViewInvalid - } - checkRef := func(ref, slot string) bool { - item, exists := byID[ref] - return accountViewIDPattern.MatchString(ref) && exists && item.Slot == slot && item.Permission == "admin" - } - if len(view.CoreFilterSurfaceRefs) > 4 || (len(view.CoreFilterSurfaceRefs) > 0 && !view.ExtendsCoreFilters) { - return ErrAccountViewInvalid - } - coreSurfaces := map[string]bool{} - for _, ref := range view.CoreFilterSurfaceRefs { - if coreSurfaces[ref] || !checkRef(ref, "surface") { - return ErrAccountViewInvalid - } - coreSurfaces[ref] = true - } - tables := 0 - surfaces := map[string]bool{} - for _, node := range view.Layout { - switch node.Kind { - case "account_table": - if node.SurfaceRef != "" { - return ErrAccountViewInvalid - } - tables++ - case "surface": - if !checkRef(node.SurfaceRef, "surface") || surfaces[node.SurfaceRef] { - return ErrAccountViewInvalid - } - surfaces[node.SurfaceRef] = true - default: - return ErrAccountViewInvalid - } - } - if tables != 1 || len(view.Table.Layouts) == 0 || len(view.Table.Layouts) > 3 || view.Table.ColumnRefs == nil || view.Table.ActionRefs == nil { - return ErrAccountViewInvalid - } - layouts := map[string]bool{} - for _, layout := range view.Table.Layouts { - if layouts[layout] || !slices.Contains([]string{"table", "compact", "cards"}, layout) { - return ErrAccountViewInvalid - } - layouts[layout] = true - } - for slot, refs := range map[string][]string{"account.columns": view.Table.ColumnRefs, "account.actions": view.Table.ActionRefs} { - seen := map[string]bool{} - if len(refs) > 32 { - return ErrAccountViewInvalid - } - for _, ref := range refs { - if seen[ref] || !checkRef(ref, slot) { - return ErrAccountViewInvalid - } - seen[ref] = true - } - } - if nav := view.Navigation; nav != nil { - if nav.Section != "admin.extensions" || nav.TargetView != contribution.ID || nav.Icon != "accounts" || nav.Order < -10000 || nav.Order > 10000 { - return ErrAccountViewInvalid - } - } - if len(view.LegacyQueryAliases) > 8 || (len(view.LegacyQueryAliases) > 0 && (manifest.ID != CindyAccountViewPluginKey || contribution.ID != CindyAccountViewID)) { - return ErrAccountViewInvalid - } - priorities, matches := map[int]bool{}, map[string]bool{} - for _, alias := range view.LegacyQueryAliases { - if !presets[alias.Preset] || len(alias.Match) == 0 || len(alias.Match) > 3 || alias.Priority < 0 || alias.Priority > 1000 || priorities[alias.Priority] { - return ErrAccountViewInvalid - } - for key, value := range alias.Match { - if map[string]string{"cindy_only": "true", "cindy_balance_status": "insufficient", "cindy_health_status": "banned"}[key] != value || value == "" { - return ErrAccountViewInvalid - } - } - raw, _ := json.Marshal(alias.Match) - if matches[string(raw)] { - return ErrAccountViewInvalid - } - matches[string(raw)], priorities[alias.Priority] = true, true - } - } - return nil -} - -func AccountViewDefinitionDigest(contribution *extensionv1.Contribution) string { - if contribution == nil || contribution.AccountView == nil { - return "" - } - raw, err := json.Marshal(contribution) - if err != nil { - return "" - } - digest := sha256.Sum256(raw) - return hex.EncodeToString(digest[:]) -} - -func validateAccountViewRegistry(installations []*PluginInstallation) error { - owners := map[string]int64{} - for _, installation := range installations { - if installation == nil { - continue - } - for _, contribution := range installation.Manifest.Contributions { - if contribution.Slot != extensionv1.AccountViewSlot { - continue - } - key := installation.PluginKey + "/" + contribution.ID - if previous, exists := owners[key]; exists && previous != installation.ID { - return ErrAccountViewUnavailable - } - owners[key] = installation.ID - } - } - return nil -} - -type accountViewContextKey struct{} -type retainedAccountViewKey struct{} - -// BoundAccountView is host-private. The primary PluginExecution is deliberately -// separate: an account-tools action may originate in a Cindy-owned view. -type BoundAccountView struct { - Request extensionv1.AccountViewContextV1 - Execution PluginExecution - manager *PluginManager - installation *PluginInstallation - contribution extensionv1.Contribution - preset extensionv1.AccountViewPreset -} - -func AccountViewFromContext(ctx context.Context) (*BoundAccountView, bool) { - if ctx == nil { - return nil, false - } - view, ok := ctx.Value(accountViewContextKey{}).(*BoundAccountView) - return view, ok && view != nil -} - -func RetainedAccountViewIdentity(ctx context.Context) (*extensionv1.AccountViewIdentityV1, bool) { - identity, ok := ctx.Value(retainedAccountViewKey{}).(extensionv1.AccountViewIdentityV1) - if !ok { - return nil, false - } - return &identity, true -} - -func AccountViewExecutionFromContext(ctx context.Context) (PluginExecution, string, bool) { - view, ok := AccountViewFromContext(ctx) - if !ok { - return PluginExecution{}, "", false - } - return view.Execution, view.Request.PluginKey, true -} - -func ValidateAccountViewIdentity(identity extensionv1.AccountViewIdentityV1) error { - if identity.Version != 1 || identity.PluginID <= 0 || !pluginIDPattern.MatchString(identity.PluginKey) || len(identity.PluginKey) > 160 || !accountViewIDPattern.MatchString(identity.ViewID) || !accountViewIDPattern.MatchString(identity.PresetID) || !accountViewDigestPattern.MatchString(identity.PackageSHA256) || !accountViewDigestPattern.MatchString(identity.ViewDefinitionDigest) { - return ErrAccountViewInvalid - } - return nil -} - -// Only a host-registered retained route may use this identity-only context. It -// cannot be mistaken for a business BoundAccountView or authorize a new job. -func (m *PluginManager) BindRetainedAccountView(ctx context.Context, request extensionv1.AccountViewContextV1) (context.Context, error) { - if err := ValidateAccountViewIdentity(request.AccountViewIdentityV1); err != nil { - return nil, err - } - if m == nil || m.repo == nil { - return nil, ErrAccountViewUnavailable - } - current, err := m.repo.GetByID(ctx, request.PluginID) - if err != nil || current == nil || current.PluginKey != request.PluginKey || current.Manifest.ID != request.PluginKey || current.PackageSHA256 != request.PackageSHA256 { - return nil, ErrAccountViewUnavailable - } - found := false - for _, contribution := range current.Manifest.Contributions { - if contribution.ID != request.ViewID || contribution.Slot != extensionv1.AccountViewSlot { - continue - } - if found || AccountViewDefinitionDigest(&contribution) != request.ViewDefinitionDigest { - return nil, ErrAccountViewUnavailable - } - for _, preset := range contribution.AccountView.Presets { - if preset.ID == request.PresetID { - found = true - } - } - } - if !found { - return nil, ErrAccountViewUnavailable - } - return context.WithValue(ctx, retainedAccountViewKey{}, request.AccountViewIdentityV1), nil -} - -func NormalizeAccountViewQuery(query extensionv1.AccountViewQueryV1) (extensionv1.AccountViewQueryV1, error) { - for _, values := range [][]string{query.Platforms, query.Types, query.Statuses, query.Plans} { - if err := boundedAccountViewStrings(values, 32); err != nil { - return query, err - } - } - if len(query.Search) > 100 || len(query.PrivacyMode) > 64 || query.GroupID < AccountListGroupUngrouped { - return query, ErrAccountViewInvalid - } - query.Search = strings.TrimSpace(query.Search) - if query.SortBy != "" && !slices.Contains([]string{"id", "name", "platform", "type", "status", "schedulable", "priority", "concurrency", "rate_multiplier", "upstream_billing_rate", "last_used_at", "created_at", "updated_at", "expires_at"}, query.SortBy) { - return query, ErrAccountViewInvalid - } - if query.SortOrder != "" && query.SortOrder != "asc" && query.SortOrder != "desc" { - return query, ErrAccountViewInvalid - } - for _, values := range [][]int64{query.Tags, query.AccountIDs} { - if len(values) > 3200 { - return query, ErrAccountViewInvalid - } - for _, id := range values { - if id <= 0 { - return query, ErrAccountViewInvalid - } - } - } - for sentinel, values := range map[string][]string{"direct": query.Proxies, "uncategorized": query.Folders} { - if len(values) > 3200 { - return query, ErrAccountViewInvalid - } - for _, value := range values { - if value == sentinel { - continue - } - id, err := strconv.ParseInt(value, 10, 64) - if err != nil || id <= 0 || strconv.FormatInt(id, 10) != value { - return query, ErrAccountViewInvalid - } - } - } - normalizeStrings := func(values []string) []string { - out := slices.Clone(values) - slices.Sort(out) - return slices.Compact(out) - } - normalizeIDs := func(values []int64) []int64 { - out := slices.Clone(values) - slices.Sort(out) - return slices.Compact(out) - } - query.Platforms, query.Types, query.Statuses, query.Plans = normalizeStrings(query.Platforms), normalizeStrings(query.Types), normalizeStrings(query.Statuses), normalizeStrings(query.Plans) - query.Proxies, query.Folders = normalizeStrings(query.Proxies), normalizeStrings(query.Folders) - query.Tags, query.AccountIDs = normalizeIDs(query.Tags), normalizeIDs(query.AccountIDs) - return query, nil -} - -func AccountViewQueryFilters(query extensionv1.AccountViewQueryV1) AccountConsoleFilters { - filters := AccountConsoleFilters{Platforms: query.Platforms, Types: query.Types, Statuses: query.Statuses, Plans: query.Plans, TagIDs: query.Tags, AccountIDs: query.AccountIDs, GroupID: query.GroupID, PrivacyMode: query.PrivacyMode, Search: query.Search, SortBy: query.SortBy, SortOrder: query.SortOrder} - for _, value := range query.Proxies { - if value == "direct" { - filters.IncludeDirect = true - } else if id, err := strconv.ParseInt(value, 10, 64); err == nil { - filters.ProxyIDs = append(filters.ProxyIDs, id) - } - } - for _, value := range query.Folders { - if value == "uncategorized" { - filters.IncludeUncategorized = true - } else if id, err := strconv.ParseInt(value, 10, 64); err == nil { - filters.FolderIDs = append(filters.FolderIDs, id) - } - } - return filters -} - -func (m *PluginManager) BindAccountViewRequest(ctx context.Context, request extensionv1.AccountViewContextV1) (context.Context, func(), error) { - if err := ValidateAccountViewIdentity(request.AccountViewIdentityV1); err != nil { - return nil, nil, err - } - query, err := NormalizeAccountViewQuery(request.Query) - if err != nil { - return nil, nil, err - } - request.Query = query - if m == nil || m.repo == nil { - return nil, nil, ErrAccountViewUnavailable - } - registry := m.extensions.Load() - if registry == nil || registry.unavailable != "" { - return nil, nil, ErrAccountViewUnavailable - } - current, err := m.repo.GetByID(ctx, request.PluginID) - if err != nil || current == nil || current.State != PluginStateEnabled || current.PluginKey != request.PluginKey || current.Manifest.ID != request.PluginKey || current.PackageSHA256 != request.PackageSHA256 || !samePluginRuntime(current, registry.installations[current.ID]) { - return nil, nil, ErrAccountViewUnavailable - } - all := make([]*PluginInstallation, 0, len(registry.installations)) - for _, installation := range registry.installations { - all = append(all, installation) - } - if err := validateAccountViewRegistry(all); err != nil { - return nil, nil, err - } - var selected *extensionv1.Contribution - for _, contribution := range current.Manifest.Contributions { - if contribution.ID == request.ViewID && contribution.Slot == extensionv1.AccountViewSlot { - if selected != nil { - return nil, nil, ErrAccountViewUnavailable - } - copy := contribution - selected = © - } - } - if selected == nil || selected.Permission != "admin" || selected.AccountView == nil || AccountViewDefinitionDigest(selected) != request.ViewDefinitionDigest || !pluginContributionBindingsEnabled(current, selected) { - return nil, nil, ErrAccountViewUnavailable - } - if err := validateAccountViewContributions(current.Manifest); err != nil { - return nil, nil, ErrAccountViewUnavailable - } - var preset *extensionv1.AccountViewPreset - for _, item := range selected.AccountView.Presets { - if item.ID == request.PresetID { - copy := item - preset = © - } - } - if preset == nil { - return nil, nil, ErrAccountViewInvalid - } - runtime := registry.runtimes[current.ID] - if runtime == nil || runtime.client == nil || runtime.client.Exited() || !pluginDependenciesHealthy(current, registry, map[int64]bool{}) { - return nil, nil, ErrAccountViewUnavailable - } - if _, applied := m.contributionAppliedRevision(current, runtime); !applied { - return nil, nil, ErrAccountViewUnavailable - } - if enabled, known := m.contributionConfigured(current, runtime, selected.ConfigFlag); !known || !enabled { - return nil, nil, ErrAccountViewUnavailable - } - bound, release, err := m.bindHostPolicyContext(ctx, current, runtime) - if err != nil { - return nil, nil, ErrAccountViewUnavailable - } - view := &BoundAccountView{Request: request, Execution: PluginExecution{current.ID, current.RuntimeGeneration}, manager: m, installation: current, contribution: *selected, preset: *preset} - return context.WithValue(bound, accountViewContextKey{}, view), release, nil -} - -func accountMatchesViewPredicate(account *Account, predicate extensionv1.AccountViewPredicate) bool { - if account == nil || account.ID <= 0 { - return false - } - facts := AccountViewFactsFromAccount(account, time.Now()) - if predicate.CindyOnly != nil && *predicate.CindyOnly != facts.CanonicalCindy { - return false - } - if len(predicate.Platforms) > 0 && !slices.Contains(predicate.Platforms, account.Platform) { - return false - } - if len(predicate.Types) > 0 && !slices.Contains(predicate.Types, account.Type) { - return false - } - if len(predicate.Statuses) > 0 && !slices.Contains(predicate.Statuses, facts.Status) { - return false - } - if len(predicate.Plans) > 0 && !slices.ContainsFunc(predicate.Plans, func(plan string) bool { return strings.EqualFold(plan, facts.Plan) }) { - return false - } - if predicate.PrivacyMode != "" { - privacy := facts.PrivacyMode - if predicate.PrivacyMode == AccountPrivacyModeUnsetFilter { - if privacy != "" { - return false - } - } else if privacy != predicate.PrivacyMode { - return false - } - } - return (predicate.CindyBalanceStatus == "" || (facts.CanonicalCindy && facts.CindyBalanceInsufficient)) && (predicate.CindyHealthStatus == "" || (facts.CanonicalCindy && facts.CindyBanned)) -} - -// Native host row facts are a scalar projection, not a plugin Account DTO. -// The declaration matcher and all native layouts consume exactly these values. -type AccountViewFactsV1 struct { - Version int `json:"version"` - Status string `json:"status"` - Plan string `json:"plan"` - PrivacyMode string `json:"privacy_mode"` - CanonicalCindy bool `json:"canonical_cindy"` - CindyBalanceInsufficient bool `json:"cindy_balance_insufficient"` - CindyBanned bool `json:"cindy_banned"` -} - -func AccountViewFactsFromAccount(account *Account, now time.Time) *AccountViewFactsV1 { - if account == nil { - return nil - } - privacy, _ := account.Extra["privacy_mode"].(string) - return &AccountViewFactsV1{Version: 1, Status: accountConsoleStatus(account, now), Plan: accountConsolePlan(account), PrivacyMode: privacy, CanonicalCindy: accountViewCanonicalCindy(account), CindyBalanceInsufficient: account.CindyBalanceInsufficientAt != nil, CindyBanned: account.CindyBannedAt != nil} -} - -func accountViewCanonicalCindy(account *Account) bool { - return account != nil && account.Platform == PlatformCindy && account.WirePlatform == WirePlatformOpenAI && account.ProviderProfile == ProviderProfileCindyLaxaV1 && IsCindyAPIKeyAccount(account.Platform, account.Type, account.Credentials) -} - -func (view *BoundAccountView) allows(account *Account) bool { - return view != nil && account != nil && accountMatchesViewPredicate(account, view.contribution.AccountView.BaseQuery) && accountMatchesViewPredicate(account, view.preset.Query) && contributionAccountAllowed(view.installation, &view.contribution, *extensionAccount(account)) -} - -func filterAccountViewAccounts(ctx context.Context, accounts []*Account) ([]*Account, error) { - view, bound := AccountViewFromContext(ctx) - if !bound { - return accounts, nil - } - if err := ValidateAccountViewFresh(ctx); err != nil { - return nil, err - } - out := make([]*Account, 0, len(accounts)) - for _, account := range accounts { - if view.allows(account) { - out = append(out, account) - } - } - return out, nil -} - -type accountViewAccountDirectory interface { - ReadAccountViewAccount(context.Context, int64) (*Account, error) -} - -// This internal directory deliberately never exposes its native Account to the -// extension protocol. It is used only to validate real selected IDs in the host. -func (s *OpenAIGatewayService) ReadAccountViewAccount(ctx context.Context, id int64) (*Account, error) { - return s.accountRepo.GetByID(ctx, id) -} - -func ValidateAccountViewTargets(ctx context.Context, ids []int64) error { - view, bound := AccountViewFromContext(ctx) - if !bound { - return nil - } - if err := ValidateAccountViewFresh(ctx); err != nil { - return err - } - directory, ok := view.manager.accountDirectory.(accountViewAccountDirectory) - if !ok { - return ErrAccountViewUnavailable - } - for _, id := range ids { - if id <= 0 { - return ErrAccountViewScope - } - if len(view.Request.Query.AccountIDs) > 0 && !slices.Contains(view.Request.Query.AccountIDs, id) { - return ErrAccountViewScope - } - account, err := directory.ReadAccountViewAccount(ctx, id) - if err != nil || account == nil || account.ID != id || !view.allows(account) { - return ErrAccountViewScope - } - } - return nil -} - -func accountViewPresetContext(ctx context.Context, preset extensionv1.AccountViewPreset) context.Context { - view, bound := AccountViewFromContext(ctx) - if !bound { - return ctx - } - copy := *view - copy.preset = preset - return context.WithValue(ctx, accountViewContextKey{}, ©) -} - -func accountViewCounter(name string, accounts []*Account) int { - count := 0 - for _, account := range accounts { - if !hasCanonicalCindyProviderIdentity(account) { - continue - } - if name == "cindy_total" || (name == "cindy_insufficient_count" && account.CindyBalanceInsufficientAt != nil) || (name == "cindy_banned_count" && account.CindyBannedAt != nil) { - count++ - } - } - return count -} - -func accountViewJSONEqual(left, right any) bool { - a, _ := json.Marshal(left) - b, _ := json.Marshal(right) - return bytes.Equal(a, b) -} - -func ValidateAccountViewPredicateQuery(ctx context.Context, supplied map[string]string) error { - view, bound := AccountViewFromContext(ctx) - if !bound { - return nil - } - expected := map[string]string{} - for _, predicate := range []extensionv1.AccountViewPredicate{view.contribution.AccountView.BaseQuery, view.preset.Query} { - if predicate.CindyOnly != nil { - expected["cindy_only"] = strconv.FormatBool(*predicate.CindyOnly) - } - if predicate.CindyBalanceStatus != "" { - expected["cindy_balance_status"] = predicate.CindyBalanceStatus - } - if predicate.CindyHealthStatus != "" { - expected["cindy_health_status"] = predicate.CindyHealthStatus - } - } - for key, value := range supplied { - if expected[key] == "" || expected[key] != value { - return ErrAccountViewInvalid - } - } - return nil -} - -func ValidateAccountViewSelection(ctx context.Context, ids []int64) error { - if err := ValidateAccountViewTargets(ctx, ids); err != nil { - return err - } - return ValidateBoundResourceTargets(ctx, ids) -} - -func ValidateAccountViewFresh(ctx context.Context) error { - view, bound := AccountViewFromContext(ctx) - if !bound { - return nil - } - if err := ctx.Err(); err != nil { - return err - } - current, err := view.manager.repo.GetByID(ctx, view.Execution.ID) - if err != nil || current == nil || current.State != PluginStateEnabled || current.Revision != view.installation.Revision || current.ConfigEncrypted != view.installation.ConfigEncrypted || !samePluginRuntime(current, view.installation) { - return ErrAccountViewUnavailable - } - return nil -} - -// Shared row locks are acquired in ascending installation ID order for both -// action and view fences. A->B and B->A operations therefore use one lock order. -type PluginExecutionFence struct { - OriginEdit bool - EditAccountID int64 - EditContributionID string - EditDefinitionSHA256 string - OriginCreate bool - CreateContributionID string - CreateDefinitionSHA256 string - ConfigSHA256 string - ID int64 - Generation int64 - PluginKey string - PackageSHA256 string - PolicyRevision int64 - OriginView bool - Primary bool -} - -func mergePluginExecutionFences(fences []PluginExecutionFence) ([]PluginExecutionFence, error) { - slices.SortFunc(fences, func(a, b PluginExecutionFence) int { - if a.ID < b.ID { - return -1 - } - if a.ID > b.ID { - return 1 - } - return 0 - }) - out := make([]PluginExecutionFence, 0, len(fences)) - for _, fence := range fences { - if fence.ID <= 0 || fence.Generation <= 0 { - return nil, ErrAccountViewUnavailable - } - if len(out) > 0 && out[len(out)-1].ID == fence.ID { - previous := &out[len(out)-1] - if previous.Generation != fence.Generation || (previous.PluginKey != "" && fence.PluginKey != "" && previous.PluginKey != fence.PluginKey) { - return nil, ErrAccountViewUnavailable - } - previousStrict, nextStrict := previous.OriginView || previous.OriginCreate || previous.OriginEdit, fence.OriginView || fence.OriginCreate || fence.OriginEdit - if previousStrict && nextStrict && (previous.PackageSHA256 != fence.PackageSHA256 || previous.PolicyRevision != fence.PolicyRevision) { - return nil, ErrAccountViewUnavailable - } - if previous.ConfigSHA256 != "" && fence.ConfigSHA256 != "" && previous.ConfigSHA256 != fence.ConfigSHA256 { - return nil, ErrAccountViewUnavailable - } - if previous.OriginCreate && fence.OriginCreate && (previous.CreateContributionID != fence.CreateContributionID || previous.CreateDefinitionSHA256 != fence.CreateDefinitionSHA256 || previous.ConfigSHA256 != fence.ConfigSHA256) { - return nil, ErrAccountViewUnavailable - } - if previous.OriginEdit && fence.OriginEdit && (previous.EditAccountID != fence.EditAccountID || previous.EditContributionID != fence.EditContributionID || previous.EditDefinitionSHA256 != fence.EditDefinitionSHA256) { - return nil, ErrAccountEditUnavailable - } - if previous.PluginKey == "" { - previous.PluginKey = fence.PluginKey - } - if previous.ConfigSHA256 == "" { - previous.ConfigSHA256 = fence.ConfigSHA256 - } - if nextStrict { - previous.PackageSHA256, previous.PolicyRevision = fence.PackageSHA256, fence.PolicyRevision - } - if fence.OriginCreate { - previous.CreateContributionID, previous.CreateDefinitionSHA256, previous.ConfigSHA256 = fence.CreateContributionID, fence.CreateDefinitionSHA256, fence.ConfigSHA256 - } - if fence.OriginEdit { - previous.EditAccountID, previous.EditContributionID, previous.EditDefinitionSHA256 = fence.EditAccountID, fence.EditContributionID, fence.EditDefinitionSHA256 - } - previous.Primary = previous.Primary || fence.Primary - previous.OriginView = previous.OriginView || fence.OriginView - previous.OriginCreate = previous.OriginCreate || fence.OriginCreate - previous.OriginEdit = previous.OriginEdit || fence.OriginEdit - continue - } - out = append(out, fence) - } - return out, nil -} - -func PluginExecutionFences(ctx context.Context, primaryKey string) ([]PluginExecutionFence, error) { - var fences []PluginExecutionFence - if edit, bound := AccountEditFromContext(ctx); bound && primaryKey == "" { - primaryKey = edit.PrimaryPluginKey - } - if create, bound := AccountCreateFromContext(ctx); bound && primaryKey == "" { - primaryKey = create.PrimaryPluginKey - } - if execution, bound := PluginExecutionFromContext(ctx); bound { - fences = append(fences, PluginExecutionFence{ID: execution.ID, Generation: execution.Generation, PluginKey: primaryKey, Primary: true}) - } - if view, bound := AccountViewFromContext(ctx); bound { - fences = append(fences, PluginExecutionFence{ID: view.Execution.ID, Generation: view.Execution.Generation, PluginKey: view.Request.PluginKey, PackageSHA256: view.Request.PackageSHA256, PolicyRevision: view.installation.Revision, ConfigSHA256: accountCreateConfigDigest(view.installation.ConfigEncrypted), OriginView: true}) - } - if create, bound := AccountCreateFromContext(ctx); bound { - fences = append(fences, PluginExecutionFence{ID: create.Execution.ID, Generation: create.Execution.Generation, - PluginKey: create.installation.PluginKey, PackageSHA256: create.installation.PackageSHA256, PolicyRevision: create.installation.Revision, - OriginCreate: true, CreateContributionID: create.contribution.ID, CreateDefinitionSHA256: AccountCreateDefinitionDigest(&create.contribution), - ConfigSHA256: accountCreateConfigDigest(create.installation.ConfigEncrypted)}) - } - if edit, bound := AccountEditFromContext(ctx); bound && edit.changed { - fences = append(fences, PluginExecutionFence{ID: edit.Execution.ID, Generation: edit.Execution.Generation, - PluginKey: edit.installation.PluginKey, PackageSHA256: edit.installation.PackageSHA256, PolicyRevision: edit.installation.Revision, - OriginEdit: true, EditAccountID: edit.AccountID, EditContributionID: edit.contribution.ID, EditDefinitionSHA256: AccountEditDefinitionDigest(&edit.contribution), - ConfigSHA256: accountCreateConfigDigest(edit.installation.ConfigEncrypted)}) - } - return mergePluginExecutionFences(fences) -} - -type pluginUIActorKey struct{} - -func WithPluginUIActor(ctx context.Context, id int64) context.Context { - return context.WithValue(ctx, pluginUIActorKey{}, id) -} - -// The encrypted UI asset token is reused as a host-only request binding. It is -// not administrator authentication: the normal admin middleware still applies. -func (m *PluginManager) ValidateUIRequestBinding(ctx context.Context, token string, pluginID, actorID int64, identity *extensionv1.AccountViewIdentityV1) error { - claims, err := m.parseUIAssetToken(token) - if err != nil || claims.Permission != "admin" || claims.PluginID != pluginID || claims.ActorID <= 0 || claims.ActorID != actorID { - return ErrAccountViewUnavailable - } - if (claims.ViewContext == nil) != (identity == nil) || (identity != nil && *claims.ViewContext != *identity) { - return ErrAccountViewUnavailable - } - installation, err := m.repo.GetByID(ctx, pluginID) - if err != nil || installation == nil || installation.PackageSHA256 != claims.PackageSHA256 { - return ErrAccountViewUnavailable - } - return nil -} diff --git a/backend/internal/service/plugin_account_views_test.go b/backend/internal/service/plugin_account_views_test.go deleted file mode 100644 index d66c1e49ece1..000000000000 --- a/backend/internal/service/plugin_account_views_test.go +++ /dev/null @@ -1,408 +0,0 @@ -//go:build unit - -package service - -import ( - "context" - "encoding/json" - "errors" - "os" - "strings" - "sync" - "testing" - "time" - - "entgo.io/ent/dialect" - entsql "entgo.io/ent/dialect/sql" - "github.com/DATA-DOG/go-sqlmock" - dbent "github.com/Wei-Shaw/sub2api/ent" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - hcplugin "github.com/hashicorp/go-plugin" - "github.com/stretchr/testify/require" -) - -type viewFixtureRepo struct { - installations map[int64]*PluginInstallation - held, released []int64 -} - -func (r *viewFixtureRepo) List(context.Context) ([]*PluginInstallation, error) { - var out []*PluginInstallation - for _, item := range r.installations { - out = append(out, item) - } - return out, nil -} -func (r *viewFixtureRepo) GetByID(_ context.Context, id int64) (*PluginInstallation, error) { - item := r.installations[id] - if item == nil { - return nil, errors.New("not found") - } - return cloneHostIOInstallation(item), nil -} -func (r *viewFixtureRepo) GetByKey(ctx context.Context, key string) (*PluginInstallation, error) { - for id, item := range r.installations { - if item.PluginKey == key { - return r.GetByID(ctx, id) - } - } - return nil, errors.New("not found") -} -func (*viewFixtureRepo) Install(context.Context, *PluginInstallation, []PluginBinding) (*PluginInstallation, error) { - return nil, errors.New("unexpected fixture install") -} -func (*viewFixtureRepo) GetArtifact(context.Context, int64) ([]byte, error) { - return nil, errors.New("unexpected fixture artifact") -} -func (*viewFixtureRepo) Delete(context.Context, int64, string) error { - return errors.New("unexpected fixture delete") -} -func (*viewFixtureRepo) BeginEnable(context.Context, int64, string, string) error { - return errors.New("unexpected fixture enable") -} -func (*viewFixtureRepo) MarkRuntimeHealthy(context.Context, int64, string, string) error { - return errors.New("unexpected fixture runtime write") -} -func (*viewFixtureRepo) UpdateState(context.Context, int64, string, string, *time.Time, string, string) error { - return errors.New("unexpected fixture state write") -} -func (*viewFixtureRepo) UpdateConfig(context.Context, int64, string, string) error { - return errors.New("unexpected fixture config write") -} -func (*viewFixtureRepo) UpdateBindingsAndState(context.Context, int64, []PluginBinding, string, string, *time.Time, string, string) error { - return errors.New("unexpected fixture bindings write") -} -func (r *viewFixtureRepo) HoldObservedPluginRuntime(_ context.Context, installation *PluginInstallation) (PluginRuntimeLease, error) { - r.held = append(r.held, installation.ID) - return &viewFixtureLease{done: make(chan struct{}), release: func() { r.released = append(r.released, installation.ID) }}, nil -} - -type viewFixtureLease struct { - done chan struct{} - once sync.Once - release func() -} - -func (l *viewFixtureLease) Done() <-chan struct{} { return l.done } -func (*viewFixtureLease) Err() error { return nil } -func (l *viewFixtureLease) Release() { l.once.Do(func() { l.release(); close(l.done) }) } - -type viewFixtureDirectory struct { - accounts map[int64]*Account - reads int -} - -func (d *viewFixtureDirectory) ReadAccountViewAccount(_ context.Context, id int64) (*Account, error) { - d.reads++ - if a := d.accounts[id]; a != nil { - copy := *a - return ©, nil - } - return nil, ErrAccountNotFound -} -func (d *viewFixtureDirectory) ReadExtensionAccount(ctx context.Context, id int64) (*extensionv1.Account, error) { - account, err := d.ReadAccountViewAccount(ctx, id) - return extensionAccount(account), err -} -func (*viewFixtureDirectory) ListExtensionAccounts(context.Context, extensionv1.AccountQuery) ([]extensionv1.Account, error) { - return nil, errors.New("unexpected unscoped list") -} -func (*viewFixtureDirectory) ResolveExtensionIdentity(context.Context, extensionv1.AccountQuery) (*extensionv1.OutboundIdentity, error) { - return nil, errors.New("credential API must not be called") -} -func (*viewFixtureDirectory) ListPluginAccounts(context.Context, PluginAccountScope, string, string) ([]PluginAccountInfo, error) { - return nil, errors.New("unexpected plugin account list") -} -func (*viewFixtureDirectory) ResolvePluginOutboundIdentity(context.Context, PluginAccountScope, int64) (*PluginOutboundIdentity, error) { - return nil, errors.New("credential API must not be called") -} - -func viewFixtureContribution() extensionv1.Contribution { - yes := true - return extensionv1.Contribution{ID: CindyAccountViewID, Slot: extensionv1.AccountViewSlot, Permission: "admin", Capability: extensionv1.CapabilityProvider, Label: map[string]string{"en": "Cindy"}, AccountView: &extensionv1.AccountViewDefinitionV1{ - Version: 1, Source: extensionv1.AccountConsoleSource, BaseQuery: extensionv1.AccountViewPredicate{CindyOnly: &yes}, DefaultPreset: "cindy", - Presets: []extensionv1.AccountViewPreset{ - {ID: "cindy", Label: map[string]string{"en": "Cindy"}, Counter: "cindy_total"}, - {ID: "insufficient", Label: map[string]string{"en": "Insufficient"}, Query: extensionv1.AccountViewPredicate{CindyBalanceStatus: "insufficient"}, Counter: "cindy_insufficient_count"}, - {ID: "banned", Label: map[string]string{"en": "Banned"}, Query: extensionv1.AccountViewPredicate{CindyHealthStatus: "banned"}, Counter: "cindy_banned_count"}, - }, Layout: []extensionv1.AccountViewLayout{{Kind: "account_table"}}, Table: extensionv1.AccountViewTable{Layouts: []string{"table", "cards", "compact"}, ColumnRefs: []string{}, ActionRefs: []string{}}, - LegacyQueryAliases: []extensionv1.AccountViewLegacyAlias{{Match: map[string]string{"cindy_only": "true"}, Preset: "cindy", Priority: 10}}, - }} -} - -func newViewFixture(t *testing.T) (*PluginManager, *viewFixtureRepo, *viewFixtureDirectory, extensionv1.AccountViewContextV1) { - t.Helper() - view := viewFixtureContribution() - grants := []extensionv1.ResourceGrant{{Name: "cindy.probe.create", Capability: extensionv1.CapabilityProvider, Permission: "admin"}} - for _, name := range []string{"cindy.cleanup.insufficient.preview", "cindy.cleanup.insufficient.submit", "cindy.cleanup.banned.preview", "cindy.cleanup.banned.submit", "cindy.balance.recover"} { - grants = append(grants, extensionv1.ResourceGrant{Name: name, Capability: extensionv1.CapabilityProvider, Permission: "admin"}) - } - owner := &PluginInstallation{ID: 1, PluginKey: CindyAccountViewPluginKey, State: PluginStateEnabled, RuntimeGeneration: 3, Revision: 7, PackageSHA256: strings.Repeat("a", 64), Manifest: PluginManifest{ID: CindyAccountViewPluginKey, Contributions: []extensionv1.Contribution{view}, Resources: grants, UI: PluginUIManifest{Entrypoint: "ui/index.html"}, Capabilities: []PluginCapability{{ID: extensionv1.CapabilityProvider, Platform: PlatformCindy, AccountType: AccountTypeAPIKey}, {ID: extensionv1.CapabilityAdmin, Platform: "*", AccountType: "*"}}}, Bindings: []PluginBinding{{Capability: extensionv1.CapabilityProvider, Platform: PlatformCindy, AccountType: AccountTypeAPIKey, Enabled: true, RolloutPercent: 100}, {Capability: extensionv1.CapabilityAdmin, Platform: PlatformCindy, AccountType: AccountTypeAPIKey, Enabled: true, RolloutPercent: 100}}} - tool := &PluginInstallation{ID: 2, PluginKey: "codexrip.account-tools", State: PluginStateEnabled, RuntimeGeneration: 4, Revision: 8, PackageSHA256: strings.Repeat("b", 64), Manifest: PluginManifest{ID: "codexrip.account-tools", Resources: []extensionv1.ResourceGrant{{Name: "tests.submit", Capability: extensionv1.CapabilityAdmin, Permission: "admin"}}}, Bindings: []PluginBinding{{Capability: extensionv1.CapabilityAdmin, Platform: "*", AccountType: "*", Enabled: true, RolloutPercent: 100}}} - repo := &viewFixtureRepo{installations: map[int64]*PluginInstallation{1: owner, 2: tool}} - manager := NewPluginManager(repo, pluginTokenEncryptor{}, nil, PluginHostInfo{}, nil) - registry := &pluginExtensionRegistry{installations: map[int64]*PluginInstallation{}, runtimes: map[int64]*pluginRuntime{}} - for id, item := range repo.installations { - registry.installations[id] = cloneHostIOInstallation(item) - registry.runtimes[id] = &pluginRuntime{installation: cloneHostIOInstallation(item), client: hcplugin.NewClient(&hcplugin.ClientConfig{}), done: make(chan struct{})} - } - manager.extensions.Store(registry) - now := time.Now().UTC() - directory := &viewFixtureDirectory{accounts: map[int64]*Account{}} - for _, id := range []int64{2, 3, 4, 5} { - directory.accounts[id] = &Account{ID: id, Name: "fixture", Platform: PlatformCindy, WirePlatform: WirePlatformOpenAI, ProviderProfile: ProviderProfileCindyLaxaV1, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, UpdatedAt: now, Credentials: map[string]any{"base_url": "https://api.laxarouter.ai", "api_key": "synthetic-private-key"}, Extra: map[string]any{"private_secret": "synthetic-extra"}} - } - directory.accounts[3].CindyBalanceInsufficientAt = &now - directory.accounts[4].CindyBannedAt = &now - directory.accounts[5].Platform = PlatformOpenAI - manager.accountDirectory = directory - request := extensionv1.AccountViewContextV1{AccountViewIdentityV1: extensionv1.AccountViewIdentityV1{Version: 1, PluginID: 1, PluginKey: owner.PluginKey, PackageSHA256: owner.PackageSHA256, ViewID: view.ID, PresetID: "cindy", ViewDefinitionDigest: AccountViewDefinitionDigest(&view)}} - return manager, repo, directory, request -} - -func applyViewFixture(t *testing.T, manager *PluginManager, repo *viewFixtureRepo) { - t.Helper() - for id, current := range repo.installations { - manager.extensions.Load().installations[id] = cloneHostIOInstallation(current) - manager.extensions.Load().runtimes[id].installation = cloneHostIOInstallation(current) - } -} - -func TestAccountViewManifestContract(t *testing.T) { - _, repo, _, _ := newViewFixture(t) - baseline := repo.installations[1].Manifest - require.NoError(t, validateAccountViewContributions(baseline)) - for name, mutate := range map[string]func(*PluginManifest){ - "cross-owner ref": func(m *PluginManifest) { - m.Contributions[0].AccountView.Table.ActionRefs = []string{"other-owner/recover"} - }, - "core refs without opt in": func(m *PluginManifest) { m.Contributions[0].AccountView.CoreFilterSurfaceRefs = []string{"controls"} }, - "unknown source": func(m *PluginManifest) { m.Contributions[0].AccountView.Source = "raw.sql" }, - "two tables": func(m *PluginManifest) { - m.Contributions[0].AccountView.Layout = append(m.Contributions[0].AccountView.Layout, extensionv1.AccountViewLayout{Kind: "account_table"}) - }, - "preset collision": func(m *PluginManifest) { m.Contributions[0].AccountView.Presets[1].ID = "cindy" }, - "alias owner": func(m *PluginManifest) { m.ID = "other.owner" }, - "admin absent": func(m *PluginManifest) { m.Capabilities = m.Capabilities[:1] }, - "raw display source": func(m *PluginManifest) { - m.Contributions = append(m.Contributions, extensionv1.Contribution{ID: "column", Slot: "account.columns", Permission: "admin", DisplayFields: []extensionv1.DisplayField{{Key: "value", Kind: "text"}}, ValueBindings: map[string]string{"value": "credentials.api_key"}}) - }, - } { - t.Run(name, func(t *testing.T) { - raw, _ := json.Marshal(baseline) - var copy PluginManifest - require.NoError(t, json.Unmarshal(raw, ©)) - mutate(©) - require.Error(t, validateAccountViewContributions(copy)) - }) - } - for _, raw := range []string{`{"version":1,"source":"accounts.console.v1","base_query":{"sql":"SELECT 1"},"default_preset":"cindy","presets":[],"layout":[],"table":{}}`, `{"version":1,"source":"accounts.console.v1","default_preset":"cindy","presets":[],"layout":[],"table":{}}`} { - var view extensionv1.AccountViewDefinitionV1 - require.Error(t, json.Unmarshal([]byte(raw), &view)) - } -} - -func TestAccountViewCindySourceDeclaration(t *testing.T) { - raw, err := os.ReadFile("../../../plugins/cindy-provider/manifest.source.json") - require.NoError(t, err) - var manifest PluginManifest - require.NoError(t, json.Unmarshal(raw, &manifest)) - require.NoError(t, validateAccountViewContributions(manifest)) - found := false - for _, contribution := range manifest.Contributions { - if contribution.Slot == extensionv1.AccountViewSlot { - found = true - require.Equal(t, CindyAccountViewID, contribution.ID) - require.Contains(t, contribution.AccountView.CoreFilterSurfaceRefs, "cindy-account-controls") - } - } - require.True(t, found) -} - -func TestAccountViewOwnerQualifiedResolution(t *testing.T) { - manager, repo, _, request := newViewFixture(t) - ctx, release, err := manager.BindAccountViewRequest(context.Background(), request) - require.NoError(t, err) - defer release() - _, bound := AccountViewFromContext(ctx) - require.True(t, bound) - _, primary := PluginExecutionFromContext(ctx) - require.False(t, primary, "view binding must not seize a later tool's owner") - wrong := request - wrong.PluginKey = "other.owner" - _, _, err = manager.BindAccountViewRequest(context.Background(), wrong) - require.Error(t, err) - wrong = request - wrong.PackageSHA256 = strings.Repeat("c", 64) - _, _, err = manager.BindAccountViewRequest(context.Background(), wrong) - require.Error(t, err) - duplicate := cloneHostIOInstallation(repo.installations[1]) - duplicate.ID = 9 - manager.extensions.Load().installations[9] = duplicate - _, _, err = manager.BindAccountViewRequest(context.Background(), request) - require.Error(t, err) -} - -type viewConsoleRepo struct { - AccountRepository - accounts map[int64]*Account -} - -func (r *viewConsoleRepo) GetByIDs(_ context.Context, ids []int64) ([]*Account, error) { - var out []*Account - for _, id := range ids { - if account := r.accounts[id]; account != nil { - copy := *account - out = append(out, ©) - } - } - return out, nil -} - -func expectViewConsoleRead(mock sqlmock.Sqlmock) { - mock.ExpectQuery(`SELECT .*"accounts"`).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(5).AddRow(3).AddRow(2)) - mock.ExpectQuery(`SELECT .*"accounts"`).WillReturnRows(sqlmock.NewRows([]string{"id"})) -} - -func TestAccountViewScopeBeforeAggregation(t *testing.T) { - manager, repo, directory, request := newViewFixture(t) - repo.installations[1].Bindings[1].RolloutPercent = 50 - applyViewFixture(t, manager, repo) - ctx, release, err := manager.BindAccountViewRequest(context.Background(), request) - require.NoError(t, err) - defer release() - require.NoError(t, ValidateAccountViewTargets(ctx, []int64{2})) - require.Error(t, ValidateAccountViewTargets(ctx, []int64{2, 3})) - require.Error(t, ValidateAccountViewTargets(ctx, []int64{-1})) - require.Error(t, ValidateAccountViewPredicateQuery(ctx, map[string]string{"cindy_only": "false"})) - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) - admin := &adminServiceImpl{entClient: client, accountRepo: &viewConsoleRepo{accounts: directory.accounts}} - expectViewConsoleRead(mock) - items, total, err := admin.ListAccountsConsole(ctx, 1, 1, AccountConsoleFilters{}) - require.NoError(t, err) - require.EqualValues(t, 1, total) - require.Len(t, items, 1) - require.EqualValues(t, 2, items[0].ID) - // Native core uses the same host source without consulting a view runtime. - expectViewConsoleRead(mock) - _, coreTotal, err := admin.ListAccountsConsole(context.Background(), 1, 1, AccountConsoleFilters{}) - require.NoError(t, err) - require.EqualValues(t, 3, coreTotal) - require.NoError(t, mock.ExpectationsWereMet()) -} - -func TestAccountViewFacetRelaxationAndPresetCounters(t *testing.T) { - manager, _, directory, request := newViewFixture(t) - request.PresetID = "insufficient" - ctx, release, err := manager.BindAccountViewRequest(context.Background(), request) - require.NoError(t, err) - defer release() - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) - admin := &adminServiceImpl{entClient: client, accountRepo: &viewConsoleRepo{accounts: directory.accounts}} - for range 4 { - expectViewConsoleRead(mock) - } - mock.ExpectQuery(`SELECT .*"account_folders"`).WillReturnRows(sqlmock.NewRows([]string{"id"})) - mock.ExpectQuery(`SELECT .*"account_tags"`).WillReturnRows(sqlmock.NewRows([]string{"id"})) - facets, err := admin.GetAccountConsoleFacets(ctx, AccountConsoleFilters{}) - require.NoError(t, err) - require.Equal(t, 1, facets.Total, "facet relaxation must retain the active insufficient preset") - require.Equal(t, map[string]int{"cindy": 2, "insufficient": 1, "banned": 0}, facets.ViewPresetCounts, "each preset counter evaluates over base+scope, not the active preset") - require.NoError(t, mock.ExpectationsWereMet()) -} - -func TestAccountViewDualOwnerLifetimeAndUICapture(t *testing.T) { - manager, repo, _, request := newViewFixture(t) - request.Query = extensionv1.AccountViewQueryV1{Search: "private-search", AccountIDs: []int64{2}} - viewCtx, releaseView, err := manager.BindAccountViewRequest(context.Background(), request) - require.NoError(t, err) - ctx, releaseTool, err := manager.BindResourceContext(viewCtx, 2, repo.installations[2].PackageSHA256, repo.installations[2].Manifest.Resources[0]) - require.NoError(t, err) - primary, _ := PluginExecutionFromContext(ctx) - origin, _, _ := AccountViewExecutionFromContext(ctx) - require.EqualValues(t, 2, primary.ID) - require.EqualValues(t, 1, origin.ID) - require.Error(t, ValidateAccountViewTargets(ctx, []int64{3}), "selection snapshot cannot be enlarged by a resource message") - fences, err := PluginExecutionFences(ctx, repo.installations[2].PluginKey) - require.NoError(t, err) - require.EqualValues(t, 1, fences[0].ID) - require.EqualValues(t, 2, fences[1].ID) - session, err := manager.CreateUIAssetSession(WithPluginUIActor(ctx, 42), 1, "", "admin", time.Minute) - require.NoError(t, err) - require.Equal(t, request.AccountViewIdentityV1, *session.ViewContext) - require.NoError(t, manager.ValidateUIRequestBinding(ctx, session.Token, 1, 42, &request.AccountViewIdentityV1)) - require.Error(t, manager.ValidateUIRequestBinding(ctx, session.Token, 1, 43, &request.AccountViewIdentityV1)) - require.Error(t, manager.ValidateUIRequestBinding(ctx, session.Token, 1, 42, nil)) - claims, err := manager.parseUIAssetToken(session.Token) - require.NoError(t, err) - raw, _ := json.Marshal(claims) - require.NotContains(t, string(raw), "private-search") - require.NotContains(t, string(raw), "synthetic-private-key") - manager.extensions.Load().runtimes[1].beginDrain() - require.ErrorIs(t, ctx.Err(), context.Canceled) - require.Equal(t, []int64{1, 2}, repo.held) - releaseTool() - releaseView() - require.ElementsMatch(t, []int64{1, 2}, repo.released) -} - -type viewDuplicateInventoryRepo struct { - AccountRepository - accounts []Account -} - -func (r *viewDuplicateInventoryRepo) ListByPlatform(_ context.Context, platform string) ([]Account, error) { - if platform != PlatformCindy { - return nil, errors.New("unexpected duplicate inventory platform") - } - return append([]Account(nil), r.accounts...), nil -} - -func TestAccountViewDuplicateInventoryFiltersBeforeAggregation(t *testing.T) { - manager, plugins, directory, request := newViewFixture(t) - plugins.installations[1].Bindings[1].RolloutPercent = 50 - applyViewFixture(t, manager, plugins) - request.PresetID = "insufficient" - ctx, release, err := manager.BindAccountViewRequest(context.Background(), request) - require.NoError(t, err) - defer release() - now := time.Now().UTC() - first, second := *directory.accounts[2], *directory.accounts[2] - first.CindyBalanceInsufficientAt, second.CindyBalanceInsufficientAt = &now, &now - second.ID = 6 // Both 2 and 6 are enrolled; account 3 is outside Admin50. - wrongBase := first - wrongBase.ID, wrongBase.WirePlatform = 5, "not-openai" - repo := &viewDuplicateInventoryRepo{accounts: []Account{*directory.accounts[3], *directory.accounts[4], wrongBase, first, second}} - admin := &adminServiceImpl{accountRepo: repo} - fixture := &capturedCindyManagement{} - previous := processExtensionOperations.Load() - processExtensionOperations.Store(&extensionOperationProvider{invoker: fixture}) - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - - groups, err := admin.BuildCindyDuplicateIdentityInventory(ctx) - require.NoError(t, err) - require.Len(t, fixture.payloads, 1) - var candidates []extensionv1.CindyDuplicateCandidate - require.NoError(t, json.Unmarshal([]byte(fixture.payloads[0]), &candidates)) - var ids []int64 - for _, candidate := range candidates { - ids = append(ids, candidate.ID) - } - require.ElementsMatch(t, []int64{2, 6}, ids, "base, preset and bucket must apply before aggregate policy sees account facts") - require.Len(t, groups, 1) - require.ElementsMatch(t, []int64{2, 6}, groups[0].OtherAccountIDs) - require.Zero(t, groups[0].ProposedOwnerID, "the existing policy keeps exhausted members terminal") - require.NotContains(t, fixture.payloads[0], "synthetic-private-key") - - _, err = admin.BuildCindyDuplicateIdentityInventory(context.Background()) - require.NoError(t, err) - require.Len(t, fixture.payloads, 2) - require.NoError(t, json.Unmarshal([]byte(fixture.payloads[1]), &candidates)) - require.Len(t, candidates, len(repo.accounts), "no-view inventory keeps the original candidate semantics") -} diff --git a/backend/internal/service/plugin_bootstrap_completion_test.go b/backend/internal/service/plugin_bootstrap_completion_test.go deleted file mode 100644 index 354a45e755a2..000000000000 --- a/backend/internal/service/plugin_bootstrap_completion_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package service - -import ( - "context" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/require" -) - -type bootstrapCompletionRaceRepository struct { - PluginRepository - applied bool - readErr error - completionErr error - appliedCalls int - imports int - completions int -} - -func (r *bootstrapCompletionRaceRepository) BundleApplied(context.Context, string, string) (bool, error) { - r.appliedCalls++ - if r.appliedCalls == 1 { - return false, nil - } - return r.applied, r.readErr -} - -func (*bootstrapCompletionRaceRepository) PrepareBundledPlugin(_ context.Context, candidate *PluginInstallation, _, _ string, _ bool, config string) (*PluginInstallation, error) { - copy := *candidate - copy.ID, copy.Revision, copy.RuntimeGeneration = 7, 1, 1 - copy.ConfigEncrypted = config - return ©, nil -} - -func (r *bootstrapCompletionRaceRepository) CompleteBundledPlugin(context.Context, int64, string) error { - r.completions++ - return r.completionErr -} - -func (*bootstrapCompletionRaceRepository) LegacyBundleSeed(_ context.Context, _, _ string, fallback PluginBundleSeed) (PluginBundleSeed, error) { - return fallback, nil -} - -func (r *bootstrapCompletionRaceRepository) ImportLegacyPluginState(context.Context, *PluginInstallation, string, json.RawMessage) error { - r.imports++ - return nil -} - -var _ PluginBundleRepository = (*bootstrapCompletionRaceRepository)(nil) - -func TestBootstrapCompletionConflictRequiresPersistedTakeover(t *testing.T) { - readFailure := errors.New("ownership read failed") - otherFailure := errors.New("completion storage failed") - for _, test := range []struct { - name string - applied bool - readErr error - completionErr error - wantErr error - wantReads int - }{ - {name: "confirmed_takeover", applied: true, completionErr: ErrPluginStateChanged, wantReads: 2}, - {name: "unconfirmed_state_change", completionErr: ErrPluginStateChanged, wantErr: ErrPluginStateChanged, wantReads: 2}, - {name: "takeover_read_failure", applied: true, readErr: readFailure, completionErr: ErrPluginStateChanged, wantErr: readFailure, wantReads: 2}, - {name: "other_completion_failure", applied: true, completionErr: otherFailure, wantErr: otherFailure, wantReads: 1}, - } { - t.Run(test.name, func(t *testing.T) { - root := t.TempDir() - cfg := testPluginConfig(root, true) - host := PluginHostInfo{Version: "0.1.179"} - manifest := testPluginManifest(nil) - manifest.ID = "codexrip.bootstrap-race" - archive := buildPluginArchive(t, manifest, nil, "", nil) - digest := sha256.Sum256(archive) - bundle := PluginBundle{SchemaVersion: 1, HostVersion: host.Version, PublisherKeyID: "completion-fixture", PublisherPublicKey: base64.StdEncoding.EncodeToString(make([]byte, 32)), Plugins: []BundledPlugin{{ID: manifest.ID, Version: manifest.Version, File: "fixture.s2plugin", SHA256: hex.EncodeToString(digest[:])}}} - raw, err := json.Marshal(bundle) - require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(root, "fixture.s2plugin"), archive, 0600)) - bundlePath := filepath.Join(root, "lock.json") - require.NoError(t, os.WriteFile(bundlePath, raw, 0600)) - repo := &bootstrapCompletionRaceRepository{applied: test.applied, readErr: test.readErr, completionErr: test.completionErr} - manager := NewPluginManager(repo, pluginTokenEncryptor{}, cfg, host, nil) - manager.bundlePath = bundlePath - err = manager.bootstrapBundle(context.Background()) - if test.wantErr == nil { - require.NoError(t, err) - } else { - require.ErrorIs(t, err, test.wantErr) - } - require.Equal(t, test.wantReads, repo.appliedCalls) - require.Equal(t, 1, repo.imports) - require.Equal(t, 1, repo.completions) - }) - } -} diff --git a/backend/internal/service/plugin_broker_scope_test.go b/backend/internal/service/plugin_broker_scope_test.go deleted file mode 100644 index c5744d3b2067..000000000000 --- a/backend/internal/service/plugin_broker_scope_test.go +++ /dev/null @@ -1,199 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "testing" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/stretchr/testify/require" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -type brokerScopeRepository struct { - *pluginTokenRepository - PluginExtensionStateStore - writes int - reads int - readErr error -} - -func (r *brokerScopeRepository) GetByID(ctx context.Context, id int64) (*PluginInstallation, error) { - r.reads++ - if r.readErr != nil { - return nil, r.readErr - } - return r.pluginTokenRepository.GetByID(ctx, id) -} - -func (r *brokerScopeRepository) CompareSwapExtensionState(context.Context, string, extensionv1.StateRequest) (extensionv1.StateResult, error) { - r.writes++ - return extensionv1.StateResult{}, nil -} - -type brokerScopeDirectory struct { - PluginAccountDirectory - accounts []extensionv1.Account - resolved int -} - -func (d *brokerScopeDirectory) ReadExtensionAccount(_ context.Context, id int64) (*extensionv1.Account, error) { - for _, account := range d.accounts { - if account.ID == id { - copy := account - return ©, nil - } - } - return nil, errors.New("synthetic account not found") -} - -func (d *brokerScopeDirectory) ListExtensionAccounts(context.Context, extensionv1.AccountQuery) ([]extensionv1.Account, error) { - return append([]extensionv1.Account(nil), d.accounts...), nil -} - -func (d *brokerScopeDirectory) ResolveExtensionIdentity(_ context.Context, query extensionv1.AccountQuery) (*extensionv1.OutboundIdentity, error) { - d.resolved++ - return &extensionv1.OutboundIdentity{AccountID: query.AccountID, Token: "synthetic-scope-token"}, nil -} - -func newBrokerScopeFixture(t *testing.T) (extensionv1.HostHandler, *brokerScopeRepository, *brokerScopeDirectory) { - t.Helper() - var included, excluded int64 - for id := int64(1); included == 0 || excluded == 0; id++ { - if stablePluginBucket(id) < 50 { - included = id - } else { - excluded = id - } - } - installation := &PluginInstallation{ID: 7, PluginKey: "scope.fixture", State: PluginStateEnabled, - Version: "1.0.0", PackageSHA256: "package-1", BinarySHA256: "binary-1", RuntimeGeneration: 3, - Manifest: PluginManifest{Requires: PluginRequirements{ExtensionAPI: 1}}} - for _, capability := range []string{extensionv1.CapabilityCredentials, extensionv1.CapabilityObservability} { - installation.Manifest.Capabilities = append(installation.Manifest.Capabilities, PluginCapability{ID: capability, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth}) - installation.Bindings = append(installation.Bindings, PluginBinding{Capability: capability, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth, Enabled: true, RolloutPercent: 50}) - } - repo := &brokerScopeRepository{pluginTokenRepository: &pluginTokenRepository{installation: installation}, PluginExtensionStateStore: &extensionReadOnlyFixture{}} - directory := &brokerScopeDirectory{accounts: []extensionv1.Account{ - {ID: included, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Identity: "included"}, - {ID: excluded, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Identity: "excluded"}, - }} - manager := NewPluginManager(repo, pluginTokenEncryptor{}, nil, PluginHostInfo{}, newFakePluginKVStore()) - manager.SetAccountDirectory(directory) - manager.traffic = &trafficPolicyCache{} - // A deliberately stale local projection must not override fresh persisted - // binding intent, nor let a previous process generation access credentials. - process := *installation - process.Bindings = append([]PluginBinding(nil), installation.Bindings...) - manager.extensions.Store(&pluginExtensionRegistry{installations: map[int64]*PluginInstallation{7: &process}, runtimes: map[int64]*pluginRuntime{}}) - host, ok := manager.buildHostServices(&process).(*pluginHostServiceServer) - require.True(t, ok) - return host.extension, repo, directory -} - -func TestPluginBrokerHonorsActualAccountRollout(t *testing.T) { - host, repo, directory := newBrokerScopeFixture(t) - for _, operation := range []extensionv1.HostOperation{extensionv1.HostAccountRead, extensionv1.HostResolveIdentity, extensionv1.HostMetricsQuery} { - t.Run(string(operation), func(t *testing.T) { - raw, err := json.Marshal(extensionv1.AccountQuery{AccountID: directory.accounts[1].ID}) - require.NoError(t, err) - _, err = host.Call(context.Background(), extensionv1.HostInvocation{Operation: operation, Payload: raw}) - require.Equal(t, codes.PermissionDenied, status.Code(err), "excluded cohort cannot be authorized by platform/type alone") - require.Zero(t, directory.resolved) - }) - } - query, err := json.Marshal(extensionv1.AccountQuery{Platform: PlatformOpenAI, AccountType: AccountTypeOAuth}) - require.NoError(t, err) - result, err := host.Call(context.Background(), extensionv1.HostInvocation{Operation: extensionv1.HostAccountList, Payload: query}) - require.NoError(t, err) - var accounts []extensionv1.Account - require.NoError(t, json.Unmarshal(result.Payload, &accounts)) - require.Equal(t, directory.accounts[:1], accounts) - require.Equal(t, 4, repo.reads, "one current scope read per broker call, not per returned account") - projection, err := json.Marshal(extensionv1.StateRequest{Namespace: "tickets", Key: "account", Value: json.RawMessage(`{}`), - Projection: &extensionv1.AccountProjection{AccountID: directory.accounts[1].ID, Identity: "excluded"}}) - require.NoError(t, err) - _, err = host.Call(context.Background(), extensionv1.HostInvocation{Operation: extensionv1.HostStateCompareSwap, Payload: projection}) - require.Equal(t, codes.PermissionDenied, status.Code(err)) - require.Zero(t, repo.writes) - - query, err = json.Marshal(extensionv1.AccountQuery{AccountID: directory.accounts[0].ID}) - require.NoError(t, err) - result, err = host.Call(context.Background(), extensionv1.HostInvocation{Operation: extensionv1.HostResolveIdentity, Payload: query}) - require.NoError(t, err) - require.Equal(t, 1, directory.resolved) - require.Contains(t, string(result.Payload), "synthetic-scope-token") -} - -func TestPluginBrokerRejectsPersistedScopeAndGenerationDrift(t *testing.T) { - for _, change := range []struct { - name string - apply func(*PluginInstallation) - }{ - {"binding revoked", func(p *PluginInstallation) { p.Bindings[0].Enabled = false }}, - {"rollout removed", func(p *PluginInstallation) { p.Bindings[0].RolloutPercent = 0 }}, - {"scope changed", func(p *PluginInstallation) { p.Bindings[0].AccountType = AccountTypeAPIKey }}, - {"disabled", func(p *PluginInstallation) { p.State = PluginStateDisabled }}, - {"updating", func(p *PluginInstallation) { p.State = PluginStateUpdating }}, - {"starting", func(p *PluginInstallation) { p.State = PluginStateStarting }}, - {"unhealthy", func(p *PluginInstallation) { p.State = PluginStateError }}, - {"incompatible", func(p *PluginInstallation) { p.State = PluginStateIncompatible }}, - {"new generation", func(p *PluginInstallation) { p.RuntimeGeneration++ }}, - {"new package", func(p *PluginInstallation) { p.PackageSHA256 = "package-2" }}, - {"new configuration", func(p *PluginInstallation) { p.ConfigEncrypted = "updated-config" }}, - } { - t.Run(change.name, func(t *testing.T) { - host, repo, directory := newBrokerScopeFixture(t) - change.apply(repo.installation) - query, err := json.Marshal(extensionv1.AccountQuery{AccountID: directory.accounts[0].ID}) - var result extensionv1.Result - require.NoError(t, err) - result, err = host.Call(context.Background(), extensionv1.HostInvocation{Operation: extensionv1.HostResolveIdentity, Payload: query}) - require.Equal(t, codes.PermissionDenied, status.Code(err), "stale in-memory bindings must not authorize sensitive broker IO") - require.Empty(t, result.Payload) - require.Zero(t, directory.resolved) - }) - } -} - -func TestPluginBrokerFailsClosedWhenCurrentScopeCannotBeRead(t *testing.T) { - host, repo, directory := newBrokerScopeFixture(t) - repo.readErr = errors.New("synthetic current installation unavailable") - raw, err := json.Marshal(extensionv1.AccountQuery{AccountID: directory.accounts[0].ID}) - require.NoError(t, err) - result, err := host.Call(context.Background(), extensionv1.HostInvocation{Operation: extensionv1.HostResolveIdentity, Payload: raw}) - require.Equal(t, codes.PermissionDenied, status.Code(err)) - require.Empty(t, result.Payload) - require.Zero(t, directory.resolved) -} - -func TestPluginBrokerFinishesOwnedObservationAfterCapabilityStop(t *testing.T) { - broker, repo, directory := newBrokerScopeFixture(t) - host, ok := broker.(*pluginExtensionHost) - require.True(t, ok) - store := "aActivityMemoryStore{} - host.activity = NewQuotaActivityService(store) - accountID := directory.accounts[0].ID - id, err := host.activity.BeginUnbilled(context.Background(), accountID, host.key) - require.NoError(t, err) - repo.installation.State = PluginStateDisabled - for index := range repo.installation.Bindings { - repo.installation.Bindings[index].Enabled = false - } - raw, err := json.Marshal(extensionv1.AccountQuery{AccountID: accountID, ObservationID: id}) - require.NoError(t, err) - _, err = host.Call(context.Background(), extensionv1.HostInvocation{Operation: extensionv1.HostFinishObservation, Payload: raw}) - require.NoError(t, err) - require.Zero(t, store.stamp.Active, "stopping new work must not strand an issued observation") - require.EqualValues(t, 1, store.stamp.Gaps, "unbilled finish remains an uncalibrated gap, not a fabricated bill") - require.Zero(t, repo.reads, "owned cleanup does not require a currently enabled account binding") - require.Zero(t, directory.resolved) - - raw, err = json.Marshal(extensionv1.AccountQuery{AccountID: accountID, ObservationID: "another-owner.00000000000000000000000000000000"}) - require.NoError(t, err) - _, err = host.Call(context.Background(), extensionv1.HostInvocation{Operation: extensionv1.HostFinishObservation, Payload: raw}) - require.Error(t, err, "the retained cleanup path still requires observation namespace ownership") - require.EqualValues(t, 1, store.stamp.Gaps) -} diff --git a/backend/internal/service/plugin_bundle.go b/backend/internal/service/plugin_bundle.go deleted file mode 100644 index 4fa76f65c8a4..000000000000 --- a/backend/internal/service/plugin_bundle.go +++ /dev/null @@ -1,266 +0,0 @@ -package service - -import ( - "context" - "crypto/sha256" - "database/sql" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "os" - "path/filepath" - "strings" -) - -type PluginBundle struct { - SchemaVersion int `json:"schema_version"` - HostVersion string `json:"host_version"` - PublisherKeyID string `json:"publisher_key_id"` - PublisherPublicKey string `json:"publisher_public_key"` - Plugins []BundledPlugin `json:"plugins"` -} - -type BundledPlugin struct { - DefaultEnabled bool `json:"default_enabled,omitempty"` - ID string `json:"id"` - Version string `json:"version"` - File string `json:"file"` - SHA256 string `json:"sha256"` - Migration string `json:"migration"` -} - -type PluginBundleSeed struct { - Enabled bool - Config json.RawMessage -} - -type PluginBundleRepository interface { - BundleApplied(context.Context, string, string) (bool, error) - PrepareBundledPlugin(context.Context, *PluginInstallation, string, string, bool, string) (*PluginInstallation, error) - CompleteBundledPlugin(context.Context, int64, string) error - LegacyBundleSeed(context.Context, string, string, PluginBundleSeed) (PluginBundleSeed, error) - ImportLegacyPluginState(context.Context, *PluginInstallation, string, json.RawMessage) error -} - -// Bundles are read from the immutable application image. They never cause a -// remote download, and a completed bundle is not reapplied on process restart. -func (m *PluginManager) bootstrapBundle(ctx context.Context) error { - path := m.bundlePath - if path == "" { - executable, err := os.Executable() - if err != nil { - return err - } - path = filepath.Join(filepath.Dir(executable), "bundled-plugins", "lock.json") - } - info, err := os.Lstat(path) - if errors.Is(err, os.ErrNotExist) && m.bundlePath == "" { - return nil - } - if err != nil { - return err - } - if !info.Mode().IsRegular() || info.Size() > 1<<20 { - return errors.New("invalid plugin bundle manifest") - } - raw, err := os.ReadFile(path) - if err != nil { - return err - } - var bundle PluginBundle - if json.Unmarshal(raw, &bundle) != nil || bundle.SchemaVersion != 1 || len(bundle.Plugins) == 0 || bundle.PublisherKeyID == "" { - return errors.New("invalid plugin bundle") - } - public, err := base64.StdEncoding.DecodeString(bundle.PublisherPublicKey) - if err != nil || len(public) != 32 { - return errors.New("invalid bundled publisher") - } - if normalizeSemver(bundle.HostVersion) != normalizeSemver(m.hostInfo.Version) { - return errors.New("plugin bundle does not match host version") - } - if publisher := extensionv1.FirstPartyPublisher(); bundle.PublisherKeyID == publisher.KeyID && bundle.PublisherPublicKey != publisher.PublicKey { - return errors.New("bundled publisher differs from trusted release identity") - } - if strings.Contains(m.hostInfo.Version, "-codexrip.") && bundle.PublisherKeyID != extensionv1.FirstPartyPublisher().KeyID { - return errors.New("release hosts require the fixed plugin publisher") - } - store, ok := m.repo.(PluginBundleRepository) - if !ok { - return errors.New("plugin bundle repository unavailable") - } - if m.installer.bundledPublishers == nil { - m.installer.bundledPublishers = make(map[string]string) - } - m.installer.bundledPublishers[bundle.PublisherKeyID] = bundle.PublisherPublicKey - digest := sha256.Sum256(raw) - bundleID := hex.EncodeToString(digest[:]) - seen := make(map[string]bool) - for _, entry := range bundle.Plugins { - if seen[entry.ID] || !strings.HasPrefix(entry.ID, "codexrip.") || !safePluginRelativePath(entry.File) || filepath.Base(entry.File) != entry.File { - return errors.New("invalid first-party plugin bundle entry") - } - seen[entry.ID] = true - applied, err := store.BundleApplied(ctx, entry.ID, bundleID) - if err != nil { - return err - } - if applied { - continue - } - packagePath := filepath.Join(filepath.Dir(path), entry.File) - info, err := os.Lstat(packagePath) - if err != nil { - return err - } - if !info.Mode().IsRegular() { - return errors.New("bundled plugin must be a regular file") - } - content, err := os.ReadFile(packagePath) - if err != nil { - return err - } - sum := sha256.Sum256(content) - if hex.EncodeToString(sum[:]) != entry.SHA256 { - return errors.New("bundled plugin digest mismatch") - } - file, err := os.Open(packagePath) - if err != nil { - return err - } - installation, installErr := m.installer.Install(ctx, file, nil) - _ = file.Close() - if installErr != nil { - return installErr - } - if installation.PluginKey != entry.ID || installation.Version != entry.Version { - return errors.Join(errors.New("bundled plugin identity mismatch"), m.cleanupInstallationFiles(installation)) - } - if handled, err := m.handleCompletedBundledCandidate(ctx, store, entry, bundleID, installation); err != nil { - return err - } else if handled { - continue - } - seed := PluginBundleSeed{Enabled: entry.DefaultEnabled, Config: json.RawMessage(`{}`)} - if entry.Migration == "codex-tickets-v1" { - config := m.cfg.Gateway.OpenAICodexTicket - models := config.Models - if len(models) == 0 { - models = []string{"gpt-6-astra", "gpt-5.6-sol"} - } - seed.Enabled = true - seed.Config, _ = json.Marshal(map[string]any{"enabled": config.Enabled, "fail_closed": config.FailClosed, "proxy_url": config.HarvestProxyURL, "models": models, "request_zstd": m.cfg.Gateway.OpenAICodexRequestZstd}) - } - if entry.Migration == "cindy-provider-v1" { - seed.Config, _ = json.Marshal(LegacyCindyProviderConfig()) - } - if entry.Migration == "image-tools-v1" { - seed.Config, _ = json.Marshal(LegacyImageToolsConfig()) - } - if entry.Migration == "admin-observability-v1" { - seed.Config, _ = json.Marshal(LegacyAdminObservabilityConfig(m.cfg)) - } - seed, err = store.LegacyBundleSeed(ctx, entry.ID, entry.Migration, seed) - if err != nil { - return fmt.Errorf("read plugin migration seed: %w", err) - } - cipher, err := m.encryptor.Encrypt(string(seed.Config)) - if err != nil { - return errors.New("could not encrypt migrated plugin configuration") - } - uncommitted := installation - installation, err = store.PrepareBundledPlugin(ctx, installation, bundleID, entry.Migration, seed.Enabled, cipher) - if err != nil { - return err - } - if installation == nil { - if err := m.cleanupInstallationFiles(uncommitted); err != nil { - return err - } - continue - } - migrationConfig, err := m.decryptConfig(installation) - if err != nil { - return err - } - if err := store.ImportLegacyPluginState(ctx, installation, entry.Migration, migrationConfig); err != nil { - return err - } - if err := store.CompleteBundledPlugin(ctx, installation.ID, bundleID); err != nil { - if errors.Is(err, ErrPluginStateChanged) { - // A concurrent update, pin or removal may have taken ownership - // after Prepare. Skip only if persisted intent confirms that. - applied, checkErr := store.BundleApplied(ctx, entry.ID, bundleID) - if checkErr != nil { - return checkErr - } - if applied { - continue - } - } - return err - } - } - return nil -} - -// No handled path calls PrepareBundledPlugin: the unique candidate files are -// still ours, even when staging stores their artifact bytes for a later update. -// An unfinished migration returns unhandled so its existing ownership transfer -// and retained paths are not changed by this completed-bundle cleanup. -func (m *PluginManager) handleCompletedBundledCandidate(ctx context.Context, store PluginBundleRepository, entry BundledPlugin, bundleID string, candidate *PluginInstallation) (handled bool, resultErr error) { - journal, ok := m.repo.(interface { - CompletedBundledPlugin(context.Context, string) (bool, error) - }) - if !ok { - return false, nil - } - completed, err := journal.CompletedBundledPlugin(ctx, entry.ID) - if err == nil && !completed { - return false, nil - } - defer func() { - if cleanupErr := m.cleanupInstallationFiles(candidate); cleanupErr != nil { - resultErr = errors.Join(resultErr, cleanupErr) - } - }() - if err != nil { - return true, err - } - previous, err := m.repo.GetByKey(ctx, entry.ID) - if errors.Is(err, sql.ErrNoRows) { - return true, confirmCompletedBundleTakeover(ctx, store, entry.ID, bundleID, err) - } - if err != nil { - return true, err - } - if previous == nil { - return true, errors.New("completed bundled plugin snapshot unavailable") - } - // The first BundleApplied observation may predate another host's pin or - // update. Only a fresh, still-following snapshot permits automatic staging. - if previous.UpdatePolicy != PluginUpdateBundled || previous.State == PluginStateUpdating { - return true, nil - } - if previous.PackageSHA256 == entry.SHA256 { - return true, nil - } - err = m.stagePluginReplacement(ctx, previous, candidate, PluginUpdateBundled) - if errors.Is(err, ErrPluginStateChanged) { - return true, confirmCompletedBundleTakeover(ctx, store, entry.ID, bundleID, err) - } - return true, err -} - -func confirmCompletedBundleTakeover(ctx context.Context, store PluginBundleRepository, key, bundleID string, original error) error { - applied, err := store.BundleApplied(ctx, key, bundleID) - if err != nil { - return errors.Join(original, err) - } - if applied { - return nil - } - return original -} diff --git a/backend/internal/service/plugin_bundle_pin_race_test.go b/backend/internal/service/plugin_bundle_pin_race_test.go deleted file mode 100644 index 87f0908f8795..000000000000 --- a/backend/internal/service/plugin_bundle_pin_race_test.go +++ /dev/null @@ -1,395 +0,0 @@ -package service - -import ( - "bytes" - "context" - "crypto/sha256" - "database/sql" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "io/fs" - "os" - "path/filepath" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -// Every interface used by the real bootstrap/stage path is implemented. An -// unexpected write fails this fixture instead of dispatching through a nil port. -type completedBundleRaceRepository struct { - t *testing.T - current, onJournal, afterGet *PluginInstallation - prepared *PluginInstallation - completed, removed, confirmed bool - journalErr, getErr, listErr error - confirmationErr, importErr error - bundleReads, journalReads int - gets, lists, stages int - prepares, imports, completes int -} - -func cloneCompletedInstallation(value *PluginInstallation) *PluginInstallation { - if value == nil { - return nil - } - copy := *value - copy.Bindings = append([]PluginBinding(nil), value.Bindings...) - copy.ArtifactData = append([]byte(nil), value.ArtifactData...) - return © -} - -func (r *completedBundleRaceRepository) List(context.Context) ([]*PluginInstallation, error) { - r.lists++ - if r.listErr != nil { - return nil, r.listErr - } - if r.current == nil { - return nil, nil - } - return []*PluginInstallation{cloneCompletedInstallation(r.current)}, nil -} -func (r *completedBundleRaceRepository) GetByKey(context.Context, string) (*PluginInstallation, error) { - r.gets++ - if r.getErr != nil { - return nil, r.getErr - } - if r.current == nil { - return nil, sql.ErrNoRows - } - snapshot := cloneCompletedInstallation(r.current) - if r.afterGet != nil { - r.current = cloneCompletedInstallation(r.afterGet) - } - return snapshot, nil -} -func (r *completedBundleRaceRepository) GetByID(context.Context, int64) (*PluginInstallation, error) { - return cloneCompletedInstallation(r.current), nil -} -func (r *completedBundleRaceRepository) BundleApplied(context.Context, string, string) (bool, error) { - r.bundleReads++ - if r.bundleReads == 1 { - return false, nil - } - return r.confirmed, r.confirmationErr -} -func (r *completedBundleRaceRepository) CompletedBundledPlugin(context.Context, string) (bool, error) { - r.journalReads++ - if r.onJournal != nil { - r.current = cloneCompletedInstallation(r.onJournal) - } - if r.removed { - r.current = nil - } - return r.completed, r.journalErr -} -func (r *completedBundleRaceRepository) PrepareBundledPlugin(_ context.Context, candidate *PluginInstallation, _, _ string, _ bool, config string) (*PluginInstallation, error) { - r.prepares++ - r.prepared = cloneCompletedInstallation(candidate) - r.prepared.ID, r.prepared.Revision, r.prepared.RuntimeGeneration = 7, 5, 10 - r.prepared.ConfigEncrypted, r.prepared.UpdatePolicy = config, PluginUpdateBundled - return cloneCompletedInstallation(r.prepared), nil -} -func (r *completedBundleRaceRepository) CompleteBundledPlugin(context.Context, int64, string) error { - r.completes++ - return nil -} -func (*completedBundleRaceRepository) LegacyBundleSeed(_ context.Context, _, _ string, fallback PluginBundleSeed) (PluginBundleSeed, error) { - return fallback, nil -} -func (r *completedBundleRaceRepository) ImportLegacyPluginState(context.Context, *PluginInstallation, string, json.RawMessage) error { - r.imports++ - return r.importErr -} -func (r *completedBundleRaceRepository) StagePluginUpdate(context.Context, *PluginInstallation, *PluginInstallation, string) error { - r.stages++ - r.t.Error("candidate process validation must not be reached in this metadata-only fixture") - return errors.New("unexpected stage write") -} -func (r *completedBundleRaceRepository) unexpectedWrite() error { - r.t.Error("unexpected repository mutation") - return errors.New("unexpected repository mutation") -} -func (r *completedBundleRaceRepository) PendingPluginArtifact(context.Context, int64) ([]byte, error) { - return nil, r.unexpectedWrite() -} -func (r *completedBundleRaceRepository) CommitPluginUpdate(context.Context, *PluginInstallation, *PluginInstallation) error { - return r.unexpectedWrite() -} -func (r *completedBundleRaceRepository) SetPluginUpdatePolicy(context.Context, int64, int64, string) error { - return r.unexpectedWrite() -} -func (r *completedBundleRaceRepository) Install(context.Context, *PluginInstallation, []PluginBinding) (*PluginInstallation, error) { - return nil, r.unexpectedWrite() -} -func (r *completedBundleRaceRepository) GetArtifact(context.Context, int64) ([]byte, error) { - return nil, r.unexpectedWrite() -} -func (r *completedBundleRaceRepository) Delete(context.Context, int64, string) error { - return r.unexpectedWrite() -} -func (r *completedBundleRaceRepository) BeginEnable(context.Context, int64, string, string) error { - return r.unexpectedWrite() -} -func (r *completedBundleRaceRepository) MarkRuntimeHealthy(context.Context, int64, string, string) error { - return r.unexpectedWrite() -} -func (r *completedBundleRaceRepository) UpdateState(context.Context, int64, string, string, *time.Time, string, string) error { - return r.unexpectedWrite() -} -func (r *completedBundleRaceRepository) UpdateConfig(context.Context, int64, string, string) error { - return r.unexpectedWrite() -} -func (r *completedBundleRaceRepository) UpdateBindingsAndState(context.Context, int64, []PluginBinding, string, string, *time.Time, string, string) error { - return r.unexpectedWrite() -} - -var _ PluginRepository = (*completedBundleRaceRepository)(nil) -var _ PluginBundleRepository = (*completedBundleRaceRepository)(nil) -var _ PluginUpdateRepository = (*completedBundleRaceRepository)(nil) - -func completedBundleFileSnapshot(t *testing.T, root string) map[string]string { - t.Helper() - files := map[string]string{} - require.NoError(t, filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { - if err != nil || entry.IsDir() { - return err - } - raw, readErr := os.ReadFile(path) - if readErr != nil { - return readErr - } - digest := sha256.Sum256(raw) - files[path] = hex.EncodeToString(digest[:]) - return nil - })) - return files -} - -func completedBundleFixture(t *testing.T, samePackage bool) (*PluginManager, *completedBundleRaceRepository, *PluginInstallation) { - t.Helper() - root := t.TempDir() - cfg := testPluginConfig(root, true) - host := PluginHostInfo{Version: "0.1.179"} - manifest := testPluginManifest(nil) - manifest.ID = "codexrip.completed-race" - archive := buildPluginArchive(t, manifest, nil, "", nil) - old, err := NewPluginPackageInstaller(cfg, host).Install(context.Background(), bytes.NewReader(archive), nil) - require.NoError(t, err) - old.ID, old.Revision, old.RuntimeGeneration = 7, 4, 9 - old.State, old.UpdatePolicy, old.ConfigEncrypted = PluginStateEnabled, PluginUpdateBundled, `ENC:{"saved":true}` - old.Bindings = []PluginBinding{{Capability: PluginCapabilityOpenAIOAuthOutbound, Platform: PlatformOpenAI, - AccountType: AccountTypeOAuth, Enabled: true, RolloutPercent: 37}} - if !samePackage { - manifest.Version = "0.1.1" - // This rejection is after the real registry/List check but before any - // runtime directory or process startup. It is not a missing-interface error. - manifest.Requires.Sub2API = ">=9.0.0" - archive = buildPluginArchive(t, manifest, nil, "", nil) - } - digest := sha256.Sum256(archive) - bundle := PluginBundle{SchemaVersion: 1, HostVersion: host.Version, PublisherKeyID: "completed-fixture", - PublisherPublicKey: base64.StdEncoding.EncodeToString(make([]byte, 32)), - Plugins: []BundledPlugin{{ID: manifest.ID, Version: manifest.Version, File: "fixture.s2plugin", SHA256: hex.EncodeToString(digest[:])}}} - raw, err := json.Marshal(bundle) - require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(root, "fixture.s2plugin"), archive, 0600)) - path := filepath.Join(root, "lock.json") - require.NoError(t, os.WriteFile(path, raw, 0600)) - repo := &completedBundleRaceRepository{t: t, current: cloneCompletedInstallation(old), completed: true} - manager := NewPluginManager(repo, pluginTokenEncryptor{}, cfg, host, nil) - manager.bundlePath = path - return manager, repo, old -} - -func assertCompletedCandidateCleaned(t *testing.T, manager *PluginManager, old *PluginInstallation, before map[string]string) { - t.Helper() - require.Equal(t, before, completedBundleFileSnapshot(t, manager.installer.RootDir()), "only the task's candidate files may disappear") - require.FileExists(t, old.ArtifactPath) - require.FileExists(t, old.BinaryPath) - installations, err := filepath.Glob(filepath.Join(manager.installer.RootDir(), "installed", "*", "*")) - require.NoError(t, err) - require.Equal(t, []string{old.InstallPath}, installations) - _, err = os.Stat(filepath.Join(manager.installer.RootDir(), "runtime")) - require.ErrorIs(t, err, os.ErrNotExist, "no candidate process or socket directory may be started") -} - -func TestCompletedBundleFreshPinAndUpdatingIntentCannotStage(t *testing.T) { - for _, mode := range []string{"pinned", "updating", "unknown-policy"} { - t.Run(mode, func(t *testing.T) { - manager, repo, old := completedBundleFixture(t, false) - before := completedBundleFileSnapshot(t, manager.installer.RootDir()) - fresh := cloneCompletedInstallation(old) - fresh.Revision++ - if mode == "updating" { - fresh.State = PluginStateUpdating - } else { - fresh.UpdatePolicy = mode - } - repo.onJournal = fresh - err := manager.bootstrapBundle(context.Background()) - require.Zero(t, repo.lists, "a fresh non-bundled/updating snapshot must stop before real stage admission") - require.NoError(t, err) - require.Zero(t, repo.stages) - require.Zero(t, repo.prepares) - require.Equal(t, 1, repo.bundleReads) - require.Equal(t, fresh, repo.current, "late operator intent must remain exactly unchanged") - assertCompletedCandidateCleaned(t, manager, old, before) - }) - } -} - -func TestCompletedBundleFailuresCleanOnlyTheirCandidate(t *testing.T) { - journalFailure := errors.New("fixture journal read failure") - getFailure := errors.New("fixture installation read failure") - registryFailure := errors.New("fixture registry read failure") - for _, test := range []struct { - name string - configure func(*completedBundleRaceRepository) - want error - wantLists int - }{ - {"journal", func(r *completedBundleRaceRepository) { r.journalErr = journalFailure }, journalFailure, 0}, - {"get", func(r *completedBundleRaceRepository) { r.getErr = getFailure }, getFailure, 0}, - {"registry", func(r *completedBundleRaceRepository) { r.listErr = registryFailure }, registryFailure, 1}, - {"eligible-validation", func(*completedBundleRaceRepository) {}, nil, 1}, - } { - t.Run(test.name, func(t *testing.T) { - manager, repo, old := completedBundleFixture(t, false) - before := completedBundleFileSnapshot(t, manager.installer.RootDir()) - test.configure(repo) - err := manager.bootstrapBundle(context.Background()) - if test.want != nil { - require.ErrorIs(t, err, test.want) - } else { - require.ErrorContains(t, err, "不满足插件要求", "eligible bundles must still take the real stage validation path") - } - require.Equal(t, test.wantLists, repo.lists) - require.Zero(t, repo.stages) - require.Zero(t, repo.prepares) - require.Equal(t, 1, repo.bundleReads, "unknown failures are not treated as takeover") - require.Equal(t, old, repo.current) - assertCompletedCandidateCleaned(t, manager, old, before) - }) - } -} - -func TestCompletedBundleRemovalOrStageConflictNeedsConfirmedTakeover(t *testing.T) { - readFailure := errors.New("fixture takeover read failure") - for _, phase := range []string{"removed", "stage-conflict"} { - for _, outcome := range []string{"confirmed", "unconfirmed", "read-failed"} { - t.Run(phase+"/"+outcome, func(t *testing.T) { - manager, repo, old := completedBundleFixture(t, false) - before := completedBundleFileSnapshot(t, manager.installer.RootDir()) - if phase == "removed" { - repo.removed = true - } else { - repo.afterGet = cloneCompletedInstallation(old) - repo.afterGet.Revision++ - if outcome != "unconfirmed" { - repo.afterGet.UpdatePolicy = PluginUpdatePinned - } - } - repo.confirmed = outcome != "unconfirmed" - if outcome == "read-failed" { - repo.confirmationErr = readFailure - } - err := manager.bootstrapBundle(context.Background()) - switch outcome { - case "confirmed": - require.NoError(t, err) - case "read-failed": - require.ErrorIs(t, err, readFailure) - default: - if phase == "removed" { - require.ErrorIs(t, err, sql.ErrNoRows) - } else { - require.ErrorIs(t, err, ErrPluginStateChanged) - } - } - require.Equal(t, 2, repo.bundleReads) - if phase == "removed" { - require.Zero(t, repo.lists) - require.Nil(t, repo.current) - } else { - require.Equal(t, 1, repo.lists) - require.Equal(t, repo.afterGet, repo.current) - } - require.Zero(t, repo.stages) - require.Zero(t, repo.prepares) - assertCompletedCandidateCleaned(t, manager, old, before) - }) - } - } -} - -func TestCompletedBundleSameDigestCleanupKeepsOldNonceFiles(t *testing.T) { - manager, repo, old := completedBundleFixture(t, true) - before := completedBundleFileSnapshot(t, manager.installer.RootDir()) - require.NoError(t, manager.bootstrapBundle(context.Background())) - require.Zero(t, repo.lists) - require.Zero(t, repo.stages) - require.Equal(t, old, repo.current) - assertCompletedCandidateCleaned(t, manager, old, before) -} - -func TestIncompleteBundlePreparedPathsRemainOwnedAfterImportFailure(t *testing.T) { - manager, repo, old := completedBundleFixture(t, false) - repo.completed = false - repo.importErr = errors.New("fixture interrupted migration") - err := manager.bootstrapBundle(context.Background()) - require.ErrorIs(t, err, repo.importErr) - require.Equal(t, 1, repo.prepares) - require.Equal(t, 1, repo.imports) - require.Zero(t, repo.completes) - require.Zero(t, repo.gets) - require.NotNil(t, repo.prepared) - require.NotEqual(t, old.InstallPath, repo.prepared.InstallPath) - require.FileExists(t, repo.prepared.ArtifactPath) - require.FileExists(t, repo.prepared.BinaryPath) - require.FileExists(t, old.ArtifactPath) - require.FileExists(t, old.BinaryPath) -} - -func TestCompletedBundleIdentityMismatchCleansUnownedCandidate(t *testing.T) { - manager, repo, old := completedBundleFixture(t, false) - raw, err := os.ReadFile(manager.bundlePath) - require.NoError(t, err) - var bundle PluginBundle - require.NoError(t, json.Unmarshal(raw, &bundle)) - bundle.Plugins[0].Version = "0.1.2" - raw, err = json.Marshal(bundle) - require.NoError(t, err) - require.NoError(t, os.WriteFile(manager.bundlePath, raw, 0600)) - before := completedBundleFileSnapshot(t, manager.installer.RootDir()) - err = manager.bootstrapBundle(context.Background()) - require.ErrorContains(t, err, "bundled plugin identity mismatch") - require.Zero(t, repo.journalReads) - require.Zero(t, repo.gets) - require.Zero(t, repo.lists) - require.Zero(t, repo.stages) - require.Zero(t, repo.prepares) - require.Equal(t, old, repo.current) - assertCompletedCandidateCleaned(t, manager, old, before) -} - -func TestCompletedBundleCleanupErrorDoesNotErasePrimaryFailure(t *testing.T) { - manager, repo, old := completedBundleFixture(t, false) - outside := filepath.Join(t.TempDir(), "must-remain") - require.NoError(t, os.WriteFile(outside, []byte("outside managed root"), 0600)) - original := errors.New("fixture journal failure") - repo.journalErr = original - // The production caller supplies unique installer paths. This direct helper - // boundary fixture additionally verifies cleanup refusal is not swallowed. - candidate := &PluginInstallation{ArtifactPath: outside, InstallPath: outside} - handled, err := manager.handleCompletedBundledCandidate(context.Background(), repo, BundledPlugin{ID: old.PluginKey}, "fixture-bundle", candidate) - require.True(t, handled) - require.ErrorIs(t, err, original) - require.ErrorContains(t, err, "拒绝删除插件根目录之外的路径") - require.FileExists(t, outside) - require.FileExists(t, old.ArtifactPath) - require.FileExists(t, old.BinaryPath) -} diff --git a/backend/internal/service/plugin_catalog_test.go b/backend/internal/service/plugin_catalog_test.go deleted file mode 100644 index 4240358fdfea..000000000000 --- a/backend/internal/service/plugin_catalog_test.go +++ /dev/null @@ -1,244 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "strings" - "testing" - - modelcatalog "github.com/HTExplicit/sub2api-plugins/modelpolicy/catalog" - "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/stretchr/testify/require" -) - -type catalogPointRepository struct { - PluginRepository - installation *PluginInstallation - reads []int64 -} - -func (r *catalogPointRepository) GetByID(_ context.Context, id int64) (*PluginInstallation, error) { - r.reads = append(r.reads, id) - if r.installation == nil || r.installation.ID != id { - return nil, errors.New("catalog installation unavailable") - } - copy := *r.installation - copy.Bindings = append([]PluginBinding(nil), r.installation.Bindings...) - return ©, nil -} - -func catalogGuardTestManager(t *testing.T, invoke func(extensionv1.Invocation) (extensionv1.Result, error)) (*PluginManager, *catalogPointRepository) { - t.Helper() - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{}, invoke) - registry := manager.extensions.Load() - installation := registry.installations[1] - installation.State, installation.RuntimeGeneration = PluginStateEnabled, 1 - installation.Bindings = []PluginBinding{{Capability: extensionv1.CapabilityCatalog, Platform: "*", AccountType: "*", Enabled: true, RolloutPercent: 100}} - repo := &catalogPointRepository{installation: installation} - manager.repo = repo - registry.runtimes[1].installation = installation - return manager, repo -} - -func TestPluginCatalogCacheHonorsActivationHealthAndConfiguration(t *testing.T) { - calls := 0 - capacity := int64(1050000) - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{}, func(in extensionv1.Invocation) (extensionv1.Result, error) { - calls++ - require.Equal(t, extensionv1.CapabilityCatalog, in.Capability) - raw, _ := json.Marshal(extensionv1.CatalogMatch{Matched: true, Entry: &OfficialModelContextCapacity{ModelContextCapacity: ModelContextCapacity{ContextWindow: capacity}, ModelID: "test-model"}}) - return extensionv1.Result{Payload: raw}, nil - }) - registry := manager.extensions.Load() - registry.installations[1].State = PluginStateEnabled - registry.installations[1].Bindings = []PluginBinding{{Capability: extensionv1.CapabilityCatalog, Platform: "*", AccountType: "*", Enabled: true}} - manager.repo = &pluginTokenRepository{installation: registry.installations[1]} - registry.runtimes[1].installation = registry.installations[1] - query := extensionv1.CatalogQuery{Candidates: []string{"test-model"}, Platform: PlatformOpenAI, AccountType: AccountTypeAPIKey} - first, err := manager.ResolveCatalog(context.Background(), query) - require.NoError(t, err) - first.Entry.ContextWindow = 1 - second, err := manager.ResolveCatalog(context.Background(), query) - require.NoError(t, err) - require.EqualValues(t, 1050000, second.Entry.ContextWindow) - require.Equal(t, 1, calls) - registry.runtimes[1].draining.Store(true) - _, err = manager.ResolveCatalog(context.Background(), query) - require.Error(t, err, "a cached reference cannot hide a failed plugin") - registry.runtimes[1].draining.Store(false) - registry.installations[1].Bindings[0].Enabled = false - disabled, err := manager.ResolveCatalog(context.Background(), query) - require.NoError(t, err) - require.Nil(t, disabled.Entry) - registry.installations[1].Bindings[0].Enabled = true - registry.runtimes[1].configRevision.Add(1) - capacity = 128000 - changed, err := manager.ResolveCatalog(context.Background(), query) - require.NoError(t, err) - require.EqualValues(t, 128000, changed.Entry.ContextWindow) - require.Equal(t, 2, calls) -} - -func TestPluginCatalogCacheRejectsConfiguringAndCanceledRequests(t *testing.T) { - calls := 0 - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{}, func(in extensionv1.Invocation) (extensionv1.Result, error) { - calls++ - raw, err := json.Marshal(extensionv1.CatalogMatch{Matched: true, Entry: &OfficialModelContextCapacity{ModelContextCapacity: ModelContextCapacity{ContextWindow: 200000}}}) - return extensionv1.Result{Payload: raw}, err - }) - registry := manager.extensions.Load() - installation := registry.installations[1] - installation.State = PluginStateEnabled - installation.Bindings = []PluginBinding{{Capability: extensionv1.CapabilityCatalog, Platform: "*", AccountType: "*", Enabled: true, RolloutPercent: 100}} - manager.repo = &pluginTokenRepository{installation: installation} - registry.runtimes[1].installation = installation - query := extensionv1.CatalogQuery{Candidates: []string{"test-model"}, Platform: PlatformOpenAI, AccountType: AccountTypeAPIKey} - prime, err := manager.ResolveCatalog(context.Background(), query) - require.NoError(t, err) - require.NotNil(t, prime.Entry) - t.Run("configuring", func(t *testing.T) { - registry.runtimes[1].configuring.Store(true) - t.Cleanup(func() { registry.runtimes[1].configuring.Store(false) }) - result, err := manager.ResolveCatalog(context.Background(), query) - require.Error(t, err, "cached results must not bypass the configuration barrier") - require.Nil(t, result.Entry) - }) - t.Run("canceled", func(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - result, err := manager.ResolveCatalog(ctx, query) - require.ErrorIs(t, err, context.Canceled) - require.Nil(t, result.Entry) - }) - require.Equal(t, 1, calls, "neither rejected request may reach the plugin") -} - -func TestPluginCatalogCarriesAccountIDThroughRolloutAndCache(t *testing.T) { - var calls []int64 - manager, _ := catalogGuardTestManager(t, func(in extensionv1.Invocation) (extensionv1.Result, error) { - var query extensionv1.CatalogQuery - require.NoError(t, json.Unmarshal(in.Payload, &query)) - require.Equal(t, in.AccountID, query.AccountID, "the invocation gate and cache query must use the same host account") - calls = append(calls, in.AccountID) - raw, err := json.Marshal(extensionv1.CatalogMatch{Matched: true, Entry: &OfficialModelContextCapacity{ModelContextCapacity: ModelContextCapacity{ContextWindow: 200000 + in.AccountID}}}) - return extensionv1.Result{Payload: raw}, err - }) - previous := processExtensionCatalog.Load() - t.Cleanup(func() { processExtensionCatalog.Store(previous) }) - processExtensionCatalog.Store(&extensionCatalogProvider{resolver: manager}) - var included, excluded int64 - for id := int64(1); included == 0 || excluded == 0; id++ { - if stablePluginBucket(id) < 50 { - included = id - } else { - excluded = id - } - } - account := &Account{ID: included, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://api.openai.com/v1"}} - installation := manager.extensions.Load().installations[1] - installation.Bindings[0].RolloutPercent = 0 - require.Nil(t, LookupOfficialModelContextCapacity(account, "test-model")) - require.Empty(t, calls, "0%% rollout must not invoke a catalog for a real account") - installation.Bindings[0].RolloutPercent = 50 - first := LookupOfficialModelContextCapacity(account, "test-model") - require.NotNil(t, first) - require.EqualValues(t, 200000+included, first.ContextWindow) - account.ID = excluded - require.Nil(t, LookupOfficialModelContextCapacity(account, "test-model")) - require.Equal(t, []int64{included}, calls) - installation.Bindings[0].RolloutPercent = 100 - second := LookupOfficialModelContextCapacity(account, "test-model") - require.NotNil(t, second) - require.EqualValues(t, 200000+excluded, second.ContextWindow, "another account cannot reuse the first account's cache") - account.ID = included - require.EqualValues(t, 200000+included, LookupOfficialModelContextCapacity(account, "test-model").ContextWindow) - require.Equal(t, []int64{included, excluded}, calls, "the original account should reuse only its own cache") -} - -func TestPluginCatalogCacheChecksPersistedScopeGenerationAndConfig(t *testing.T) { - calls := 0 - manager, repo := catalogGuardTestManager(t, func(extensionv1.Invocation) (extensionv1.Result, error) { - calls++ - raw, err := json.Marshal(extensionv1.CatalogMatch{Matched: true, Entry: &OfficialModelContextCapacity{ModelContextCapacity: ModelContextCapacity{ContextWindow: 200000}}}) - return extensionv1.Result{Payload: raw}, err - }) - query := extensionv1.CatalogQuery{AccountID: 42, Candidates: []string{"test-model"}, Platform: PlatformOpenAI, AccountType: AccountTypeAPIKey} - _, err := manager.ResolveCatalog(context.Background(), query) - require.NoError(t, err) - baseline := repo.installation - for _, tc := range []struct { - name string - change func(*PluginInstallation) - err bool - }{ - {"disabled-binding", func(i *PluginInstallation) { i.Bindings[0].Enabled = false }, false}, - {"zero-rollout", func(i *PluginInstallation) { i.Bindings[0].RolloutPercent = 0 }, false}, - {"disabled-state", func(i *PluginInstallation) { i.State = PluginStateDisabled }, false}, - {"new-generation", func(i *PluginInstallation) { i.RuntimeGeneration++ }, true}, - {"new-persisted-config", func(i *PluginInstallation) { i.ConfigEncrypted = "changed-config" }, true}, - } { - t.Run(tc.name, func(t *testing.T) { - updated := *baseline - updated.Bindings = append([]PluginBinding(nil), baseline.Bindings...) - tc.change(&updated) - repo.installation = &updated - result, err := manager.ResolveCatalog(context.Background(), query) - require.Equal(t, tc.err, err != nil) - require.Nil(t, result.Entry, "stale local registry/cache must not hide persisted changes") - }) - } - require.Equal(t, 1, calls) - require.Equal(t, []int64{1, 1, 1, 1, 1, 1}, repo.reads, "only the selected plugin is point-read, never the full installation table") -} - -func TestPluginCatalogDiscardsReplyFromChangedConfigRevision(t *testing.T) { - calls := 0 - var runtime *pluginRuntime - manager, _ := catalogGuardTestManager(t, func(extensionv1.Invocation) (extensionv1.Result, error) { - calls++ - if calls == 1 { - runtime.configRevision.Add(1) - } - raw, err := json.Marshal(extensionv1.CatalogMatch{Matched: true, Entry: &OfficialModelContextCapacity{ModelContextCapacity: ModelContextCapacity{ContextWindow: 128000}}}) - return extensionv1.Result{Payload: raw}, err - }) - runtime = manager.extensions.Load().runtimes[1] - query := extensionv1.CatalogQuery{AccountID: 42, Candidates: []string{"test-model"}, Platform: PlatformOpenAI, AccountType: AccountTypeAPIKey} - result, err := manager.ResolveCatalog(context.Background(), query) - require.Error(t, err) - require.Nil(t, result.Entry) - require.Zero(t, runtime.catalogCacheSize.Load(), "a reply spanning configuration revisions must not populate a cache") - result, err = manager.ResolveCatalog(context.Background(), query) - require.NoError(t, err) - require.EqualValues(t, 128000, result.Entry.ContextWindow) - _, err = manager.ResolveCatalog(context.Background(), query) - require.NoError(t, err) - require.Equal(t, 2, calls) - require.Zero(t, runtime.inFlight.Load(), "catalog policy leases must always be released") -} - -func TestPluginCatalogNamespaceLimitMatchesHostCandidates(t *testing.T) { - previous := processExtensionCatalog.Load() - t.Cleanup(func() { processExtensionCatalog.Store(previous) }) - module := modelcatalog.New() - processExtensionCatalog.Store(&extensionCatalogProvider{resolver: module}) - account := &Account{ID: 42, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://api.openai.com/v1"}} - model := strings.Repeat("namespace", 35) + "/gpt-6-astra" - require.Greater(t, len(model), 300) - require.True(t, validModelContextID(model)) - require.Contains(t, modelContextReferenceCandidates(model), "gpt-6-astra") - entry := LookupOfficialModelContextCapacity(account, model) - require.NotNil(t, entry, "the original long namespace must not suppress the valid leaf candidate") - require.Equal(t, "gpt-6-astra", entry.ModelID) - model = strings.Repeat("n", 512-len("/gpt-6-astra")) + "/gpt-6-astra" - require.Len(t, model, 512) - require.NotNil(t, LookupOfficialModelContextCapacity(account, model)) - tooLong := "n" + model - require.Len(t, tooLong, 513) - require.False(t, validModelContextID(tooLong)) - require.Nil(t, LookupOfficialModelContextCapacity(account, tooLong)) - _, err := module.ResolveCatalog(context.Background(), extensionv1.CatalogQuery{Candidates: modelContextReferenceCandidates(tooLong), Platform: PlatformOpenAI, AccountType: AccountTypeAPIKey}) - require.Error(t, err, "the plugin must independently reject a 513-byte candidate") -} diff --git a/backend/internal/service/plugin_cindy.go b/backend/internal/service/plugin_cindy.go index 70255ee1bcbe..e6a973dbd248 100644 --- a/backend/internal/service/plugin_cindy.go +++ b/backend/internal/service/plugin_cindy.go @@ -6,13 +6,13 @@ import ( "github.com/tidwall/gjson" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func currentCindyProviderConfig() (extensionv1.CindyProviderConfig, bool) { ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() - out, err := invokeProcessExtensionCached(ctx, PlatformCindy, AccountTypeAPIKey, extensionv1.Invocation{Capability: extensionv1.CapabilityProvider, Operation: "cindy.features", Payload: json.RawMessage(`{}`)}) + out, err := invokeCindyProviderCached(ctx, extensionv1.Invocation{Capability: extensionv1.CapabilityProvider, Operation: "cindy.features", Payload: json.RawMessage(`{}`)}) var config extensionv1.CindyProviderConfig if err != nil || out.Code != "" || json.Unmarshal(out.Payload, &config) != nil { return extensionv1.CindyProviderConfig{}, false @@ -36,7 +36,7 @@ func classifyCindyProviderResponse(account *Account, status int, body []byte) ex raw, _ := json.Marshal(observed) ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() - result, err := invokeProcessExtensionCached(ctx, PlatformCindy, AccountTypeAPIKey, extensionv1.Invocation{Capability: extensionv1.CapabilityProvider, Operation: "cindy.health", AccountID: account.ID, Payload: raw}) + result, err := invokeCindyProviderCached(ctx, extensionv1.Invocation{Capability: extensionv1.CapabilityProvider, Operation: "cindy.health", AccountID: account.ID, Payload: raw}) var decision extensionv1.CindyResponseDecision if err != nil || result.Code != "" || json.Unmarshal(result.Payload, &decision) != nil { return extensionv1.CindyResponseDecision{} diff --git a/backend/internal/service/plugin_cindy_codex.go b/backend/internal/service/plugin_cindy_codex.go index 2f01392480de..a237d5989b49 100644 --- a/backend/internal/service/plugin_cindy_codex.go +++ b/backend/internal/service/plugin_cindy_codex.go @@ -4,8 +4,8 @@ import ( "encoding/json" "slices" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" ) // The provider owns descriptor policy. The host validates the protocol plan, diff --git a/backend/internal/service/plugin_cindy_codex_test.go b/backend/internal/service/plugin_cindy_codex_test.go index fc5080a8425b..8b3594669a91 100644 --- a/backend/internal/service/plugin_cindy_codex_test.go +++ b/backend/internal/service/plugin_cindy_codex_test.go @@ -5,7 +5,7 @@ package service import ( "testing" - cindyregistry "github.com/HTExplicit/sub2api-plugins/cindyprovider/catalog" + cindyregistry "github.com/Wei-Shaw/sub2api/internal/cindyprovider/catalog" "github.com/stretchr/testify/require" ) diff --git a/backend/internal/service/plugin_cindy_management.go b/backend/internal/service/plugin_cindy_management.go index 3be5471bd95f..4d4ffc245d71 100644 --- a/backend/internal/service/plugin_cindy_management.go +++ b/backend/internal/service/plugin_cindy_management.go @@ -7,7 +7,7 @@ import ( "strings" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func invokeCindyManagement(ctx context.Context, operation string, input, output any) error { @@ -17,7 +17,7 @@ func invokeCindyManagement(ctx context.Context, operation string, input, output } call, cancel := context.WithTimeout(ctx, time.Second) defer cancel() - result, err := invokeProcessExtension(call, PlatformCindy, AccountTypeAPIKey, extensionv1.Invocation{Capability: extensionv1.CapabilityProvider, Operation: operation, Payload: raw}) + result, err := invokeCindyProvider(call, extensionv1.Invocation{Capability: extensionv1.CapabilityProvider, Operation: operation, Payload: raw}) if err != nil { return ErrCindyGroupAdminUnavailable } diff --git a/backend/internal/service/plugin_cindy_management_test.go b/backend/internal/service/plugin_cindy_management_test.go index 8dc2bd97b980..db1bb93bbdc9 100644 --- a/backend/internal/service/plugin_cindy_management_test.go +++ b/backend/internal/service/plugin_cindy_management_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" ) @@ -21,10 +21,10 @@ func (f *capturedCindyManagement) InvokeOperation(ctx context.Context, platform, } func TestCindyManagementKeepsCredentialsInHostAndStopsWhenProviderDisabled(t *testing.T) { - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) + previous := captureNativeCindyTestInvoker() + t.Cleanup(func() { restoreNativeCindyTestInvoker(previous) }) fixture := &capturedCindyManagement{} - processExtensionOperations.Store(&extensionOperationProvider{invoker: fixture}) + setNativeCindyTestInvoker(fixture) accounts := []Account{{ID: 1, Platform: PlatformCindy, Type: AccountTypeAPIKey, Status: StatusActive, Credentials: map[string]any{"api_key": "private-key-material", "base_url": "https://api.laxarouter.ai"}}} duplicate := accounts[0] duplicate.ID = 2 @@ -35,7 +35,7 @@ func TestCindyManagementKeepsCredentialsInHostAndStopsWhenProviderDisabled(t *te require.Len(t, fixture.payloads, 1) require.NotContains(t, fixture.payloads[0], "private-key-material") require.NotContains(t, fixture.payloads[0], "api.laxarouter.ai") - processExtensionOperations.Store(nil) + setNativeCindyTestInvoker(nil) _, err = buildCindyDuplicateIdentityInventory(context.Background(), accounts) require.ErrorIs(t, err, ErrCindyGroupAdminUnavailable) _, err = PlanCindyGroupPartition(context.Background(), 1, 1, "cindy") diff --git a/backend/internal/service/plugin_cindy_probe.go b/backend/internal/service/plugin_cindy_probe.go index cffa93ff5737..aa93441e0921 100644 --- a/backend/internal/service/plugin_cindy_probe.go +++ b/backend/internal/service/plugin_cindy_probe.go @@ -7,8 +7,8 @@ import ( "strings" "time" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" ) func requireCindyBalanceProbePolicy(ctx context.Context) error { @@ -29,8 +29,7 @@ func invokeCindyProbePolicy(ctx context.Context, accountID int64, operation stri } call, cancel := context.WithTimeout(ctx, time.Second) defer cancel() - result, err := invokeProcessExtensionCached(call, PlatformCindy, AccountTypeAPIKey, - extensionv1.Invocation{Capability: extensionv1.CapabilityProvider, Operation: operation, AccountID: accountID, Payload: raw}) + result, err := invokeCindyProviderCached(call, extensionv1.Invocation{Capability: extensionv1.CapabilityProvider, Operation: operation, AccountID: accountID, Payload: raw}) if err != nil { return err } diff --git a/backend/internal/service/plugin_cindy_probe_test.go b/backend/internal/service/plugin_cindy_probe_test.go index d05fa80b4dfd..40dc99b15466 100644 --- a/backend/internal/service/plugin_cindy_probe_test.go +++ b/backend/internal/service/plugin_cindy_probe_test.go @@ -2,26 +2,24 @@ package service import ( "context" - "encoding/json" "sync" "testing" "time" - "github.com/Wei-Shaw/sub2api/internal/config" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" "github.com/stretchr/testify/require" ) func TestCindyProbeAPIReportsDisabledAndFailedProviderDistinctly(t *testing.T) { - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - processExtensionOperations.Store(nil) + previous := captureNativeCindyTestInvoker() + t.Cleanup(func() { restoreNativeCindyTestInvoker(previous) }) + setNativeCindyTestInvoker(nil) err := requireCindyBalanceProbePolicy(context.Background()) var apiError *infraerrors.ApplicationError require.ErrorAs(t, err, &apiError) require.EqualValues(t, 404, apiError.Code) - processExtensionOperations.Store(&extensionOperationProvider{invoker: failedPromptProcess{}}) + setNativeCindyTestInvoker(failedPromptProcess{}) err = requireCindyBalanceProbePolicy(context.Background()) require.ErrorAs(t, err, &apiError) require.EqualValues(t, 503, apiError.Code) @@ -58,10 +56,10 @@ func (r *cindyProbeLifecycleRepository) RecoverInterruptedItems(ctx context.Cont } func TestCindyProbeDisabledPolicyDoesNotClaimJobsOrDeleteHistory(t *testing.T) { - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) + previous := captureNativeCindyTestInvoker() + t.Cleanup(func() { restoreNativeCindyTestInvoker(previous) }) fixture := &disabledCindyProbeFixture{seen: make(chan struct{})} - processExtensionOperations.Store(&extensionOperationProvider{invoker: fixture}) + setNativeCindyTestInvoker(fixture) ctx, cancel := context.WithCancel(context.Background()) defer cancel() repo := &cindyProbeLifecycleRepository{} @@ -80,19 +78,11 @@ func TestCindyProbeDisabledPolicyDoesNotClaimJobsOrDeleteHistory(t *testing.T) { require.ErrorIs(t, err, ErrExtensionOperationDisabled) } -func TestCindyProbeActiveJobContextEndsWhenProviderStops(t *testing.T) { - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{}, func(extensionv1.Invocation) (extensionv1.Result, error) { - raw, _ := json.Marshal(extensionv1.CindyProbePlan{Models: [2]string{"fixture-a", "fixture-b"}, Input: "Reply OK.", MaxOutputTokens: 1}) - return extensionv1.Result{Payload: raw}, nil - }) - registry := manager.extensions.Load() - registry.installations[1].Bindings = []PluginBinding{{Capability: extensionv1.CapabilityProvider, Platform: PlatformCindy, AccountType: AccountTypeAPIKey, Enabled: true}} - registry.installations[1].Manifest.Operations = map[string][]string{extensionv1.CapabilityProvider: {"cindy.probe.plan"}} - processExtensionOperations.Store(&extensionOperationProvider{invoker: manager}) - repo := &cindyProbeLifecycleRepository{stop: registry.runtimes[1].beginDrain} - svc := &CindyBalanceProbeService{ctx: context.Background(), repo: repo, now: time.Now} +func TestCindyProbeActiveJobContextEndsWhenNativeParentStops(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + repo := &cindyProbeLifecycleRepository{stop: cancel} + svc := &CindyBalanceProbeService{ctx: ctx, repo: repo, now: time.Now} svc.processJob(&CindyBalanceProbeJob{ID: 17}, "fixture-lease") require.NotNil(t, repo.recoveredContext) require.ErrorIs(t, repo.recoveredContext.Err(), context.Canceled) diff --git a/backend/internal/service/plugin_codex_identity.go b/backend/internal/service/plugin_codex_identity.go index c73239ee6a5c..7bd205243e11 100644 --- a/backend/internal/service/plugin_codex_identity.go +++ b/backend/internal/service/plugin_codex_identity.go @@ -7,7 +7,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) // Only shared/pre-create policy material may use the account-free domain path. @@ -15,11 +15,11 @@ import ( // policy operation, including validation and metadata-only diagnostics. func invokeCodexIdentityPolicyForAccount(ctx context.Context, account *Account, operation string, query extensionv1.CodexIdentityQuery) (extensionv1.CodexIdentityResult, error) { if account != nil && !account.IsOpenAIOAuthLike() { - return extensionv1.CodexIdentityResult{}, ErrExtensionOperationDisabled + return extensionv1.CodexIdentityResult{}, ErrNativeCodexPolicyDisabled } raw, err := json.Marshal(query) if err != nil || len(raw) > 8192 { - return extensionv1.CodexIdentityResult{}, ErrExtensionOperationUnavailable + return extensionv1.CodexIdentityResult{}, ErrNativeCodexRuntimeUnavailable } call, cancel := context.WithTimeout(ctx, time.Second) defer cancel() @@ -28,17 +28,17 @@ func invokeCodexIdentityPolicyForAccount(ctx context.Context, account *Account, } var result extensionv1.Result if account == nil { - result, err = invokeProcessDomainExtension(call, invocation, true) + result, err = invokeNativeCodex(call, "", "", invocation) } else { invocation.AccountID = account.ID - result, err = invokeProcessExtensionCached(call, account.Platform, account.Type, invocation) + result, err = invokeNativeCodex(call, account.Platform, account.Type, invocation) } if err != nil { return extensionv1.CodexIdentityResult{}, err } var identity extensionv1.CodexIdentityResult if result.Code != "" || json.Unmarshal(result.Payload, &identity) != nil { - return identity, ErrExtensionOperationUnavailable + return identity, ErrNativeCodexRuntimeUnavailable } return identity, nil } @@ -73,8 +73,8 @@ func resolveCodexOutboundIdentityForAccountContext(ctx context.Context, account Seed: codexClientIdentitySeed(account), Profile: profile, Version: canonical.version, }) if err != nil { - if errors.Is(err, ErrExtensionOperationDisabled) { - err = ErrExtensionOperationUnavailable + if errors.Is(err, ErrNativeCodexPolicyDisabled) { + err = ErrNativeCodexRuntimeUnavailable } return codexOutboundIdentity{}, err } @@ -82,11 +82,11 @@ func resolveCodexOutboundIdentityForAccountContext(ctx context.Context, account return canonical, nil } if result.UserAgent == "" { - return codexOutboundIdentity{}, ErrExtensionOperationUnavailable + return codexOutboundIdentity{}, ErrNativeCodexRuntimeUnavailable } originator, paired, valid := openai.PairCodexClientIdentity(result.UserAgent) if !valid { - return codexOutboundIdentity{}, ErrExtensionOperationUnavailable + return codexOutboundIdentity{}, ErrNativeCodexRuntimeUnavailable } return codexOutboundIdentity{userAgent: paired, originator: originator, version: openai.CodexUserAgentVersion(paired)}, nil } @@ -94,10 +94,10 @@ func resolveCodexOutboundIdentityForAccountContext(ctx context.Context, account func codexIdentityPolicyAvailable(ctx context.Context, accountType string, accountID int64) (bool, error) { call, cancel := context.WithTimeout(ctx, time.Second) defer cancel() - result, err := invokeProcessExtensionCached(call, PlatformOpenAI, accountType, extensionv1.Invocation{ + result, err := invokeNativeCodex(call, PlatformOpenAI, accountType, extensionv1.Invocation{ Capability: extensionv1.CapabilityRequest, Operation: "codex.identity.available", Payload: []byte(`{}`), AccountID: accountID, }) - if errors.Is(err, ErrExtensionOperationDisabled) { + if errors.Is(err, ErrNativeCodexPolicyDisabled) { return false, nil // Explicitly disabled: use the upstream host identity contract. } if err != nil { @@ -105,7 +105,7 @@ func codexIdentityPolicyAvailable(ctx context.Context, accountType string, accou } var status extensionv1.CodexIdentityResult if result.Code != "" || json.Unmarshal(result.Payload, &status) != nil || !status.Valid { - return false, ErrExtensionOperationUnavailable + return false, ErrNativeCodexRuntimeUnavailable } return true, nil } diff --git a/backend/internal/service/plugin_codex_identity_test.go b/backend/internal/service/plugin_codex_identity_test.go index 87fb9289b5d9..95f542e2b966 100644 --- a/backend/internal/service/plugin_codex_identity_test.go +++ b/backend/internal/service/plugin_codex_identity_test.go @@ -7,8 +7,7 @@ import ( "testing" "time" - "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" ) @@ -18,33 +17,33 @@ func (partialCodexIdentityFailure) InvokeOperation(_ context.Context, _, _ strin if in.Operation == "codex.identity.available" { return extensionv1.Result{Payload: []byte(`{"valid":true}`)}, nil } - return extensionv1.Result{}, ErrExtensionOperationUnavailable + return extensionv1.Result{}, ErrNativeCodexRuntimeUnavailable } func TestCodexIdentityPlanFailureCannotSendCanonicalFallback(t *testing.T) { - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - processExtensionOperations.Store(&extensionOperationProvider{invoker: partialCodexIdentityFailure{}}) + previous := invokeNativeCodex + t.Cleanup(func() { invokeNativeCodex = previous }) + invokeNativeCodex = partialCodexIdentityFailure{}.InvokeOperation account := &Account{ID: 7, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"refresh_token": "fixture-refresh"}, Extra: map[string]any{codexFingerprintSeedExtraKey: "1c0a3d9e-58b2-4f8c-a2d1-7f3b9e6c4a55"}} headers := http.Header{"Originator": []string{"fixture-original"}} err := enforceCodexIdentityHeadersForAccountContext(context.Background(), headers, account, "") - require.ErrorIs(t, err, ErrExtensionOperationUnavailable) + require.ErrorIs(t, err, ErrNativeCodexRuntimeUnavailable) require.Equal(t, "fixture-original", headers.Get("Originator")) req, err := http.NewRequest(http.MethodPost, "https://chatgpt.com/backend-api/codex/responses", strings.NewReader(`{"input":"fixture"}`)) require.NoError(t, err) _, err = prepareCodexTransport(req, account) - require.ErrorIs(t, err, ErrExtensionOperationUnavailable) + require.ErrorIs(t, err, ErrNativeCodexRuntimeUnavailable) client := &identityRefreshingOAuthClientStub{} _, err = NewOpenAIOAuthService(nil, client).RefreshAccountToken(context.Background(), account) - require.ErrorIs(t, err, ErrExtensionOperationUnavailable) + require.ErrorIs(t, err, ErrNativeCodexRuntimeUnavailable) require.Empty(t, client.userAgent) } func TestCodexIdentityDisabledKeepsDataAndFailureBlocksHTTPAndRefresh(t *testing.T) { - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) + previous := invokeNativeCodex + t.Cleanup(func() { invokeNativeCodex = previous }) account := &Account{ID: 7, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"refresh_token": "fixture-refresh"}, Extra: map[string]any{codexFingerprintSeedExtraKey: "1c0a3d9e-58b2-4f8c-a2d1-7f3b9e6c4a55"}} @@ -52,7 +51,9 @@ func TestCodexIdentityDisabledKeepsDataAndFailureBlocksHTTPAndRefresh(t *testing stored := account.Extra[CodexClientIdentityExtraKey] require.NotNil(t, stored) require.Equal(t, codexFingerprintDevice, account.GetCodexFingerprintMode()) - processExtensionOperations.Store(nil) + invokeNativeCodex = func(context.Context, string, string, extensionv1.Invocation) (extensionv1.Result, error) { + return extensionv1.Result{}, ErrNativeCodexPolicyDisabled + } _, found := account.CodexClientIdentity() require.False(t, found) require.Equal(t, stored, account.Extra[CodexClientIdentityExtraKey]) @@ -66,25 +67,27 @@ func TestCodexIdentityDisabledKeepsDataAndFailureBlocksHTTPAndRefresh(t *testing wire, err := prepareCodexTransport(req, account) require.NoError(t, err) require.Same(t, req, wire) - processExtensionOperations.Store(&extensionOperationProvider{invoker: failedPromptProcess{}}) + invokeNativeCodex = func(context.Context, string, string, extensionv1.Invocation) (extensionv1.Result, error) { + return extensionv1.Result{}, ErrNativeCodexRuntimeUnavailable + } _, err = prepareCodexTransport(req, account) - require.ErrorIs(t, err, ErrExtensionOperationUnavailable, "policy failure cannot silently send canonical identity") + require.ErrorIs(t, err, ErrNativeCodexRuntimeUnavailable, "policy failure cannot silently send canonical identity") client := &identityRefreshingOAuthClientStub{} _, err = NewOpenAIOAuthService(nil, client).RefreshAccountToken(context.Background(), account) - require.ErrorIs(t, err, ErrExtensionOperationUnavailable) + require.ErrorIs(t, err, ErrNativeCodexRuntimeUnavailable) require.Empty(t, client.userAgent) } func TestCodexProfileMetadataDoesNotBroadenAccountBinding(t *testing.T) { - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{}, func(in extensionv1.Invocation) (extensionv1.Result, error) { - return promptPolicyFixture{}.InvokeOperation(context.Background(), "", "", in) - }) - registry := manager.extensions.Load() - registry.installations[1].Bindings = []PluginBinding{{Capability: extensionv1.CapabilityRequest, Platform: PlatformOpenAI, AccountType: AccountTypeSetupToken, Enabled: true}} - registry.installations[1].Manifest.Operations = map[string][]string{extensionv1.CapabilityRequest: {"codex.identity.available", "codex.identity.derive", "codex.identity.validate"}} - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - processExtensionOperations.Store(&extensionOperationProvider{invoker: manager}) + previous := invokeNativeCodex + t.Cleanup(func() { invokeNativeCodex = previous }) + invokeNativeCodex = func(ctx context.Context, platform, kind string, in extensionv1.Invocation) (extensionv1.Result, error) { + if kind != AccountTypeSetupToken { + return extensionv1.Result{}, ErrNativeCodexPolicyDisabled + } + return (promptPolicyFixture{}).InvokeOperation(ctx, platform, kind, in) + } + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{codexFingerprintSeedExtraKey: "1c0a3d9e-58b2-4f8c-a2d1-7f3b9e6c4a55"}} _, found := account.CodexClientIdentity() require.False(t, found) diff --git a/backend/internal/service/plugin_codex_recovery.go b/backend/internal/service/plugin_codex_recovery.go index b294c3d2068e..1ff3d1a5ecba 100644 --- a/backend/internal/service/plugin_codex_recovery.go +++ b/backend/internal/service/plugin_codex_recovery.go @@ -7,7 +7,7 @@ import ( "slices" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/tidwall/gjson" ) @@ -27,31 +27,28 @@ func withCodexRecoveryScope(ctx context.Context, account *Account) context.Conte func invokeCodexRecoveryPolicy(ctx context.Context, accountType, operation string, input, output any) error { raw, err := json.Marshal(input) if err != nil || len(raw) > extensionv1.MaxPayloadBytes { - return ErrExtensionOperationUnavailable + return ErrNativeCodexRuntimeUnavailable } call, cancel := context.WithTimeout(ctx, time.Second) defer cancel() invocation := extensionv1.Invocation{Capability: extensionv1.CapabilityRecovery, Operation: operation, Payload: raw} if scope, ok := ctx.Value(codexRecoveryScopeKey{}).(codexRecoveryScope); ok { if scope.platform != PlatformOpenAI { - return ErrExtensionOperationDisabled + return ErrNativeCodexPolicyDisabled } accountType, invocation.AccountID = scope.accountType, scope.accountID } var result extensionv1.Result - cached := operation == "codex.recovery.enabled" || operation == "codex.recovery.available" || operation == "codex.replay.rules" if accountType == "" { - result, err = invokeProcessDomainExtension(call, invocation, cached) - } else if cached { - result, err = invokeProcessExtensionCached(call, PlatformOpenAI, accountType, invocation) + result, err = invokeNativeCodex(call, "", "", invocation) } else { - result, err = invokeProcessExtension(call, PlatformOpenAI, accountType, invocation) + result, err = invokeNativeCodex(call, PlatformOpenAI, accountType, invocation) } if err != nil { return err } if result.Code != "" || json.Unmarshal(result.Payload, output) != nil { - return ErrExtensionOperationUnavailable + return ErrNativeCodexRuntimeUnavailable } return nil } @@ -65,7 +62,7 @@ func readOpenAIReplayRules(ctx context.Context) (extensionv1.ReplayRules, error) return rules, nil } if rules.Version == "" || len(rules.Version) > 64 || rules.MaxToolCalls < 1 || rules.MaxToolCalls > 32 || len(rules.ChatFields) > 6 || len(rules.OutputKinds) > 3 { - return rules, ErrExtensionOperationUnavailable + return rules, ErrNativeCodexRuntimeUnavailable } validate := func(values, allowed []string) bool { seen := map[string]bool{} @@ -78,7 +75,7 @@ func readOpenAIReplayRules(ctx context.Context) (extensionv1.ReplayRules, error) return true } if !validate(rules.ChatFields, []string{"role", "content", "reasoning_content", "reasoning", "tool_calls", "refusal"}) || !validate(rules.OutputKinds, []string{"reasoning", "message", "function_call"}) { - return rules, ErrExtensionOperationUnavailable + return rules, ErrNativeCodexRuntimeUnavailable } slices.Sort(rules.ChatFields) slices.Sort(rules.OutputKinds) @@ -93,7 +90,7 @@ func openAIReasoningPolicyEnabled(ctx context.Context, account *Account, key str value, valid := raw.(bool) var enabled bool err := invokeCodexRecoveryPolicy(withCodexRecoveryScope(ctx, account), account.Type, "codex.recovery.enabled", extensionv1.RecoverySetting{Configured: configured, Valid: valid, Value: value}, &enabled) - if errors.Is(err, ErrExtensionOperationDisabled) { + if errors.Is(err, ErrNativeCodexPolicyDisabled) { return false, nil } return enabled, err @@ -163,7 +160,7 @@ func selectOpenAIRecoveryIndices(ctx context.Context, body []byte, param string) } for _, index := range selection.Indices { if !allowed[index] { - return extensionv1.RecoverySelection{}, ErrExtensionOperationUnavailable + return extensionv1.RecoverySelection{}, ErrNativeCodexRuntimeUnavailable } delete(allowed, index) } diff --git a/backend/internal/service/plugin_codex_recovery_test.go b/backend/internal/service/plugin_codex_recovery_test.go index 0225bf423fe9..1d52420d1cd9 100644 --- a/backend/internal/service/plugin_codex_recovery_test.go +++ b/backend/internal/service/plugin_codex_recovery_test.go @@ -7,10 +7,9 @@ import ( "net/http" "testing" - codexrecovery "github.com/HTExplicit/sub2api-plugins/codexruntime/recovery" - "github.com/Wei-Shaw/sub2api/internal/config" + codexrecovery "github.com/Wei-Shaw/sub2api/internal/codexruntime/recovery" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" ) @@ -27,10 +26,10 @@ func (f *capturedRecoveryFixture) InvokeOperation(ctx context.Context, platform, } func TestRecoveryPolicyDoesNotReceiveHistoryCiphertextOrCredentialMaterial(t *testing.T) { - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) + previous := invokeNativeCodex + t.Cleanup(func() { invokeNativeCodex = previous }) fixture := &capturedRecoveryFixture{} - processExtensionOperations.Store(&extensionOperationProvider{invoker: fixture}) + invokeNativeCodex = fixture.InvokeOperation state, _, _ := newReasoningRecoveryTestState(t, context.Background(), reasoningRecoveryFixture) _, retry := state.TryRecover(http.StatusBadRequest, nil, []byte(`{"error":{"code":"invalid_encrypted_content","message":"private-upstream-text"}}`), false) require.True(t, retry) @@ -43,10 +42,12 @@ func TestRecoveryPolicyDoesNotReceiveHistoryCiphertextOrCredentialMaterial(t *te } func TestRecoveryDisableAfterFirstAttemptDoesNotSpendAnotherRequest(t *testing.T) { - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) + previous := invokeNativeCodex + t.Cleanup(func() { invokeNativeCodex = previous }) state, _, _ := newReasoningRecoveryTestState(t, context.Background(), reasoningRecoveryFixture) - processExtensionOperations.Store(nil) + invokeNativeCodex = func(context.Context, string, string, extensionv1.Invocation) (extensionv1.Result, error) { + return extensionv1.Result{}, ErrNativeCodexPolicyDisabled + } _, retry := state.TryRecover(http.StatusBadRequest, nil, []byte(`{"error":{"code":"invalid_encrypted_content"}}`), false) require.False(t, retry) require.False(t, state.retryUsed) @@ -55,21 +56,22 @@ func TestRecoveryDisableAfterFirstAttemptDoesNotSpendAnotherRequest(t *testing.T } func TestRecoverySelectionCannotEscapeActualAccountScopeOrRollout(t *testing.T) { - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) + previous := invokeNativeCodex + t.Cleanup(func() { invokeNativeCodex = previous }) var called []extensionv1.Invocation - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{}, func(in extensionv1.Invocation) (extensionv1.Result, error) { + enabled := true + invokeNativeCodex = func(ctx context.Context, platform, kind string, in extensionv1.Invocation) (extensionv1.Result, error) { + if platform != PlatformOpenAI || kind != AccountTypeOAuth || !enabled { + return extensionv1.Result{}, ErrNativeCodexPolicyDisabled + } called = append(called, in) - return codexrecovery.Invoke(context.Background(), in) - }) - installation := manager.extensions.Load().installations[1] - installation.Bindings = []PluginBinding{{Capability: extensionv1.CapabilityRecovery, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth, Enabled: true, RolloutPercent: 100}} - installation.Manifest.Operations = map[string][]string{extensionv1.CapabilityRecovery: {"codex.recovery.enabled", "codex.recovery.rejection", "codex.recovery.select"}} - processExtensionOperations.Store(&extensionOperationProvider{invoker: manager}) + return codexrecovery.Invoke(ctx, in) + } + account := &Account{ID: 37, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} ctx := withCodexRecoveryScope(context.Background(), account) _, _, err := readOpenAIRecoveryRejection(ctx, []byte(`{"error":{"code":"invalid_encrypted_content"}}`)) - require.ErrorIs(t, err, ErrExtensionOperationDisabled) + require.ErrorIs(t, err, ErrNativeCodexPolicyDisabled) require.Empty(t, called) account.Type = AccountTypeOAuth ctx = withCodexRecoveryScope(context.Background(), account) @@ -81,15 +83,17 @@ func TestRecoverySelectionCannotEscapeActualAccountScopeOrRollout(t *testing.T) for _, in := range called { require.EqualValues(t, 37, in.AccountID) } - installation.Bindings[0].RolloutPercent = int(stablePluginBucket(37)) + enabled = false _, err = selectOpenAIRecoveryIndices(ctx, []byte(reasoningRecoveryFixture), "") - require.ErrorIs(t, err, ErrExtensionOperationDisabled) + require.ErrorIs(t, err, ErrNativeCodexPolicyDisabled) } func TestSignatureRejectionRemainsRequestScopedWhenPluginIsAbsent(t *testing.T) { - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - processExtensionOperations.Store(nil) + previous := invokeNativeCodex + t.Cleanup(func() { invokeNativeCodex = previous }) + invokeNativeCodex = func(context.Context, string, string, extensionv1.Invocation) (extensionv1.Result, error) { + return extensionv1.Result{}, ErrNativeCodexPolicyDisabled + } _, ok := parseOpenAIReasoningRejection([]byte(`{"error":{"code":"thinking_signature_invalid"}}`)) require.True(t, ok) _, ok = parseOpenAIReasoningRejection([]byte(`{"error":{"status":429,"code":"thinking_signature_invalid"}}`)) diff --git a/backend/internal/service/plugin_codex_scope_test.go b/backend/internal/service/plugin_codex_scope_test.go index 6742024f6e6d..07b3c3394817 100644 --- a/backend/internal/service/plugin_codex_scope_test.go +++ b/backend/internal/service/plugin_codex_scope_test.go @@ -3,14 +3,14 @@ package service import ( "context" "encoding/json" + "github.com/Wei-Shaw/sub2api/internal/config" "net/http" "strconv" "strings" "testing" "time" - "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" ) @@ -32,23 +32,37 @@ func codexScopeFixtureAccount(id int64, kind string, stored bool) *Account { return account } -func codexScopeFixtureManager(t *testing.T, percent int, kind string, calls *[]extensionv1.Invocation) *PluginManager { +type nativeCodexScopeFixture struct{ unavailable bool } + +func codexScopeFixtureManager(t *testing.T, percent int, kind string, calls *[]extensionv1.Invocation) *nativeCodexScopeFixture { t.Helper() - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{}, func(in extensionv1.Invocation) (extensionv1.Result, error) { + fixture := &nativeCodexScopeFixture{} + oldInvoke, oldBind := invokeNativeCodex, bindNativeCodexContext + t.Cleanup(func() { invokeNativeCodex, bindNativeCodexContext = oldInvoke, oldBind }) + check := func(actual string, in extensionv1.Invocation) error { + if in.AccountID > 0 && (actual != kind || int(stablePluginBucket(in.AccountID)) >= percent) { + return ErrNativeCodexPolicyDisabled + } + if fixture.unavailable { + return ErrNativeCodexRuntimeUnavailable + } + return nil + } + invokeNativeCodex = func(ctx context.Context, platform, actual string, in extensionv1.Invocation) (extensionv1.Result, error) { + if err := check(actual, in); err != nil { + return extensionv1.Result{}, err + } *calls = append(*calls, in) - return (promptPolicyFixture{}).InvokeOperation(withCodexTransportFixture(context.Background(), true), "", "", in) - }) - installation := manager.extensions.Load().installations[1] - installation.Bindings = []PluginBinding{{Capability: extensionv1.CapabilityRequest, Platform: PlatformOpenAI, - AccountType: kind, Enabled: true, RolloutPercent: percent}} - installation.Manifest.Operations = map[string][]string{extensionv1.CapabilityRequest: { - "codex.identity.available", "codex.identity.plan", "codex.identity.derive", - "codex.identity.validate", "codex.identity.agent", "codex.identity.sandbox", "codex.transport.plan", - }} - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - processExtensionOperations.Store(&extensionOperationProvider{invoker: manager}) - return manager + return (promptPolicyFixture{}).InvokeOperation(withCodexTransportFixture(ctx, true), platform, actual, in) + } + bindNativeCodexContext = func(ctx context.Context, _ string, actual string, in extensionv1.Invocation) (context.Context, context.CancelFunc, error) { + if err := check(actual, in); err != nil { + return nil, nil, err + } + bound, cancel := context.WithCancel(ctx) + return bound, cancel, nil + } + return fixture } func TestCodexAccountPoliciesCarryActualScope(t *testing.T) { @@ -181,13 +195,13 @@ func TestCodexIdentityBackfillUnavailablePolicyIsAccountScoped(t *testing.T) { t.Run(strconv.Itoa(percent), func(t *testing.T) { var calls []extensionv1.Invocation manager := codexScopeFixtureManager(t, percent, AccountTypeOAuth, &calls) - manager.extensions.Load().runtimes = map[int64]*pluginRuntime{} + manager.unavailable = true repo := &codexScopeBackfillRepo{accounts: []Account{*codexScopeFixtureAccount(2, AccountTypeOAuth, false)}} updated, failed, err := NewCodexClientIdentityBackfillService(repo).RunOnce(context.Background()) if percent == 0 { require.NoError(t, err, "an account outside rollout must not bind the unavailable domain runtime") } else { - require.ErrorIs(t, err, ErrExtensionOperationUnavailable) + require.ErrorIs(t, err, ErrNativeCodexRuntimeUnavailable) } require.Zero(t, updated) require.Zero(t, failed) @@ -205,7 +219,7 @@ func TestCodexRefreshScopeAndUnavailableRuntime(t *testing.T) { var calls []extensionv1.Invocation manager := codexScopeFixtureManager(t, percent, AccountTypeOAuth, &calls) if !runtimeAvailable { - manager.extensions.Load().runtimes = map[int64]*pluginRuntime{} + manager.unavailable = true } account := codexScopeFixtureAccount(id, AccountTypeOAuth, true) client := &identityRefreshingOAuthClientStub{} @@ -214,7 +228,7 @@ func TestCodexRefreshScopeAndUnavailableRuntime(t *testing.T) { _, err := service.RefreshAccountToken(context.Background(), account) inScope := int(stablePluginBucket(id)) < percent if inScope && !runtimeAvailable { - require.ErrorIs(t, err, ErrExtensionOperationUnavailable) + require.ErrorIs(t, err, ErrNativeCodexRuntimeUnavailable) require.Empty(t, client.userAgent) return } diff --git a/backend/internal/service/plugin_codex_transport.go b/backend/internal/service/plugin_codex_transport.go index 7735e75afe28..10a426839ef2 100644 --- a/backend/internal/service/plugin_codex_transport.go +++ b/backend/internal/service/plugin_codex_transport.go @@ -7,20 +7,20 @@ import ( "net/http" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func codexTransportPlan(ctx context.Context, accountType string, accountID int64, query extensionv1.CodexTransportQuery) (extensionv1.CodexTransportPlan, error) { raw, err := json.Marshal(query) if err != nil || len(raw) > 16384 { - return extensionv1.CodexTransportPlan{}, ErrExtensionOperationUnavailable + return extensionv1.CodexTransportPlan{}, ErrNativeCodexRuntimeUnavailable } call, cancel := context.WithTimeout(ctx, time.Second) defer cancel() - result, err := invokeProcessExtensionCached(call, PlatformOpenAI, accountType, extensionv1.Invocation{ + result, err := invokeNativeCodex(call, PlatformOpenAI, accountType, extensionv1.Invocation{ Capability: extensionv1.CapabilityRequest, Operation: "codex.transport.plan", Payload: raw, AccountID: accountID, }) - if errors.Is(err, ErrExtensionOperationDisabled) { + if errors.Is(err, ErrNativeCodexPolicyDisabled) { return extensionv1.CodexTransportPlan{}, nil } if err != nil { @@ -28,7 +28,7 @@ func codexTransportPlan(ctx context.Context, accountType string, accountID int64 } var plan extensionv1.CodexTransportPlan if result.Code != "" || json.Unmarshal(result.Payload, &plan) != nil || (plan.Compress && !plan.Enabled) { - return plan, ErrExtensionOperationUnavailable + return plan, ErrNativeCodexRuntimeUnavailable } return plan, nil } diff --git a/backend/internal/service/plugin_compatibility.go b/backend/internal/service/plugin_compatibility.go index 1f7b68b54e0d..02c3ba3444f3 100644 --- a/backend/internal/service/plugin_compatibility.go +++ b/backend/internal/service/plugin_compatibility.go @@ -4,7 +4,6 @@ import ( "fmt" "strings" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" "golang.org/x/mod/semver" ) @@ -24,7 +23,6 @@ func EvaluatePluginCompatibility(manifest PluginManifest, host PluginHostInfo) P UIBridge: manifest.Requires.UIBridge, } if manifest.Requires.PluginProtocol != pluginv1.ProtocolVersion || - (manifest.Requires.ExtensionAPI != 0 && manifest.Requires.ExtensionAPI != extensionv1.Version) || manifest.Requires.TransportAPI != pluginv1.TransportAPIVersion || manifest.Requires.UIBridge != pluginv1.UIBridgeVersion { result.Status = "incompatible" diff --git a/backend/internal/service/plugin_config_receipt_test.go b/backend/internal/service/plugin_config_receipt_test.go deleted file mode 100644 index 9556599cad29..000000000000 --- a/backend/internal/service/plugin_config_receipt_test.go +++ /dev/null @@ -1,140 +0,0 @@ -package service - -import ( - "context" - "strings" - "testing" - - pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" - "github.com/stretchr/testify/require" - "google.golang.org/grpc" -) - -type configReceiptRepository struct { - *pluginConfigRepository - afterWrite func() - writes int -} - -func (r *configReceiptRepository) UpdateConfigReturningRevision(ctx context.Context, id int64, encrypted, binary string) (int64, error) { - r.writes++ - if id != r.installation.ID || binary != r.installation.BinarySHA256 || validatePluginPrecondition(ctx, r.installation) != nil { - return 0, ErrPluginStateChanged - } - if r.installation.ConfigEncrypted != encrypted { - r.installation.Revision++ - } - r.installation.ConfigEncrypted = encrypted - revision := r.installation.Revision - if r.afterWrite != nil { - r.afterWrite() - } - return revision, nil -} - -func configReceiptManager(t *testing.T, normalized string) (*PluginManager, *configReceiptRepository, *normalizingPluginClient, context.Context) { - t.Helper() - installation := &PluginInstallation{ID: 7, Revision: 4, PackageSHA256: strings.Repeat("a", 64), BinarySHA256: strings.Repeat("b", 64), ConfigEncrypted: "ENC:{}"} - runtimeCopy := *installation - repo := &configReceiptRepository{pluginConfigRepository: &pluginConfigRepository{installation: installation}} - client := &normalizingPluginClient{normalized: []byte(normalized)} - manager := &PluginManager{repo: repo, encryptor: pluginTokenEncryptor{}, runtimes: map[int64]*pluginRuntime{7: {installation: &runtimeCopy, api: client}}} - ctx := WithPluginExpectedPackage(WithPluginExpectedRevision(context.Background(), 4), installation.PackageSHA256) - return manager, repo, client, ctx -} - -func TestPluginSaveReceiptDoesNotRebaseOnConcurrentAdminWrite(t *testing.T) { - manager, repo, client, ctx := configReceiptManager(t, `{"enabled":true}`) - repo.afterWrite = func() { - repo.installation.Revision++ - repo.installation.ConfigEncrypted = `ENC:{"other_admin":true}` - } - snapshot, err := manager.SaveConfigSnapshot(ctx, 7, []byte(`{"enabled":true}`)) - require.ErrorIs(t, err, ErrPluginStateChanged) - require.Nil(t, snapshot) - require.Equal(t, int64(6), repo.installation.Revision) - require.Empty(t, client.applied, "a newer persistent config must not be blessed by an old canonical receipt") -} - -func TestPluginSaveReceiptMatchesCanonicalAndUnchangedSaveDoesNotCancel(t *testing.T) { - manager, repo, client, ctx := configReceiptManager(t, `{"enabled":true}`) - snapshot, err := manager.SaveConfigSnapshot(ctx, 7, []byte(`{"enabled":false}`)) - require.NoError(t, err) - require.Equal(t, int64(5), snapshot.Revision) - require.Equal(t, repo.installation.PackageSHA256, snapshot.PackageSHA256) - require.JSONEq(t, `{"enabled":true}`, string(snapshot.Config)) - require.Equal(t, []byte(snapshot.Config), client.applied) - runtime := manager.runtimes[7] - work, release, err := runtime.bindPolicyContext(context.Background()) - require.NoError(t, err) - defer release() - client.applied = nil - ctx = WithPluginExpectedRevision(ctx, snapshot.Revision) - unchanged, err := manager.SaveConfigSnapshot(ctx, 7, snapshot.Config) - require.NoError(t, err) - require.Equal(t, snapshot.Revision, unchanged.Revision) - require.Equal(t, 2, repo.writes, "an unchanged save must still establish its compare-and-return receipt") - require.Empty(t, client.applied) - require.NoError(t, work.Err()) -} - -func TestPluginStaleUIActionAndJobPackageRejectedBeforeRuntime(t *testing.T) { - manager, repo, _ := hostIOAdmissionManager(t) - repo.current.Manifest.Contributions = nil - ctx := WithPluginExpectedPackage(context.Background(), strings.Repeat("c", 64)) - _, err := manager.InvokeAdminExtension(ctx, repo.current.ID, 0, "proxy.test", []byte(`{}`)) - require.ErrorIs(t, err, ErrPluginStateChanged) - _, _, err = manager.BindAccountJobExecution(ctx, repo.current.ID, 0) - require.ErrorIs(t, err, ErrPluginStateChanged) - require.Zero(t, repo.holds) -} - -type configReceiptRaceClient struct { - *normalizingPluginClient - validated func() - applied func() -} - -func (c *configReceiptRaceClient) ValidateConfig(ctx context.Context, request *pluginv1.ValidateConfigRequest, options ...grpc.CallOption) (*pluginv1.ValidateConfigResponse, error) { - if c.validated != nil { - c.validated() - } - return c.normalizingPluginClient.ValidateConfig(ctx, request, options...) -} -func (c *configReceiptRaceClient) ApplyConfig(ctx context.Context, request *pluginv1.ApplyConfigRequest, options ...grpc.CallOption) (*pluginv1.ApplyConfigResponse, error) { - result, err := c.normalizingPluginClient.ApplyConfig(ctx, request, options...) - if c.applied != nil { - c.applied() - } - return result, err -} - -func TestPluginUnchangedConfigStillRejectsConcurrentReadVersion(t *testing.T) { - manager, repo, client, ctx := configReceiptManager(t, `{}`) - runtime := manager.runtimes[7] - work, release, err := runtime.bindPolicyContext(context.Background()) - require.NoError(t, err) - defer release() - runtime.api = &configReceiptRaceClient{normalizingPluginClient: client, validated: func() { repo.installation.Revision++ }} - snapshot, err := manager.SaveConfigSnapshot(ctx, 7, []byte(`{}`)) - require.ErrorIs(t, err, ErrPluginStateChanged) - require.Nil(t, snapshot) - require.Equal(t, 1, repo.writes) - require.Empty(t, client.applied) - require.NoError(t, work.Err()) -} - -func TestPluginSaveReceiptRemainsOwnVersionIfAnotherAdminWritesDuringApply(t *testing.T) { - manager, repo, client, ctx := configReceiptManager(t, `{"mine":true}`) - manager.runtimes[7].api = &configReceiptRaceClient{normalizingPluginClient: client, applied: func() { - repo.installation.Revision++ - repo.installation.ConfigEncrypted = `ENC:{"other":true}` - }} - snapshot, err := manager.SaveConfigSnapshot(ctx, 7, []byte(`{"mine":true}`)) - require.NoError(t, err) - require.Equal(t, int64(5), snapshot.Revision) - require.Equal(t, int64(6), repo.installation.Revision) - require.JSONEq(t, `{"mine":true}`, string(snapshot.Config)) - _, err = manager.SaveConfigSnapshot(WithPluginExpectedRevision(ctx, snapshot.Revision), 7, snapshot.Config) - require.ErrorIs(t, err, ErrPluginStateChanged, "later save cannot borrow the other administrator's revision") -} diff --git a/backend/internal/service/plugin_contribution_admission.go b/backend/internal/service/plugin_contribution_admission.go deleted file mode 100644 index 1741edf531d8..000000000000 --- a/backend/internal/service/plugin_contribution_admission.go +++ /dev/null @@ -1,156 +0,0 @@ -package service - -import ( - "sort" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" -) - -// Host-owned metadata, not a manifest or public plugin protocol field. An empty -// versioned binding list denies every account; absence retains legacy metadata. -type PluginContributionAccountScope struct { - Version int `json:"version"` - Bindings []PluginContributionScope `json:"bindings"` -} - -type PluginContributionScope struct { - Platform string `json:"platform"` - AccountType string `json:"account_type"` - RolloutPercent int `json:"rollout_percent"` -} - -// A current persisted record cannot authorize flags read from an older applied -// configuration. Runtime installation/config publication uses this same lock; -// no storage query or RPC is performed while holding it. -func (m *PluginManager) contributionAppliedRevision(installation *PluginInstallation, runtime *pluginRuntime) (uint64, bool) { - m.mu.Lock() - defer m.mu.Unlock() - if runtime == nil || runtime.configuring.Load() || !samePluginRuntime(installation, runtime.installation) || - installation.ConfigEncrypted != runtime.installation.ConfigEncrypted { - return 0, false - } - return runtime.configRevision.Load(), true -} - -func contributionRequiredCapabilities(contribution *extensionv1.Contribution) []string { - if contribution == nil { - return nil - } - var required []string - add := func(capability string) { - if capability == "" { - return - } - for _, previous := range required { - if previous == capability { - return - } - } - required = append(required, capability) - } - add(contribution.Capability) - if (contribution.Action != "" || contribution.ResourceAction != nil || contribution.Slot == extensionv1.AccountViewSlot || contribution.Slot == extensionv1.AccountCreateSlot || contribution.Slot == extensionv1.AccountEditSlot) && contribution.Permission == "admin" { - add(extensionv1.CapabilityAdmin) - } - if contribution.Slot == "theme" { - add(extensionv1.CapabilityUI) - } - return required -} - -func intersectContributionScope(left, right string) (string, bool) { - if left == right { - return left, true - } - if left == "*" { - return right, true - } - if right == "*" { - return left, true - } - return "", false -} - -func contributionEffectiveBindings(installation *PluginInstallation, contribution *extensionv1.Contribution) []PluginContributionScope { - out := []PluginContributionScope{{Platform: "*", AccountType: "*", RolloutPercent: 100}} - if installation == nil { - return []PluginContributionScope{} - } - for _, capability := range contributionRequiredCapabilities(contribution) { - next := map[[2]string]int{} - for _, current := range out { - for _, binding := range installation.Bindings { - if !binding.Enabled || binding.Capability != capability || binding.RolloutPercent < 0 || binding.RolloutPercent > 100 { - continue - } - platform, platformOK := intersectContributionScope(current.Platform, binding.Platform) - kind, kindOK := intersectContributionScope(current.AccountType, binding.AccountType) - if !platformOK || !kindOK { - continue - } - key, percent := [2]string{platform, kind}, min(current.RolloutPercent, binding.RolloutPercent) - if previous, exists := next[key]; !exists || percent > previous { - next[key] = percent - } - } - } - out = make([]PluginContributionScope, 0, len(next)) - for key, percent := range next { - out = append(out, PluginContributionScope{Platform: key[0], AccountType: key[1], RolloutPercent: percent}) - } - } - sort.Slice(out, func(i, j int) bool { - if out[i].Platform != out[j].Platform { - return out[i].Platform < out[j].Platform - } - return out[i].AccountType < out[j].AccountType - }) - return out -} - -func pluginContributionBindingsEnabled(installation *PluginInstallation, contribution *extensionv1.Contribution) bool { - if installation == nil || contribution == nil { - return false - } - if contribution.Slot == "theme" || contribution.AllAccounts { - if contribution.AllAccounts && contribution.Capability == "" { - return false - } - for _, binding := range contributionEffectiveBindings(installation, contribution) { - if binding.Platform == "*" && binding.AccountType == "*" && binding.RolloutPercent == 100 { - return true - } - } - return false - } - // Old optional-capability contributions keep their existing page admission. - // Execution still has its intrinsic Admin gate; no business dependency is guessed. - if contribution.Capability == "" { - return hasEnabledPluginBinding(installation.Bindings) - } - for _, capability := range contributionRequiredCapabilities(contribution) { - found := false - for _, binding := range installation.Bindings { - if binding.Enabled && binding.Capability == capability { - found = true - break - } - } - if !found { - return false - } - } - return true -} - -func contributionAccountAllowed(installation *PluginInstallation, contribution *extensionv1.Contribution, account extensionv1.Account) bool { - if account.ID <= 0 || !extensionv1.AccountMatchesFilter(account, contribution.AccountFilter) { - return false - } - for _, capability := range contributionRequiredCapabilities(contribution) { - if !pluginHasInvocationCapability(installation, extensionv1.Invocation{Capability: capability, AccountID: account.ID}, account.Platform, account.Type) { - return false - } - } - return true -} diff --git a/backend/internal/service/plugin_contribution_admission_test.go b/backend/internal/service/plugin_contribution_admission_test.go deleted file mode 100644 index fe021fe271ea..000000000000 --- a/backend/internal/service/plugin_contribution_admission_test.go +++ /dev/null @@ -1,166 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "os" - "strings" - "testing" - - "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/stretchr/testify/require" -) - -func TestContributionAdmissionIntersectsScopesAndPreservesSharedLegacy(t *testing.T) { - var vectors []struct { - ID int64 `json:"id"` - Bucket uint64 `json:"bucket"` - } - raw, err := os.ReadFile("testdata/plugin_contribution_buckets.json") - require.NoError(t, err) - require.NoError(t, json.Unmarshal(raw, &vectors)) - for _, vector := range vectors { - require.Equal(t, vector.Bucket, stablePluginBucket(vector.ID)) - } - contribution := &extensionv1.Contribution{Capability: extensionv1.CapabilityCredentials, Action: "harvest", Permission: "admin", Slot: "account.actions"} - installation := &PluginInstallation{Bindings: []PluginBinding{ - {Capability: extensionv1.CapabilityAdmin, Platform: "*", AccountType: AccountTypeOAuth, Enabled: true, RolloutPercent: 70}, - {Capability: extensionv1.CapabilityAdmin, Platform: PlatformOpenAI, AccountType: AccountTypeSetupToken, Enabled: true, RolloutPercent: 100}, - {Capability: extensionv1.CapabilityCredentials, Platform: PlatformOpenAI, AccountType: "*", Enabled: true, RolloutPercent: 50}, - {Capability: extensionv1.CapabilityCredentials, Platform: PlatformOpenAI, AccountType: AccountTypeSetupToken, Enabled: true, RolloutPercent: 80}, - }} - require.Equal(t, []PluginContributionScope{{PlatformOpenAI, AccountTypeOAuth, 50}, {PlatformOpenAI, AccountTypeSetupToken, 80}}, contributionEffectiveBindings(installation, contribution)) - require.True(t, pluginUIContributionBindingEnabled(installation, contribution), "partial scope must not globally hide a contribution") - require.True(t, contributionAccountAllowed(installation, contribution, extensionv1.Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth})) - require.False(t, contributionAccountAllowed(installation, contribution, extensionv1.Account{ID: 3, Platform: PlatformOpenAI, Type: AccountTypeOAuth})) - require.True(t, contributionAccountAllowed(installation, contribution, extensionv1.Account{ID: 3, Platform: PlatformOpenAI, Type: AccountTypeSetupToken})) - legacy := &extensionv1.Contribution{Slot: "admin.settings", Action: "legacy.describe", Permission: "admin"} - installation.Bindings = installation.Bindings[2:] - require.True(t, pluginUIContributionBindingEnabled(installation, legacy), "old missing-primary page admission remains compatible") - require.False(t, contributionAccountAllowed(installation, legacy, extensionv1.Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth}), "legacy action execution still requires Admin") - shared := &extensionv1.Contribution{Capability: extensionv1.CapabilityCredentials, Slot: "admin.settings", Permission: "admin"} - require.True(t, pluginUIContributionBindingEnabled(installation, shared), "shared management does not require wildcard scope or an invented account 0") -} - -func TestContributionAdminAdmissionRequiresDeclaredBusinessCapability(t *testing.T) { - for _, business := range []string{extensionv1.CapabilityObservability, extensionv1.CapabilityCredentials} { - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{}, nil) - installation := manager.extensions.Load().installations[1] - installation.State = PluginStateEnabled - installation.Manifest.Contributions = []extensionv1.Contribution{{ID: "fixture", Slot: "account.actions", Permission: "admin", Capability: business, Action: "fixture.run"}} - installation.Bindings = []PluginBinding{ - {Capability: extensionv1.CapabilityAdmin, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth, Enabled: true, RolloutPercent: 100}, - {Capability: business, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth, Enabled: true, RolloutPercent: 50}, - } - repo := &pluginTokenRepository{installation: installation} - manager.repo = repo - manager.accountDirectory = &resourceAccountDirectory{accounts: map[int64]extensionv1.Account{ - 2: {ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth}, 3: {ID: 3, Platform: PlatformOpenAI, Type: AccountTypeOAuth}, - }} - visible := manager.Contributions() - require.Len(t, visible, 1, "partial rollout must remain present in the host registry") - require.True(t, visible[0].Available) - require.Equal(t, &PluginContributionAccountScope{Version: 1, Bindings: []PluginContributionScope{{PlatformOpenAI, AccountTypeOAuth, 50}}}, visible[0].AccountScope) - require.NoError(t, manager.ValidateAdminExtension(context.Background(), 1, 2, "fixture.run")) - require.Error(t, manager.ValidateAdminExtension(context.Background(), 1, 3, "fixture.run"), business) - changed := *installation - changed.Bindings = append([]PluginBinding(nil), installation.Bindings...) - changed.Bindings[1].Enabled = false - repo.installation = &changed - require.Error(t, manager.ValidateAdminExtension(context.Background(), 1, 2, "fixture.run"), "persisted business-cap disable must reject before dispatch") - } -} - -func TestContributionAdminAdmissionRejectsUnappliedConfiguration(t *testing.T) { - calls := 0 - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{}, func(in extensionv1.Invocation) (extensionv1.Result, error) { - calls++ - require.Equal(t, extensionv1.CapabilityAdmin, in.Capability) - require.Equal(t, "telemetry.read", in.Operation) - require.EqualValues(t, 2, in.AccountID) - return extensionv1.Result{Payload: json.RawMessage(`{"invoked":true}`)}, nil - }) - registry := manager.extensions.Load() - installation := registry.installations[1] - installation.State, installation.RuntimeGeneration, installation.Revision = PluginStateEnabled, 1, 1 - installation.PackageSHA256, installation.ConfigEncrypted = strings.Repeat("a", 64), "fixture-telemetry-enabled" - installation.Manifest.Contributions = []extensionv1.Contribution{{ID: "account-traffic", Slot: "account.details", Permission: "admin", Capability: extensionv1.CapabilityObservability, Action: "telemetry.read", ConfigFlag: "telemetry_enabled"}} - installation.Bindings = []PluginBinding{ - {Capability: extensionv1.CapabilityAdmin, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth, Enabled: true, RolloutPercent: 100}, - {Capability: extensionv1.CapabilityObservability, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth, Enabled: true, RolloutPercent: 100}, - } - // This repository explicitly implements every port method. The positive - // control must reach the serialized RPC before any rejection is asserted. - repo := &hostIOAdmissionRepository{t: t, current: cloneHostIOInstallation(installation)} - manager.repo = repo - manager.accountDirectory = &resourceAccountDirectory{accounts: map[int64]extensionv1.Account{2: {ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth}}} - runtime := registry.runtimes[1] - runtime.installation = cloneHostIOInstallation(installation) - enabled := json.RawMessage(`{"telemetry_enabled":true}`) - disabled := json.RawMessage(`{"telemetry_enabled":false}`) - runtime.configSnapshot.Store(&enabled) - - result, err := manager.InvokeAdminExtension(context.Background(), 1, 2, "telemetry.read", json.RawMessage(`{}`)) - require.NoError(t, err) - require.JSONEq(t, `{"invoked":true}`, string(result.Payload)) - require.Equal(t, 1, calls, "the declared operation and repository must work before testing stale configuration") - calls = 0 - - repo.current = cloneHostIOInstallation(installation) - repo.current.Revision++ - repo.current.ConfigEncrypted = "fixture-telemetry-disabled" - result, err = manager.InvokeAdminExtension(context.Background(), 1, 2, "telemetry.read", json.RawMessage(`{}`)) - require.ErrorIs(t, err, ErrExtensionOperationUnavailable, "fresh disabled configuration cannot be authorized by the old true snapshot") - require.Empty(t, result.Payload) - require.Zero(t, calls) - - // Once the new configuration has actually been applied, its false flag - // remains decisive; an applied-snapshot check must not bypass flag policy. - runtime.installation = cloneHostIOInstallation(repo.current) - runtime.configSnapshot.Store(&disabled) - result, err = manager.InvokeAdminExtension(context.Background(), 1, 2, "telemetry.read", json.RawMessage(`{}`)) - require.Error(t, err) - require.Empty(t, result.Payload) - require.Zero(t, calls) - - repo.current = cloneHostIOInstallation(installation) - runtime.installation = cloneHostIOInstallation(installation) - runtime.configSnapshot.Store(&enabled) - runtime.configuring.Store(true) - result, err = manager.InvokeAdminExtension(context.Background(), 1, 2, "telemetry.read", json.RawMessage(`{}`)) - require.ErrorIs(t, err, ErrExtensionOperationUnavailable) - require.Empty(t, result.Payload) - require.Zero(t, calls) - runtime.configuring.Store(false) - _, err = manager.InvokeAdminExtension(context.Background(), 1, 2, "telemetry.read", json.RawMessage(`{}`)) - require.NoError(t, err, "matching enabled configuration should remain callable") - require.Equal(t, 1, calls) -} - -func TestAccountTestOptionsKeepActualAccountScopeAndNativeDefaults(t *testing.T) { - var calls []extensionv1.Invocation - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{}, func(in extensionv1.Invocation) (extensionv1.Result, error) { - calls = append(calls, in) - return extensionv1.Result{Payload: json.RawMessage(`{}`)}, nil - }) - installation := manager.extensions.Load().installations[1] - installation.Manifest.Operations = map[string][]string{extensionv1.CapabilityAdmin: {"test.prompt", "test.reasoning"}} - installation.Bindings = []PluginBinding{{Capability: extensionv1.CapabilityAdmin, Platform: PlatformOpenAI, AccountType: AccountTypeAPIKey, Enabled: true, RolloutPercent: 50}} - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - processExtensionOperations.Store(&extensionOperationProvider{invoker: manager}) - for _, id := range []int64{2, 3} { - account := &Account{ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} - promptErr := validateAccountPromptExtension(context.Background(), account, "custom", "gpt-5.4", "default") - reasoningErr := ValidateAccountTestReasoningContext(context.Background(), account, "gpt-5.4", "default", "high") - require.Equal(t, id == 3, promptErr != nil) - require.Equal(t, id == 3, reasoningErr != nil) - require.NoError(t, validateAccountPromptExtension(context.Background(), account, "", "gpt-5.4", "default")) - require.NoError(t, ValidateAccountTestReasoningContext(context.Background(), account, "gpt-5.4", "default", "")) - } - require.Len(t, calls, 2) - for _, call := range calls { - require.EqualValues(t, 2, call.AccountID) - } -} diff --git a/backend/internal/service/plugin_credential_scope_test.go b/backend/internal/service/plugin_credential_scope_test.go deleted file mode 100644 index c2be368f5c7d..000000000000 --- a/backend/internal/service/plugin_credential_scope_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "testing" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/stretchr/testify/require" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -type credentialScopeDirectory struct{ calls int } - -func (d *credentialScopeDirectory) ReadExtensionAccount(context.Context, int64) (*extensionv1.Account, error) { - return &extensionv1.Account{ID: 7, Platform: PlatformOpenAI, Type: AccountTypeOAuth}, nil -} -func (d *credentialScopeDirectory) ListExtensionAccounts(context.Context, extensionv1.AccountQuery) ([]extensionv1.Account, error) { - return nil, nil -} -func (d *credentialScopeDirectory) ResolveExtensionIdentity(context.Context, extensionv1.AccountQuery) (*extensionv1.OutboundIdentity, error) { - d.calls++ - return &extensionv1.OutboundIdentity{AccountID: 7, Token: "synthetic-only"}, nil -} - -func TestRequestPolicyCapabilityDoesNotImplyCredentialAccess(t *testing.T) { - directory := &credentialScopeDirectory{} - installation := &PluginInstallation{Manifest: PluginManifest{Capabilities: []PluginCapability{{ID: extensionv1.CapabilityRequest, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth}}}} - host := &pluginExtensionHost{state: &extensionReadOnlyFixture{}, directory: directory, installation: installation} - raw, _ := json.Marshal(extensionv1.AccountQuery{AccountID: 7}) - _, err := host.Call(context.Background(), extensionv1.HostInvocation{Operation: extensionv1.HostAccountRead, Payload: raw}) - require.NoError(t, err) - _, err = host.Call(context.Background(), extensionv1.HostInvocation{Operation: extensionv1.HostResolveIdentity, Payload: raw}) - require.Equal(t, codes.PermissionDenied, status.Code(err)) - require.Zero(t, directory.calls) - installation.Manifest.Capabilities = append(installation.Manifest.Capabilities, PluginCapability{ID: extensionv1.CapabilityCredentials, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth}) - result, err := host.Call(context.Background(), extensionv1.HostInvocation{Operation: extensionv1.HostResolveIdentity, Payload: raw}) - require.NoError(t, err) - require.Equal(t, 1, directory.calls) - require.Contains(t, string(result.Payload), "synthetic-only") - host.allows = func(capability, _, _ string, _ int64) bool { return capability != extensionv1.CapabilityCredentials } - _, err = host.Call(context.Background(), extensionv1.HostInvocation{Operation: extensionv1.HostResolveIdentity, Payload: raw}) - require.Equal(t, codes.PermissionDenied, status.Code(err), "a declaration cannot override a revoked live capability") - require.Equal(t, 1, directory.calls) -} diff --git a/backend/internal/service/plugin_domain_operation.go b/backend/internal/service/plugin_domain_operation.go deleted file mode 100644 index 4c18f62e46cb..000000000000 --- a/backend/internal/service/plugin_domain_operation.go +++ /dev/null @@ -1,105 +0,0 @@ -package service - -import ( - "context" - "slices" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" -) - -// Domain operations manage shared policy material and carry no account input. -// Account request hooks continue to resolve against the actual target scope. -type domainOperationInvoker interface { - InvokeDomainOperation(context.Context, extensionv1.Invocation, bool) (extensionv1.Result, error) - BindDomainOperationContext(context.Context, extensionv1.Invocation) (context.Context, context.CancelFunc, error) -} - -func invokeProcessDomainExtension(ctx context.Context, in extensionv1.Invocation, cached bool) (extensionv1.Result, error) { - provider := processExtensionOperations.Load() - if provider == nil { - return extensionv1.Result{}, ErrExtensionOperationDisabled - } - if invoker, ok := provider.invoker.(domainOperationInvoker); ok { - return invoker.InvokeDomainOperation(ctx, in, cached) - } - // In-process contract fixtures have no installation registry. - return provider.invoker.InvokeOperation(ctx, "*", "*", in) -} - -func bindProcessDomainExtensionContext(ctx context.Context, in extensionv1.Invocation) (context.Context, context.CancelFunc, error) { - provider := processExtensionOperations.Load() - if provider == nil { - return nil, nil, ErrExtensionOperationDisabled - } - if invoker, ok := provider.invoker.(domainOperationInvoker); ok { - return invoker.BindDomainOperationContext(ctx, in) - } - if binder, ok := provider.invoker.(extensionOperationContextBinder); ok { - return binder.BindOperationContext(ctx, "*", "*", in) - } - bound, cancel := context.WithCancel(ctx) - return bound, cancel, nil -} - -func (m *PluginManager) domainOperationScope(ctx context.Context, in extensionv1.Invocation) (string, string, error) { - registry := m.extensions.Load() - if registry == nil || registry.unavailable != "" { - return "", "", ErrExtensionOperationUnavailable - } - var owner int64 - var contextualOwner int64 - if execution, ok := PluginExecutionFromContext(ctx); ok { - if candidate := registry.installations[execution.ID]; candidate != nil && slices.Contains(candidate.Manifest.Operations[in.Capability], in.Operation) { - if candidate.RuntimeGeneration != execution.Generation { - return "", "", ErrExtensionOperationUnavailable - } - contextualOwner = execution.ID - } - } - var platform, accountType string - for id, installation := range registry.installations { - if contextualOwner != 0 && contextualOwner != id { - continue - } - if !slices.Contains(installation.Manifest.Operations[in.Capability], in.Operation) { - continue - } - for _, binding := range installation.Bindings { - if !binding.Enabled || binding.Capability != in.Capability { - continue - } - if owner != 0 && owner != id { - // Different account scopes may have different request providers; - // their shared policy material has no unambiguous global owner. - return "", "", ErrExtensionOperationUnavailable - } - if owner == 0 || binding.Platform+"\x00"+binding.AccountType < platform+"\x00"+accountType { - platform, accountType = binding.Platform, binding.AccountType - } - owner = id - } - } - if owner == 0 { - return "", "", ErrExtensionOperationDisabled - } - return platform, accountType, nil -} - -func (m *PluginManager) InvokeDomainOperation(ctx context.Context, in extensionv1.Invocation, cached bool) (extensionv1.Result, error) { - platform, accountType, err := m.domainOperationScope(ctx, in) - if err != nil { - return extensionv1.Result{}, err - } - if cached { - return m.InvokeCachedOperation(ctx, platform, accountType, in) - } - return m.InvokeOperation(ctx, platform, accountType, in) -} - -func (m *PluginManager) BindDomainOperationContext(ctx context.Context, in extensionv1.Invocation) (context.Context, context.CancelFunc, error) { - platform, accountType, err := m.domainOperationScope(ctx, in) - if err != nil { - return nil, nil, err - } - return m.BindOperationContext(ctx, platform, accountType, in) -} diff --git a/backend/internal/service/plugin_domain_operation_test.go b/backend/internal/service/plugin_domain_operation_test.go deleted file mode 100644 index c5e0771a7aa4..000000000000 --- a/backend/internal/service/plugin_domain_operation_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package service - -import ( - "context" - "testing" - - "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/stretchr/testify/require" -) - -func scopedPromptManager(t *testing.T) *PluginManager { - t.Helper() - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{}, func(in extensionv1.Invocation) (extensionv1.Result, error) { - switch in.Operation { - case "prompt.availability": - return extensionv1.Result{Payload: []byte(`{"ready":true}`)}, nil - case "prompt.template": - return extensionv1.Result{Payload: []byte(`{"name":"scoped"}`)}, nil - default: - t.Fatalf("unexpected domain operation: %s", in.Operation) - return extensionv1.Result{}, nil - } - }) - installation := manager.extensions.Load().installations[1] - installation.Bindings = []PluginBinding{{Capability: extensionv1.CapabilityRequest, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth, Enabled: true}} - installation.Manifest.Operations = map[string][]string{extensionv1.CapabilityRequest: {"prompt.availability", "prompt.template", "prompt.source.fetch", "prompt.plan"}} - return manager -} - -func TestPromptDomainUsesActiveScopeWithoutBroadeningRequestBindings(t *testing.T) { - manager := scopedPromptManager(t) - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - processExtensionOperations.Store(&extensionOperationProvider{invoker: manager}) - require.NoError(t, promptPolicyAvailability(context.Background())) - prompts := &BusinessSystemPromptService{} - prompts.snapshot.Store(&BusinessSystemPromptSnapshot{Enabled: true, Body: "scoped-server"}) - snapshot, ok := prompts.CurrentSnapshot() - require.True(t, ok) - require.Equal(t, "scoped-server", snapshot.Body) - name := "scoped" - plan, err := planPromptTemplate(context.Background(), extensionv1.PromptTemplatePolicyRequest{Name: &name}) - require.NoError(t, err) - require.Equal(t, name, *plan.Name) - body := []byte(`{"input":"client"}`) - out, applied, err := ApplyBusinessSystemPromptToJSONContext(context.Background(), body, snapshot, - BusinessSystemPromptTarget{Platform: PlatformOpenAI, AccountType: AccountTypeAPIKey, Protocol: BusinessSystemPromptProtocolResponses}) - require.NoError(t, err) - require.Equal(t, body, out) - require.False(t, applied.Applied, "global metadata must not broaden account request scope") -} - -func TestPromptDomainAvailabilityDistinguishesDisabledFailureAndAmbiguousOwners(t *testing.T) { - manager := scopedPromptManager(t) - query := extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "prompt.availability", Payload: []byte(`{}`)} - registry := manager.extensions.Load() - registry.installations[1].Bindings[0].Enabled = false - _, err := manager.InvokeDomainOperation(context.Background(), query, true) - require.ErrorIs(t, err, ErrExtensionOperationDisabled) - registry.installations[1].Bindings[0].Enabled = true - registry.runtimes[1].draining.Store(true) - _, err = manager.InvokeDomainOperation(context.Background(), query, true) - require.ErrorIs(t, err, ErrExtensionOperationUnavailable) - registry.runtimes[1].draining.Store(false) - other := *registry.installations[1] - other.ID = 2 - other.Bindings = []PluginBinding{{Capability: extensionv1.CapabilityRequest, Platform: PlatformOpenAI, AccountType: AccountTypeAPIKey, Enabled: true}} - registry.installations[2] = &other - _, err = manager.InvokeDomainOperation(context.Background(), query, true) - require.ErrorIs(t, err, ErrExtensionOperationUnavailable) -} - -func TestPromptDomainSourceContextStillCancelsWhenScopedPluginStops(t *testing.T) { - manager := scopedPromptManager(t) - ctx, cancel, err := manager.BindDomainOperationContext(context.Background(), extensionv1.Invocation{ - Capability: extensionv1.CapabilityRequest, Operation: "prompt.source.fetch", - }) - require.NoError(t, err) - defer cancel() - manager.extensions.Load().runtimes[1].beginDrain() - require.ErrorIs(t, ctx.Err(), context.Canceled) -} - -func TestResourceContextSelectsItsDeclaredDomainOwner(t *testing.T) { - manager := scopedPromptManager(t) - registry := manager.extensions.Load() - first := registry.installations[1] - other := *first - other.ID = 2 - other.Bindings = []PluginBinding{{Capability: extensionv1.CapabilityRequest, Platform: PlatformOpenAI, AccountType: AccountTypeAPIKey, Enabled: true}} - registry.installations[2] = &other - input := extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "prompt.template"} - _, _, err := manager.domainOperationScope(context.Background(), input) - require.ErrorIs(t, err, ErrExtensionOperationUnavailable) - platform, kind, err := manager.domainOperationScope(WithPluginExecution(context.Background(), first), input) - require.NoError(t, err) - require.Equal(t, PlatformOpenAI, platform) - require.Equal(t, AccountTypeOAuth, kind) -} diff --git a/backend/internal/service/plugin_error_diagnostics_test.go b/backend/internal/service/plugin_error_diagnostics_test.go deleted file mode 100644 index 1cc832df87ef..000000000000 --- a/backend/internal/service/plugin_error_diagnostics_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "testing" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/stretchr/testify/require" -) - -func TestPluginErrorDiagnosticsUseStoredAccountsAndRemovePrivateFields(t *testing.T) { - installation := &PluginInstallation{ID: 7, RuntimeGeneration: 2, State: PluginStateEnabled, Bindings: []PluginBinding{{Capability: extensionv1.CapabilityRecovery, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth, Enabled: true, RolloutPercent: 100}}} - manager := NewPluginManager(&pluginTokenRepository{installation: installation}, pluginTokenEncryptor{}, nil, PluginHostInfo{}, nil) - manager.accountDirectory = &resourceAccountDirectory{accounts: map[int64]extensionv1.Account{ - 1: {ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth}, 2: {ID: 2, Platform: PlatformCindy, Type: AccountTypeAPIKey}, - }} - ctx := WithPluginExecution(context.Background(), installation) - detail := &OpsErrorLogDetail{UpstreamErrors: `[ - {"account_id":1,"account_name":"private-name","upstream_response_body":"private-body","continuation_diagnostic":{"classification":"thinking_signature_invalid","upstream_error":{"error_code":{"kind":"string","value":"thinking_signature_invalid"},"message":{"kind":"string","value":"private-message"},"hints":["private-hint","call_id"]},"incoming":{"instructions":{"kind":"string","value":"private-instructions"}}}}, - {"account_id":2,"platform":"openai","continuation_diagnostic":{"classification":"invalid_encrypted_content"}}, - {"account_id":0,"continuation_diagnostic":{"classification":"invalid_encrypted_content"}} - ]`} - projected := manager.ProjectErrorDiagnostics(ctx, detail) - require.Len(t, projected, 1) - require.EqualValues(t, 1, projected[0].AccountID) - require.Equal(t, "thinking_signature_invalid", projected[0].Diagnostic.UpstreamError.ErrorCode.Value) - require.Equal(t, []string{"call_id"}, projected[0].Diagnostic.UpstreamError.Hints) - raw, err := json.Marshal(projected) - require.NoError(t, err) - require.NotContains(t, string(raw), "private-") - require.Empty(t, manager.ProjectErrorDiagnostics(context.Background(), detail), "no execution context grants no account data") - installation.Bindings[0].RolloutPercent = int(stablePluginBucket(1)) - require.Empty(t, manager.ProjectErrorDiagnostics(ctx, detail), "the actual account must also pass rollout") - installation.Bindings[0].RolloutPercent = 100 - installation.RuntimeGeneration++ - require.Empty(t, manager.ProjectErrorDiagnostics(ctx, detail), "a replaced UI generation cannot read account facts") -} diff --git a/backend/internal/service/plugin_extension_package_test.go b/backend/internal/service/plugin_extension_package_test.go deleted file mode 100644 index a4f33517644d..000000000000 --- a/backend/internal/service/plugin_extension_package_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package service - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestFirstPartyExtensionSignedPackageContainsIndependentRuntimeAndUI(t *testing.T) { - path := os.Getenv("SUB2API_EXTENSION_TEST_PACKAGE") - public := os.Getenv("SUB2API_EXTENSION_TEST_PUBLIC_KEY") - keyID := os.Getenv("SUB2API_EXTENSION_TEST_KEY_ID") - if path == "" || public == "" { - t.Skip("provide the locally signed first-party extension package and public test key") - } - if keyID == "" { - keyID = "codexrip-plugins-test" - } - file, err := os.Open(path) - require.NoError(t, err) - defer func() { _ = file.Close() }() - cfg := testPluginConfig(t.TempDir(), false) - cfg.Plugins.TrustedPublishers = map[string]string{keyID: public} - installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.2.7-codexrip.2", BuildType: "release"}) - installed, err := installer.Install(context.Background(), file, nil) - require.NoError(t, err) - require.Equal(t, "codexrip.codex-runtime", installed.PluginKey) - require.Equal(t, PluginSignatureTrusted, installed.SignatureStatus) - require.Equal(t, 1, installed.Manifest.Requires.ExtensionAPI) - require.FileExists(t, installed.BinaryPath) - for _, name := range []string{"index.html", "assets/app.js", "assets/styles.css", "assets/bridge.js"} { - require.FileExists(t, filepath.Join(installed.InstallPath, "ui", name)) - } - for name := range installed.Manifest.Files { - require.NotContains(t, name, "publisher.key") - require.NotContains(t, name, "internal/service") - } -} - -func TestFirstPartyBundleContainsMatchingSignedDomainPackages(t *testing.T) { - path := os.Getenv("SUB2API_EXTENSION_TEST_BUNDLE") - if path == "" { - t.Skip("provide the freshly built first-party bundle lock") - } - raw, err := os.ReadFile(path) - require.NoError(t, err) - var bundle PluginBundle - require.NoError(t, json.Unmarshal(raw, &bundle)) - sourceRaw, err := os.ReadFile(filepath.Join("..", "..", "..", "plugins", "bundle.source.json")) - require.NoError(t, err) - var source struct { - Plugins []struct { - Directory string `json:"directory"` - } `json:"plugins"` - } - require.NoError(t, json.Unmarshal(sourceRaw, &source)) - require.Len(t, bundle.Plugins, len(source.Plugins)) - expectedFiles := map[string]bool{} - for _, entry := range source.Plugins { - expectedFiles[entry.Directory+".s2plugin"] = true - } - cfg := testPluginConfig(t.TempDir(), false) - cfg.Plugins.TrustedPublishers = map[string]string{bundle.PublisherKeyID: bundle.PublisherPublicKey} - installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: bundle.HostVersion, BuildType: "release"}) - for _, entry := range bundle.Plugins { - require.True(t, expectedFiles[entry.File], "bundle contains an undeclared or duplicate domain") - delete(expectedFiles, entry.File) - packagePath := filepath.Join(filepath.Dir(path), entry.File) - content, err := os.ReadFile(packagePath) - require.NoError(t, err) - sum := sha256.Sum256(content) - require.Equal(t, entry.SHA256, hex.EncodeToString(sum[:])) - file, err := os.Open(packagePath) - require.NoError(t, err) - installed, err := installer.Install(context.Background(), file, nil) - _ = file.Close() - require.NoError(t, err) - require.Equal(t, entry.ID, installed.PluginKey) - require.Equal(t, PluginSignatureTrusted, installed.SignatureStatus) - for _, asset := range []string{"index.html", "assets/app.js", "assets/bridge.js", "assets/styles.css"} { - require.FileExists(t, filepath.Join(installed.InstallPath, "ui", asset)) - } - for name := range installed.Manifest.Files { - require.NotContains(t, name, "publisher.key") - require.NotContains(t, name, "internal/service") - } - } -} diff --git a/backend/internal/service/plugin_extension_runtime_test.go b/backend/internal/service/plugin_extension_runtime_test.go deleted file mode 100644 index c61110d57cbd..000000000000 --- a/backend/internal/service/plugin_extension_runtime_test.go +++ /dev/null @@ -1,271 +0,0 @@ -package service - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "os" - "path/filepath" - "testing" - "time" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/stretchr/testify/require" -) - -type extensionReadOnlyFixture struct{ PluginExtensionStateStore } - -func (*extensionReadOnlyFixture) ReadExtensionState(context.Context, string, extensionv1.StateRequest) (extensionv1.StateResult, error) { - return extensionv1.StateResult{}, nil -} -func (*extensionReadOnlyFixture) DueExtensionStates(context.Context, string, extensionv1.DueStateRequest) ([]extensionv1.DueState, error) { - return nil, nil -} - -func firstPartyTestVersion(t *testing.T, domain string) string { - t.Helper() - raw, err := os.ReadFile(filepath.Join("..", "..", "..", "plugins", domain, "manifest.source.json")) - require.NoError(t, err) - var manifest PluginManifest - require.NoError(t, json.Unmarshal(raw, &manifest)) - require.NotEmpty(t, manifest.Version) - return manifest.Version -} - -func TestExtensionRuntimeUsesOfficialProcessAndHostBroker(t *testing.T) { - binary := os.Getenv("SUB2API_EXTENSION_TEST_BINARY") - if binary == "" { - t.Skip("set SUB2API_EXTENSION_TEST_BINARY to the locally built codex runtime") - } - data, err := os.ReadFile(binary) - require.NoError(t, err) - digest := sha256.Sum256(data) - installation := &PluginInstallation{ID: 7, PluginKey: "codexrip.codex-runtime", Version: firstPartyTestVersion(t, "codex-runtime"), BinaryPath: binary, BinarySHA256: hex.EncodeToString(digest[:]), Manifest: PluginManifest{Requires: PluginRequirements{ExtensionAPI: 1}}} - installation.Manifest.Capabilities = []PluginCapability{{ID: extensionv1.CapabilityScheduling, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth}} - host := newPluginHostServiceServer(installation.PluginKey, nil, nil, PluginAccountScope{}) - host.extension = &pluginExtensionHost{key: installation.PluginKey, state: &extensionReadOnlyFixture{}} - socketDir := filepath.Join(t.TempDir(), "runtime") - require.NoError(t, os.MkdirAll(socketDir, 0700)) - runtime, err := startPluginRuntime(context.Background(), installation, 15*time.Second, socketDir, host) - require.NoError(t, err) - t.Cleanup(runtime.kill) - require.NoError(t, runtime.validateAndApplyConfig(context.Background(), []byte(`{"enabled":true,"fail_closed":true}`))) - require.NotNil(t, runtime.scheduling.Load()) - require.Len(t, *runtime.scheduling.Load(), 1) - health, err := runtime.status(context.Background()) - require.NoError(t, err) - require.True(t, health.Healthy) - raw, err := json.Marshal(extensionv1.SchedulingRequest{Account: extensionv1.Account{ID: 9, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Identity: "test-principal"}, Model: "gpt-6-astra", Now: time.Now().UTC()}) - require.NoError(t, err) - result, err := runtime.extension.Invoke(context.Background(), extensionv1.Invocation{Capability: extensionv1.CapabilityScheduling, Operation: "admit", Payload: raw}) - require.NoError(t, err) - var decision extensionv1.SchedulingDecision - require.NoError(t, json.Unmarshal(result.Payload, &decision)) - require.False(t, decision.Allowed) - require.Equal(t, "ticket_missing", decision.Reason) - // Only a read-only missing-state fixture is exposed. No token resolver or - // HTTP upstream exists in this test, so it cannot issue a real model request. -} - -func TestCatalogExtensionRuntimeUsesIndependentProcess(t *testing.T) { - binary := os.Getenv("SUB2API_MODEL_POLICY_TEST_BINARY") - if binary == "" { - t.Skip("set SUB2API_MODEL_POLICY_TEST_BINARY to the independent model-policy program") - } - data, err := os.ReadFile(binary) - require.NoError(t, err) - digest := sha256.Sum256(data) - installation := &PluginInstallation{ID: 8, PluginKey: "codexrip.model-policy", Version: firstPartyTestVersion(t, "model-policy"), BinaryPath: binary, BinarySHA256: hex.EncodeToString(digest[:]), Manifest: PluginManifest{Requires: PluginRequirements{ExtensionAPI: 1}, Capabilities: []PluginCapability{{ID: extensionv1.CapabilityCatalog, Platform: "*", AccountType: "*"}}}} - host := newPluginHostServiceServer(installation.PluginKey, nil, nil, PluginAccountScope{}) - host.extension = &pluginExtensionHost{key: installation.PluginKey, state: &extensionReadOnlyFixture{}} - socketDir := filepath.Join(t.TempDir(), "runtime") - require.NoError(t, os.MkdirAll(socketDir, 0700)) - runtime, err := startPluginRuntime(context.Background(), installation, 15*time.Second, socketDir, host) - require.NoError(t, err) - t.Cleanup(runtime.kill) - require.NoError(t, runtime.validateAndApplyConfig(context.Background(), []byte(`{}`))) - raw, _ := json.Marshal(extensionv1.CatalogQuery{Candidates: []string{"gpt-6-astra"}, Platform: PlatformOpenAI, AccountType: AccountTypeAPIKey, Scheme: "https", Host: "api.openai.com"}) - result, err := runtime.extension.Invoke(context.Background(), extensionv1.Invocation{Capability: extensionv1.CapabilityCatalog, Operation: "resolve", Payload: raw}) - require.NoError(t, err) - var match extensionv1.CatalogMatch - require.NoError(t, json.Unmarshal(result.Payload, &match)) - require.NotNil(t, match.Entry) - require.EqualValues(t, 1050000, match.Entry.ContextWindow) - require.Equal(t, "gpt-6-astra", match.Entry.ModelID) -} - -func TestPromptExtensionRuntimeUsesIndependentProcess(t *testing.T) { - binary := os.Getenv("SUB2API_PROMPT_SKILLS_TEST_BINARY") - if binary == "" { - t.Skip("set SUB2API_PROMPT_SKILLS_TEST_BINARY to the independent prompt policy program") - } - data, err := os.ReadFile(binary) - require.NoError(t, err) - digest := sha256.Sum256(data) - installation := &PluginInstallation{ID: 9, PluginKey: "codexrip.prompt-skills", Version: firstPartyTestVersion(t, "prompt-skills"), BinaryPath: binary, BinarySHA256: hex.EncodeToString(digest[:]), Manifest: PluginManifest{Requires: PluginRequirements{ExtensionAPI: 1}, Capabilities: []PluginCapability{{ID: extensionv1.CapabilityRequest, Platform: PlatformOpenAI, AccountType: "*"}}}} - host := newPluginHostServiceServer(installation.PluginKey, nil, nil, PluginAccountScope{}) - host.extension = &pluginExtensionHost{key: installation.PluginKey, state: &extensionReadOnlyFixture{}} - socketDir := filepath.Join(t.TempDir(), "runtime") - require.NoError(t, os.MkdirAll(socketDir, 0700)) - runtime, err := startPluginRuntime(context.Background(), installation, 15*time.Second, socketDir, host) - require.NoError(t, err) - t.Cleanup(runtime.kill) - require.NoError(t, runtime.validateAndApplyConfig(context.Background(), []byte(`{}`))) - raw, _ := json.Marshal(extensionv1.PromptPlanRequest{Snapshot: BusinessSystemPromptSnapshot{Enabled: true, Body: "fixture-server"}, Target: BusinessSystemPromptTarget{Platform: PlatformOpenAI, AccountType: AccountTypeOAuth, Protocol: BusinessSystemPromptProtocolResponses}}) - out, err := runtime.extension.Invoke(context.Background(), extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "prompt.plan", Payload: raw}) - require.NoError(t, err) - require.Empty(t, out.Code) - var application BusinessSystemPromptApplication - require.NoError(t, json.Unmarshal(out.Payload, &application)) - require.True(t, application.Applied) - require.Equal(t, "fixture-server", application.ServerInstructions) - index, err := runtime.extension.Invoke(context.Background(), extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "skills.seed.index", Payload: []byte(`{}`)}) - require.NoError(t, err) - var manifest extensionv1.SkillManifest - require.NoError(t, json.Unmarshal(index.Payload, &manifest)) - total := 0 - for _, entry := range manifest.Files { - digest := sha256.New() - for offset := 0; offset < entry.ByteLength; { - query, _ := json.Marshal(extensionv1.SkillSeedChunkRequest{Path: entry.Path, Offset: offset, Limit: 256 << 10}) - result, err := runtime.extension.Invoke(context.Background(), extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "skills.seed.chunk", Payload: query}) - require.NoError(t, err) - require.Empty(t, result.Code) - require.Less(t, len(result.Payload), extensionv1.MaxPayloadBytes) - var chunk extensionv1.SkillSeedChunk - require.NoError(t, json.Unmarshal(result.Payload, &chunk)) - require.Equal(t, entry.ByteLength, chunk.TotalBytes) - require.NotEmpty(t, chunk.Data) - _, _ = digest.Write(chunk.Data) - offset += len(chunk.Data) - total += len(chunk.Data) - } - require.Equal(t, entry.SHA256, hex.EncodeToString(digest.Sum(nil))) - } - require.Greater(t, total, extensionv1.MaxPayloadBytes, "a complete seed larger than the RPC ceiling must remain loadable") -} - -func TestObservabilityExtensionRuntimeUsesScopedMetricsBroker(t *testing.T) { - binary := os.Getenv("SUB2API_ADMIN_OBSERVABILITY_TEST_BINARY") - if binary == "" { - t.Skip("set SUB2API_ADMIN_OBSERVABILITY_TEST_BINARY to the independent observability program") - } - data, err := os.ReadFile(binary) - require.NoError(t, err) - digest := sha256.Sum256(data) - installation := &PluginInstallation{ID: 13, PluginKey: "codexrip.admin-observability", Version: firstPartyTestVersion(t, "admin-observability"), BinaryPath: binary, BinarySHA256: hex.EncodeToString(digest[:]), Manifest: PluginManifest{Requires: PluginRequirements{ExtensionAPI: 1}, Capabilities: []PluginCapability{{ID: extensionv1.CapabilityObservability, Platform: "*", AccountType: "*"}, {ID: extensionv1.CapabilityAdmin, Platform: "*", AccountType: "*"}, {ID: extensionv1.CapabilityUI, Platform: "*", AccountType: "*"}}}} - directory := &credentialScopeDirectory{} - host := newPluginHostServiceServer(installation.PluginKey, nil, nil, PluginAccountScope{}) - host.extension = &pluginExtensionHost{key: installation.PluginKey, installation: installation, directory: directory, traffic: &trafficPolicyCache{}} - socketDir := filepath.Join(t.TempDir(), "runtime") - require.NoError(t, os.MkdirAll(socketDir, 0700)) - runtime, err := startPluginRuntime(context.Background(), installation, 15*time.Second, socketDir, host) - require.NoError(t, err) - t.Cleanup(runtime.kill) - require.NoError(t, runtime.validateAndApplyConfig(context.Background(), []byte(`{}`))) - out, err := runtime.extension.Invoke(context.Background(), extensionv1.Invocation{Capability: extensionv1.CapabilityAdmin, Operation: "telemetry.read", Payload: []byte(`{"account_id":7}`)}) - require.NoError(t, err) - require.Empty(t, out.Code) - var rows []extensionv1.AccountTrafficDisplay - require.NoError(t, json.Unmarshal(out.Payload, &rows)) - require.Len(t, rows, 1) - require.Equal(t, "http", rows[0].Protocol) - require.EqualValues(t, 3, rows[0].Finished) - require.InDelta(t, 2.0/3, *rows[0].CompletionRate, 1e-12) - require.Zero(t, directory.calls, "metrics access must not resolve credentials") -} - -func TestImageToolsExtensionRuntimeUsesIndependentProcess(t *testing.T) { - binary := os.Getenv("SUB2API_IMAGE_TOOLS_TEST_BINARY") - if binary == "" { - t.Skip("set SUB2API_IMAGE_TOOLS_TEST_BINARY to the independent image tools program") - } - data, err := os.ReadFile(binary) - require.NoError(t, err) - digest := sha256.Sum256(data) - installation := &PluginInstallation{ID: 12, PluginKey: "codexrip.image-tools", Version: firstPartyTestVersion(t, "image-tools"), BinaryPath: binary, BinarySHA256: hex.EncodeToString(digest[:]), Manifest: PluginManifest{Requires: PluginRequirements{ExtensionAPI: 1}, Capabilities: []PluginCapability{{ID: extensionv1.CapabilityRequest, Platform: "*", AccountType: "*"}}}} - host := newPluginHostServiceServer(installation.PluginKey, nil, nil, PluginAccountScope{}) - host.extension = &pluginExtensionHost{key: installation.PluginKey, state: &extensionReadOnlyFixture{}} - socketDir := filepath.Join(t.TempDir(), "runtime") - require.NoError(t, os.MkdirAll(socketDir, 0700)) - runtime, err := startPluginRuntime(context.Background(), installation, 15*time.Second, socketDir, host) - require.NoError(t, err) - t.Cleanup(runtime.kill) - require.NoError(t, runtime.validateAndApplyConfig(context.Background(), []byte(`{}`))) - raw, _ := json.Marshal(extensionv1.ImageStudioPlanRequest{APIKeySelected: true, Mode: "generate", Model: "gpt-image-2", PromptBytes: 5, Count: 4}) - invocation := extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "image.studio.plan", Payload: raw} - out, err := runtime.extension.Invoke(context.Background(), invocation) - require.NoError(t, err) - require.Equal(t, "studio_disabled", out.Code) - require.NoError(t, runtime.validateAndApplyConfig(context.Background(), []byte(`{"studio_enabled":true}`))) - out, err = runtime.extension.Invoke(context.Background(), invocation) - require.NoError(t, err) - require.Empty(t, out.Code) - var plan extensionv1.ImageStudioPlan - require.NoError(t, json.Unmarshal(out.Payload, &plan)) - require.Equal(t, 1, plan.OutputPerRequest) - require.Equal(t, "/v1/images/generations", plan.Endpoint) - // Exercise the new operation through the actual process transport. Only - // synthetic provider metadata crosses the RPC, without any upstream client. - raw, _ = json.Marshal(extensionv1.ImageBridgeRequest{Stage: "validate", Model: "controller", Tools: []extensionv1.ImageBridgeTool{{Index: 0}}}) - out, err = runtime.extension.Invoke(context.Background(), extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "image.responses.plan", AccountID: 9, Payload: raw}) - require.NoError(t, err) - require.Equal(t, "image_model_not_found", out.Code) -} - -func TestCindyProviderExtensionRuntimeUsesIndependentProcess(t *testing.T) { - binary := os.Getenv("SUB2API_CINDY_PROVIDER_TEST_BINARY") - if binary == "" { - t.Skip("set SUB2API_CINDY_PROVIDER_TEST_BINARY to the independent Cindy provider program") - } - data, err := os.ReadFile(binary) - require.NoError(t, err) - digest := sha256.Sum256(data) - installation := &PluginInstallation{ID: 11, PluginKey: "codexrip.cindy-provider", Version: firstPartyTestVersion(t, "cindy-provider"), BinaryPath: binary, BinarySHA256: hex.EncodeToString(digest[:]), Manifest: PluginManifest{Requires: PluginRequirements{ExtensionAPI: 1}, Capabilities: []PluginCapability{{ID: extensionv1.CapabilityProvider, Platform: PlatformCindy, AccountType: AccountTypeAPIKey}}}} - host := newPluginHostServiceServer(installation.PluginKey, nil, nil, PluginAccountScope{}) - host.extension = &pluginExtensionHost{key: installation.PluginKey, state: &extensionReadOnlyFixture{}} - socketDir := filepath.Join(t.TempDir(), "runtime") - require.NoError(t, os.MkdirAll(socketDir, 0700)) - runtime, err := startPluginRuntime(context.Background(), installation, 15*time.Second, socketDir, host) - require.NoError(t, err) - t.Cleanup(runtime.kill) - require.NoError(t, runtime.validateAndApplyConfig(context.Background(), []byte(`{"catalog_enabled":true,"search_enabled":true}`))) - out, err := runtime.extension.Invoke(context.Background(), extensionv1.Invocation{Capability: extensionv1.CapabilityProvider, Operation: "cindy.pricing", Payload: []byte(`{}`)}) - require.NoError(t, err) - require.Empty(t, out.Code) - var snapshot extensionv1.CindyPricingSnapshot - require.NoError(t, json.Unmarshal(out.Payload, &snapshot)) - require.True(t, snapshot.Config.CatalogEnabled) - var pricing CindyTextPricing - var known bool - require.True(t, queryCindyPricingSnapshot(&snapshot, "CindyTextPricingForModel", "gpt-5.6-luna", []any{&pricing, &known})) - require.True(t, known) - require.Positive(t, pricing.InputCostPerToken) -} - -func TestAccountToolsExtensionRuntimeUsesIndependentProcess(t *testing.T) { - binary := os.Getenv("SUB2API_ACCOUNT_TOOLS_TEST_BINARY") - if binary == "" { - t.Skip("set SUB2API_ACCOUNT_TOOLS_TEST_BINARY to the independent account-tools program") - } - data, err := os.ReadFile(binary) - require.NoError(t, err) - digest := sha256.Sum256(data) - installation := &PluginInstallation{ID: 10, PluginKey: "codexrip.account-tools", Version: firstPartyTestVersion(t, "account-tools"), BinaryPath: binary, BinarySHA256: hex.EncodeToString(digest[:]), Manifest: PluginManifest{Requires: PluginRequirements{ExtensionAPI: 1}, Capabilities: []PluginCapability{{ID: extensionv1.CapabilityAdmin, Platform: "*", AccountType: "*"}}}} - host := newPluginHostServiceServer(installation.PluginKey, nil, nil, PluginAccountScope{}) - host.extension = &pluginExtensionHost{key: installation.PluginKey, state: &extensionReadOnlyFixture{}} - socketDir := filepath.Join(t.TempDir(), "runtime") - require.NoError(t, os.MkdirAll(socketDir, 0700)) - runtime, err := startPluginRuntime(context.Background(), installation, 15*time.Second, socketDir, host) - require.NoError(t, err) - t.Cleanup(runtime.kill) - require.NoError(t, runtime.validateAndApplyConfig(context.Background(), []byte(`{}`))) - out, err := runtime.extension.Invoke(context.Background(), extensionv1.Invocation{Capability: extensionv1.CapabilityAdmin, Operation: "taxonomy.name", Payload: []byte(`{"name":" Production "}`)}) - require.NoError(t, err) - require.Empty(t, out.Code) - var name extensionv1.TaxonomyName - require.NoError(t, json.Unmarshal(out.Payload, &name)) - require.Equal(t, "Production", name.Name) - require.Equal(t, "production", name.Normalized) -} diff --git a/backend/internal/service/plugin_extensions.go b/backend/internal/service/plugin_extensions.go deleted file mode 100644 index d8dce1741922..000000000000 --- a/backend/internal/service/plugin_extensions.go +++ /dev/null @@ -1,478 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "sort" - "strings" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" -) - -func (m *PluginManager) InvokeAdminExtension(ctx context.Context, id, accountID int64, operation string, payload json.RawMessage) (extensionv1.Result, error) { - // Browser actions retain a target-generation lease across validation and - // invocation. A package swap cannot certify an old iframe's same-name action. - if PluginExpectedPackage(ctx) != "" { - bound, release, err := m.BindAccountJobExecution(ctx, id, 0) - if err != nil { - return extensionv1.Result{}, err - } - defer release() - ctx = bound - } - platform, accountType, err := m.validateAdminExtension(ctx, id, accountID, operation) - if err != nil { - return extensionv1.Result{}, err - } - var body map[string]json.RawMessage - if json.Unmarshal(payload, &body) != nil || body == nil { - return extensionv1.Result{}, errors.New("object payload required") - } - if accountID > 0 { - body["account_id"], _ = json.Marshal(accountID) - } else { - delete(body, "account_id") - } - raw, err := json.Marshal(body) - if err != nil { - return extensionv1.Result{}, err - } - return m.InvokeExtension(ctx, id, platform, accountType, extensionv1.Invocation{Capability: extensionv1.CapabilityAdmin, Operation: operation, AccountID: accountID, Payload: raw}) -} - -func (m *PluginManager) ValidateAdminExtension(ctx context.Context, id, accountID int64, operation string) error { - _, _, err := m.validateAdminExtension(ctx, id, accountID, operation) - return err -} - -func (m *PluginManager) validateAdminExtension(ctx context.Context, id, accountID int64, operation string) (string, string, error) { - registry := m.extensions.Load() - if registry == nil || registry.unavailable != "" { - return "", "", errors.New("plugin registry unavailable") - } - installation := registry.installations[id] - if installation == nil || !hasEnabledPluginBinding(installation.Bindings) { - return "", "", errors.New("plugin disabled") - } - if m.repo == nil { - return "", "", errors.New("plugin storage unavailable") - } - current, readErr := m.repo.GetByID(ctx, id) - if readErr == nil && current != nil && PluginExpectedPackage(ctx) != "" && current.PackageSHA256 != PluginExpectedPackage(ctx) { - return "", "", ErrPluginStateChanged - } - if readErr != nil || !samePluginRuntime(current, installation) || current.State != PluginStateEnabled { - return "", "", errors.New("plugin state changed") - } - runtime := registry.runtimes[id] - configRevision, applied := m.contributionAppliedRevision(current, runtime) - if !applied || runtime.client == nil || runtime.draining.Load() || runtime.client.Exited() { - return "", "", ErrExtensionOperationUnavailable - } - installation = current - declared := false - var selected *extensionv1.Contribution - for _, contribution := range installation.Manifest.Contributions { - if contribution.Action == operation && contribution.Permission == "admin" { - if strings.HasPrefix(contribution.Slot, "account.") && accountID <= 0 { - return "", "", errors.New("account context required") - } - declared = true - selected = &contribution - break - } - } - if !declared { - return "", "", errors.New("undeclared plugin admin operation") - } - if !pluginContributionBindingsEnabled(installation, selected) { - return "", "", errors.New("plugin contribution capability is disabled") - } - if enabled, known := m.contributionConfigured(installation, runtime, selected.ConfigFlag); !known || !enabled { - return "", "", errors.New("plugin contribution is unavailable") - } - platform, accountType := "*", "*" - if accountID == 0 { - for _, binding := range installation.Bindings { - if binding.Enabled && binding.Capability == extensionv1.CapabilityAdmin { - platform, accountType = binding.Platform, binding.AccountType - break - } - } - } - if accountID > 0 { - directory, ok := m.accountDirectory.(PluginExtensionAccountDirectory) - if !ok { - return "", "", errors.New("account directory unavailable") - } - account, err := directory.ReadExtensionAccount(ctx, accountID) - if err != nil || account == nil || account.ID != accountID { - return "", "", errors.New("account unavailable") - } - platform, accountType = account.Platform, account.Type - if !contributionAccountAllowed(installation, selected, *account) { - return "", "", errors.New("plugin action is not available for this account") - } - } - if !pluginHasInvocationCapability(installation, extensionv1.Invocation{Capability: extensionv1.CapabilityAdmin, AccountID: accountID}, platform, accountType) { - return "", "", errors.New("plugin admin capability outside scope") - } - latestRevision, stillApplied := m.contributionAppliedRevision(installation, runtime) - if !stillApplied || latestRevision != configRevision || runtime.draining.Load() || runtime.client.Exited() { - return "", "", ErrExtensionOperationUnavailable - } - return platform, accountType, nil -} - -type pluginExtensionRegistry struct { - installations map[int64]*PluginInstallation - runtimes map[int64]*pluginRuntime - unavailable string -} - -// ValidatePluginRegistry is a pure internal-service invariant shared by -// preflight checks and the repository's transaction-local final admission. -// It neither reads runtime health nor calls a plugin or external service. -func ValidatePluginRegistry(installations []*PluginInstallation) error { - return validatePluginRegistry(installations) -} - -func validatePluginRegistry(installations []*PluginInstallation) error { - if err := validateAccountViewRegistry(installations); err != nil { - return err - } - if err := validateAccountCreateRegistry(installations); err != nil { - return err - } - if err := validateAccountEditRegistry(installations); err != nil { - return err - } - exclusive := make(map[string]int64) - type scopedOperation struct { - owner int64 - capability, name, platform, accountType string - } - var operationOwners []scopedOperation - byID := make(map[int64]*PluginInstallation) - for _, installation := range installations { - if !hasEnabledPluginBinding(installation.Bindings) { - continue - } - byID[installation.ID] = installation - for _, binding := range installation.Bindings { - if !binding.Enabled { - continue - } - for _, name := range installation.Manifest.Operations[binding.Capability] { - for _, owner := range operationOwners { - if owner.owner != installation.ID && owner.capability == binding.Capability && owner.name == name && - (pluginScopeMatches(owner.platform, binding.Platform) || pluginScopeMatches(binding.Platform, owner.platform)) && - (pluginScopeMatches(owner.accountType, binding.AccountType) || pluginScopeMatches(binding.AccountType, owner.accountType)) { - return fmt.Errorf("extension operation has overlapping enabled owners: %s", name) - } - } - operationOwners = append(operationOwners, scopedOperation{installation.ID, binding.Capability, name, binding.Platform, binding.AccountType}) - } - } - for _, binding := range installation.Bindings { - if !binding.Enabled || (binding.Capability != PluginCapabilityOpenAIOAuthOutbound && binding.Capability != extensionv1.CapabilityProvider) { - continue - } - key := binding.Capability + "/" + binding.Platform + "/" + binding.AccountType - if owner, ok := exclusive[key]; ok && owner != installation.ID { - return fmt.Errorf("exclusive plugin capability already enabled: %s", key) - } - exclusive[key] = installation.ID - } - if err := pluginDependenciesReady(installation, installations); err != nil { - return err - } - } - visiting, done := make(map[int64]bool), make(map[int64]bool) - var visit func(int64) error - visit = func(id int64) error { - if visiting[id] { - return errors.New("plugin dependency cycle") - } - if done[id] { - return nil - } - visiting[id] = true - for _, dependency := range byID[id].Manifest.Dependencies { - for target, candidate := range byID { - if target == id { - continue - } - for _, binding := range candidate.Bindings { - if binding.Enabled && binding.Capability == dependency.Capability && (dependency.Platform == "" || pluginScopeMatches(binding.Platform, dependency.Platform)) && (dependency.AccountType == "" || pluginScopeMatches(binding.AccountType, dependency.AccountType)) { - if err := visit(target); err != nil { - return err - } - } - } - } - } - visiting[id] = false - done[id] = true - return nil - } - for id := range byID { - if err := visit(id); err != nil { - return err - } - } - return nil -} - -func pluginDependenciesHealthy(installation *PluginInstallation, registry *pluginExtensionRegistry, seen map[int64]bool) bool { - if installation == nil || seen[installation.ID] { - return false - } - seen[installation.ID] = true - defer delete(seen, installation.ID) - for _, dependency := range installation.Manifest.Dependencies { - ready := false - for id, candidate := range registry.installations { - if id == installation.ID { - continue - } - runtime := registry.runtimes[id] - if runtime == nil || runtime.draining.Load() || runtime.client.Exited() { - continue - } - for _, binding := range candidate.Bindings { - if binding.Enabled && binding.Capability == dependency.Capability && (dependency.Platform == "" || pluginScopeMatches(binding.Platform, dependency.Platform)) && (dependency.AccountType == "" || pluginScopeMatches(binding.AccountType, dependency.AccountType)) && pluginDependenciesHealthy(candidate, registry, seen) { - ready = true - } - } - } - if !ready { - return false - } - } - return true -} - -type PluginContribution struct { - EditDefinitionDigest string `json:"edit_definition_digest,omitempty"` - CreateDefinitionDigest string `json:"create_definition_digest,omitempty"` - RuntimeGeneration int64 `json:"runtime_generation,omitempty"` - PackageSHA256 string `json:"package_sha256,omitempty"` - PluginKey string `json:"plugin_key,omitempty"` - ViewDefinitionDigest string `json:"view_definition_digest,omitempty"` - extensionv1.Contribution - AccountScope *PluginContributionAccountScope `json:"account_scope,omitempty"` - StylesheetURL string `json:"stylesheet_url,omitempty"` - PluginID int64 `json:"plugin_id"` - Available bool `json:"available"` - Reason string `json:"reason,omitempty"` -} - -func hasEnabledPluginBinding(bindings []PluginBinding) bool { - for _, binding := range bindings { - if binding.Enabled { - return true - } - } - return false -} - -func pluginScopeMatches(wanted, actual string) bool { return wanted == "*" || wanted == actual } - -func pluginHasCapability(installation *PluginInstallation, capability, platform, accountType string) bool { - if installation == nil { - return false - } - for _, binding := range installation.Bindings { - if binding.Enabled && binding.Capability == capability && - pluginScopeMatches(binding.Platform, platform) && pluginScopeMatches(binding.AccountType, accountType) { - return true - } - } - return false -} - -func pluginDependenciesReady(installation *PluginInstallation, all []*PluginInstallation) error { - for _, dependency := range installation.Manifest.Dependencies { - found := false - for _, candidate := range all { - if candidate.ID == installation.ID { - continue - } - for _, binding := range candidate.Bindings { - if binding.Enabled && binding.Capability == dependency.Capability && - (dependency.Platform == "" || pluginScopeMatches(binding.Platform, dependency.Platform)) && - (dependency.AccountType == "" || pluginScopeMatches(binding.AccountType, dependency.AccountType)) { - found = true - } - } - } - if !found { - return fmt.Errorf("missing enabled plugin dependency %s", dependency.Capability) - } - } - return nil -} - -func (m *PluginManager) Contributions() []PluginContribution { - registry := m.extensions.Load() - if registry == nil { - return nil - } - var out []PluginContribution - for id, installation := range registry.installations { - if !hasEnabledPluginBinding(installation.Bindings) { - continue - } - runtime := registry.runtimes[id] - available := registry.unavailable == "" && runtime != nil && !runtime.draining.Load() && !runtime.client.Exited() && pluginDependenciesHealthy(installation, registry, map[int64]bool{}) - for _, contribution := range installation.Manifest.Contributions { - if (contribution.Slot == extensionv1.AccountViewSlot || contribution.Slot == extensionv1.AccountCreateSlot || contribution.Slot == extensionv1.AccountEditSlot) && installation.State == PluginStateDisabled { - continue - } - if !pluginContributionBindingsEnabled(installation, &contribution) { - continue - } - flag, known := m.contributionConfigured(installation, runtime, contribution.ConfigFlag) - if known && !flag { - continue - } - item := PluginContribution{Contribution: contribution, PluginID: id, PluginKey: installation.PluginKey, Available: available, PackageSHA256: installation.PackageSHA256, ViewDefinitionDigest: AccountViewDefinitionDigest(&contribution)} - if contribution.Slot == extensionv1.AccountCreateSlot { - item.CreateDefinitionDigest = AccountCreateDefinitionDigest(&contribution) - item.RuntimeGeneration = installation.RuntimeGeneration - if _, ready := m.accountCreateReady(installation, &contribution); !ready { - item.Available, item.Reason = false, "account_create_unavailable" - } - } - if contribution.Slot == extensionv1.AccountEditSlot { - item.EditDefinitionDigest = AccountEditDefinitionDigest(&contribution) - item.RuntimeGeneration = installation.RuntimeGeneration - if _, ready := m.accountEditReady(installation, &contribution, nil); !ready { - item.Available, item.Reason = false, "account_edit_unavailable" - } - } - if contribution.Capability != "" { - item.AccountScope = &PluginContributionAccountScope{Version: 1, Bindings: contributionEffectiveBindings(installation, &contribution)} - } - if !available || !known { - item.Available = false - item.Reason = "plugin_unavailable" - } - out = append(out, item) - } - } - sort.Slice(out, func(i, j int) bool { - if out[i].Order != out[j].Order { - return out[i].Order < out[j].Order - } - if out[i].PluginID != out[j].PluginID { - return out[i].PluginID < out[j].PluginID - } - return out[i].ID < out[j].ID - }) - return out -} - -func (m *PluginManager) contributionConfigured(installation *PluginInstallation, runtime *pluginRuntime, flag string) (bool, bool) { - if flag == "" { - return true, true - } - var raw json.RawMessage - if runtime != nil { - if snapshot := runtime.configSnapshot.Load(); snapshot != nil { - raw = *snapshot - } - } - if len(raw) == 0 && m.encryptor != nil { - var err error - raw, err = m.decryptConfig(installation) - if err != nil { - return false, false - } - } - var fields map[string]json.RawMessage - if json.Unmarshal(raw, &fields) != nil { - return false, false - } - var enabled bool - value, exists := fields[flag] - if !exists { - return false, true - } - if json.Unmarshal(value, &enabled) != nil { - return false, false - } - return enabled, true -} - -// Public metadata never includes an admin operation, iframe entrypoint or -// account filter. User surfaces execute through existing authenticated APIs. -func (m *PluginManager) PublicContributions() []PluginContribution { - out := make([]PluginContribution, 0) - for _, item := range m.Contributions() { - isTheme := item.Permission == "public" && item.Slot == "theme" - if !isTheme && (item.Permission != "user" || item.Slot != "surface") { - continue - } - if isTheme { - installation, _ := m.installedByID(item.PluginID) - if installation == nil { - continue - } - item.StylesheetURL = publicThemeAssetURL(installation, item.Entrypoint) - } - item.Action, item.Entrypoint, item.ConfigFlag = "", "", "" - item.Fields, item.AccountFilter, item.Assets = nil, nil, nil - item.DisplayFields = nil - item.AccountScope = nil - out = append(out, item) - } - return out -} - -func (m *PluginManager) InvokeExtension(ctx context.Context, id int64, platform, accountType string, in extensionv1.Invocation) (extensionv1.Result, error) { - admission, err := m.admitPluginInvocation(ctx, m.extensions.Load(), id, platform, accountType, in) - if err != nil { - return extensionv1.Result{}, err - } - defer admission.release() - return admission.invoke(in) -} - -func (m *PluginManager) publishExtensionRegistryLocked(installations []*PluginInstallation, unavailable string) { - registry := &pluginExtensionRegistry{installations: make(map[int64]*PluginInstallation), runtimes: make(map[int64]*pluginRuntime), unavailable: unavailable} - for _, installation := range installations { - copy := *installation - copy.DesiredEnabled = hasEnabledPluginBinding(copy.Bindings) - registry.installations[copy.ID] = © - if copy.DesiredEnabled { - registry.runtimes[copy.ID] = m.runtimes[copy.ID] - } - } - m.extensions.Store(registry) -} - -func (m *PluginManager) replaceExtensionRegistrationLocked(installation *PluginInstallation, runtime *pluginRuntime) { - registry := &pluginExtensionRegistry{installations: make(map[int64]*PluginInstallation), runtimes: make(map[int64]*pluginRuntime)} - if previous := m.extensions.Load(); previous != nil { - registry.unavailable = previous.unavailable - for id, item := range previous.installations { - registry.installations[id] = item - } - for id, item := range previous.runtimes { - registry.runtimes[id] = item - } - } - copy := *installation - copy.DesiredEnabled = hasEnabledPluginBinding(copy.Bindings) - registry.installations[copy.ID] = © - if runtime == nil { - delete(registry.runtimes, copy.ID) - } else { - registry.runtimes[copy.ID] = runtime - } - m.extensions.Store(registry) -} diff --git a/backend/internal/service/plugin_extensions_test.go b/backend/internal/service/plugin_extensions_test.go deleted file mode 100644 index f77730c7b00c..000000000000 --- a/backend/internal/service/plugin_extensions_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package service - -import ( - "encoding/json" - "testing" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/stretchr/testify/require" -) - -type pluginContributionCipher struct{} - -func (pluginContributionCipher) Encrypt(value string) (string, error) { return value, nil } -func (pluginContributionCipher) Decrypt(value string) (string, error) { return value, nil } - -func TestPublicPluginSurfaceRetainsFailureAndHidesDisabledConfiguration(t *testing.T) { - m := NewPluginManager(nil, pluginContributionCipher{}, nil, PluginHostInfo{}, nil) - installation := &PluginInstallation{ID: 2, State: PluginStateError, ConfigEncrypted: `{"studio_enabled":true}`, - Manifest: PluginManifest{Contributions: []extensionv1.Contribution{ - {ID: "image-studio", Slot: "surface", Permission: "user", ConfigFlag: "studio_enabled", Action: "private.action", Entrypoint: "ui/index.html"}, - {ID: "settings", Slot: "admin.settings", Permission: "admin"}, - }}, - Bindings: []PluginBinding{{Capability: extensionv1.CapabilityRequest, Platform: "*", AccountType: "*", Enabled: true}}, - } - m.publishExtensionRegistryLocked([]*PluginInstallation{installation}, "") - items := m.PublicContributions() - require.Len(t, items, 1) - require.False(t, items[0].Available) - raw, err := json.Marshal(items) - require.NoError(t, err) - require.NotContains(t, string(raw), "private.action") - require.NotContains(t, string(raw), "ui/index.html") - require.NotContains(t, string(raw), "studio_enabled") - installation.ConfigEncrypted = `{"studio_enabled":false}` - m.publishExtensionRegistryLocked([]*PluginInstallation{installation}, "") - require.Empty(t, m.PublicContributions()) - installation.ConfigEncrypted = `{"studio_enabled":true}` - installation.Bindings[0].Enabled = false - m.publishExtensionRegistryLocked([]*PluginInstallation{installation}, "") - require.Empty(t, m.PublicContributions()) -} - -func TestPluginExtensionDesiredStateAndContributions(t *testing.T) { - m := NewPluginManager(nil, nil, nil, PluginHostInfo{}, nil) - installation := &PluginInstallation{ID: 1, State: PluginStateError, - Manifest: PluginManifest{Contributions: []extensionv1.Contribution{{ID: "tickets", Slot: "account.actions", Permission: "admin"}}}, - Bindings: []PluginBinding{{Capability: extensionv1.CapabilityAdmin, Platform: "openai", AccountType: "oauth", Enabled: true}}, - } - m.publishExtensionRegistryLocked([]*PluginInstallation{installation}, "") - items := m.Contributions() - require.Len(t, items, 1, "an enabled unhealthy plugin must retain its unavailable UI contribution") - require.False(t, items[0].Available) - require.Equal(t, "plugin_unavailable", items[0].Reason) - require.True(t, pluginHasCapability(installation, extensionv1.CapabilityAdmin, "openai", "oauth")) - require.False(t, pluginHasCapability(installation, extensionv1.CapabilityAdmin, "cindy", "apikey")) - m.removeRuntimeLocked(installation.ID) - require.Empty(t, m.Contributions(), "disabling must remove contributions immediately") -} - -func TestContributionFollowsItsOwnCapabilityWhileOtherCapabilitiesRemainEnabled(t *testing.T) { - m := NewPluginManager(nil, nil, nil, PluginHostInfo{}, nil) - installation := &PluginInstallation{ID: 1, - Manifest: PluginManifest{Contributions: []extensionv1.Contribution{{ID: "recovery", Slot: "surface", Capability: extensionv1.CapabilityRecovery, Permission: "admin"}}}, - Bindings: []PluginBinding{ - {Capability: extensionv1.CapabilityAdmin, Platform: "*", AccountType: "*", Enabled: true}, - {Capability: extensionv1.CapabilityRecovery, Platform: "openai", AccountType: "*", Enabled: false}, - }, - } - m.publishExtensionRegistryLocked([]*PluginInstallation{installation}, "") - require.Empty(t, m.Contributions()) - installation.Bindings[1].Enabled = true - m.publishExtensionRegistryLocked([]*PluginInstallation{installation}, "") - items := m.Contributions() - require.Len(t, items, 1) - require.False(t, items[0].Available, "an enabled failed capability stays visible with its reason") -} - -func TestPluginExtensionDependenciesRespectEnabledBindings(t *testing.T) { - provider := &PluginInstallation{ID: 1, Bindings: []PluginBinding{{Capability: extensionv1.CapabilityProvider, Platform: "cindy", AccountType: "apikey", Enabled: true}}} - consumer := &PluginInstallation{ID: 2, Manifest: PluginManifest{Dependencies: []extensionv1.Dependency{{Capability: extensionv1.CapabilityProvider, Platform: "cindy", AccountType: "apikey"}}}} - require.NoError(t, pluginDependenciesReady(consumer, []*PluginInstallation{provider, consumer})) - provider.Bindings[0].Enabled = false - require.Error(t, pluginDependenciesReady(consumer, []*PluginInstallation{provider, consumer})) -} diff --git a/backend/internal/service/plugin_host_io_admission_test.go b/backend/internal/service/plugin_host_io_admission_test.go deleted file mode 100644 index 5930e9ea2d9d..000000000000 --- a/backend/internal/service/plugin_host_io_admission_test.go +++ /dev/null @@ -1,163 +0,0 @@ -package service - -import ( - "context" - "errors" - "strings" - "sync" - "testing" - "time" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - hcplugin "github.com/hashicorp/go-plugin" - "github.com/stretchr/testify/require" -) - -// Explicitly implements every repository method: an omitted embedded port -// cannot manufacture a failure that would make an admission test pass. -type hostIOAdmissionRepository struct { - t *testing.T - current *PluginInstallation - holds int - businessHolds int - active int -} - -var _ PluginRepository = (*hostIOAdmissionRepository)(nil) -var _ PluginRuntimeLocker = (*hostIOAdmissionRepository)(nil) - -func (r *hostIOAdmissionRepository) unexpected(method string) error { - r.t.Helper() - r.t.Errorf("unexpected repository call: %s", method) - return errors.New("unexpected repository call") -} -func (r *hostIOAdmissionRepository) List(context.Context) ([]*PluginInstallation, error) { - return nil, r.unexpected("List") -} -func (r *hostIOAdmissionRepository) GetByID(_ context.Context, id int64) (*PluginInstallation, error) { - if r.current == nil || r.current.ID != id { - return nil, ErrPluginStateChanged - } - return cloneHostIOInstallation(r.current), nil -} -func (r *hostIOAdmissionRepository) GetByKey(context.Context, string) (*PluginInstallation, error) { - return nil, r.unexpected("GetByKey") -} -func (r *hostIOAdmissionRepository) Install(context.Context, *PluginInstallation, []PluginBinding) (*PluginInstallation, error) { - return nil, r.unexpected("Install") -} -func (r *hostIOAdmissionRepository) GetArtifact(context.Context, int64) ([]byte, error) { - return nil, r.unexpected("GetArtifact") -} -func (r *hostIOAdmissionRepository) Delete(context.Context, int64, string) error { - return r.unexpected("Delete") -} -func (r *hostIOAdmissionRepository) BeginEnable(context.Context, int64, string, string) error { - return r.unexpected("BeginEnable") -} -func (r *hostIOAdmissionRepository) MarkRuntimeHealthy(context.Context, int64, string, string) error { - return r.unexpected("MarkRuntimeHealthy") -} -func (r *hostIOAdmissionRepository) UpdateState(context.Context, int64, string, string, *time.Time, string, string) error { - return r.unexpected("UpdateState") -} -func (r *hostIOAdmissionRepository) UpdateConfig(context.Context, int64, string, string) error { - return r.unexpected("UpdateConfig") -} -func (r *hostIOAdmissionRepository) UpdateBindingsAndState(context.Context, int64, []PluginBinding, string, string, *time.Time, string, string) error { - return r.unexpected("UpdateBindingsAndState") -} -func (r *hostIOAdmissionRepository) HoldPluginRuntime(ctx context.Context, expected *PluginInstallation) (func(), error) { - r.holds++ - if r.current == nil || !samePluginRuntime(r.current, expected) || r.current.State == PluginStateUpdating { - return nil, ErrPluginStateChanged - } - if PluginBusinessIOLeaseRequired(ctx) { - r.businessHolds++ - if r.current.State != PluginStateEnabled || r.current.Revision != expected.Revision || r.current.ConfigEncrypted != expected.ConfigEncrypted { - return nil, ErrPluginStateChanged - } - } - r.active++ - var once sync.Once - return func() { once.Do(func() { r.active-- }) }, nil -} - -func cloneHostIOInstallation(in *PluginInstallation) *PluginInstallation { - copy := *in - copy.Bindings = append([]PluginBinding(nil), in.Bindings...) - return © -} - -func hostIOAdmissionManager(t *testing.T) (*PluginManager, *hostIOAdmissionRepository, *pluginRuntime) { - t.Helper() - installation := &PluginInstallation{ID: 7, PluginKey: "fixture.host-io", Version: "1.0.0", Revision: 1, RuntimeGeneration: 1, - PackageSHA256: strings.Repeat("a", 64), BinarySHA256: strings.Repeat("b", 64), ConfigEncrypted: "old-config", State: PluginStateEnabled, - Manifest: PluginManifest{Operations: map[string][]string{extensionv1.CapabilityRequest: {"host.io"}}}, - Bindings: []PluginBinding{{Capability: extensionv1.CapabilityRequest, Platform: "*", AccountType: "*", Enabled: true, RolloutPercent: 100}}} - repo := &hostIOAdmissionRepository{t: t, current: cloneHostIOInstallation(installation)} - manager := &PluginManager{repo: repo} - runtime := &pluginRuntime{installation: cloneHostIOInstallation(installation), client: hcplugin.NewClient(&hcplugin.ClientConfig{}), done: make(chan struct{})} - manager.extensions.Store(&pluginExtensionRegistry{installations: map[int64]*PluginInstallation{7: installation}, runtimes: map[int64]*pluginRuntime{7: runtime}}) - return manager, repo, runtime -} - -func TestHostIOAdmissionRejectsPersistedIntentChanges(t *testing.T) { - for _, tc := range []struct { - name string - edit func(*PluginInstallation) - }{ - {"disabled", func(i *PluginInstallation) { i.State = PluginStateDisabled; i.Bindings[0].Enabled = false }}, - {"config", func(i *PluginInstallation) { i.ConfigEncrypted = "new-config" }}, - {"binding", func(i *PluginInstallation) { i.Bindings[0].RolloutPercent = 0 }}, - } { - t.Run(tc.name, func(t *testing.T) { - manager, repo, runtime := hostIOAdmissionManager(t) - invoke := extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "host.io", AccountID: 42} - ctx, release, err := manager.BindOperationContext(context.Background(), PlatformOpenAI, AccountTypeAPIKey, invoke) - require.NoError(t, err, "positive control must reach a functioning repository/locker") - require.NoError(t, ctx.Err()) - require.Equal(t, 1, repo.active) - require.False(t, PluginBusinessIOLeaseRequired(ctx), "purpose marker must not leak into later diagnostic/process calls") - release() - repo.current.Revision++ - tc.edit(repo.current) - ctx, release, err = manager.BindOperationContext(context.Background(), PlatformOpenAI, AccountTypeAPIKey, invoke) - if release != nil { - t.Cleanup(release) - } - require.ErrorIs(t, err, ErrExtensionOperationUnavailable) - require.Nil(t, ctx) - require.Equal(t, 2, repo.businessHolds, "both positive and negative control must use the business admission contract") - require.Zero(t, repo.active) - require.Zero(t, runtime.inFlight.Load()) - }) - } -} - -func TestHostIOAdmissionRejectsFreshSnapshotForOldAppliedRuntime(t *testing.T) { - for _, tc := range []struct { - name string - edit func(*PluginInstallation) - }{ - {"config", func(i *PluginInstallation) { i.ConfigEncrypted = "new-config" }}, - {"generation", func(i *PluginInstallation) { i.RuntimeGeneration++ }}, - {"package", func(i *PluginInstallation) { i.PackageSHA256 = strings.Repeat("c", 64) }}, - } { - t.Run(tc.name, func(t *testing.T) { - manager, repo, runtime := hostIOAdmissionManager(t) - repo.current.Revision++ - tc.edit(repo.current) - fresh, err := repo.GetByID(context.Background(), repo.current.ID) - require.NoError(t, err) - ctx, release, err := manager.bindHostPolicyContext(context.Background(), fresh, runtime) - if release != nil { - t.Cleanup(release) - } - require.ErrorIs(t, err, ErrExtensionOperationUnavailable, "fresh storage cannot certify an old applied runtime") - require.Nil(t, ctx) - require.Zero(t, repo.holds, "runtime mismatch must fail before asking storage for a lease") - require.Zero(t, runtime.inFlight.Load()) - }) - } -} diff --git a/backend/internal/service/plugin_host_services.go b/backend/internal/service/plugin_host_services.go index 927a582dfbf7..da6c957a1633 100644 --- a/backend/internal/service/plugin_host_services.go +++ b/backend/internal/service/plugin_host_services.go @@ -6,9 +6,7 @@ import ( "strings" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" - "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -145,17 +143,10 @@ type pluginHostServiceServer struct { pluginKey string store PluginKVStore directory PluginAccountDirectory - extension extensionv1.HostHandler // scope 是宿主授予本插件的账号可见范围。账号目录的两个 RPC 都以它为权限边界。 scope PluginAccountScope } -func (s *pluginHostServiceServer) RegisterAdditionalServices(server grpc.ServiceRegistrar) { - if s.extension != nil { - extensionv1.RegisterHost(server, s.extension) - } -} - func newPluginHostServiceServer(pluginKey string, store PluginKVStore, directory PluginAccountDirectory, scope PluginAccountScope) *pluginHostServiceServer { return &pluginHostServiceServer{pluginKey: pluginKey, store: store, directory: directory, scope: scope} } diff --git a/backend/internal/service/plugin_host_services_broker_test.go b/backend/internal/service/plugin_host_services_broker_test.go index d73163e35503..0fd27543f914 100644 --- a/backend/internal/service/plugin_host_services_broker_test.go +++ b/backend/internal/service/plugin_host_services_broker_test.go @@ -10,8 +10,6 @@ import ( hcplugin "github.com/hashicorp/go-plugin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" ) // brokerProbePlugin 是仅用于测试的插件传输实现:它实现 HostBrokerReceiver 以拿到 @@ -92,7 +90,7 @@ func TestOfferPluginHostServices_BrokerRoundtrip(t *testing.T) { store := newFakePluginKVStore() hostServer := newPluginHostServiceServer("test.plugin", store, nil, PluginAccountScope{}) - require.NoError(t, offerPluginHostServices(context.Background(), &PluginInstallation{PluginKey: "test.plugin"}, tc.TransportPluginClient, tc.Broker, hostServer, 5*time.Second)) + offerPluginHostServices(context.Background(), &PluginInstallation{PluginKey: "test.plugin"}, tc.TransportPluginClient, tc.Broker, hostServer, 5*time.Second) probe.mu.Lock() defer probe.mu.Unlock() @@ -115,9 +113,7 @@ func TestOfferPluginHostServices_UnimplementedIsGraceful(t *testing.T) { store := newFakePluginKVStore() hostServer := newPluginHostServiceServer("test.plugin", store, nil, PluginAccountScope{}) require.NotPanics(t, func() { - err := offerPluginHostServices(context.Background(), &PluginInstallation{PluginKey: "test.plugin"}, tc.TransportPluginClient, tc.Broker, hostServer, 5*time.Second) - require.Error(t, err) - require.Equal(t, codes.Unimplemented, status.Code(err)) + offerPluginHostServices(context.Background(), &PluginInstallation{PluginKey: "test.plugin"}, tc.TransportPluginClient, tc.Broker, hostServer, 5*time.Second) }) _, found, err := store.Get(context.Background(), "test.plugin", "state", "probe") @@ -129,6 +125,6 @@ func TestOfferPluginHostServices_UnimplementedIsGraceful(t *testing.T) { func TestOfferPluginHostServices_NilHostServicesNoop(t *testing.T) { tc := dispenseTransportClient(t, &noHostServicesPlugin{}) require.NotPanics(t, func() { - require.EqualError(t, offerPluginHostServices(context.Background(), &PluginInstallation{PluginKey: "test.plugin"}, tc.TransportPluginClient, tc.Broker, nil, 5*time.Second), "host broker unavailable") + offerPluginHostServices(context.Background(), &PluginInstallation{PluginKey: "test.plugin"}, tc.TransportPluginClient, tc.Broker, nil, 5*time.Second) }) } diff --git a/backend/internal/service/plugin_image_bridge.go b/backend/internal/service/plugin_image_bridge.go index 742dc95e691b..d01367df0e11 100644 --- a/backend/internal/service/plugin_image_bridge.go +++ b/backend/internal/service/plugin_image_bridge.go @@ -7,9 +7,8 @@ import ( "fmt" "math" "strings" - "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func imageBridgeText(value any) (string, bool) { @@ -80,28 +79,15 @@ func queryImageBridgeWithSnapshot(ctx context.Context, account *Account, request request.Capabilities = snapshot.Capabilities request.Aliases = snapshot.CompatibilityAliases request.CatalogEnabled = snapshot.Config.CatalogEnabled - raw, err := json.Marshal(request) - var plan extensionv1.ImageBridgePlan - if err != nil { - return plan, err - } - in := extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "image.responses.plan", Payload: raw} - if account != nil { - in.AccountID = account.ID - } - call, cancel := context.WithTimeout(ctx, time.Second) - defer cancel() - result, err := invokeProcessExtensionCached(call, PlatformCindy, AccountTypeAPIKey, in) - if err != nil { - return plan, err - } - if result.Code == "image_model_not_found" { - return plan, fmt.Errorf("%w: %s", ErrCindyResponsesImageToolModelNotFound, result.Message) + config, _ := currentImageToolsConfig() + plan, code, message := planResponsesImageBridge(request, config) + if code == "image_model_not_found" { + return plan, fmt.Errorf("%w: %s", ErrCindyResponsesImageToolModelNotFound, message) } - if result.Code != "" { - return plan, errors.New(result.Message) + if code != "" { + return plan, errors.New(message) } - if json.Unmarshal(result.Payload, &plan) != nil || !validImageBridgePlan(request, plan) { + if !validImageBridgePlan(request, plan) { return plan, ErrExtensionOperationUnavailable } return plan, nil diff --git a/backend/internal/service/plugin_image_bridge_test.go b/backend/internal/service/plugin_image_bridge_test.go index aced28b01b0c..a5fd0191b72d 100644 --- a/backend/internal/service/plugin_image_bridge_test.go +++ b/backend/internal/service/plugin_image_bridge_test.go @@ -6,56 +6,16 @@ import ( "strings" "testing" - "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" ) -type capturedImageBridgeFixture struct { - promptPolicyFixture - queries []extensionv1.Invocation -} - -func (f *capturedImageBridgeFixture) InvokeOperation(ctx context.Context, platform, kind string, in extensionv1.Invocation) (extensionv1.Result, error) { - if in.Operation == "image.responses.plan" || in.Operation == "image.native.validate" { - f.queries = append(f.queries, in) - } - return f.promptPolicyFixture.InvokeOperation(ctx, platform, kind, in) -} - -func TestCindyNativeImagePolicyReceivesNoPromptOrImageContent(t *testing.T) { - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - fixture := &capturedImageBridgeFixture{} - processExtensionOperations.Store(&extensionOperationProvider{invoker: fixture}) - request := &OpenAIImagesRequest{Endpoint: openAIImagesEditsEndpoint, Prompt: "private-image-prompt", InputImageURLs: []string{"https://private.invalid/reference"}, N: 1, Size: "1024x1024", Quality: "low", HasMask: true} - account := cindyHTTPToWSV2TestAccount() - account.ID = 37 - err := ValidateCindyImageRequestForAccount(context.Background(), account, "gpt-image-2", request) - require.Error(t, err) - require.Len(t, fixture.queries, 1) - require.EqualValues(t, 37, fixture.queries[0].AccountID) - require.NotContains(t, string(fixture.queries[0].Payload), "private-image-prompt") - require.NotContains(t, string(fixture.queries[0].Payload), "private.invalid") - processExtensionOperations.Store(nil) - require.ErrorIs(t, ValidateCindyImageRequest("gpt-image-2", request), ErrExtensionOperationDisabled) -} - -func TestImageBridgeUsesOnlyBoundedFactsAndAuthorizedAccount(t *testing.T) { - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - fixture := &capturedImageBridgeFixture{} - processExtensionOperations.Store(&extensionOperationProvider{invoker: fixture}) +func TestImageBridgeUsesOnlyBoundedFacts(t *testing.T) { body := []byte(`{"model":"gpt-5.6-luna","input":"private-prompt","reasoning":{"encrypted_content":"private-cipher"},"tools":[{"type":"image_generation","model":"gpt-image-2","mask":"private-image","quality":{"text":"private-object"}}]}`) account := cindyHTTPToWSV2TestAccount() account.ID = 37 _, err := ResolveCindyResponsesImageToolsForAccount(context.Background(), account, body) require.ErrorIs(t, err, ErrCindyResponsesImageToolModelNotFound) - require.Len(t, fixture.queries, 1) - require.EqualValues(t, 37, fixture.queries[0].AccountID) - for _, private := range []string{"private-prompt", "private-cipher", "private-image", "private-object"} { - require.NotContains(t, string(fixture.queries[0].Payload), private) - } var request map[string]any require.NoError(t, json.Unmarshal([]byte(`{"model":"gpt-5.6-luna","tools":[{"type":"image_generation"}]}`), &request)) tools, ok := request["tools"].([]any) @@ -68,36 +28,12 @@ func TestImageBridgeUsesOnlyBoundedFactsAndAuthorizedAccount(t *testing.T) { require.NoError(t, err) require.True(t, facts.Tools[0].InvalidModel) require.Empty(t, facts.Tools[0].Model) - processExtensionOperations.Store(nil) - _, err = ResolveCindyResponsesImageTools(body) - require.ErrorIs(t, err, ErrExtensionOperationDisabled) text := []byte(`{"model":"gpt-5.6-luna", "input":"keep text byte stable"}`) resolved, err := ResolveCindyResponsesImageTools(text) require.NoError(t, err) require.Equal(t, text, resolved) } -func TestImageBridgeAccountScopeIsCheckedBeforeCachedPlansAndLeases(t *testing.T) { - calls := 0 - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{}, func(in extensionv1.Invocation) (extensionv1.Result, error) { - calls++ - return extensionv1.Result{Payload: []byte(`{}`)}, nil - }) - installation := manager.extensions.Load().installations[1] - installation.Manifest.Operations = map[string][]string{extensionv1.CapabilityRequest: {"image.responses.plan"}} - installation.Bindings = []PluginBinding{{Capability: extensionv1.CapabilityRequest, Platform: PlatformCindy, AccountType: AccountTypeAPIKey, Enabled: true, RolloutPercent: 100}} - in := extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "image.responses.plan", AccountID: 37, Payload: []byte(`{}`)} - _, err := manager.InvokeCachedOperation(context.Background(), PlatformCindy, AccountTypeAPIKey, in) - require.NoError(t, err) - require.Equal(t, 1, calls) - installation.Bindings[0].RolloutPercent = int(stablePluginBucket(37)) - _, err = manager.InvokeCachedOperation(context.Background(), PlatformCindy, AccountTypeAPIKey, in) - require.ErrorIs(t, err, ErrExtensionOperationDisabled) - _, _, err = manager.BindOperationContext(context.Background(), PlatformCindy, AccountTypeAPIKey, in) - require.ErrorIs(t, err, ErrExtensionOperationDisabled) - require.Equal(t, 1, calls) -} - func TestImageBridgePlanCannotChangeBillingIdentityOrUnrelatedTools(t *testing.T) { request := extensionv1.ImageBridgeRequest{Model: "luna", Capabilities: []extensionv1.CindyCapability{ {PublicID: "luna", LiveUpstreamID: "openai/luna", PublicModel: true, Kind: CindyModelKindText}, diff --git a/backend/internal/service/plugin_image_tools.go b/backend/internal/service/plugin_image_tools.go deleted file mode 100644 index 95f962bc895a..000000000000 --- a/backend/internal/service/plugin_image_tools.go +++ /dev/null @@ -1,115 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "slices" - "strings" - "time" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" -) - -func currentImageToolsConfig() (extensionv1.ImageToolsConfig, bool) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - result, err := invokeProcessExtensionCached(ctx, "*", "*", extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "image.features", Payload: json.RawMessage(`{}`)}) - var config extensionv1.ImageToolsConfig - if err != nil || result.Code != "" || json.Unmarshal(result.Payload, &config) != nil { - return extensionv1.ImageToolsConfig{}, false - } - return config, true -} - -func ValidateCindyImageRequest(model string, request *OpenAIImagesRequest) error { - return ValidateCindyImageRequestForAccount(context.Background(), nil, model, request) -} - -func ValidateCindyImageRequestForAccount(ctx context.Context, account *Account, model string, request *OpenAIImagesRequest) error { - if request == nil { - return errors.New("image request is required") - } - for _, value := range []string{model, request.Size, request.Quality, request.ResponseFormat} { - if len(value) > 256 { - return errors.New("image control exceeds maximum length") - } - } - snapshot, err := LoadCindyCatalogSnapshot(ctx, account) - if err != nil { - return err - } - capability, found := snapshot.Capability(model) - endpoint := CindyEndpointImagesGenerate - if request.IsEdits() { - endpoint = CindyEndpointImagesEdit - } - verified := found && capability.PublicModel && snapshot.Config.CatalogEnabled && snapshot.Images.StudioEnabled && slices.Contains(capability.VerifiedEndpoints, endpoint) - facts := extensionv1.ImageNativeRequest{Model: strings.TrimSpace(model), Capability: capability, Verified: verified, Editing: request.IsEdits(), Stream: request.Stream, Count: request.N, Size: strings.TrimSpace(request.Size), Quality: strings.TrimSpace(request.Quality), ResponseFormat: strings.TrimSpace(request.ResponseFormat), HasReference: request.InputImageCount() > 0, HasMask: request.HasMask, - UnverifiedControls: request.Background != "" || request.OutputFormat != "" || request.Moderation != "" || request.InputFidelity != "" || request.Style != "" || request.OutputCompression != nil || request.PartialImages != nil} - raw, err := json.Marshal(facts) - if err != nil { - return ErrExtensionOperationUnavailable - } - in := extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "image.native.validate", Payload: raw} - if account != nil { - in.AccountID = account.ID - } - call, cancel := context.WithTimeout(ctx, time.Second) - defer cancel() - result, err := invokeProcessExtension(call, PlatformCindy, AccountTypeAPIKey, in) - if err != nil { - return err - } - if result.Code != "" { - return errors.New(result.Message) - } - var valid bool - if json.Unmarshal(result.Payload, &valid) != nil || !valid { - return ErrExtensionOperationUnavailable - } - return nil -} - -func EnsureImageStudioAvailable(ctx context.Context) error { - result, err := invokeProcessExtensionCached(ctx, "*", "*", extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "image.features", Payload: json.RawMessage(`{}`)}) - var config extensionv1.ImageToolsConfig - if err != nil || result.Code != "" || json.Unmarshal(result.Payload, &config) != nil { - return newImageStudioError(503, "studio_unavailable", "Image Studio is unavailable") - } - if !config.StudioEnabled { - return newImageStudioError(404, "studio_disabled", "Image Studio is not enabled") - } - return nil -} - -func planImageStudio(ctx context.Context, input ImageStudioCreateInput, hasReference, hasMask, execute bool) (extensionv1.ImageStudioPlan, error) { - request := extensionv1.ImageStudioPlanRequest{APIKeySelected: input.APIKeyID > 0, Mode: string(input.Mode), Model: input.Model, PromptBytes: len(strings.TrimSpace(input.Prompt)), Count: input.Count, Size: input.Size, Quality: input.Quality, HasReference: hasReference, HasMask: hasMask} - raw, _ := json.Marshal(request) - operation := "image.studio.validate" - if execute { - operation = "image.studio.plan" - } - call, cancel := context.WithTimeout(ctx, time.Second) - defer cancel() - result, err := invokeProcessExtension(call, "*", "*", extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: operation, Payload: raw}) - var plan extensionv1.ImageStudioPlan - if err != nil { - return plan, newImageStudioError(503, "studio_unavailable", "Image Studio is unavailable") - } - if result.Code != "" { - return plan, newImageStudioError(result.HTTPStatus, result.Code, result.Message) - } - if json.Unmarshal(result.Payload, &plan) != nil || plan.Model == "" || plan.OutputPerRequest != 1 || (plan.Endpoint != "/v1/images/generations" && plan.Endpoint != "/v1/images/edits") || plan.ResponseFormat != "b64_json" { - return plan, newImageStudioError(503, "studio_unavailable", "Image Studio returned an invalid request plan") - } - return plan, nil -} - -func PlanImageStudioExecution(ctx context.Context, request ImageStudioExecutionRequest) (extensionv1.ImageStudioPlan, error) { - keyID := request.Job.APIKeyID - if request.APIKey != nil { - keyID = request.APIKey.ID - } - return planImageStudio(ctx, ImageStudioCreateInput{APIKeyID: keyID, Mode: request.Job.Mode, Model: request.Job.Model, Prompt: request.Job.Prompt, Count: 1, Size: request.Job.Size, Quality: request.Job.Quality}, len(request.Reference) > 0, len(request.Mask) > 0, true) -} diff --git a/backend/internal/service/plugin_install_guard_test.go b/backend/internal/service/plugin_install_guard_test.go deleted file mode 100644 index 8d83e1dd390d..000000000000 --- a/backend/internal/service/plugin_install_guard_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package service - -import ( - "bytes" - "context" - "database/sql" - "io/fs" - "net/http" - "path/filepath" - "testing" - - infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" - "github.com/stretchr/testify/require" -) - -type pluginInstallGuardRepository struct { - *pluginTokenRepository - lookupError error - installErr error - installs int - candidate *PluginInstallation -} - -func (r *pluginInstallGuardRepository) GetByKey(ctx context.Context, _ string) (*PluginInstallation, error) { - if r.lookupError != nil { - return nil, r.lookupError - } - return r.GetByID(ctx, 7) -} - -func (r *pluginInstallGuardRepository) Install(_ context.Context, candidate *PluginInstallation, bindings []PluginBinding) (*PluginInstallation, error) { - r.installs++ - r.candidate = candidate - if r.installErr != nil { - return nil, r.installErr - } - installed := *candidate - installed.ID, installed.RuntimeGeneration, installed.Revision = 7, 1, 1 - installed.UpdatePolicy = PluginUpdatePinned - installed.Bindings = append([]PluginBinding(nil), bindings...) - r.installation = &installed - return &installed, nil -} - -func pluginInstallGuardFiles(t *testing.T, root string) []string { - t.Helper() - var files []string - require.NoError(t, filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - if !entry.IsDir() { - files = append(files, path) - } - return nil - })) - return files -} - -func TestPluginInstallRejectsExistingIdentity(t *testing.T) { - for _, state := range []string{PluginStateDisabled, PluginStateStarting, PluginStateEnabled, PluginStateError, PluginStateIncompatible, PluginStateUpdating} { - t.Run(state, func(t *testing.T) { - cfg := testPluginConfig(t.TempDir(), true) - host := PluginHostInfo{Version: "0.1.179"} - installer := NewPluginPackageInstaller(cfg, host) - old, err := installer.Install(context.Background(), bytes.NewReader(buildTestPluginArchive(t, nil, "")), nil) - require.NoError(t, err) - old.ID, old.RuntimeGeneration, old.Revision = 7, 9, 4 - old.State, old.ConfigEncrypted, old.UpdatePolicy = state, `ENC:{"saved":true}`, PluginUpdateBundled - old.Bindings = []PluginBinding{{Capability: PluginCapabilityOpenAIOAuthOutbound, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth, RolloutPercent: 37, Enabled: state == PluginStateEnabled}} - before := *old - before.Bindings = append([]PluginBinding(nil), old.Bindings...) - files := pluginInstallGuardFiles(t, cfg.Plugins.DataDir) - repo := &pluginInstallGuardRepository{pluginTokenRepository: &pluginTokenRepository{installation: old}} - manager := NewPluginManager(repo, pluginTokenEncryptor{}, cfg, host, nil) - manager.localInstallations[old.ID] = old - manifest := old.Manifest - manifest.Description = "same immutable version with changed content" - result, err := manager.Install(context.Background(), bytes.NewReader(buildPluginArchive(t, manifest, nil, "", nil)), nil) - require.Nil(t, result) - require.ErrorIs(t, err, ErrPluginAlreadyInstalled) - require.Equal(t, http.StatusConflict, infraerrors.Code(err)) - require.Contains(t, infraerrors.Message(err), "独立更新") - require.Zero(t, repo.installs) - require.Equal(t, &before, repo.installation) - require.Same(t, old, manager.localInstallations[old.ID]) - require.Equal(t, files, pluginInstallGuardFiles(t, cfg.Plugins.DataDir), "only the rejected candidate extraction is removed") - }) - } -} - -func TestPluginInstallDatabaseConflictCleansOnlyCandidate(t *testing.T) { - cfg := testPluginConfig(t.TempDir(), true) - repo := &pluginInstallGuardRepository{pluginTokenRepository: &pluginTokenRepository{}, lookupError: sql.ErrNoRows, installErr: ErrPluginAlreadyInstalled} - manager := NewPluginManager(repo, pluginTokenEncryptor{}, cfg, PluginHostInfo{Version: "0.1.179"}, nil) - result, err := manager.Install(context.Background(), bytes.NewReader(buildTestPluginArchive(t, nil, "")), nil) - require.Nil(t, result) - require.ErrorIs(t, err, ErrPluginAlreadyInstalled) - require.Equal(t, 1, repo.installs) - require.NotNil(t, repo.candidate) - require.NoFileExists(t, repo.candidate.ArtifactPath) - require.NoDirExists(t, repo.candidate.InstallPath) - require.Empty(t, manager.localInstallations) -} - -func TestPluginInstallInitialPackageRemainsDisabled(t *testing.T) { - cfg := testPluginConfig(t.TempDir(), true) - repo := &pluginInstallGuardRepository{pluginTokenRepository: &pluginTokenRepository{}, lookupError: sql.ErrNoRows} - manager := NewPluginManager(repo, pluginTokenEncryptor{}, cfg, PluginHostInfo{Version: "0.1.179"}, nil) - result, err := manager.Install(context.Background(), bytes.NewReader(buildTestPluginArchive(t, nil, "")), nil) - require.NoError(t, err) - require.Equal(t, 1, repo.installs) - require.Equal(t, PluginStateDisabled, result.State) - require.Equal(t, PluginUpdatePinned, result.UpdatePolicy) - require.Len(t, result.Bindings, 1) - require.False(t, result.Bindings[0].Enabled) - require.Equal(t, 100, result.Bindings[0].RolloutPercent) - require.FileExists(t, result.ArtifactPath) - require.FileExists(t, result.BinaryPath) -} diff --git a/backend/internal/service/plugin_jobs.go b/backend/internal/service/plugin_jobs.go deleted file mode 100644 index ac431e2ce6ba..000000000000 --- a/backend/internal/service/plugin_jobs.go +++ /dev/null @@ -1,239 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "sync" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" -) - -type PluginJobPayload struct { - PluginID int64 `json:"plugin_id"` - Operation string `json:"operation"` - Items map[string]json.RawMessage `json:"items"` -} - -// PluginJobExecutor preserves the host's durable job, cancellation, retry and -// billing boundaries. Domain operations execute only in the enabled plugin. -type PluginJobExecutor struct { - manager *PluginManager - core AccountJobExecutor -} - -func NewPluginJobExecutor(manager *PluginManager, core AccountJobExecutor) *PluginJobExecutor { - return &PluginJobExecutor{manager: manager, core: core} -} - -func (e *PluginJobExecutor) PrepareAccountJob(ctx context.Context, job *AccountJob, payload json.RawMessage) (context.Context, func(), error) { - if job == nil { - return ctx, nil, ErrAccountJobPluginUnavailable - } - if err := validateAccountViewJobKind(ctx, job.Kind, job.Metadata); err != nil { - return ctx, nil, err - } - owner, err := AccountJobPluginExecution(job.Metadata) - if err != nil { - return ctx, nil, err - } - ctx, releaseView, err := e.manager.BindAccountJobView(ctx, job.Metadata, payload, false) - if err != nil { - return ctx, nil, err - } - releasePrimary := func() {} - var releaseOnce sync.Once - release := func() { releaseOnce.Do(func() { releasePrimary(); releaseView() }) } - preparedOK := false - defer func() { - if !preparedOK { - release() - } - }() - if owner.ID > 0 { - if owner.Generation <= 0 { - return ctx, nil, ErrAccountJobPluginUnavailable - } - ctx, releasePrimary, err = e.manager.BindAccountJobExecution(ctx, owner.ID, owner.Generation, job.Kind) - if err != nil { - releasePrimary = func() {} - return ctx, nil, err - } - } else { - // Pre-plugin pending jobs keep their records, but use the current - // domain policy and lifetime once their execution path has migrated. - operation := "" - switch job.Kind { - case AccountJobKindImportData: - operation = "import.plan" - case AccountJobKindBatchTest: - operation = "test.batch" - case AccountJobKindBulkTaxonomy: - operation = "taxonomy.bulk" - } - if isCindyCleanupAccountJob(job.Kind) { - if e.manager == nil { - return ctx, nil, ErrAccountJobPluginUnavailable - } - id, lookupErr := e.manager.PinnedPluginOwner(ctx, CindyAccountViewPluginKey) - if lookupErr != nil { - return ctx, nil, ErrAccountJobPluginUnavailable - } - ctx, releasePrimary, err = e.manager.BindAccountJobExecution(ctx, id, 0, job.Kind) - if err != nil { - releasePrimary = func() {} - return ctx, nil, err - } - } else if operation != "" { - if e.manager == nil { - return ctx, nil, ErrAccountJobPluginUnavailable - } - id, _, lookupErr := e.manager.operationOwner("*", "*", extensionv1.Invocation{Capability: extensionv1.CapabilityAdmin, Operation: operation}) - if lookupErr != nil { - return ctx, nil, ErrAccountJobPluginUnavailable - } - ctx, releasePrimary, err = e.manager.BindAccountJobExecution(ctx, id, 0, job.Kind) - if err != nil { - releasePrimary = func() {} - return ctx, nil, err - } - } else if job.Kind == AccountJobKindExtensionOperation { - return ctx, nil, ErrAccountJobPluginUnavailable - } - } - if job.Kind != AccountJobKindExtensionOperation { - if preparer, ok := e.core.(AccountJobPreparingExecutor); ok { - prepared, cleanup, err := preparer.PrepareAccountJob(ctx, job, payload) - if err != nil { - if cleanup != nil { - cleanup() - } - release() - return ctx, nil, err - } - if prepared == nil { - prepared = ctx - } - preparedOK = true - return prepared, func() { - if cleanup != nil { - cleanup() - } - release() - }, nil - } - } - preparedOK = true - return ctx, release, nil -} - -func (e *PluginJobExecutor) ExecuteAccountJob(ctx context.Context, job *AccountJob, payload json.RawMessage, items []AccountJobItem) ([]AccountJobExecutionResult, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - if job == nil { - return nil, ErrAccountJobPluginUnavailable - } - if err := validateAccountJobViewExecution(ctx, job, payload, items); err != nil { - return nil, err - } - owner, ownerErr := AccountJobPluginExecution(job.Metadata) - if ownerErr != nil { - return nil, ownerErr - } - execution, bound := PluginExecutionFromContext(ctx) - if owner.ID == 0 && bound { - owner = execution - } - if owner.ID > 0 { - ok := bound - if !ok || execution != owner { - return nil, ErrAccountJobPluginUnavailable - } - var ids []int64 - for _, item := range items { - if item.TargetAccountID != nil { - ids = append(ids, *item.TargetAccountID) - } - } - if isCindyCleanupAccountJob(job.Kind) { - if err := e.manager.ValidateResourcePolicy(ctx, CindyCleanupResourcePolicy(), nil, true); err != nil { - return nil, ErrAccountJobPluginUnavailable - } - } else if _, viewBound := AccountViewFromContext(ctx); viewBound { - targets := make([]*int64, 0, len(items)) - for _, item := range items { - targets = append(targets, item.TargetAccountID) - } - viewIDs, err := accountViewJobTargets(job.Kind, payload, targets) - if err != nil { - return nil, err - } - if err := e.manager.ValidateResourcePolicy(ctx, extensionv1.ResourceDescriptor{ResourceGrant: extensionv1.ResourceGrant{Capability: extensionv1.CapabilityAdmin}}, viewIDs, false); err != nil { - return nil, ErrAccountJobPluginUnavailable - } - } else if err := e.manager.ValidateResourceAccounts(ctx, extensionv1.CapabilityAdmin, ids, len(ids) != len(items)); err != nil { - return nil, ErrAccountJobPluginUnavailable - } - } - if job.Kind != AccountJobKindExtensionOperation { - return e.core.ExecuteAccountJob(ctx, job, payload, items) - } - var request PluginJobPayload - if json.Unmarshal(payload, &request) != nil || request.PluginID <= 0 || request.Operation == "" || len(items) != 1 { - return nil, errors.New("invalid plugin job") - } - if owner.ID != request.PluginID { - return nil, ErrAccountJobPluginUnavailable - } - item := items[0] - result := AccountJobExecutionResult{ItemID: item.ID, Status: AccountJobItemStatusFailed, ErrorCode: "execution_failed", ErrorMessage: "plugin operation failed"} - if item.TargetAccountID == nil { - return []AccountJobExecutionResult{result}, nil - } - id := *item.TargetAccountID - var operation map[string]json.RawMessage - if json.Unmarshal(request.Items[item.Action], &operation) != nil || operation == nil { - return []AccountJobExecutionResult{result}, nil - } - operation["operation_id"], _ = json.Marshal(fmt.Sprintf("job-%d", job.ID)) - raw, err := json.Marshal(operation) - if err != nil { - return nil, err - } - out, err := e.manager.InvokeAdminExtension(ctx, request.PluginID, id, request.Operation, raw) - if err != nil { - if ctx.Err() != nil { - result.Status = AccountJobItemStatusCanceled - result.ErrorCode = "" - result.ErrorMessage = "" - } - return []AccountJobExecutionResult{result}, nil - } - if ValidateAccountJobMetadata(out.Payload) != nil { - return []AccountJobExecutionResult{result}, nil - } - metadata := map[string]any{} - _ = json.Unmarshal(item.Metadata, &metadata) - var details map[string]any - if json.Unmarshal(out.Payload, &details) == nil { - for key, value := range details { - metadata[key] = value - } - } - result.Metadata, _ = json.Marshal(metadata) - var outcome struct { - Success bool `json:"success"` - } - if out.Code == "" && json.Unmarshal(out.Payload, &outcome) == nil && outcome.Success { - result.Status = AccountJobItemStatusSucceeded - result.ErrorCode = "" - result.ErrorMessage = "" - } else if ctx.Err() != nil { - result.Status = AccountJobItemStatusCanceled - result.ErrorCode = "" - result.ErrorMessage = "" - } - return []AccountJobExecutionResult{result}, nil -} diff --git a/backend/internal/service/plugin_legacy_host_port_test.go b/backend/internal/service/plugin_legacy_host_port_test.go deleted file mode 100644 index 50372ee5fc01..000000000000 --- a/backend/internal/service/plugin_legacy_host_port_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package service - -import ( - "context" - "testing" - - pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" - "github.com/stretchr/testify/require" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -func TestExtensionRuntimeCannotUseUnfencedLegacyStoragePort(t *testing.T) { - store := newFakePluginKVStore() - manager := &PluginManager{kvStore: store} - installation := &PluginInstallation{ID: 7, PluginKey: "fixture.extended", RuntimeGeneration: 2} - installation.Manifest.Requires.ExtensionAPI = 1 - host, ok := manager.buildHostServices(installation).(*pluginHostServiceServer) - require.True(t, ok) - require.NotNil(t, host.extension, "the versioned extension broker remains available") - _, err := host.KVSet(context.Background(), &pluginv1.KVSetRequest{Namespace: "state", Key: "late", Value: []byte("bypassed-generation")}) - require.Equal(t, codes.Unavailable, status.Code(err), "an extension process must use the versioned state/CAS broker") - _, found, err := store.Get(context.Background(), installation.PluginKey, "state", "late") - require.NoError(t, err) - require.False(t, found) - _, err = host.KVDelete(context.Background(), &pluginv1.KVDeleteRequest{Namespace: "state", Key: "late"}) - require.Equal(t, codes.Unavailable, status.Code(err)) - - legacy := &PluginInstallation{ID: 8, PluginKey: "fixture.legacy"} - legacyHost, ok := manager.buildHostServices(legacy).(*pluginHostServiceServer) - require.True(t, ok) - require.Nil(t, legacyHost.extension) - _, err = legacyHost.KVSet(context.Background(), &pluginv1.KVSetRequest{Namespace: "state", Key: "same", Value: []byte("official-contract")}) - require.NoError(t, err) - value, found, err := store.Get(context.Background(), legacy.PluginKey, "state", "same") - require.NoError(t, err) - require.True(t, found) - require.Equal(t, "official-contract", string(value)) -} diff --git a/backend/internal/service/plugin_manager.go b/backend/internal/service/plugin_manager.go index de49ebf53967..23ae5ae8242d 100644 --- a/backend/internal/service/plugin_manager.go +++ b/backend/internal/service/plugin_manager.go @@ -14,7 +14,6 @@ import ( "log/slog" "net/http" "os" - "path" "path/filepath" "strings" "sync" @@ -22,8 +21,6 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" - infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" ) @@ -35,8 +32,6 @@ const ( pluginUITokenPrefix = "sub2api:plugin-ui:v1:" ) -var ErrPluginAlreadyInstalled = infraerrors.Conflict("PLUGIN_ALREADY_INSTALLED", "插件已安装,请使用独立更新接口;重新安装前请先卸载") - type pluginRoute struct { pluginID int64 runtime *pluginRuntime @@ -46,14 +41,11 @@ type pluginRoute struct { // PluginManager 管理插件安装、配置、进程生命周期和 OpenAI OAuth 能力绑定。 type PluginManager struct { - bundlePath string - quotaActivity *QuotaActivityService - traffic AccountTrafficObserveCache - repo PluginRepository - encryptor SecretEncryptor - cfg *config.Config - hostInfo PluginHostInfo - installer *PluginPackageInstaller + repo PluginRepository + encryptor SecretEncryptor + cfg *config.Config + hostInfo PluginHostInfo + installer *PluginPackageInstaller // kvStore 为运行中的插件提供通用宿主键值存储;为 nil 时不向插件暴露宿主服务。 kvStore PluginKVStore // accountDirectory 为声明了对应能力的插件提供账号目录与出站身份解析(敏感能力); @@ -68,13 +60,10 @@ type PluginManager struct { reconcileCancel context.CancelFunc reconcileDone chan struct{} route atomic.Pointer[pluginRoute] - extensions atomic.Pointer[pluginExtensionRegistry] - codexDemandMu sync.Mutex - codexDemandAt map[string]time.Time } func NewPluginManager(repo PluginRepository, encryptor SecretEncryptor, cfg *config.Config, hostInfo PluginHostInfo, kvStore PluginKVStore) *PluginManager { - manager := &PluginManager{ + return &PluginManager{ repo: repo, encryptor: encryptor, cfg: cfg, @@ -84,8 +73,6 @@ func NewPluginManager(repo PluginRepository, encryptor SecretEncryptor, cfg *con runtimes: make(map[int64]*pluginRuntime), localInstallations: make(map[int64]*PluginInstallation), } - manager.extensions.Store(&pluginExtensionRegistry{installations: map[int64]*PluginInstallation{}, runtimes: map[int64]*pluginRuntime{}, unavailable: "plugin registry not initialized"}) - return manager } func (m *PluginManager) MaxUploadBytes() int64 { @@ -103,11 +90,6 @@ func (m *PluginManager) Start(ctx context.Context) error { m.operationMu.Unlock() return nil } - if err := m.bootstrapBundle(ctx); err != nil { - m.mu.Unlock() - m.operationMu.Unlock() - return err - } if err := os.MkdirAll(filepath.Join(m.installer.RootDir(), "runtime"), 0o700); err != nil { m.mu.Unlock() m.operationMu.Unlock() @@ -129,12 +111,6 @@ func (m *PluginManager) Start(ctx context.Context) error { } func (m *PluginManager) Stop() { - if provider := processExtensionOperations.Load(); provider != nil && provider.invoker == m { - processExtensionOperations.CompareAndSwap(provider, nil) - } - if provider := processExtensionCatalog.Load(); provider != nil && provider.resolver == m { - processExtensionCatalog.CompareAndSwap(provider, nil) - } m.mu.Lock() cancel := m.reconcileCancel done := m.reconcileDone @@ -172,7 +148,6 @@ func (m *PluginManager) List(ctx context.Context) ([]*PluginInstallation, error) defer m.mu.Unlock() route := m.route.Load() for _, installation := range plugins { - installation.DesiredEnabled = hasEnabledPluginBinding(installation.Bindings) installation.Compatibility = EvaluatePluginCompatibility(installation.Manifest, m.hostInfo) if runtime := m.runtimes[installation.ID]; runtime != nil && !runtime.client.Exited() { installation.RuntimeHealthy = true @@ -193,7 +168,6 @@ func (m *PluginManager) Get(ctx context.Context, id int64) (*PluginInstallation, return nil, err } installation.Compatibility = EvaluatePluginCompatibility(installation.Manifest, m.hostInfo) - installation.DesiredEnabled = hasEnabledPluginBinding(installation.Bindings) m.mu.Lock() runtime := m.runtimes[id] m.mu.Unlock() @@ -213,9 +187,13 @@ func (m *PluginManager) Install(ctx context.Context, reader io.Reader, installed if err != nil { return nil, err } - if _, getErr := m.repo.GetByKey(ctx, packageInfo.PluginKey); getErr == nil { - cleanupErr := m.cleanupInstallationFiles(packageInfo) - return nil, errors.Join(ErrPluginAlreadyInstalled, cleanupErr) + var previous *PluginInstallation + if existing, getErr := m.repo.GetByKey(ctx, packageInfo.PluginKey); getErr == nil { + if existing.State == PluginStateEnabled || hasEnabledOpenAIBinding(existing.Bindings) { + cleanupErr := m.cleanupInstallationFiles(packageInfo) + return nil, errors.Join(errors.New("请先停用当前插件,再上传同 ID 的新版本"), cleanupErr) + } + previous = existing } else if !errors.Is(getErr, sql.ErrNoRows) { cleanupErr := m.cleanupInstallationFiles(packageInfo) return nil, errors.Join(getErr, cleanupErr) @@ -240,8 +218,19 @@ func (m *PluginManager) Install(ctx context.Context, reader io.Reader, installed local.ConfigEncrypted = installed.ConfigEncrypted local.Bindings = append([]PluginBinding(nil), installed.Bindings...) m.mu.Lock() + localPrevious := m.localInstallations[installed.ID] m.localInstallations[installed.ID] = &local m.mu.Unlock() + if previous != nil { + if cleanupErr := m.cleanupInstallationFiles(previous); cleanupErr != nil { + slog.Warn("plugin_previous_install_cleanup_failed", "plugin_id", previous.ID, "error", cleanupErr) + } + if localPrevious != nil && (localPrevious.InstallPath != previous.InstallPath || localPrevious.ArtifactPath != previous.ArtifactPath) { + if cleanupErr := m.cleanupInstallationFiles(localPrevious); cleanupErr != nil { + slog.Warn("plugin_previous_local_install_cleanup_failed", "plugin_id", previous.ID, "error", cleanupErr) + } + } + } return m.Get(ctx, installed.ID) } @@ -274,150 +263,63 @@ func (m *PluginManager) reconcileLoop(ctx context.Context, done chan struct{}) { } } -func (m *PluginManager) readPluginRegistryForReconcile(ctx context.Context) ([]*PluginInstallation, error) { - installations, err := m.repo.List(ctx) - if err != nil { - m.publishUnavailableRoute(0, 100, "插件启用状态暂时无法读取") - m.mu.Lock() - previous := m.extensions.Load() - if previous != nil { - copy := *previous - copy.unavailable = "plugin state unavailable" - m.extensions.Store(©) - } - m.mu.Unlock() - return nil, fmt.Errorf("读取插件启用状态: %w", err) - } - return installations, nil -} - -func (m *PluginManager) pluginRegistryAfterUpdates(ctx context.Context, installations []*PluginInstallation, promoted bool) ([]*PluginInstallation, error) { - if !promoted { - return installations, nil - } - // Updating the promoted entry alone retains the peers' pre-drain snapshot. - // Read their current intent before validating or publishing the new runtime. - return m.readPluginRegistryForReconcile(ctx) -} - // reconcileOnce 以数据库中的绑定为权威状态,让每个实例独立恢复并启动同一插件。 func (m *PluginManager) reconcileOnce(ctx context.Context) error { m.operationMu.Lock() defer m.operationMu.Unlock() - installations, err := m.readPluginRegistryForReconcile(ctx) - if err != nil { - return err - } - promoted := false - for index, installation := range installations { - if installation.State == PluginStateUpdating { - if updated, updateErr := m.resumePluginUpdate(ctx, installation); updateErr != nil { - slog.Warn("plugin_update_pending", "plugin_id", installation.ID, "error", updateErr) - } else if updated != nil { - installations[index] = updated - promoted = true - } - } - } - installations, err = m.pluginRegistryAfterUpdates(ctx, installations, promoted) + + installations, err := m.repo.List(ctx) if err != nil { - return err + // 无法读取权威绑定状态时不能假设插件未启用,否则会把 OAuth 请求静默回落到旧直连路径。 + m.publishUnavailableRoute(0, 100, "插件启用状态暂时无法读取") + return fmt.Errorf("读取插件启用状态: %w", err) } m.cleanupStaleLocalInstallations(installations) - if err := validatePluginRegistry(installations); err != nil { - m.publishUnavailableRoute(0, 100, err.Error()) - m.mu.Lock() - m.publishExtensionRegistryLocked(installations, "plugin bindings invalid") - m.mu.Unlock() - return err - } - desired := make(map[int64]bool) - var failures []error - var transport *PluginInstallation + var enabled *PluginInstallation for _, installation := range installations { - if installation.State == PluginStateUpdating { - if hasEnabledOpenAIBinding(installation.Bindings) { - transport = installation - } - continue - } - if !hasEnabledPluginBinding(installation.Bindings) { - if installation.State == PluginStateStarting && m.startingStateExpired(installation) { - if err := m.repo.UpdateState(ctx, installation.ID, PluginStateDisabled, "插件未启用", nil, installation.BinarySHA256, PluginStateStarting); err != nil && !errors.Is(err, ErrPluginStateChanged) { - failures = append(failures, err) - } - } - continue - } - desired[installation.ID] = true - if hasEnabledOpenAIBinding(installation.Bindings) { - if transport != nil { - err := errors.New("检测到多个 OpenAI OAuth 出站插件同时启用") - m.publishUnavailableRoute(0, 100, err.Error()) - return err - } - transport = installation - } - if err := pluginDependenciesReady(installation, installations); err != nil { - failures = append(failures, err) + if !hasEnabledOpenAIBinding(installation.Bindings) { continue } - if err := m.reconcilePlugin(ctx, installation); err != nil { - failures = append(failures, err) + if enabled != nil { + err := errors.New("检测到多个 OpenAI OAuth 出站插件同时启用") + m.publishUnavailableRoute(enabled.ID, 100, err.Error()) + return err } + enabled = installation } - m.mu.Lock() - var stale []*pluginRuntime - for id, runtime := range m.runtimes { - if !desired[id] { - runtime.beginDrain() - stale = append(stale, runtime) - delete(m.runtimes, id) + if enabled == nil { + for _, installation := range installations { + if installation.State != PluginStateStarting || !m.startingStateExpired(installation) { + continue + } + if err := m.repo.UpdateState( + ctx, installation.ID, PluginStateDisabled, "插件启动超时,已自动恢复为停用状态", nil, + installation.BinarySHA256, PluginStateStarting, + ); err != nil && !errors.Is(err, ErrPluginStateChanged) { + return fmt.Errorf("恢复超时插件状态: %w", err) + } } - } - if transport == nil { - m.route.Store(nil) - } else { - route := &pluginRoute{pluginID: transport.ID, rolloutPercent: bindingRollout(transport.Bindings), runtime: m.runtimes[transport.ID]} - if route.runtime == nil { - route.unavailable = "插件已启用但暂不可用" - } - m.route.Store(route) - } - m.publishExtensionRegistryLocked(installations, "") - m.mu.Unlock() - for _, runtime := range stale { - runtime.drain(10 * time.Second) - } - return errors.Join(failures...) -} - -func (m *PluginManager) reconcilePlugin(ctx context.Context, enabled *PluginInstallation) error { - ctx = WithPluginExecution(ctx, enabled) - compatibility := EvaluatePluginCompatibility(enabled.Manifest, m.hostInfo) - if !compatibility.Compatible { - m.pausePluginForUpdate(enabled.ID) - if enabled.State != PluginStateIncompatible { - return m.repo.UpdateState(ctx, enabled.ID, PluginStateIncompatible, compatibility.Message, enabled.EnabledAt, enabled.BinarySHA256, enabled.State) + runtimes := m.detachAllRuntimes() + for _, runtime := range runtimes { + runtime.drain(10 * time.Second) } return nil } - m.mu.Lock() - current := m.runtimes[enabled.ID] - m.mu.Unlock() - if current != nil && !current.client.Exited() && - samePluginRuntime(current.installation, enabled) && - current.installation.ConfigEncrypted == enabled.ConfigEncrypted { + + rollout := bindingRollout(enabled.Bindings) + current := m.route.Load() + if current != nil && current.pluginID == enabled.ID && current.runtime != nil && + !current.runtime.client.Exited() && current.rolloutPercent == rollout && + current.runtime.installation.BinarySHA256 == enabled.BinarySHA256 && + current.runtime.installation.ConfigEncrypted == enabled.ConfigEncrypted { healthCtx, cancel := context.WithTimeout(ctx, pluginHealthTimeout) - healthErr := current.checkHealth(healthCtx) + healthErr := current.runtime.checkHealth(healthCtx) cancel() if healthErr != nil { - m.mu.Lock() - delete(m.runtimes, enabled.ID) - m.mu.Unlock() - current.drain(10 * time.Second) - stateErr := m.repo.UpdateState(ctx, enabled.ID, PluginStateError, "插件健康检查失败", enabled.EnabledAt, enabled.BinarySHA256, enabled.State) - return errors.Join(healthErr, stateErr) + if stateErr := m.markRuntimeUnavailable(current, healthErr.Error()); stateErr != nil { + return errors.Join(healthErr, stateErr) + } + return healthErr } if enabled.State == PluginStateError || (enabled.State == PluginStateStarting && m.startingStateExpired(enabled)) { return m.repo.MarkRuntimeHealthy(ctx, enabled.ID, enabled.BinarySHA256, enabled.ConfigEncrypted) @@ -425,15 +327,20 @@ func (m *PluginManager) reconcilePlugin(ctx context.Context, enabled *PluginInst return nil } if enabled.State == PluginStateStarting && !m.startingStateExpired(enabled) { + if current == nil { + m.route.Store(&pluginRoute{pluginID: enabled.ID, rolloutPercent: rollout, unavailable: "插件正在其他实例中启动"}) + } return nil } local, err := m.ensureLocalInstallation(ctx, enabled) if err != nil { + m.publishUnavailableRoute(enabled.ID, rollout, err.Error()) return err } runtime, err := m.prepareRuntime(ctx, local, true) if err != nil { + m.publishUnavailableRoute(enabled.ID, rollout, err.Error()) return err } @@ -443,8 +350,8 @@ func (m *PluginManager) reconcilePlugin(ctx context.Context, enabled *PluginInst runtime.kill() return err } - if !hasEnabledPluginBinding(latest.Bindings) || latest.State == PluginStateUpdating || !samePluginRuntime(latest, enabled) || - latest.ConfigEncrypted != enabled.ConfigEncrypted { + if !hasEnabledOpenAIBinding(latest.Bindings) || latest.BinarySHA256 != enabled.BinarySHA256 || + latest.ConfigEncrypted != enabled.ConfigEncrypted || bindingRollout(latest.Bindings) != rollout { runtime.kill() return nil } @@ -461,11 +368,21 @@ func (m *PluginManager) reconcilePlugin(ctx context.Context, enabled *PluginInst } m.mu.Lock() - stale := m.runtimes[enabled.ID] + stale := make([]*pluginRuntime, 0, len(m.runtimes)) + for id, candidate := range m.runtimes { + if candidate != runtime { + candidate.draining.Store(true) + stale = append(stale, candidate) + } + if id != enabled.ID { + delete(m.runtimes, id) + } + } m.runtimes[enabled.ID] = runtime + m.route.Store(&pluginRoute{pluginID: enabled.ID, runtime: runtime, rolloutPercent: rollout}) m.mu.Unlock() - if stale != nil { - stale.drain(10 * time.Second) + for _, candidate := range stale { + candidate.drain(10 * time.Second) } return nil } @@ -485,11 +402,24 @@ func (m *PluginManager) startingStateExpired(installation *PluginInstallation) b return time.Since(installation.UpdatedAt) > recoveryDelay } +func (m *PluginManager) detachAllRuntimes() []*pluginRuntime { + m.mu.Lock() + defer m.mu.Unlock() + runtimes := make([]*pluginRuntime, 0, len(m.runtimes)) + for id, runtime := range m.runtimes { + runtime.draining.Store(true) + runtimes = append(runtimes, runtime) + delete(m.runtimes, id) + } + m.route.Store(nil) + return runtimes +} + func (m *PluginManager) publishUnavailableRoute(pluginID int64, rollout int, message string) { m.mu.Lock() stale := make([]*pluginRuntime, 0, len(m.runtimes)) for id, runtime := range m.runtimes { - runtime.beginDrain() + runtime.draining.Store(true) stale = append(stale, runtime) delete(m.runtimes, id) } @@ -507,7 +437,7 @@ func (m *PluginManager) ensureLocalInstallation(ctx context.Context, installatio m.mu.Lock() local := m.localInstallations[installation.ID] m.mu.Unlock() - if local != nil && samePluginPackage(local, installation) { + if local != nil && local.BinarySHA256 == installation.BinarySHA256 && local.Version == installation.Version { if err := verifyLocalPluginBinary(local, m.installer.RootDir()); err == nil { return mergeLocalInstallation(local, installation), nil } @@ -555,7 +485,7 @@ func (m *PluginManager) cleanupStaleLocalInstallations(installations []*PluginIn stale := make([]*PluginInstallation, 0) for id, local := range m.localInstallations { current := persisted[id] - if current == nil || !samePluginPackage(local, current) { + if current == nil || current.BinarySHA256 != local.BinarySHA256 || current.Version != local.Version { stale = append(stale, local) delete(m.localInstallations, id) } @@ -583,9 +513,6 @@ func samePluginPackage(local, persisted *PluginInstallation) bool { local.Version != persisted.Version || local.BinarySHA256 != persisted.BinarySHA256 { return false } - if persisted.PackageSHA256 != "" { - return local.PackageSHA256 == persisted.PackageSHA256 - } localManifest, localErr := json.Marshal(local.Manifest) persistedManifest, persistedErr := json.Marshal(persisted.Manifest) return localErr == nil && persistedErr == nil && bytes.Equal(localManifest, persistedManifest) @@ -637,17 +564,10 @@ func (m *PluginManager) Enable(ctx context.Context, id int64, acceptUntested boo if err != nil { return nil, err } - if installation.State == PluginStateUpdating || validatePluginPrecondition(ctx, installation) != nil { - return nil, ErrPluginStateChanged - } - if installation.Manifest.Requires.ExtensionAPI > 0 && rolloutPercent != 100 { - return nil, errors.New("功能插件整体启用,不支持按流量比例启用") - } - if active := m.route.Load(); active != nil && active.pluginID != id && pluginDeclaresOpenAIOAuthCapability(installation.Manifest) { + if active := m.route.Load(); active != nil && active.pluginID != id { return nil, errors.New("OpenAI OAuth 出站能力已有启用插件,请先停用当前插件") } - if installation.State == PluginStateEnabled && hasEnabledPluginBinding(installation.Bindings) { - installation.DesiredEnabled = true + if installation.State == PluginStateEnabled && hasEnabledOpenAIBinding(installation.Bindings) { installation.Compatibility = EvaluatePluginCompatibility(installation.Manifest, m.hostInfo) m.mu.Lock() runtime := m.runtimes[id] @@ -674,28 +594,9 @@ func (m *PluginManager) Enable(ctx context.Context, id int64, acceptUntested boo installation.Bindings[index].Enabled = true installation.Bindings[index].RolloutPercent = rolloutPercent } - all, err := m.repo.List(ctx) - if err != nil { + if err := m.repo.BeginEnable(ctx, id, installation.BinarySHA256, installation.State); err != nil { return nil, err } - for i, candidate := range all { - if candidate.ID == installation.ID { - all[i] = installation - } - } - if err := validatePluginRegistry(all); err != nil { - return nil, err - } - if err := m.repo.UpdateBindingsAndState(WithPluginExpectedRevision(ctx, installation.Revision), id, installation.Bindings, PluginStateStarting, "", installation.EnabledAt, installation.State, installation.BinarySHA256); err != nil { - return nil, err - } - starting, err := m.repo.GetByID(ctx, id) - if err != nil { - return nil, err - } - if starting.State != PluginStateStarting || !samePluginRuntime(starting, installation) || starting.ConfigEncrypted != installation.ConfigEncrypted { - return nil, ErrPluginStateChanged - } runtime, err := m.prepareRuntime(ctx, installation, true) if err != nil { stateCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) @@ -707,7 +608,7 @@ func (m *PluginManager) Enable(ctx context.Context, id int64, acceptUntested boo return nil, errors.Join(err, stateErr) } now := time.Now() - if err := m.repo.UpdateBindingsAndState(WithPluginExpectedRevision(ctx, starting.Revision), id, installation.Bindings, PluginStateEnabled, "", &now, PluginStateStarting, installation.BinarySHA256); err != nil { + if err := m.repo.UpdateBindingsAndState(ctx, id, installation.Bindings, PluginStateEnabled, "", &now, PluginStateStarting, installation.BinarySHA256); err != nil { runtime.kill() if errors.Is(err, ErrPluginStateChanged) { return nil, err @@ -725,7 +626,6 @@ func (m *PluginManager) Enable(ctx context.Context, id int64, acceptUntested boo return nil, err } result.Compatibility = compatibility - result.DesiredEnabled = hasEnabledPluginBinding(result.Bindings) result.RuntimeHealthy = true result.RuntimeMessage = "插件进程运行中" return result, nil @@ -734,23 +634,6 @@ func (m *PluginManager) Enable(ctx context.Context, id int64, acceptUntested boo func (m *PluginManager) Disable(ctx context.Context, id int64) (*PluginInstallation, error) { m.operationMu.Lock() defer m.operationMu.Unlock() - all, err := m.repo.List(ctx) - if err != nil { - return nil, err - } - remaining := make([]*PluginInstallation, 0, len(all)) - for _, candidate := range all { - if candidate.ID != id { - remaining = append(remaining, candidate) - } - } - for _, candidate := range remaining { - if hasEnabledPluginBinding(candidate.Bindings) { - if err := pluginDependenciesReady(candidate, remaining); err != nil { - return nil, fmt.Errorf("插件仍被启用能力依赖: %w", err) - } - } - } m.mu.Lock() installation, err := m.repo.GetByID(ctx, id) if err != nil { @@ -760,11 +643,7 @@ func (m *PluginManager) Disable(ctx context.Context, id int64) (*PluginInstallat for index := range installation.Bindings { installation.Bindings[index].Enabled = false } - if validatePluginPrecondition(ctx, installation) != nil { - m.mu.Unlock() - return nil, ErrPluginStateChanged - } - if err := m.repo.UpdateBindingsAndState(WithPluginExpectedRevision(ctx, installation.Revision), id, installation.Bindings, PluginStateDisabled, "", nil, "", installation.BinarySHA256); err != nil { + if err := m.repo.UpdateBindingsAndState(ctx, id, installation.Bindings, PluginStateDisabled, "", nil, "", installation.BinarySHA256); err != nil { m.mu.Unlock() return nil, err } @@ -785,15 +664,11 @@ func (m *PluginManager) Delete(ctx context.Context, id int64) error { m.mu.Unlock() return err } - if err := validatePluginPrecondition(ctx, installation); err != nil { - m.mu.Unlock() - return err - } - if installation.State == PluginStateEnabled || hasEnabledPluginBinding(installation.Bindings) { + if installation.State == PluginStateEnabled || hasEnabledOpenAIBinding(installation.Bindings) { m.mu.Unlock() return errors.New("请先停用插件,再执行卸载") } - if err := m.repo.Delete(WithPluginExpectedRevision(ctx, installation.Revision), id, installation.BinarySHA256); err != nil { + if err := m.repo.Delete(ctx, id, installation.BinarySHA256); err != nil { m.mu.Unlock() return err } @@ -812,44 +687,14 @@ func (m *PluginManager) Delete(ctx context.Context, id int64) error { } func (m *PluginManager) GetConfig(ctx context.Context, id int64) (json.RawMessage, error) { - snapshot, err := m.GetConfigSnapshot(ctx, id) - if err != nil { - return nil, err - } - return snapshot.Config, nil -} - -func (m *PluginManager) GetConfigSnapshot(ctx context.Context, id int64) (*PluginConfigSnapshot, error) { installation, err := m.repo.GetByID(ctx, id) if err != nil { return nil, err } - if err := validatePluginPrecondition(ctx, installation); err != nil { - return nil, err - } - config, err := m.decryptConfig(installation) - if err != nil { - return nil, err - } - return &PluginConfigSnapshot{Config: config, Revision: installation.Revision, PackageSHA256: installation.PackageSHA256}, nil + return m.decryptConfig(installation) } func (m *PluginManager) SaveConfig(ctx context.Context, id int64, raw json.RawMessage) (json.RawMessage, error) { - snapshot, err := m.saveConfigSnapshot(ctx, id, raw) - if err != nil { - return nil, err - } - return snapshot.Config, nil -} - -func (m *PluginManager) SaveConfigSnapshot(ctx context.Context, id int64, raw json.RawMessage) (*PluginConfigSnapshot, error) { - if _, ok := m.repo.(PluginConfigRevisionRepository); !ok { - return nil, ErrPluginConfigReceiptUnavailable - } - return m.saveConfigSnapshot(ctx, id, raw) -} - -func (m *PluginManager) saveConfigSnapshot(ctx context.Context, id int64, raw json.RawMessage) (*PluginConfigSnapshot, error) { m.operationMu.Lock() defer m.operationMu.Unlock() if len(raw) == 0 || len(raw) > pluginConfigMaxBytes || !json.Valid(raw) { @@ -863,9 +708,6 @@ func (m *PluginManager) saveConfigSnapshot(ctx context.Context, id int64, raw js if err != nil { return nil, err } - if installation.State == PluginStateUpdating || validatePluginPrecondition(ctx, installation) != nil { - return nil, ErrPluginStateChanged - } var normalized any if err := json.Unmarshal(raw, &normalized); err != nil { return nil, err @@ -884,9 +726,6 @@ func (m *PluginManager) saveConfigSnapshot(ctx context.Context, id int64, raw js runtime := m.runtimes[id] m.mu.Unlock() temporary := false - if runtime != nil && (!samePluginRuntime(runtime.installation, installation) || runtime.installation.ConfigEncrypted != installation.ConfigEncrypted) { - return nil, ErrPluginStateChanged - } if runtime == nil { installation, err = m.ensureLocalInstallation(ctx, installation) if err != nil { @@ -901,62 +740,46 @@ func (m *PluginManager) saveConfigSnapshot(ctx context.Context, id int64, raw js } if runtime != nil { applyCtx, cancel := context.WithTimeout(ctx, 15*time.Second) - canonical, err = runtime.validateNormalizedConfig(applyCtx, canonical) + canonical, err = runtime.validateAndApplyNormalizedConfig(applyCtx, canonical) cancel() if err != nil { return nil, err } } - unchanged := bytes.Equal(bytes.TrimSpace(previousConfig), canonical) - encrypted := installation.ConfigEncrypted - if !unchanged { - encrypted, err = m.encryptor.Encrypt(string(canonical)) - if err != nil { - return nil, fmt.Errorf("加密插件配置: %w", err) + encrypted, err := m.encryptor.Encrypt(string(canonical)) + if err != nil { + if !temporary { + err = errors.Join(err, m.restoreRuntimeConfig(id, runtime, previousConfig)) } - runtime.configuring.Store(true) - defer runtime.configuring.Store(false) + return nil, fmt.Errorf("加密插件配置: %w", err) } - receipt := &PluginConfigSnapshot{Config: canonical, PackageSHA256: installation.PackageSHA256} - writeCtx := WithPluginExpectedRevision(ctx, installation.Revision) - if store, ok := m.repo.(PluginConfigRevisionRepository); ok { - receipt.Revision, err = store.UpdateConfigReturningRevision(writeCtx, id, encrypted, installation.BinarySHA256) - if err != nil { - return nil, err - } - if receipt.Revision <= 0 { - return nil, ErrPluginConfigReceiptUnavailable + if err := m.repo.UpdateConfig(ctx, id, encrypted, installation.BinarySHA256); err != nil { + if !temporary { + err = errors.Join(err, m.restoreRuntimeConfig(id, runtime, previousConfig)) } - } else if !unchanged { - if err = m.repo.UpdateConfig(writeCtx, id, encrypted, installation.BinarySHA256); err != nil { - return nil, err - } - } - if unchanged { - return receipt, nil + return nil, err } if !temporary { - if receipt.Revision > 0 { - current, readErr := m.repo.GetByID(ctx, id) - if readErr != nil { - return nil, readErr - } - if current.Revision != receipt.Revision || current.PackageSHA256 != receipt.PackageSHA256 || current.ConfigEncrypted != encrypted || current.State == PluginStateUpdating { - return nil, ErrPluginStateChanged - } - } - applyCtx, cancel := context.WithTimeout(ctx, 15*time.Second) - _, err = runtime.applyNormalizedConfig(applyCtx, canonical) - cancel() - if err != nil { - m.pausePluginForUpdate(id) - return nil, err - } - m.mu.Lock() runtime.installation.ConfigEncrypted = encrypted - m.mu.Unlock() } - return receipt, nil + return canonical, nil +} + +func (m *PluginManager) restoreRuntimeConfig(id int64, runtime *pluginRuntime, previous json.RawMessage) error { + if runtime == nil { + return nil + } + rollbackCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := runtime.validateAndApplyConfig(rollbackCtx, previous); err != nil { + route := m.route.Load() + if route != nil && route.pluginID == id && route.runtime == runtime { + stateErr := m.markRuntimeUnavailable(route, "插件配置回滚失败: "+err.Error()) + return errors.Join(err, stateErr) + } + return err + } + return nil } func (m *PluginManager) Test(ctx context.Context, id int64) (*pluginv1.TestConfigResponse, error) { @@ -966,9 +789,6 @@ func (m *PluginManager) Test(ctx context.Context, id int64) (*pluginv1.TestConfi if err != nil { return nil, err } - if installation.State == PluginStateUpdating || validatePluginPrecondition(ctx, installation) != nil { - return nil, ErrPluginStateChanged - } configJSON, err := m.decryptConfig(installation) if err != nil { return nil, err @@ -977,9 +797,6 @@ func (m *PluginManager) Test(ctx context.Context, id int64) (*pluginv1.TestConfi runtime := m.runtimes[id] m.mu.Unlock() temporary := false - if runtime != nil && (!samePluginRuntime(runtime.installation, installation) || runtime.installation.ConfigEncrypted != installation.ConfigEncrypted) { - return nil, ErrPluginStateChanged - } if runtime == nil { installation, err = m.ensureLocalInstallation(ctx, installation) if err != nil { @@ -1023,210 +840,88 @@ func (m *PluginManager) Status(ctx context.Context, id int64) (*pluginv1.HealthR } type pluginUIAssetClaims struct { - ActorID int64 `json:"actor_id,omitempty"` - ViewContext *extensionv1.AccountViewIdentityV1 `json:"view_context,omitempty"` - Entrypoint string `json:"entrypoint"` - Permission string `json:"permission"` - PackageSHA256 string `json:"package_sha256"` - Version int `json:"version"` - PluginID int64 `json:"plugin_id"` - Expires int64 `json:"expires"` + Version int `json:"version"` + PluginID int64 `json:"plugin_id"` + Expires int64 `json:"expires"` } // CreateUIAssetToken 创建可跨实例校验的短时能力令牌,令牌不包含管理员凭据。 func (m *PluginManager) CreateUIAssetToken(ctx context.Context, id int64, ttl time.Duration) (string, time.Time, error) { - session, err := m.CreateUIAssetSession(ctx, id, "", "admin", ttl) - if err != nil { - return "", time.Time{}, err - } - return session.Token, session.Expires, nil -} - -type PluginUIAssetSession struct { - ViewContext *extensionv1.AccountViewIdentityV1 - PluginKey string - Token string - Expires time.Time - PackageSHA256 string - Permission string -} - -func (m *PluginManager) CreateUIAssetSession(ctx context.Context, id int64, contribution, permission string, ttl time.Duration) (PluginUIAssetSession, error) { - var session PluginUIAssetSession if ttl <= 0 || ttl > time.Hour { - return session, errors.New("插件 UI 会话有效期无效") - } - installation, err := m.repo.GetByID(ctx, id) - if err != nil { - return session, err - } - if permission != "admin" && permission != "user" { - return session, errors.New("invalid UI permission") - } - entrypoint := installation.Manifest.UI.Entrypoint - var selected *extensionv1.Contribution - if contribution != "" { - found := false - for _, item := range installation.Manifest.Contributions { - if item.ID != contribution || item.Entrypoint == "" || (item.Permission != permission && (permission != "admin" || item.Permission != "user")) { - continue - } - entrypoint, permission, found = item.Entrypoint, item.Permission, true - selected = &item - break - } - if !found { - return session, errors.New("plugin page is not declared for this role") - } - } else if permission != "admin" { - return session, errors.New("a user page contribution is required") - } - // The unqualified administrator page is the plugin configuration surface. - // It must remain reachable while a plugin is disabled or unhealthy so an - // administrator can repair its configuration. It does not authorize any - // plugin operation; resource/invoke/job paths perform their own live gates. - if selected != nil { - // A disabled plugin must not mint a new business-page session, even if a - // stale process or fixture still carries an enabled binding. Error and - // incompatible states intentionally remain renderable so the static page - // can preserve user input while its actions report unavailable. - if installation.State == PluginStateDisabled || !pluginUIContributionBindingEnabled(installation, selected) { - return session, ErrExtensionOperationDisabled - } + return "", time.Time{}, errors.New("插件 UI 会话有效期无效") } - if selected != nil && selected.ConfigFlag != "" { - if enabled, known := m.contributionConfigured(installation, nil, selected.ConfigFlag); known && !enabled { - return session, ErrExtensionOperationDisabled - } + if _, err := m.repo.GetByID(ctx, id); err != nil { + return "", time.Time{}, err } expires := time.Now().Add(ttl) - actorID, _ := ctx.Value(pluginUIActorKey{}).(int64) - var viewIdentity *extensionv1.AccountViewIdentityV1 - if view, bound := AccountViewFromContext(ctx); bound { - if actorID <= 0 { - return session, ErrAccountViewInvalid - } - identity := view.Request.AccountViewIdentityV1 - viewIdentity = &identity - } - raw, err := json.Marshal(pluginUIAssetClaims{Version: 1, PluginID: id, Expires: expires.Unix(), PackageSHA256: installation.PackageSHA256, Entrypoint: entrypoint, Permission: permission, ActorID: actorID, ViewContext: viewIdentity}) + raw, err := json.Marshal(pluginUIAssetClaims{Version: 1, PluginID: id, Expires: expires.Unix()}) if err != nil { - return session, err + return "", time.Time{}, err } // 加用途前缀,避免复用同一 AES-GCM 密钥的其他密文被当作 UI 能力令牌。 encrypted, err := m.encryptor.Encrypt(pluginUITokenPrefix + string(raw)) if err != nil { - return session, fmt.Errorf("加密插件 UI 会话: %w", err) + return "", time.Time{}, fmt.Errorf("加密插件 UI 会话: %w", err) } - return PluginUIAssetSession{Token: base64.RawURLEncoding.EncodeToString([]byte(encrypted)), Expires: expires, PackageSHA256: installation.PackageSHA256, Permission: permission, PluginKey: installation.PluginKey, ViewContext: viewIdentity}, nil -} - -// pluginUIContributionBindingEnabled mirrors the contribution admission rules -// used by the host registry for a newly opened business page. A contribution -// without a capability still needs the plugin to have an enabled binding (for -// example, legacy admin actions whose capability is inferred by the host). A -// global contribution is stronger: only a wildcard binding at 100% can expose -// it because the page has no account-specific scope to apply. -func pluginUIContributionBindingEnabled(installation *PluginInstallation, contribution *extensionv1.Contribution) bool { - return pluginContributionBindingsEnabled(installation, contribution) + return base64.RawURLEncoding.EncodeToString([]byte(encrypted)), expires, nil } func (m *PluginManager) ResolveUIAssetToken(token string) (int64, error) { - claims, err := m.parseUIAssetToken(token) - if err != nil { - return 0, err - } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - installation, err := m.repo.GetByID(ctx, claims.PluginID) - if err != nil || installation.PackageSHA256 != claims.PackageSHA256 { - return 0, ErrPluginUISessionChanged - } - return claims.PluginID, nil -} - -var ErrPluginUISessionChanged = errors.New("插件 UI 会话无效或版本已更新,请重新打开界面") - -func (m *PluginManager) parseUIAssetToken(token string) (*pluginUIAssetClaims, error) { if len(token) == 0 || len(token) > 4096 { - return nil, errors.New("插件 UI 会话无效") + return 0, errors.New("插件 UI 会话无效") } encrypted, err := base64.RawURLEncoding.DecodeString(token) if err != nil { - return nil, errors.New("插件 UI 会话无效") + return 0, errors.New("插件 UI 会话无效") } plaintext, err := m.encryptor.Decrypt(string(encrypted)) if err != nil { - return nil, errors.New("插件 UI 会话无效") + return 0, errors.New("插件 UI 会话无效") } plaintext, ok := strings.CutPrefix(plaintext, pluginUITokenPrefix) if !ok { - return nil, errors.New("插件 UI 会话无效") + return 0, errors.New("插件 UI 会话无效") } var claims pluginUIAssetClaims decoder := json.NewDecoder(strings.NewReader(plaintext)) decoder.DisallowUnknownFields() if err := decoder.Decode(&claims); err != nil || claims.Version != 1 || claims.PluginID <= 0 { - return nil, errors.New("插件 UI 会话无效") + return 0, errors.New("插件 UI 会话无效") } if err := decoder.Decode(&struct{}{}); err != io.EOF { - return nil, errors.New("插件 UI 会话无效") - } - if (claims.Permission != "admin" && claims.Permission != "user") || (claims.Entrypoint != "" && (!safePluginRelativePath(claims.Entrypoint) || !strings.HasPrefix(claims.Entrypoint, "ui/"))) { - return nil, ErrPluginUISessionChanged + return 0, errors.New("插件 UI 会话无效") } now := time.Now().Unix() if now >= claims.Expires { - return nil, errors.New("插件 UI 会话已过期") + return 0, errors.New("插件 UI 会话已过期") } if claims.Expires > now+int64(time.Hour/time.Second) { - return nil, errors.New("插件 UI 会话无效") + return 0, errors.New("插件 UI 会话无效") } - return &claims, nil + return claims.PluginID, nil } func (m *PluginManager) ReadUIAsset(ctx context.Context, id int64, relative string) ([]byte, string, error) { - return m.readUIAsset(ctx, id, relative, nil, "") -} - -func (m *PluginManager) ReadUIAssetForToken(ctx context.Context, token, relative string) ([]byte, string, error) { - claims, err := m.parseUIAssetToken(token) - if err != nil { - return nil, "", ErrPluginUISessionChanged - } - return m.readUIAsset(ctx, claims.PluginID, relative, &claims.PackageSHA256, claims.Entrypoint) -} - -func (m *PluginManager) readUIAsset(ctx context.Context, id int64, relative string, expected *string, entrypoint string) ([]byte, string, error) { m.operationMu.Lock() defer m.operationMu.Unlock() installation, err := m.repo.GetByID(ctx, id) if err != nil { return nil, "", err } - if expected != nil && installation.PackageSHA256 != *expected { - return nil, "", ErrPluginUISessionChanged - } installation, err = m.ensureLocalInstallation(ctx, installation) if err != nil { return nil, "", err } - if entrypoint == "" { - entrypoint = installation.Manifest.UI.Entrypoint - } - logical := strings.TrimPrefix(strings.ReplaceAll(relative, "\\", "/"), "/") - if logical == "" || logical == "index.html" { - logical = entrypoint + path := strings.TrimPrefix(strings.ReplaceAll(relative, "\\", "/"), "/") + if path == "" || path == "index.html" { + path = installation.Manifest.UI.Entrypoint } else { - if !safePluginRelativePath(logical) { - return nil, "", os.ErrNotExist - } - logical = path.Join(path.Dir(entrypoint), logical) + path = "ui/" + path } - if _, declared := installation.Manifest.Files[logical]; !declared || !strings.HasPrefix(logical, "ui/") { + if _, declared := installation.Manifest.Files[path]; !declared || !strings.HasPrefix(path, "ui/") { return nil, "", os.ErrNotExist } - fullPath, err := safePluginJoin(installation.InstallPath, logical) + fullPath, err := safePluginJoin(installation.InstallPath, path) if err != nil { return nil, "", err } @@ -1243,10 +938,10 @@ func (m *PluginManager) readUIAsset(ctx context.Context, id int64, relative stri return nil, "", errors.New("插件 UI 资源超过大小限制") } digest := sha256.Sum256(data) - if hex.EncodeToString(digest[:]) != installation.Manifest.Files[logical] { + if hex.EncodeToString(digest[:]) != installation.Manifest.Files[path] { return nil, "", errors.New("插件 UI 资源哈希不匹配") } - return data, logical, nil + return data, path, nil } func (m *PluginManager) RoundTripOpenAIOAuth(ctx context.Context, request *http.Request, proxyURL string, account *Account) (*http.Response, bool, error) { @@ -1336,10 +1031,11 @@ func (m *PluginManager) publishRuntimeLocked(installation *PluginInstallation, r old.kill() } m.runtimes[installation.ID] = runtime - m.replaceExtensionRegistrationLocked(installation, runtime) - if hasEnabledOpenAIBinding(installation.Bindings) { - m.route.Store(&pluginRoute{pluginID: installation.ID, runtime: runtime, rolloutPercent: bindingRollout(installation.Bindings)}) - } + m.route.Store(&pluginRoute{ + pluginID: installation.ID, + runtime: runtime, + rolloutPercent: bindingRollout(installation.Bindings), + }) } func (m *PluginManager) newRuntime(ctx context.Context, installation *PluginInstallation) (*pluginRuntime, error) { @@ -1348,25 +1044,7 @@ func (m *PluginManager) newRuntime(ctx context.Context, installation *PluginInst return nil, err } timeout := time.Duration(m.cfg.Plugins.StartTimeoutSeconds) * time.Second - var lease PluginRuntimeLease - if installation.RuntimeGeneration > 0 { - var err error - lease, err = acquirePluginRuntimeLease(ctx, m.repo, installation) - if err != nil { - return nil, err - } - } - runtime, err := startPluginRuntime(ctx, installation, timeout, socketDir, m.buildHostServices(installation)) - if err != nil { - if lease != nil { - lease.Release() - } - return nil, err - } - if err := runtime.observeLease(lease); err != nil { - return nil, err - } - return runtime, nil + return startPluginRuntime(ctx, installation, timeout, socketDir, m.buildHostServices(installation)) } // SetAccountDirectory 注入账号目录实现(敏感能力)。仅在启动装配阶段调用一次, @@ -1387,47 +1065,12 @@ func (m *PluginManager) buildHostServices(installation *PluginInstallation) plug } scope := pluginAccountScopeFromManifest(installation.Manifest) var directory PluginAccountDirectory - if installation.Manifest.Requires.ExtensionAPI == 0 && !scope.Empty() { + if !scope.Empty() { m.mu.Lock() directory = m.accountDirectory m.mu.Unlock() } - host := newPluginHostServiceServer(installation.PluginKey, m.kvStore, directory, scope) - if installation.Manifest.Requires.ExtensionAPI > 0 { - // Versioned extensions must use the generation-fenced named broker. - // Offering the legacy KV port as well would let a late old process write - // state by pluginKey alone after its execution generation was replaced. - // The official legacy transport protocol remains unchanged for plugins - // that do not opt into the extension API. - host.store = nil - state, _ := m.repo.(PluginExtensionStateStore) - directory, _ := m.accountDirectory.(PluginExtensionAccountDirectory) - host.extension = &pluginExtensionHost{key: installation.PluginKey, state: state, directory: directory, installation: installation, activity: m.quotaActivity, traffic: m.traffic, accountScope: func(ctx context.Context) (*PluginInstallation, error) { - registry := m.extensions.Load() - if registry == nil || registry.unavailable != "" || !samePluginRuntime(registry.installations[installation.ID], installation) { - return nil, ErrExtensionOperationUnavailable - } - current, err := m.repo.GetByID(ctx, installation.ID) - if err != nil { - return nil, ErrExtensionOperationUnavailable - } - m.mu.Lock() - matches := samePluginRuntime(current, installation) && current.ConfigEncrypted == installation.ConfigEncrypted - m.mu.Unlock() - if !matches || current.State != PluginStateEnabled { - return nil, ErrExtensionOperationUnavailable - } - return current, nil - }, active: func() bool { - registry := m.extensions.Load() - if registry == nil || registry.unavailable != "" { - return false - } - current := registry.installations[installation.ID] - return current != nil && current.State != PluginStateUpdating && samePluginRuntime(current, installation) && hasEnabledPluginBinding(current.Bindings) && pluginDependenciesHealthy(current, registry, map[int64]bool{}) - }} - } - return host + return newPluginHostServiceServer(installation.PluginKey, m.kvStore, directory, scope) } // pluginCapabilityAccountScopeGrants 把「能力 id」映射到它授予的账号范围条目。这是 @@ -1452,29 +1095,7 @@ func pluginAccountScopeFromManifest(manifest PluginManifest) PluginAccountScope return newPluginAccountScope(entries...) } -// pluginDeclaresOpenAIOAuthCapability keeps the single-active OpenAI OAuth -// outbound plugin rule; upstream v0.2.8 dropped it with its account scope rework. -func pluginDeclaresOpenAIOAuthCapability(manifest PluginManifest) bool { - for _, capability := range manifest.Capabilities { - if capability.ID == PluginCapabilityOpenAIOAuthOutbound && - capability.Platform == PlatformOpenAI && capability.AccountType == AccountTypeOAuth { - return true - } - } - return false -} - func (m *PluginManager) removeRuntimeLocked(id int64) *pluginRuntime { - if registry := m.extensions.Load(); registry != nil { - if installation := registry.installations[id]; installation != nil { - copy := *installation - copy.Bindings = append([]PluginBinding(nil), installation.Bindings...) - for i := range copy.Bindings { - copy.Bindings[i].Enabled = false - } - m.replaceExtensionRegistrationLocked(©, nil) - } - } runtime := m.runtimes[id] delete(m.runtimes, id) if route := m.route.Load(); route != nil && route.pluginID == id { diff --git a/backend/internal/service/plugin_manifest.go b/backend/internal/service/plugin_manifest.go index 7de23c1f32f5..b06e3d352d29 100644 --- a/backend/internal/service/plugin_manifest.go +++ b/backend/internal/service/plugin_manifest.go @@ -11,7 +11,6 @@ import ( "strings" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" ) @@ -27,28 +26,22 @@ const ( ) var pluginIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)+$`) -var pluginResourceNamePattern = regexp.MustCompile(`^[a-z][a-z0-9]*(-[a-z0-9]+)*(\.[a-z][a-z0-9]*(-[a-z0-9]+)*)+$`) var ErrPluginStateChanged = errors.New("插件状态已在其他实例中变化,请刷新后重试") // PluginManifest 是 .s2plugin 包中可在执行二进制前检查的声明。 type PluginManifest struct { - SourceRevision string `json:"source_revision,omitempty"` - SchemaVersion int `json:"schema_version"` - ID string `json:"id"` - Name string `json:"name"` - Version string `json:"version"` - Description string `json:"description,omitempty"` - Author string `json:"author,omitempty"` - Requires PluginRequirements `json:"requires"` - Capabilities []PluginCapability `json:"capabilities"` - Runtimes map[string]PluginRuntime `json:"runtimes"` - UI PluginUIManifest `json:"ui"` - Files map[string]string `json:"files"` - Dependencies []extensionv1.Dependency `json:"dependencies,omitempty"` - Contributions []extensionv1.Contribution `json:"contributions,omitempty"` - Operations map[string][]string `json:"operations,omitempty"` - Resources []extensionv1.ResourceGrant `json:"resources,omitempty"` + SchemaVersion int `json:"schema_version"` + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description,omitempty"` + Author string `json:"author,omitempty"` + Requires PluginRequirements `json:"requires"` + Capabilities []PluginCapability `json:"capabilities"` + Runtimes map[string]PluginRuntime `json:"runtimes"` + UI PluginUIManifest `json:"ui"` + Files map[string]string `json:"files"` } type PluginRequirements struct { @@ -58,7 +51,6 @@ type PluginRequirements struct { PluginProtocol int `json:"plugin_protocol"` TransportAPI int `json:"transport_api"` UIBridge int `json:"ui_bridge"` - ExtensionAPI int `json:"extension_api,omitempty"` } type PluginCapability struct { @@ -96,35 +88,30 @@ type PluginCompatibility struct { } type PluginInstallation struct { - Revision int64 `json:"revision"` - RuntimeGeneration int64 `json:"-"` - PackageSHA256 string `json:"package_sha256"` - UpdatePolicy string `json:"update_policy"` - 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"` - ArtifactData []byte `json:"-"` - ArtifactPath string `json:"-"` - InstallPath string `json:"-"` - BinaryPath string `json:"-"` - BinarySHA256 string `json:"binary_sha256"` - SignatureStatus string `json:"signature_status"` - State string `json:"state"` - ConfigEncrypted string `json:"-"` - 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"` - Compatibility PluginCompatibility `json:"compatibility"` - RuntimeHealthy bool `json:"runtime_healthy"` - RuntimeMessage string `json:"runtime_message"` - DesiredEnabled bool `json:"desired_enabled"` + 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"` + ArtifactData []byte `json:"-"` + ArtifactPath string `json:"-"` + InstallPath string `json:"-"` + BinaryPath string `json:"-"` + BinarySHA256 string `json:"binary_sha256"` + SignatureStatus string `json:"signature_status"` + State string `json:"state"` + ConfigEncrypted string `json:"-"` + 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"` + Compatibility PluginCompatibility `json:"compatibility"` + RuntimeHealthy bool `json:"runtime_healthy"` + RuntimeMessage string `json:"runtime_message"` } type PluginBinding struct { @@ -158,13 +145,6 @@ func (m PluginManifest) RuntimeKey() string { } func (m PluginManifest) Validate() error { - return m.ValidateForRuntime(m.RuntimeKey()) -} - -func (m PluginManifest) ValidateForRuntime(runtimeKey string) error { - if m.SourceRevision != "" && !regexp.MustCompile(`^[a-f0-9]{40}$`).MatchString(m.SourceRevision) { - return errors.New("插件源码版本必须是完整提交摘要") - } if m.SchemaVersion != 1 { return fmt.Errorf("不支持的插件清单版本: %d", m.SchemaVersion) } @@ -189,168 +169,13 @@ func (m PluginManifest) ValidateForRuntime(runtimeKey string) error { return errors.New("插件必须声明至少一个能力") } for _, capability := range m.Capabilities { - if extensionv1.IsCapability(capability.ID) { - if m.Requires.ExtensionAPI != extensionv1.Version || capability.Platform == "" || capability.AccountType == "" { - return errors.New("扩展能力必须声明兼容版本及明确作用范围") - } - if capability.ID == extensionv1.CapabilityProvider && (capability.Platform == "*" || capability.AccountType == "*") { - return errors.New("Provider能力必须声明具体平台与账号类型") - } - } else if capability.ID != PluginCapabilityOpenAIOAuthOutbound || capability.Platform != PlatformOpenAI || capability.AccountType != AccountTypeOAuth { - return fmt.Errorf("不支持插件能力 %s", capability.ID) - } - } - if m.Requires.ExtensionAPI != 0 && m.Requires.ExtensionAPI != extensionv1.Version { - return errors.New("扩展 API 版本不兼容") - } - for capability, operations := range m.Operations { - declared := false - for _, entry := range m.Capabilities { - if entry.ID == capability { - declared = true - break - } - } - if !declared || !extensionv1.IsCapability(capability) || len(operations) > 64 { - return errors.New("扩展操作必须属于已声明的能力") - } - seenOperations := map[string]bool{} - for _, operation := range operations { - if len(operation) == 0 || len(operation) > 80 || !regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)*$`).MatchString(operation) || seenOperations[operation] { - return errors.New("扩展操作标识无效或重复") - } - seenOperations[operation] = true - } - } - seen := make(map[string]bool) - if err := validateAccountViewContributions(m); err != nil { - return err - } - if err := validateAccountCreateContributions(m); err != nil { - return err - } - if err := validateAccountEditContributions(m); err != nil { - return err - } - resourceNames := make(map[string]bool) - if len(m.Resources) > 256 { - return errors.New("插件资源操作数量超过限制") - } - for _, resource := range m.Resources { - if !pluginResourceNamePattern.MatchString(resource.Name) || len(resource.Name) > 100 || resourceNames[resource.Name] || (resource.Permission != "admin" && resource.Permission != "user") { - return errors.New("插件资源操作声明无效") - } - declared := false - for _, capability := range m.Capabilities { - declared = declared || capability.ID == resource.Capability - } - if !declared { - return errors.New("插件资源引用未声明的能力") - } - resourceNames[resource.Name] = true - } - for _, contribution := range m.Contributions { - if len(contribution.Events) > 16 { - return errors.New("插件界面事件数量超过限制") - } - for _, event := range contribution.Events { - if !regexp.MustCompile(`^[a-z][a-z0-9_-]{0,63}$`).MatchString(event) { - return errors.New("插件界面事件声明无效") - } - } - if contribution.Capability != "" { - declared := false - for _, capability := range m.Capabilities { - if capability.ID == contribution.Capability { - declared = true - break - } - } - if !declared { - return errors.New("插件界面贡献引用未声明的能力") - } - } - if contribution.AllAccounts && (contribution.Capability == "" || contribution.Permission != "admin") { - return errors.New("全账号界面必须声明管理员权限和依赖能力") - } - if contribution.Slot == "theme" { - if contribution.Permission != "public" || !strings.HasSuffix(contribution.Entrypoint, ".css") || len(contribution.Assets) > 32 { - return errors.New("主题贡献必须声明公开 CSS 和有界资源集合") - } - } else if len(contribution.Assets) != 0 { - return errors.New("只有主题贡献可公开资源") - } - for _, asset := range contribution.Assets { - if !safePluginRelativePath(asset) || !strings.HasPrefix(asset, "ui/") || !strings.HasSuffix(asset, ".woff2") { - return errors.New("主题附加资源必须是包内 WOFF2 字体") - } - } - if contribution.ConfigFlag != "" && !regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`).MatchString(contribution.ConfigFlag) { - return errors.New("插件界面条件必须引用布尔配置字段") - } - if contribution.ID == "" || seen[contribution.ID] || !extensionv1.ValidSlot(contribution.Slot) || - contribution.Permission == "" || len(contribution.Label) == 0 { - return errors.New("插件界面贡献的标识、挂载点或权限无效") - } - seen[contribution.ID] = true - fieldKeys := map[string]bool{} - if len(contribution.DisplayFields) > 16 { - return errors.New("插件显示字段数量超限") - } - for _, field := range contribution.DisplayFields { - if !regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`).MatchString(field.Key) || field.Key == "constructor" || field.Key == "prototype" || fieldKeys[field.Key] || len(field.Prefix) > 32 || len(field.Values) > 64 { - return errors.New("插件显示字段声明无效") - } - if field.Kind != "text" && field.Kind != "badge" && field.Kind != "datetime" { - return errors.New("插件显示字段类型无效") - } - for _, value := range field.Values { - if len(value.Label) == 0 || (value.Tone != "" && value.Tone != "neutral" && value.Tone != "success" && value.Tone != "warning" && value.Tone != "danger" && value.Tone != "info") { - return errors.New("插件显示值声明无效") - } - } - fieldKeys[field.Key] = true - } - for _, field := range contribution.Fields { - validKey := regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`) - if !validKey.MatchString(field.Key) || field.Key == "constructor" || field.Key == "prototype" || fieldKeys[field.Key] || len(field.Label) == 0 || (field.DefaultSource != "" && !validKey.MatchString(field.DefaultSource)) { - return errors.New("插件表单字段声明无效") - } - switch field.Kind { - case "text": - if field.MaxLength < 1 || field.MaxLength > 65536 || field.Rows != 0 || field.OptionsSource != "" { - return errors.New("插件文本字段范围无效") - } - case "select": - if !validKey.MatchString(field.OptionsSource) || len(field.DefaultLabel) == 0 { - return errors.New("插件选择字段声明无效") - } - case "textarea": - if field.Rows < 1 || field.Rows > 12 || field.MaxLength < 1 || field.MaxLength > 65536 { - return errors.New("插件文本字段范围无效") - } - default: - return errors.New("插件表单字段类型无效") - } - fieldKeys[field.Key] = true - } - if contribution.Entrypoint != "" && (!safePluginRelativePath(contribution.Entrypoint) || !strings.HasPrefix(contribution.Entrypoint, "ui/")) { - return errors.New("插件界面贡献入口必须位于 ui/ 目录") - } - if contribution.Entrypoint != "" { - if _, declared := m.Files[contribution.Entrypoint]; !declared { - return errors.New("插件界面贡献入口未包含在签名资源中") - } - } - } - for _, dependency := range m.Dependencies { - if !extensionv1.IsCapability(dependency.Capability) && dependency.Capability != PluginCapabilityOpenAIOAuthOutbound { - return errors.New("插件依赖能力未知") + if capability.ID != PluginCapabilityOpenAIOAuthOutbound || capability.Platform != PlatformOpenAI || capability.AccountType != AccountTypeOAuth { + return fmt.Errorf("初期仅支持能力 %s", PluginCapabilityOpenAIOAuthOutbound) } } - runtimeEntry, ok := m.Runtimes[runtimeKey] + runtimeEntry, ok := m.Runtimes[m.RuntimeKey()] if !ok || !safePluginRelativePath(runtimeEntry.Path) { - return fmt.Errorf("插件不支持当前运行平台 %s", runtimeKey) + return fmt.Errorf("插件不支持当前运行平台 %s", m.RuntimeKey()) } if !safePluginRelativePath(m.UI.Entrypoint) || !strings.HasPrefix(m.UI.Entrypoint, "ui/") { return errors.New("插件 UI 入口必须位于 ui/ 目录") diff --git a/backend/internal/service/plugin_manifest_schema_test.go b/backend/internal/service/plugin_manifest_schema_test.go deleted file mode 100644 index 02b6abc2dd18..000000000000 --- a/backend/internal/service/plugin_manifest_schema_test.go +++ /dev/null @@ -1,130 +0,0 @@ -//go:build unit - -package service - -import ( - "encoding/json" - "os" - "reflect" - "strings" - "testing" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" -) - -// The manifest closes each contribution with additionalProperties:false. -// Mentioning a member only in an if/then condition does not declare it there. -func TestPluginManifestSchemaDeclaresContributionFields(t *testing.T) { - raw, err := os.ReadFile("../../pkg/pluginapi/v1/manifest.schema.json") - if err != nil { - t.Fatal(err) - } - var document struct { - Properties struct { - Contributions struct { - Items struct { - AdditionalProperties bool `json:"additionalProperties"` - Properties map[string]struct { - Type string `json:"type"` - } `json:"properties"` - } `json:"items"` - } `json:"contributions"` - } `json:"properties"` - } - if err := json.Unmarshal(raw, &document); err != nil { - t.Fatal(err) - } - items := document.Properties.Contributions.Items - if items.AdditionalProperties || len(items.Properties) == 0 { - t.Fatal("contributions must declare a closed property set") - } - fields := reflect.TypeOf(extensionv1.Contribution{}) - for index := 0; index < fields.NumField(); index++ { - name := strings.Split(fields.Field(index).Tag.Get("json"), ",")[0] - if name == "" || name == "-" { - continue - } - if _, declared := items.Properties[name]; !declared { - t.Errorf("public contribution field %q is missing from the closed manifest schema", name) - } - } - if items.Properties["all_accounts"].Type != "boolean" { - t.Error("all_accounts must use its existing boolean SDK contract") - } -} - -func TestPluginManifestAcceptsRegisteredHyphenatedResources(t *testing.T) { - raw, err := os.ReadFile("../../../plugins/admin-observability/manifest.source.json") - if err != nil { - t.Fatal(err) - } - var source PluginManifest - if err := json.Unmarshal(raw, &source); err != nil { - t.Fatal(err) - } - if len(source.Resources) == 0 { - t.Fatal("the fixture must use the actual registered Prompt Audit resources") - } - manifest := testPluginManifest(nil) - manifest.Requires.ExtensionAPI = extensionv1.Version - manifest.Capabilities = source.Capabilities - manifest.Resources = source.Resources - if err := manifest.Validate(); err != nil { - t.Fatalf("existing registered resource names must be representable: %v", err) - } -} - -func TestPluginManifestResourcePatternParityAndBoundaries(t *testing.T) { - raw, err := os.ReadFile("../../pkg/pluginapi/v1/manifest.schema.json") - if err != nil { - t.Fatal(err) - } - var schema map[string]any - if err := json.Unmarshal(raw, &schema); err != nil { - t.Fatal(err) - } - lookup := func(keys ...string) string { - var value any = schema - for _, key := range keys { - object, ok := value.(map[string]any) - if !ok { - t.Fatalf("missing schema object before %q", key) - } - value = object[key] - } - text, ok := value.(string) - if !ok { - t.Fatal("missing schema pattern") - } - return text - } - for _, pattern := range []string{ - lookup("properties", "resources", "items", "properties", "name", "pattern"), - lookup("properties", "contributions", "items", "properties", "resource_action", "properties", "resource", "pattern"), - } { - if pattern != pluginResourceNamePattern.String() { - t.Errorf("resource grant, action and runtime name patterns must agree: %q", pattern) - } - } - manifest := testPluginManifest(nil) - manifest.Requires.ExtensionAPI = extensionv1.Version - manifest.Capabilities = []PluginCapability{{ID: extensionv1.CapabilityUI, Platform: "*", AccountType: "*"}} - grant := extensionv1.ResourceGrant{Name: "prompt-audit.batch-delete", Capability: extensionv1.CapabilityUI, Permission: "admin"} - for _, name := range []string{"prompt--audit.config", "prompt-audit-.config", "prompt_audit.config", "prompt-audit..config", "../prompt-audit.config", "prompt-audit.config/extra", "prompt-audit", "Prompt-audit.config", "prompt-audit." + strings.Repeat("a", 90)} { - grant.Name = name - manifest.Resources = []extensionv1.ResourceGrant{grant} - if err := manifest.Validate(); err == nil { - t.Errorf("invalid resource name %q must still be rejected", name) - } - } - grant.Name = "prompt-audit.batch-delete" - manifest.Resources = []extensionv1.ResourceGrant{grant, grant} - if err := manifest.Validate(); err == nil { - t.Error("duplicate grants must still be rejected") - } - grant.Capability = extensionv1.CapabilityAdmin - manifest.Resources = []extensionv1.ResourceGrant{grant} - if err := manifest.Validate(); err == nil { - t.Error("undeclared capabilities must still be rejected") - } -} diff --git a/backend/internal/service/plugin_observability.go b/backend/internal/service/plugin_observability.go deleted file mode 100644 index 04587bdc5a56..000000000000 --- a/backend/internal/service/plugin_observability.go +++ /dev/null @@ -1,47 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "time" - - "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" -) - -func LegacyAdminObservabilityConfig(cfg *config.Config) extensionv1.AdminObservabilityConfig { - return extensionv1.AdminObservabilityConfig{TelemetryEnabled: cfg == nil || !cfg.Gateway.AccountTrafficTelemetryDisabled, ThemeEnabled: true} -} - -func currentTrafficObservationPolicy(ctx context.Context) (extensionv1.TrafficObservationPolicy, bool) { - return resolveTrafficObservationPolicy(ctx, "*", "*", 0) -} - -func currentAccountTrafficObservationPolicy(ctx context.Context, account *Account) (extensionv1.TrafficObservationPolicy, bool) { - if account == nil || account.ID <= 0 || account.Platform == "" || account.Platform == "*" || account.Type == "" || account.Type == "*" { - return extensionv1.TrafficObservationPolicy{}, false - } - return resolveTrafficObservationPolicy(ctx, account.Platform, account.Type, account.ID) -} - -func resolveTrafficObservationPolicy(ctx context.Context, platform, accountType string, accountID int64) (extensionv1.TrafficObservationPolicy, bool) { - if ctx == nil { - ctx = context.Background() - } - call, cancel := context.WithTimeout(ctx, 100*time.Millisecond) - defer cancel() - result, err := invokeProcessExtensionCached(call, platform, accountType, extensionv1.Invocation{Capability: extensionv1.CapabilityObservability, Operation: "observability.policy", AccountID: accountID, Payload: json.RawMessage(`{}`)}) - var policy extensionv1.TrafficObservationPolicy - if err != nil || result.Code != "" || json.Unmarshal(result.Payload, &policy) != nil || policy.Classification.Validate() != nil { - return policy, false - } - if !AccountTrafficOutcome(policy.Classification.Default).Valid() { - return policy, false - } - for _, rule := range policy.Classification.Rules { - if !AccountTrafficOutcome(rule.Result).Valid() { - return policy, false - } - } - return policy, true -} diff --git a/backend/internal/service/plugin_observability_test.go b/backend/internal/service/plugin_observability_test.go deleted file mode 100644 index 782e76cb830d..000000000000 --- a/backend/internal/service/plugin_observability_test.go +++ /dev/null @@ -1,207 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "testing" - - "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/stretchr/testify/require" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -type trafficScopeCache struct { - beginIDs []int64 - protocols []AccountTrafficProtocol - snapshotIDs []int64 - finishedIDs []int64 -} - -func (c *trafficScopeCache) Begin(_ context.Context, id int64, protocol AccountTrafficProtocol) error { - c.beginIDs = append(c.beginIDs, id) - c.protocols = append(c.protocols, protocol) - return nil -} -func (c *trafficScopeCache) Finish(_ context.Context, id int64, _ AccountTrafficProtocol, _ AccountTrafficOutcome) error { - c.finishedIDs = append(c.finishedIDs, id) - return nil -} -func (c *trafficScopeCache) Snapshot(_ context.Context, id int64) (map[AccountTrafficProtocol]AccountTrafficObserveState, error) { - c.snapshotIDs = append(c.snapshotIDs, id) - return map[AccountTrafficProtocol]AccountTrafficObserveState{AccountTrafficProtocolHTTP: {Started: 1}}, nil -} - -type trafficPolicyCall struct { - platform, kind string - invocation extensionv1.Invocation -} - -// The embedded concrete manager supplies the full operation implementation; -// only the already-authorized process boundary is replaced by a fake RPC. -type trafficScopeInvoker struct { - *PluginManager - calls []trafficPolicyCall -} - -func (f *trafficScopeInvoker) InvokeCachedOperation(ctx context.Context, platform, kind string, in extensionv1.Invocation) (extensionv1.Result, error) { - f.calls = append(f.calls, trafficPolicyCall{platform, kind, in}) - return f.PluginManager.InvokeCachedOperation(ctx, platform, kind, in) -} - -var _ extensionv1.OperationInvoker = (*trafficScopeInvoker)(nil) -var _ extensionv1.CachedOperationInvoker = (*trafficScopeInvoker)(nil) - -func scopedTrafficObserver(t *testing.T, platform, kind string, percent int) (*AccountTrafficObserver, *trafficScopeCache, *trafficScopeInvoker, *int) { - t.Helper() - rpcCalls := new(int) - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{}, func(in extensionv1.Invocation) (extensionv1.Result, error) { - *rpcCalls++ - return (promptPolicyFixture{}).InvokeOperation(context.Background(), "", "", in) - }) - installation := manager.extensions.Load().installations[1] - installation.Bindings = []PluginBinding{{Capability: extensionv1.CapabilityObservability, Platform: platform, AccountType: kind, Enabled: true, RolloutPercent: percent}} - installation.Manifest.Operations = map[string][]string{extensionv1.CapabilityObservability: {"observability.policy"}} - invoker := &trafficScopeInvoker{PluginManager: manager} - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - processExtensionOperations.Store(&extensionOperationProvider{invoker: invoker}) - cache := &trafficScopeCache{} - return NewAccountTrafficObserver(cache, nil), cache, invoker, rpcCalls -} - -func TestTrafficObservationScopeUsesActualAccountForBeginAndSnapshot(t *testing.T) { - for _, tc := range []struct { - name, boundPlatform, boundType string - percent int - account Account - allowed bool - }{ - {"zero-rollout", "*", "*", 0, Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth}, false}, - {"half-included", "*", "*", 50, Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth}, true}, - {"half-excluded", "*", "*", 50, Account{ID: 3, Platform: PlatformOpenAI, Type: AccountTypeOAuth}, false}, - {"full-rollout", "*", "*", 100, Account{ID: 3, Platform: PlatformOpenAI, Type: AccountTypeOAuth}, true}, - {"scoped-included", PlatformOpenAI, AccountTypeOAuth, 100, Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth}, true}, - {"other-platform", PlatformOpenAI, AccountTypeOAuth, 100, Account{ID: 2, Platform: PlatformAnthropic, Type: AccountTypeOAuth}, false}, - {"other-type", PlatformOpenAI, AccountTypeOAuth, 100, Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, false}, - } { - for _, protocol := range AccountTrafficProtocols() { - t.Run(tc.name+"/begin-"+string(protocol), func(t *testing.T) { - observer, cache, invoker, rpcCalls := scopedTrafficObserver(t, tc.boundPlatform, tc.boundType, tc.percent) - turn := observer.Begin(context.Background(), &tc.account, protocol) - if tc.allowed { - require.NotNil(t, turn) - require.Equal(t, []int64{tc.account.ID}, cache.beginIDs) - require.Equal(t, []AccountTrafficProtocol{protocol}, cache.protocols) - require.Equal(t, 1, *rpcCalls) - } else { - require.Nil(t, turn, "out-of-scope account must not open a counted turn") - require.Empty(t, cache.beginIDs) - require.Zero(t, *rpcCalls, "excluded account must not reach the policy process") - } - require.Len(t, invoker.calls, 1) - require.Equal(t, tc.account.Platform, invoker.calls[0].platform) - require.Equal(t, tc.account.Type, invoker.calls[0].kind) - require.Equal(t, tc.account.ID, invoker.calls[0].invocation.AccountID) - require.JSONEq(t, `{}`, string(invoker.calls[0].invocation.Payload), "credentials/account bodies must not cross into classification policy") - }) - } - t.Run(tc.name+"/snapshot", func(t *testing.T) { - observer, cache, invoker, rpcCalls := scopedTrafficObserver(t, tc.boundPlatform, tc.boundType, tc.percent) - snapshot, err := observer.Snapshot(context.Background(), &tc.account) - if tc.allowed { - require.NoError(t, err) - require.EqualValues(t, 1, snapshot[AccountTrafficProtocolHTTP].Started) - require.Equal(t, []int64{tc.account.ID}, cache.snapshotIDs) - require.Equal(t, 1, *rpcCalls) - } else { - require.ErrorIs(t, err, ErrAccountTrafficTelemetryUnavailable) - require.Nil(t, snapshot) - require.Empty(t, cache.snapshotIDs) - require.Zero(t, *rpcCalls) - } - require.Len(t, invoker.calls, 1) - require.Equal(t, tc.account.Platform, invoker.calls[0].platform) - require.Equal(t, tc.account.Type, invoker.calls[0].kind) - require.Equal(t, tc.account.ID, invoker.calls[0].invocation.AccountID) - }) - } -} - -func TestTrafficObservationRejectsMissingAccountIdentity(t *testing.T) { - observer, cache, invoker, rpcCalls := scopedTrafficObserver(t, "*", "*", 100) - for _, account := range []*Account{ - nil, - {Platform: PlatformOpenAI, Type: AccountTypeOAuth}, - {ID: 2, Type: AccountTypeOAuth}, - {ID: 2, Platform: PlatformOpenAI}, - {ID: 2, Platform: "*", Type: AccountTypeOAuth}, - {ID: 2, Platform: PlatformOpenAI, Type: "*"}, - } { - require.Nil(t, observer.Begin(context.Background(), account, AccountTrafficProtocolHTTP)) - snapshot, err := observer.Snapshot(context.Background(), account) - require.ErrorIs(t, err, ErrAccountTrafficTelemetryUnavailable) - require.Nil(t, snapshot) - } - require.Empty(t, invoker.calls, "missing account facts must not become a shared policy lookup") - require.Zero(t, *rpcCalls) - require.Empty(t, cache.beginIDs) - require.Empty(t, cache.snapshotIDs) -} - -type trafficPolicyCache struct { - started int - outcomes []AccountTrafficOutcome -} - -func (c *trafficPolicyCache) Begin(context.Context, int64, AccountTrafficProtocol) error { - c.started++ - return nil -} -func (c *trafficPolicyCache) Finish(_ context.Context, _ int64, _ AccountTrafficProtocol, outcome AccountTrafficOutcome) error { - c.outcomes = append(c.outcomes, outcome) - return nil -} -func (*trafficPolicyCache) Snapshot(context.Context, int64) (map[AccountTrafficProtocol]AccountTrafficObserveState, error) { - return map[AccountTrafficProtocol]AccountTrafficObserveState{AccountTrafficProtocolHTTP: {Started: 3, Completed2xx: 2, Upstream429: 1}, "unrecognized": {Started: 42}}, nil -} - -func TestTrafficObservationFinishesWithCapturedPolicyAfterPluginDisable(t *testing.T) { - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - cache := &trafficPolicyCache{} - observer := NewAccountTrafficObserver(cache, nil) - account := &Account{ID: 7, Platform: PlatformOpenAI, Type: AccountTypeOAuth} - turn := observer.Begin(context.Background(), account, AccountTrafficProtocolHTTP) - require.NotNil(t, turn) - processExtensionOperations.Store(nil) - turn.Finish(&OpenAIForwardResult{}, nil, false) - turn.Finish(nil, nil, false) - require.Equal(t, []AccountTrafficOutcome{AccountTrafficOutcomeCompleted2xx}, cache.outcomes) - require.Nil(t, observer.Begin(context.Background(), account, AccountTrafficProtocolHTTP)) - require.Equal(t, 1, cache.started) -} - -func TestMetricsBrokerRequiresCurrentObservabilityCapabilityAndExposesOnlyCounters(t *testing.T) { - directory := &credentialScopeDirectory{} - installation := &PluginInstallation{Manifest: PluginManifest{Capabilities: []PluginCapability{{ID: extensionv1.CapabilityAdmin, Platform: "*", AccountType: "*"}}}} - allowed := true - host := &pluginExtensionHost{directory: directory, installation: installation, traffic: &trafficPolicyCache{}, allows: func(string, string, string, int64) bool { return allowed }} - raw, _ := json.Marshal(extensionv1.AccountQuery{AccountID: 7}) - invocation := extensionv1.HostInvocation{Operation: extensionv1.HostMetricsQuery, Payload: raw} - _, err := host.Call(context.Background(), invocation) - require.Equal(t, codes.PermissionDenied, status.Code(err)) - installation.Manifest.Capabilities = append(installation.Manifest.Capabilities, PluginCapability{ID: extensionv1.CapabilityObservability, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth}) - result, err := host.Call(context.Background(), invocation) - require.NoError(t, err) - var snapshot extensionv1.AccountTrafficSnapshot - require.NoError(t, json.Unmarshal(result.Payload, &snapshot)) - require.EqualValues(t, 7, snapshot.AccountID) - require.Len(t, snapshot.Protocols, 1) - require.EqualValues(t, 3, snapshot.Protocols["http"].Started) - require.Zero(t, directory.calls) - allowed = false - _, err = host.Call(context.Background(), invocation) - require.Equal(t, codes.PermissionDenied, status.Code(err)) -} diff --git a/backend/internal/service/plugin_operation.go b/backend/internal/service/plugin_operation.go deleted file mode 100644 index b9a7183b5068..000000000000 --- a/backend/internal/service/plugin_operation.go +++ /dev/null @@ -1,133 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "slices" - "strconv" - "sync/atomic" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" -) - -var ErrExtensionOperationDisabled = errors.New("extension operation is not enabled") -var ErrExtensionOperationUnavailable = errors.New("enabled extension operation is unavailable") - -type extensionOperationProvider struct{ invoker extensionv1.OperationInvoker } - -var processExtensionOperations atomic.Pointer[extensionOperationProvider] - -func invokeProcessExtension(ctx context.Context, platform, accountType string, in extensionv1.Invocation) (extensionv1.Result, error) { - provider := processExtensionOperations.Load() - if provider == nil { - return extensionv1.Result{}, ErrExtensionOperationDisabled - } - return provider.invoker.InvokeOperation(ctx, platform, accountType, in) -} - -func invokeProcessExtensionCached(ctx context.Context, platform, accountType string, in extensionv1.Invocation) (extensionv1.Result, error) { - provider := processExtensionOperations.Load() - if provider == nil { - return extensionv1.Result{}, ErrExtensionOperationDisabled - } - if cached, ok := provider.invoker.(extensionv1.CachedOperationInvoker); ok { - return cached.InvokeCachedOperation(ctx, platform, accountType, in) - } - return provider.invoker.InvokeOperation(ctx, platform, accountType, in) -} - -// Only callers of immutable configuration-derived policies use this entrypoint. -// Health and activation are checked before consulting the bounded cache. -func (m *PluginManager) InvokeCachedOperation(ctx context.Context, platform, accountType string, in extensionv1.Invocation) (extensionv1.Result, error) { - if err := ctx.Err(); err != nil { - return extensionv1.Result{}, err - } - id, registry, err := m.operationOwner(platform, accountType, in) - if err != nil { - return extensionv1.Result{}, err - } - admission, err := m.admitPluginInvocation(ctx, registry, id, platform, accountType, in) - if err != nil { - return extensionv1.Result{}, err - } - defer admission.release() - runtime := admission.runtime - key := in.Capability + "\x00" + in.Operation + "\x00" + strconv.FormatInt(in.AccountID, 10) + "\x00" + strconv.FormatUint(admission.revision, 10) + "\x00" + string(in.Payload) - if cached, ok := runtime.catalogCache.Load(key); ok { - payload, valid := cached.(json.RawMessage) - if !valid { - return extensionv1.Result{}, ErrExtensionOperationUnavailable - } - var result extensionv1.Result - if json.Unmarshal(payload, &result) == nil { - if err := admission.validate(); err != nil { - return extensionv1.Result{}, err - } - result.PluginID = id - return result, nil - } - } - result, err := admission.invoke(in) - if err != nil { - return result, err - } - if result.Code == "" && runtime.catalogCacheSize.Load() < 2048 { - if raw, encodeErr := json.Marshal(result); encodeErr == nil { - if _, loaded := runtime.catalogCache.LoadOrStore(key, json.RawMessage(raw)); !loaded { - runtime.catalogCacheSize.Add(1) - } - } - } - return result, nil -} - -// Explicit operation declarations keep independent request extensions from -// being called for another domain's hook. Conflicting owners fail closed. -func (m *PluginManager) InvokeOperation(ctx context.Context, platform, accountType string, in extensionv1.Invocation) (extensionv1.Result, error) { - selected, _, err := m.operationOwner(platform, accountType, in) - if err != nil { - return extensionv1.Result{}, err - } - result, err := m.InvokeExtension(ctx, selected, platform, accountType, in) - if err != nil { - return extensionv1.Result{}, ErrExtensionOperationUnavailable - } - return result, nil -} - -func (m *PluginManager) operationOwner(platform, accountType string, in extensionv1.Invocation) (int64, *pluginExtensionRegistry, error) { - registry := m.extensions.Load() - if registry == nil || registry.unavailable != "" { - return 0, nil, ErrExtensionOperationUnavailable - } - var selected int64 - for id, installation := range registry.installations { - if !pluginHasInvocationCapability(installation, in, platform, accountType) || !slices.Contains(installation.Manifest.Operations[in.Capability], in.Operation) { - continue - } - if selected != 0 { - return 0, nil, ErrExtensionOperationUnavailable - } - selected = id - } - if selected == 0 { - return 0, nil, ErrExtensionOperationDisabled - } - return selected, registry, nil -} - -func pluginHasInvocationCapability(installation *PluginInstallation, in extensionv1.Invocation, platform, accountType string) bool { - if !pluginHasCapability(installation, in.Capability, platform, accountType) { - return false - } - if in.AccountID <= 0 { - return true - } - for _, binding := range installation.Bindings { - if binding.Enabled && binding.Capability == in.Capability && pluginScopeMatches(binding.Platform, platform) && pluginScopeMatches(binding.AccountType, accountType) && int(stablePluginBucket(in.AccountID)) < binding.RolloutPercent { - return true - } - } - return false -} diff --git a/backend/internal/service/plugin_operation_admission.go b/backend/internal/service/plugin_operation_admission.go deleted file mode 100644 index 1d49ade6f943..000000000000 --- a/backend/internal/service/plugin_operation_admission.go +++ /dev/null @@ -1,93 +0,0 @@ -package service - -import ( - "context" - "slices" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" -) - -type pluginInvocationAdmission struct { - ctx context.Context - release context.CancelFunc - runtime *pluginRuntime - revision uint64 - owner int64 -} - -// Admission precedes both a cached policy read and a process call. A local -// registry can lag another host's persisted disable/config/binding changes. -// The exact business lease fences the fresh snapshot against target updates; -// it is released after this operation, not inherited by a later host IO call. -func (m *PluginManager) admitPluginInvocation(ctx context.Context, registry *pluginExtensionRegistry, id int64, platform, accountType string, in extensionv1.Invocation) (*pluginInvocationAdmission, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - if in.AccountID < 0 || registry == nil || registry.unavailable != "" { - return nil, ErrExtensionOperationUnavailable - } - installation := registry.installations[id] - runtime := registry.runtimes[id] - if installation == nil || runtime == nil || runtime.client == nil || runtime.extension == nil || runtime.client.Exited() { - return nil, ErrExtensionOperationUnavailable - } - if m.repo != nil { - current, err := m.repo.GetByID(ctx, id) - if err != nil || !samePluginRuntime(current, installation) { - return nil, ErrExtensionOperationUnavailable - } - if current.State == PluginStateDisabled { - return nil, ErrExtensionOperationDisabled - } - if current.State != PluginStateEnabled { - return nil, ErrExtensionOperationUnavailable - } - // Older direct injection adapters may have no operation declarations. - // A declared operation may never disappear merely because our registry - // still remembers the previous declaration. - if _, declared := installation.Manifest.Operations[in.Capability]; declared && !slices.Contains(current.Manifest.Operations[in.Capability], in.Operation) { - return nil, ErrExtensionOperationDisabled - } - installation = current - } - // Repository-free process-contract fixtures retain their explicit local - // snapshot; production managers always use the persisted port above. - if !pluginHasInvocationCapability(installation, in, platform, accountType) { - return nil, ErrExtensionOperationDisabled - } - if !pluginDependenciesHealthy(installation, registry, map[int64]bool{}) { - return nil, ErrExtensionOperationUnavailable - } - bound, release, err := m.bindHostPolicyContext(ctx, installation, runtime) - if err != nil { - return nil, err - } - admission := &pluginInvocationAdmission{ctx: bound, release: release, runtime: runtime, revision: runtime.configRevision.Load(), owner: id} - if err := admission.validate(); err != nil { - release() - return nil, err - } - return admission, nil -} - -func (a *pluginInvocationAdmission) validate() error { - if err := a.ctx.Err(); err != nil { - return err - } - if a.runtime.draining.Load() || a.runtime.configuring.Load() || a.runtime.client.Exited() || a.runtime.configRevision.Load() != a.revision { - return ErrExtensionOperationUnavailable - } - return nil -} - -func (a *pluginInvocationAdmission) invoke(in extensionv1.Invocation) (extensionv1.Result, error) { - result, err := a.runtime.extension.Invoke(a.ctx, in) - if err != nil { - return extensionv1.Result{}, err - } - if err := a.validate(); err != nil { - return extensionv1.Result{}, err - } - result.PluginID = a.owner - return result, nil -} diff --git a/backend/internal/service/plugin_operation_admission_test.go b/backend/internal/service/plugin_operation_admission_test.go deleted file mode 100644 index 6304ecad832e..000000000000 --- a/backend/internal/service/plugin_operation_admission_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package service - -import ( - "context" - "strings" - "testing" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/stretchr/testify/require" -) - -func TestNamedPolicyCacheRechecksPersistedAdmission(t *testing.T) { - for _, change := range []struct { - name string - edit func(*PluginInstallation) - }{ - {"disabled", func(i *PluginInstallation) { i.State = PluginStateDisabled; i.Bindings[0].Enabled = false }}, - {"rollout", func(i *PluginInstallation) { i.Bindings[0].RolloutPercent = 0 }}, - {"config", func(i *PluginInstallation) { i.ConfigEncrypted = "new-config" }}, - {"package", func(i *PluginInstallation) { i.PackageSHA256 = strings.Repeat("c", 64) }}, - {"operation", func(i *PluginInstallation) { i.Manifest.Operations = map[string][]string{} }}, - } { - t.Run(change.name, func(t *testing.T) { - manager, repo, runtime := hostIOAdmissionManager(t) - calls := 0 - runtime.extension = extensionv1.NewClient(&ticketExtensionTestConn{invoke: func(extensionv1.Invocation) (extensionv1.Result, error) { - calls++ - return extensionv1.Result{Payload: []byte(`{"allowed":true}`)}, nil - }}) - in := extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "host.io", AccountID: 42, Payload: []byte(`{}`)} - _, err := manager.InvokeCachedOperation(context.Background(), PlatformOpenAI, AccountTypeAPIKey, in) - require.NoError(t, err) - require.Equal(t, 1, calls, "positive control must invoke the actual serialized boundary") - _, err = manager.InvokeCachedOperation(context.Background(), PlatformOpenAI, AccountTypeAPIKey, in) - require.NoError(t, err) - require.Equal(t, 1, calls, "unchanged policy can still use its cache") - require.Equal(t, 2, repo.businessHolds, "a cache hit still acquires current business admission") - repo.current.Revision++ - change.edit(repo.current) - _, err = manager.InvokeCachedOperation(context.Background(), PlatformOpenAI, AccountTypeAPIKey, in) - require.Error(t, err, "a stale local registry cannot authorize a cached policy") - require.Equal(t, 1, calls) - require.Zero(t, repo.active) - require.Zero(t, runtime.inFlight.Load()) - }) - } -} - -func TestNamedPolicyInvocationRejectsChangedAppliedConfigAndCanceledResult(t *testing.T) { - for _, scenario := range []string{"unapplied", "changed-during-call", "canceled-during-call"} { - t.Run(scenario, func(t *testing.T) { - manager, repo, runtime := hostIOAdmissionManager(t) - calls := 0 - runtime.extension = extensionv1.NewClient(&ticketExtensionTestConn{invoke: func(extensionv1.Invocation) (extensionv1.Result, error) { - calls++ - switch scenario { - case "changed-during-call": - runtime.configRevision.Add(1) - case "canceled-during-call": - runtime.beginDrain() - } - return extensionv1.Result{Payload: []byte(`{"allowed":true}`)}, nil - }}) - if scenario == "unapplied" { - repo.current.ConfigEncrypted = "new-config" - repo.current.Revision++ - } - _, err := manager.InvokeOperation(context.Background(), PlatformOpenAI, AccountTypeAPIKey, - extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "host.io", AccountID: 42, Payload: []byte(`{}`)}) - require.Error(t, err) - if scenario == "unapplied" { - require.Zero(t, calls) - } else { - require.Equal(t, 1, calls) - } - require.Zero(t, repo.active) - require.Zero(t, runtime.inFlight.Load()) - }) - } -} - -func TestNamedPolicyInvocationRejectsInvalidCachedPayload(t *testing.T) { - manager, repo, runtime := hostIOAdmissionManager(t) - calls := 0 - runtime.extension = extensionv1.NewClient(&ticketExtensionTestConn{invoke: func(extensionv1.Invocation) (extensionv1.Result, error) { - calls++ - return extensionv1.Result{Payload: []byte(`{"allowed":true}`)}, nil - }}) - in := extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "host.io", AccountID: 42, Payload: []byte(`{}`)} - _, err := manager.InvokeCachedOperation(context.Background(), PlatformOpenAI, AccountTypeAPIKey, in) - require.NoError(t, err) - require.Equal(t, 1, calls) - var keys []any - runtime.catalogCache.Range(func(key, _ any) bool { - keys = append(keys, key) - return true - }) - require.Len(t, keys, 1) - runtime.catalogCache.Store(keys[0], false) - _, err = manager.InvokeCachedOperation(context.Background(), PlatformOpenAI, AccountTypeAPIKey, in) - require.ErrorIs(t, err, ErrExtensionOperationUnavailable) - require.Equal(t, 1, calls, "invalid cached policy must not invoke a fallback") - require.Zero(t, repo.active) - require.Zero(t, runtime.inFlight.Load()) -} diff --git a/backend/internal/service/plugin_operation_context.go b/backend/internal/service/plugin_operation_context.go deleted file mode 100644 index 377e2ad15097..000000000000 --- a/backend/internal/service/plugin_operation_context.go +++ /dev/null @@ -1,147 +0,0 @@ -package service - -import ( - "context" - "sync" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" -) - -// Host IO executed under a plugin policy must stop when that policy is -// disabled, replaced, or reconfigured. Persistent results stay host-owned. -type extensionOperationContextBinder interface { - BindOperationContext(context.Context, string, string, extensionv1.Invocation) (context.Context, context.CancelFunc, error) -} - -type pluginPolicySignalsKey struct{} - -// Policy cancellation is independent from a client disconnect. Upstream IO -// may detach from that disconnect, but must still stop on plugin replacement. -func detachPluginPolicyContext(parent context.Context) (context.Context, context.CancelFunc) { - signals, _ := parent.Value(pluginPolicySignalsKey{}).([]context.Context) - if len(signals) == 0 { - return context.WithoutCancel(parent), func() {} - } - ctx, cancel := context.WithCancel(context.WithoutCancel(parent)) - stops := make([]func() bool, 0, len(signals)) - for _, signal := range signals { - if signal.Err() != nil { - cancel() - } - stops = append(stops, context.AfterFunc(signal, cancel)) - } - return ctx, func() { - for _, stop := range stops { - stop() - } - cancel() - } -} - -func bindProcessExtensionContext(ctx context.Context, platform, accountType string, in extensionv1.Invocation) (context.Context, context.CancelFunc, error) { - provider := processExtensionOperations.Load() - if provider == nil { - return nil, nil, ErrExtensionOperationDisabled - } - if binder, ok := provider.invoker.(extensionOperationContextBinder); ok { - return binder.BindOperationContext(ctx, platform, accountType, in) - } - // In-process contract fixtures use only the caller's cancellation lifetime. - bound, cancel := context.WithCancel(ctx) - return bound, cancel, nil -} - -func (m *PluginManager) BindOperationContext(ctx context.Context, platform, accountType string, in extensionv1.Invocation) (context.Context, context.CancelFunc, error) { - id, registry, err := m.operationOwner(platform, accountType, in) - if err != nil { - return nil, nil, err - } - runtime := registry.runtimes[id] - if runtime == nil || runtime.client == nil || runtime.client.Exited() || !pluginDependenciesHealthy(registry.installations[id], registry, map[int64]bool{}) { - return nil, nil, ErrExtensionOperationUnavailable - } - return m.bindHostPolicyContext(ctx, registry.installations[id], runtime) -} - -func (m *PluginManager) bindHostPolicyContext(ctx context.Context, installation *PluginInstallation, runtime *pluginRuntime) (context.Context, context.CancelFunc, error) { - // A freshly read installation must not certify a process that still runs an - // older package, generation or applied configuration. Config publication - // updates runtime.installation under this same short-lived manager lock. - m.mu.Lock() - applied := runtime != nil && samePluginRuntime(installation, runtime.installation) && - installation.ConfigEncrypted == runtime.installation.ConfigEncrypted - m.mu.Unlock() - if !applied || !runtime.beginRequest() { - return nil, nil, ErrExtensionOperationUnavailable - } - bound, cancel, err := runtime.bindPolicyContext(ctx) - if err != nil { - runtime.finishRequest() - return nil, nil, err - } - // Mark only this admission call. The returned context must not turn a - // later disabled-plugin Validate/Test process lease into a business lease. - lease, err := acquirePluginRuntimeLease(WithPluginBusinessIOLease(bound), m.repo, installation) - if err != nil { - cancel() - runtime.finishRequest() - return nil, nil, ErrExtensionOperationUnavailable - } - // cancel also revokes the independent policy signal retained by detached - // upstream contexts. Keep the lease and in-flight slot until actual cleanup. - watchPluginRuntimeLease(lease, cancel) - var once sync.Once - return bound, func() { - once.Do(func() { - cancel() - if lease != nil { - lease.Release() - } - runtime.finishRequest() - }) - }, nil -} - -func (r *pluginRuntime) bindPolicyContext(parent context.Context) (context.Context, context.CancelFunc, error) { - r.policyMu.Lock() - defer r.policyMu.Unlock() - if r.draining.Load() || r.configuring.Load() { - return nil, nil, ErrExtensionOperationUnavailable - } - ctx, cancel := context.WithCancel(parent) - signal, stopPolicy := context.WithCancel(context.Background()) - signals, _ := parent.Value(pluginPolicySignalsKey{}).([]context.Context) - ctx = context.WithValue(ctx, pluginPolicySignalsKey{}, append(append([]context.Context{}, signals...), signal)) - stopPropagation := context.AfterFunc(signal, cancel) - if r.policyLeases == nil { - r.policyLeases = map[uint64]context.CancelFunc{} - } - r.policyLeaseID++ - id := r.policyLeaseID - r.policyLeases[id] = func() { stopPolicy(); cancel() } - var once sync.Once - return ctx, func() { - once.Do(func() { - stopPropagation() - stopPolicy() - cancel() - r.policyMu.Lock() - delete(r.policyLeases, id) - r.policyMu.Unlock() - }) - }, nil -} - -func (r *pluginRuntime) cancelPolicyContexts() { - r.policyMu.Lock() - defer r.policyMu.Unlock() - for _, cancel := range r.policyLeases { - cancel() - } - clear(r.policyLeases) -} - -func (r *pluginRuntime) beginDrain() { - r.draining.Store(true) - r.cancelPolicyContexts() -} diff --git a/backend/internal/service/plugin_operation_context_test.go b/backend/internal/service/plugin_operation_context_test.go deleted file mode 100644 index 22e05cb14380..000000000000 --- a/backend/internal/service/plugin_operation_context_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package service - -import ( - "context" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -func TestPluginPolicyCancellationSurvivesUpstreamDetachWithoutCancelingBilling(t *testing.T) { - for _, stream := range []bool{false, true} { - parent, disconnect := context.WithCancel(context.Background()) - first, second := &pluginRuntime{}, &pluginRuntime{} - ctx, releaseFirst, err := first.bindPolicyContext(parent) - require.NoError(t, err) - ctx, releaseSecond, err := second.bindPolicyContext(ctx) - require.NoError(t, err) - var upstream context.Context - var releaseUpstream context.CancelFunc - if stream { - upstream, releaseUpstream = detachStreamUpstreamContext(ctx, true) - } else { - upstream, releaseUpstream = detachUpstreamContext(ctx) - } - billing, releaseBilling := detachedBillingContext(ctx) - disconnect() - require.NoError(t, upstream.Err(), "client disconnect must preserve upstream settlement") - first.beginDrain() - select { - case <-upstream.Done(): - case <-time.After(time.Second): - t.Fatal("plugin stop was detached from upstream IO") - } - require.NoError(t, billing.Err(), "already observed usage still commits") - late, releaseLate := detachUpstreamContext(ctx) - require.ErrorIs(t, late.Err(), context.Canceled, "a stopped policy cannot start a late request") - releaseLate() - releaseBilling() - releaseUpstream() - releaseSecond() - releaseFirst() - } -} - -func TestPluginPolicyContextsStopOnConfigurationChangeAndDisable(t *testing.T) { - runtime := &pluginRuntime{} - first, releaseFirst, err := runtime.bindPolicyContext(context.Background()) - require.NoError(t, err) - defer releaseFirst() - runtime.configuring.Store(true) - runtime.cancelPolicyContexts() - require.ErrorIs(t, first.Err(), context.Canceled) - _, _, err = runtime.bindPolicyContext(context.Background()) - require.ErrorIs(t, err, ErrExtensionOperationUnavailable) - runtime.configuring.Store(false) - second, releaseSecond, err := runtime.bindPolicyContext(context.Background()) - require.NoError(t, err) - defer releaseSecond() - require.NoError(t, second.Err()) - runtime.beginDrain() - require.ErrorIs(t, second.Err(), context.Canceled) - _, _, err = runtime.bindPolicyContext(context.Background()) - require.ErrorIs(t, err, ErrExtensionOperationUnavailable) - releaseFirst() - releaseSecond() -} - -func TestSavingIdenticalPluginConfigurationDoesNotCancelHostWork(t *testing.T) { - client := &normalizingPluginClient{normalized: []byte(`{"enabled":true}`)} - runtime := &pluginRuntime{api: client} - require.NoError(t, runtime.validateAndApplyConfig(context.Background(), []byte(`{}`))) - work, release, err := runtime.bindPolicyContext(context.Background()) - require.NoError(t, err) - defer release() - client.applied = nil - require.NoError(t, runtime.validateAndApplyConfig(context.Background(), []byte(`{"enabled":true}`))) - require.Empty(t, client.applied) - require.NoError(t, work.Err()) - client.normalized = []byte(`{"enabled":false}`) - require.NoError(t, runtime.validateAndApplyConfig(context.Background(), []byte(`{"enabled":false}`))) - require.ErrorIs(t, work.Err(), context.Canceled) -} diff --git a/backend/internal/service/plugin_operation_test.go b/backend/internal/service/plugin_operation_test.go deleted file mode 100644 index a97ea29855ab..000000000000 --- a/backend/internal/service/plugin_operation_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "net/http" - "testing" - - "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/stretchr/testify/require" -) - -func TestRequestExtensionCannotReplaceHostCredentialsOrFraming(t *testing.T) { - for _, bad := range []map[string]string{ - {"Authorization": "replacement"}, {"ChatGPT-Account-ID": "replacement"}, - {"Content-Length": "0"}, {"OpenAI-Project": "replacement"}, - {"X-Invalid": "value\r\ninjected: true"}, {"X-Fixture": "one", "x-fixture": "two"}, - } { - t.Run(func() string { raw, _ := json.Marshal(bad); return string(raw) }(), func(t *testing.T) { - bad["X-Allowed"] = "must-not-partially-apply" - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{}, func(extensionv1.Invocation) (extensionv1.Result, error) { - raw, _ := json.Marshal(map[string]any{"headers": bad}) - return extensionv1.Result{Payload: raw}, nil - }) - headers := http.Header{"Authorization": []string{"Bearer host-fixture"}} - require.Error(t, manager.ApplyRequestHeaders(context.Background(), ticketTestAccount(41), "gpt-6-astra", headers)) - require.Equal(t, http.Header{"Authorization": []string{"Bearer host-fixture"}}, headers) - }) - } -} - -func TestNamedRequestOperationsDoNotReceiveAnotherDomainsHeaders(t *testing.T) { - firstCalls, secondCalls := 0, 0 - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{}, func(in extensionv1.Invocation) (extensionv1.Result, error) { - firstCalls++ - require.Equal(t, "inject", in.Operation) - return extensionv1.Result{Payload: []byte(`{"headers":{"X-Ticket-Fixture":"ready"}}`)}, nil - }) - second := ticketTestManager(t, config.OpenAICodexTicketConfig{}, func(in extensionv1.Invocation) (extensionv1.Result, error) { - secondCalls++ - require.Equal(t, "prompt.plan", in.Operation) - return extensionv1.Result{PluginID: 999, Payload: []byte(`{}`)}, nil - }) - registry := manager.extensions.Load() - other := second.extensions.Load() - installation := other.installations[1] - installation.ID = 2 - installation.Manifest.Operations = map[string][]string{extensionv1.CapabilityRequest: {"prompt.plan"}} - registry.installations[2], registry.runtimes[2] = installation, other.runtimes[1] - manager.extensions.Store(registry) - headers := http.Header{} - require.NoError(t, manager.ApplyRequestHeaders(context.Background(), ticketTestAccount(41), "gpt-6-astra", headers)) - require.Equal(t, 1, firstCalls) - require.Zero(t, secondCalls) - require.Equal(t, "ready", headers.Get("X-Ticket-Fixture")) - request := extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "prompt.plan", Payload: []byte(`{}`)} - result, err := manager.InvokeOperation(context.Background(), PlatformOpenAI, AccountTypeOAuth, request) - require.NoError(t, err) - require.EqualValues(t, 2, result.PluginID, "the host owns operation provenance") - require.Equal(t, 1, secondCalls) - _, err = manager.InvokeOperation(context.Background(), PlatformOpenAI, AccountTypeAPIKey, request) - require.ErrorIs(t, err, ErrExtensionOperationDisabled) - registry.runtimes[2].draining.Store(true) - _, err = manager.InvokeOperation(context.Background(), PlatformOpenAI, AccountTypeOAuth, request) - require.ErrorIs(t, err, ErrExtensionOperationUnavailable) -} - -func TestNamedOperationOwnershipRejectsOverlappingWildcardBindings(t *testing.T) { - one := &PluginInstallation{ID: 1, Bindings: []PluginBinding{{Capability: extensionv1.CapabilityRequest, Platform: "openai", AccountType: "*", Enabled: true}}, Manifest: PluginManifest{Operations: map[string][]string{extensionv1.CapabilityRequest: {"prompt.plan"}}}} - two := &PluginInstallation{ID: 2, Bindings: []PluginBinding{{Capability: extensionv1.CapabilityRequest, Platform: "openai", AccountType: "oauth", Enabled: true}}, Manifest: PluginManifest{Operations: map[string][]string{extensionv1.CapabilityRequest: {"prompt.plan"}}}} - require.Error(t, validatePluginRegistry([]*PluginInstallation{one, two})) - two.Manifest.Operations[extensionv1.CapabilityRequest] = []string{"another.operation"} - require.NoError(t, validatePluginRegistry([]*PluginInstallation{one, two})) - two.Manifest.Operations[extensionv1.CapabilityRequest] = []string{"prompt.plan"} - two.Bindings[0].Enabled = false - require.NoError(t, validatePluginRegistry([]*PluginInstallation{one, two})) -} diff --git a/backend/internal/service/plugin_package.go b/backend/internal/service/plugin_package.go index 65d815965178..a6aceaf95702 100644 --- a/backend/internal/service/plugin_package.go +++ b/backend/internal/service/plugin_package.go @@ -17,7 +17,6 @@ import ( "strings" "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" ) const ( @@ -30,10 +29,9 @@ const ( ) type PluginPackageInstaller struct { - bundledPublishers map[string]string - cfg *config.Config - hostInfo PluginHostInfo - rootDir string + cfg *config.Config + hostInfo PluginHostInfo + rootDir string } func NewPluginPackageInstaller(cfg *config.Config, hostInfo PluginHostInfo) *PluginPackageInstaller { @@ -167,8 +165,6 @@ func (i *PluginPackageInstaller) Install(ctx context.Context, reader io.Reader, } runtimeEntry := manifest.Runtimes[manifest.RuntimeKey()] return &PluginInstallation{ - PackageSHA256: artifactSHA, - UpdatePolicy: PluginUpdatePinned, PluginKey: manifest.ID, Name: manifest.Name, Version: manifest.Version, @@ -188,10 +184,6 @@ func (i *PluginPackageInstaller) Install(ctx context.Context, reader io.Reader, } func (i *PluginPackageInstaller) inspectArchive(archive *zip.Reader) (PluginManifest, []byte, string, error) { - return i.inspectArchiveForRuntime(archive, PluginManifest{}.RuntimeKey()) -} - -func (i *PluginPackageInstaller) inspectArchiveForRuntime(archive *zip.Reader, runtimeKey string) (PluginManifest, []byte, string, error) { if len(archive.File) == 0 || len(archive.File) > pluginArchiveMaxFiles { return PluginManifest{}, nil, "", errors.New("插件包文件数量无效") } @@ -237,7 +229,7 @@ func (i *PluginPackageInstaller) inspectArchiveForRuntime(archive *zip.Reader, r if err := decoder.Decode(&struct{}{}); err != io.EOF { return PluginManifest{}, nil, "", errors.New("插件清单只能包含一个 JSON 对象") } - if err := manifest.ValidateForRuntime(runtimeKey); err != nil { + if err := manifest.Validate(); err != nil { return PluginManifest{}, nil, "", err } for path := range entries { @@ -260,35 +252,6 @@ func (i *PluginPackageInstaller) inspectArchiveForRuntime(archive *zip.Reader, r return manifest, manifestRaw, signatureStatus, nil } -// VerifyPackage checks foreign-target artifacts during image cross compilation. -// It reuses the production archive and extraction checks without registering or -// executing a package, and the scratch extraction is always removed. -func (i *PluginPackageInstaller) VerifyPackage(ctx context.Context, artifact []byte, runtimeKey string) (PluginManifest, error) { - if i == nil || i.cfg == nil || int64(len(artifact)) > i.cfg.Plugins.MaxUploadBytes { - return PluginManifest{}, errors.New("invalid package size") - } - archive, err := zip.NewReader(bytes.NewReader(artifact), int64(len(artifact))) - if err != nil { - return PluginManifest{}, err - } - manifest, _, _, err := i.inspectArchiveForRuntime(archive, runtimeKey) - if err != nil { - return PluginManifest{}, err - } - if err = os.MkdirAll(i.RootDir(), 0700); err != nil { - return PluginManifest{}, err - } - stage, err := os.MkdirTemp(i.RootDir(), "verify-") - if err != nil { - return PluginManifest{}, err - } - defer func() { _ = os.RemoveAll(stage) }() - if err = i.extractArchive(ctx, archive, manifest, stage); err != nil { - return PluginManifest{}, err - } - return manifest, nil -} - func (i *PluginPackageInstaller) verifySignature(file *zip.File, manifestRaw []byte, pluginID string) (string, error) { if file == nil { if i.cfg.Plugins.AllowUnsigned { @@ -308,9 +271,6 @@ func (i *PluginPackageInstaller) verifySignature(file *zip.File, manifestRaw []b return "", errors.New("插件签名算法或密钥 ID 无效") } encodedKey := trustedPluginPublisherKey(i.cfg, signature.KeyID, pluginID) - if encodedKey == "" && strings.HasPrefix(pluginID, "codexrip.") { - encodedKey = i.bundledPublishers[signature.KeyID] - } if encodedKey == "" { return "", fmt.Errorf("插件发布者密钥不受信任: %s", signature.KeyID) } @@ -326,9 +286,6 @@ func (i *PluginPackageInstaller) verifySignature(file *zip.File, manifestRaw []b } func trustedPluginPublisherKey(cfg *config.Config, keyID, pluginID string) string { - if key, owned := extensionv1.FirstPartyPublisherKey(keyID, pluginID); owned { - return key - } // 内置公钥是官方私有插件的固定信任根,不允许被部署配置覆盖。 if keyID == builtInOpenAITransportPublisherKeyID { if pluginID != builtInOpenAITransportPluginID { diff --git a/backend/internal/service/plugin_package_test.go b/backend/internal/service/plugin_package_test.go index 6a3fb7064b8c..194184dbc5b0 100644 --- a/backend/internal/service/plugin_package_test.go +++ b/backend/internal/service/plugin_package_test.go @@ -13,7 +13,6 @@ import ( "os" "path/filepath" "runtime" - "strings" "testing" "github.com/Wei-Shaw/sub2api/internal/config" @@ -84,28 +83,6 @@ func TestPluginPackageInstallerVerifiesTrustedSignature(t *testing.T) { assert.Equal(t, PluginSignatureTrusted, installation.SignatureStatus) } -func TestPluginPackageVerifierChecksForeignRuntimeAndEveryAsset(t *testing.T) { - public, private, err := ed25519.GenerateKey(rand.Reader) - require.NoError(t, err) - cfg := testPluginConfig(t.TempDir(), false) - cfg.Plugins.TrustedPublishers["verify-test"] = base64.StdEncoding.EncodeToString(public) - inspector := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"}) - manifest := testPluginManifest(nil) - manifest.Runtimes = map[string]PluginRuntime{"linux-arm64": {Path: "bin/plugin"}} - archive := buildPluginArchive(t, manifest, private, "verify-test", nil) - verified, err := inspector.VerifyPackage(context.Background(), archive, "linux-arm64") - require.NoError(t, err) - require.Equal(t, manifest.ID, verified.ID) - // The fixture's program is just the word "binary". Verification must never - // execute it, including when its target differs from this machine. - _, err = inspector.VerifyPackage(context.Background(), archive, "linux-amd64") - require.Error(t, err) - manifest.Files["ui/index.html"] = strings.Repeat("0", 64) - archive = buildPluginArchive(t, manifest, private, "verify-test", nil) - _, err = inspector.VerifyPackage(context.Background(), archive, "linux-arm64") - require.Error(t, err) -} - func TestBuiltInOpenAITransportPublisherDoesNotRequireConfiguration(t *testing.T) { cfg := testPluginConfig(t.TempDir(), false) cfg.Plugins.TrustedPublishers[builtInOpenAITransportPublisherKeyID] = "不能覆盖内置公钥" diff --git a/backend/internal/service/plugin_precondition.go b/backend/internal/service/plugin_precondition.go deleted file mode 100644 index 1cda0246c4ac..000000000000 --- a/backend/internal/service/plugin_precondition.go +++ /dev/null @@ -1,41 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - - infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" -) - -type pluginExpectedPackageKey struct{} - -func WithPluginExpectedPackage(ctx context.Context, digest string) context.Context { - return context.WithValue(ctx, pluginExpectedPackageKey{}, digest) -} - -func PluginExpectedPackage(ctx context.Context) string { - digest, _ := ctx.Value(pluginExpectedPackageKey{}).(string) - return digest -} - -func validatePluginPrecondition(ctx context.Context, installation *PluginInstallation) error { - if installation == nil || (PluginExpectedRevision(ctx) > 0 && installation.Revision != PluginExpectedRevision(ctx)) || - (PluginExpectedPackage(ctx) != "" && installation.PackageSHA256 != PluginExpectedPackage(ctx)) { - return ErrPluginStateChanged - } - return nil -} - -type PluginConfigSnapshot struct { - Config json.RawMessage - Revision int64 - PackageSHA256 string -} - -// Production HTTP saves need a receipt from the conditional write itself, -// not an unrelated later read that may belong to another administrator. -type PluginConfigRevisionRepository interface { - UpdateConfigReturningRevision(context.Context, int64, string, string) (int64, error) -} - -var ErrPluginConfigReceiptUnavailable = infraerrors.ServiceUnavailable("PLUGIN_CONFIG_RECEIPT_UNAVAILABLE", "plugin configuration revision receipt is unavailable") diff --git a/backend/internal/service/plugin_prompt_fixture_test.go b/backend/internal/service/plugin_prompt_fixture_test.go index 4ab9d07acb21..329ee2ba58eb 100644 --- a/backend/internal/service/plugin_prompt_fixture_test.go +++ b/backend/internal/service/plugin_prompt_fixture_test.go @@ -6,16 +6,14 @@ import ( "strings" "testing" - cindy "github.com/HTExplicit/sub2api-plugins/cindyprovider/catalog" - codexprofile "github.com/HTExplicit/sub2api-plugins/codexruntime/profile" - codexrecovery "github.com/HTExplicit/sub2api-plugins/codexruntime/recovery" + cindy "github.com/Wei-Shaw/sub2api/internal/cindyprovider/catalog" + codexprofile "github.com/Wei-Shaw/sub2api/internal/codexruntime/profile" + codexrecovery "github.com/Wei-Shaw/sub2api/internal/codexruntime/recovery" - accounttools "github.com/HTExplicit/sub2api-plugins/accounttools/policy" - observability "github.com/HTExplicit/sub2api-plugins/adminobservability/policy" - imagetools "github.com/HTExplicit/sub2api-plugins/imagetools/policy" + accounttools "github.com/Wei-Shaw/sub2api/internal/accounttools/policy" - policy "github.com/HTExplicit/sub2api-plugins/promptskills/policy" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + policy "github.com/Wei-Shaw/sub2api/internal/promptskills/policy" ) type promptPolicyFixture struct{} @@ -53,17 +51,6 @@ func (promptPolicyFixture) InvokeOperation(ctx context.Context, _ string, _ stri if strings.HasPrefix(in.Operation, "codex.identity.") { return codexprofile.Invoke(ctx, in) } - if strings.HasPrefix(in.Operation, "observability.") { - return observability.New().Invoke(ctx, in) - } - if strings.HasPrefix(in.Operation, "image.") { - module := imagetools.New() - raw, _ := json.Marshal(LegacyImageToolsConfig()) - if err := module.ApplyConfig(ctx, raw); err != nil { - return extensionv1.Result{}, err - } - return module.Invoke(ctx, in) - } if strings.HasPrefix(in.Operation, "cindy.") { module := cindy.New() raw, _ := json.Marshal(LegacyCindyProviderConfig()) @@ -78,5 +65,9 @@ func (promptPolicyFixture) InvokeOperation(ctx context.Context, _ string, _ stri return promptFixtureModule.Invoke(ctx, in) } func init() { - processExtensionOperations.Store(&extensionOperationProvider{invoker: promptPolicyFixture{}}) + invokeNativeCodex = promptPolicyFixture{}.InvokeOperation + bindNativeCodexContext = func(ctx context.Context, _ string, _ string, _ extensionv1.Invocation) (context.Context, context.CancelFunc, error) { + bound, cancel := context.WithCancel(ctx) + return bound, cancel, nil + } } diff --git a/backend/internal/service/plugin_prompt_test.go b/backend/internal/service/plugin_prompt_test.go index 2ecc47475bb1..894c73876fa1 100644 --- a/backend/internal/service/plugin_prompt_test.go +++ b/backend/internal/service/plugin_prompt_test.go @@ -7,8 +7,8 @@ import ( "strings" "testing" - policy "github.com/HTExplicit/sub2api-plugins/promptskills/policy" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + policy "github.com/Wei-Shaw/sub2api/internal/promptskills/policy" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) diff --git a/backend/internal/service/plugin_provider.go b/backend/internal/service/plugin_provider.go index 57003195f012..fc257b8b61e7 100644 --- a/backend/internal/service/plugin_provider.go +++ b/backend/internal/service/plugin_provider.go @@ -1,31 +1,10 @@ package service -import ( - "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" -) +import "github.com/Wei-Shaw/sub2api/internal/config" -func ProvidePluginManager(repo PluginRepository, encryptor SecretEncryptor, cfg *config.Config, hostInfo PluginHostInfo, kv PluginKVStore, activity *QuotaActivityService, gateway *OpenAIGatewayService, traffic AccountTrafficObserveCache) *PluginManager { +// ProvidePluginManager composes the upstream third-party framework only. +func ProvidePluginManager(repo PluginRepository, encryptor SecretEncryptor, cfg *config.Config, hostInfo PluginHostInfo, kv PluginKVStore, _ *QuotaActivityService, gateway *OpenAIGatewayService, _ AccountTrafficObserveCache) *PluginManager { manager := NewPluginManager(repo, encryptor, cfg, hostInfo, kv) - manager.quotaActivity = activity - manager.traffic = traffic manager.SetAccountDirectory(gateway) - ConfigureProcessExtensionServices(manager, manager) return manager } - -// ConfigureProcessExtensionServices is application composition, called before -// serving requests. Production supplies the plugin manager; contract fixtures -// can use the same independent modules without spawning one process per case. -func ConfigureProcessExtensionServices(catalog extensionv1.CatalogResolver, operations extensionv1.OperationInvoker) { - if catalog == nil { - processExtensionCatalog.Store(nil) - } else { - processExtensionCatalog.Store(&extensionCatalogProvider{resolver: catalog}) - } - if operations == nil { - processExtensionOperations.Store(nil) - } else { - processExtensionOperations.Store(&extensionOperationProvider{invoker: operations}) - } -} diff --git a/backend/internal/service/plugin_reconcile_update_registry_test.go b/backend/internal/service/plugin_reconcile_update_registry_test.go deleted file mode 100644 index 979147699870..000000000000 --- a/backend/internal/service/plugin_reconcile_update_registry_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package service - -import ( - "context" - "errors" - "testing" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/stretchr/testify/require" -) - -type postUpdateRegistryRepository struct { - PluginRepository - installations []*PluginInstallation - err error - lists int -} - -func (r *postUpdateRegistryRepository) List(context.Context) ([]*PluginInstallation, error) { - r.lists++ - return r.installations, r.err -} - -func TestPluginRegistryAfterPromotionReplacesPreDrainPeerSnapshot(t *testing.T) { - for _, test := range []struct { - name string - change func(*PluginInstallation, *PluginInstallation) - want string - }{ - { - name: "dependency_disabled", - change: func(target, peer *PluginInstallation) { - target.Manifest.Dependencies = []extensionv1.Dependency{{Capability: extensionv1.CapabilityProvider, Platform: "cindy", AccountType: "apikey"}} - peer.Bindings[0].Enabled = false - }, - want: "missing enabled plugin dependency", - }, - { - name: "operation_owner_added", - change: func(target, peer *PluginInstallation) { - target.Manifest.Operations = map[string][]string{extensionv1.CapabilityAdmin: {"admin.promoted"}} - peer.Manifest.Operations = map[string][]string{extensionv1.CapabilityAdmin: {"admin.promoted"}} - peer.Bindings = append(peer.Bindings, PluginBinding{Capability: extensionv1.CapabilityAdmin, Platform: "*", AccountType: "*", Enabled: true}) - }, - want: "extension operation has overlapping enabled owners", - }, - } { - t.Run(test.name, func(t *testing.T) { - target := &PluginInstallation{ID: 7, RuntimeGeneration: 2, State: PluginStateEnabled, Bindings: []PluginBinding{{Capability: extensionv1.CapabilityAdmin, Platform: "*", AccountType: "*", Enabled: true}}} - peer := &PluginInstallation{ID: 8, State: PluginStateEnabled, Bindings: []PluginBinding{{Capability: extensionv1.CapabilityProvider, Platform: "cindy", AccountType: "apikey", Enabled: true}}} - freshPeer := *peer - freshPeer.Bindings = append([]PluginBinding(nil), peer.Bindings...) - test.change(target, &freshPeer) - // This is the former reconcile state: the promoted target is new, - // while every other installation still comes from before the drain. - before := []*PluginInstallation{target, peer} - require.NoError(t, validatePluginRegistry(before)) - repo := &postUpdateRegistryRepository{installations: []*PluginInstallation{target, &freshPeer}} - manager := NewPluginManager(repo, nil, nil, PluginHostInfo{}, nil) - current, err := manager.pluginRegistryAfterUpdates(context.Background(), before, true) - require.NoError(t, err) - require.Equal(t, 1, repo.lists) - require.Same(t, &freshPeer, current[1]) - require.ErrorContains(t, validatePluginRegistry(current), test.want) - require.NoError(t, validatePluginRegistry(before), "the old peer snapshot alone would still pass") - }) - } -} - -func TestPluginRegistryAfterPromotionReadFailureRemainsUnavailable(t *testing.T) { - repo := &postUpdateRegistryRepository{err: errors.New("registry read failed after promotion")} - manager := NewPluginManager(repo, nil, nil, PluginHostInfo{}, nil) - manager.extensions.Store(&pluginExtensionRegistry{installations: map[int64]*PluginInstallation{7: {ID: 7}}, runtimes: map[int64]*pluginRuntime{}}) - current, err := manager.pluginRegistryAfterUpdates(context.Background(), []*PluginInstallation{{ID: 7}}, true) - require.Nil(t, current) - require.ErrorContains(t, err, "registry read failed after promotion") - require.Equal(t, 1, repo.lists) - require.NotEmpty(t, manager.extensions.Load().unavailable) - require.Contains(t, manager.extensions.Load().installations, int64(7)) - require.NotEmpty(t, manager.route.Load().unavailable) -} - -func TestPluginRegistryWithoutPromotionDoesNotAddDatabaseRead(t *testing.T) { - repo := &postUpdateRegistryRepository{err: errors.New("unexpected registry read")} - manager := NewPluginManager(repo, nil, nil, PluginHostInfo{}, nil) - before := []*PluginInstallation{{ID: 7}} - current, err := manager.pluginRegistryAfterUpdates(context.Background(), before, false) - require.NoError(t, err) - require.Zero(t, repo.lists) - require.Same(t, before[0], current[0]) -} diff --git a/backend/internal/service/plugin_request.go b/backend/internal/service/plugin_request.go deleted file mode 100644 index 78c92b2bb2c8..000000000000 --- a/backend/internal/service/plugin_request.go +++ /dev/null @@ -1,105 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "slices" - "sort" - "strings" - "time" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "golang.org/x/net/http/httpguts" -) - -func (m *PluginManager) ApplyRequestHeaders(ctx context.Context, account *Account, model string, headers http.Header) error { - if m == nil || account == nil || headers == nil { - return nil - } - registry := m.extensions.Load() - if registry == nil || registry.unavailable != "" { - return errors.New("plugin registry unavailable") - } - var ids []int64 - invocation := extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "inject", AccountID: account.ID} - for id, installation := range registry.installations { - operations, explicit := installation.Manifest.Operations[extensionv1.CapabilityRequest] - if (!explicit || slices.Contains(operations, "inject")) && pluginHasInvocationCapability(installation, invocation, account.Platform, account.Type) { - ids = append(ids, id) - } - } - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) - raw, err := json.Marshal(extensionv1.SchedulingRequest{Account: *extensionAccount(account), Model: model, Now: time.Now().UTC()}) - if err != nil { - return err - } - invocation.Payload = raw - projected := headers.Clone() - for _, id := range ids { - result, err := m.InvokeExtension(ctx, id, account.Platform, account.Type, invocation) - if err != nil { - return err - } - if result.Code != "" { - return errors.New("plugin request prerequisite unavailable") - } - var changes extensionv1.CodexRoutingInjection - if json.Unmarshal(result.Payload, &changes) != nil { - return errors.New("invalid plugin header result") - } - if changes.Qualification != nil && (registry.installations[id].PluginKey != codexRuntimePluginKey || !changes.Qualification.Valid(time.Now(), account.ID, CodexTicketAccountIdentity(account), model)) { - return errors.New("invalid routing qualification reference") - } - seen := map[string]bool{} - for name, value := range changes.Headers { - lower := strings.ToLower(name) - // Credentials, account routing, tenant correlation and HTTP framing - // stay host-owned. Per-request ticket material is the explicit exception - // to the ordinary header-override policy. - blocked := isHeaderOverrideBlockedName(lower) && lower != "x-codex-turn-state" - switch lower { - case "openai-organization", "openai-project", "api-key", "set-cookie": - blocked = true - } - if blocked || seen[lower] || !httpguts.ValidHeaderFieldName(name) || !httpguts.ValidHeaderFieldValue(value) { - return errors.New("plugin returned a protected or invalid request header") - } - seen[lower] = true - projected.Set(name, value) - } - } - for name, values := range projected { - headers[name] = values - } - return nil -} - -func (m *PluginManager) installedByKey(key string) (*PluginInstallation, *pluginRuntime) { - if m == nil { - return nil, nil - } - registry := m.extensions.Load() - if registry == nil { - return nil, nil - } - for id, installation := range registry.installations { - if installation.PluginKey == key { - return installation, registry.runtimes[id] - } - } - return nil, nil -} - -func (m *PluginManager) activeConfig(key string) (json.RawMessage, bool) { - installation, runtime := m.installedByKey(key) - if installation == nil || !hasEnabledPluginBinding(installation.Bindings) || runtime == nil { - return nil, false - } - config := runtime.configSnapshot.Load() - if config == nil { - return nil, false - } - return append(json.RawMessage(nil), (*config)...), true -} diff --git a/backend/internal/service/plugin_request_scope_test.go b/backend/internal/service/plugin_request_scope_test.go deleted file mode 100644 index 9590d4780291..000000000000 --- a/backend/internal/service/plugin_request_scope_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package service - -import ( - "context" - "net/http" - "strconv" - "testing" - "time" - - "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/stretchr/testify/require" -) - -func TestPluginRequestInjectionUsesActualAccountRollout(t *testing.T) { - for _, percent := range []int{0, 50, 100} { - t.Run(strconv.Itoa(percent), func(t *testing.T) { - var calls []extensionv1.Invocation - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{}, func(in extensionv1.Invocation) (extensionv1.Result, error) { - calls = append(calls, in) - return extensionv1.Result{Payload: []byte(`{"headers":{"X-Scoped-Policy":"applied"}}`)}, nil - }) - for index := range manager.extensions.Load().installations[1].Bindings { - manager.extensions.Load().installations[1].Bindings[index].RolloutPercent = percent - } - for _, id := range []int64{2, 3} { - before := len(calls) - headers := http.Header{"Authorization": {"Bearer synthetic-host-token"}} - require.NoError(t, manager.ApplyRequestHeaders(context.Background(), ticketTestAccount(id), "gpt-6-astra", headers)) - require.Equal(t, "Bearer synthetic-host-token", headers.Get("Authorization")) - if int(stablePluginBucket(id)) < percent { - require.Len(t, calls, before+1) - require.Equal(t, id, calls[before].AccountID, "payload metadata does not replace the invocation scope") - require.Equal(t, "applied", headers.Get("X-Scoped-Policy")) - } else { - require.Len(t, calls, before, "outside-rollout account must not enter the request policy") - require.Empty(t, headers.Get("X-Scoped-Policy")) - } - } - }) - } -} - -func TestPluginSchedulingUsesTheSameAccountRolloutAsRequestInjection(t *testing.T) { - for _, percent := range []int{0, 50, 100} { - t.Run(strconv.Itoa(percent), func(t *testing.T) { - manager := ticketTestManager(t, config.OpenAICodexTicketConfig{Enabled: true, FailClosed: true}, nil) - registry := manager.extensions.Load() - for index := range registry.installations[1].Bindings { - registry.installations[1].Bindings[index].RolloutPercent = percent - } - for _, id := range []int64{2, 3} { - inScope := int(stablePluginBucket(id)) < percent - decision := manager.SchedulingDecision(ticketTestAccount(id), "gpt-6-astra", time.Now()) - require.Equal(t, !inScope, decision.Allowed, "only in-scope accounts are subject to this plugin's missing-ticket policy") - if inScope { - require.Equal(t, "ticket_missing", decision.Reason) - } - } - registry.runtimes = map[int64]*pluginRuntime{} - for _, id := range []int64{2, 3} { - decision := manager.SchedulingDecision(ticketTestAccount(id), "gpt-6-astra", time.Now()) - if int(stablePluginBucket(id)) < percent { - require.False(t, decision.Allowed) - require.Equal(t, "plugin_unavailable", decision.Reason) - } else { - require.True(t, decision.Allowed, "a failed plugin cannot block an account outside its binding") - } - } - }) - } -} diff --git a/backend/internal/service/plugin_resources.go b/backend/internal/service/plugin_resources.go deleted file mode 100644 index 9a7a5dbf8180..000000000000 --- a/backend/internal/service/plugin_resources.go +++ /dev/null @@ -1,303 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "slices" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" -) - -// Resource calls retain the original authenticated HTTP handler and middleware. -// This gate additionally binds the call to a declared capability, package and -// execution lifetime; compatibility routes cannot bypass a disabled plugin. -func (m *PluginManager) BindResourceContext(ctx context.Context, id int64, expectedPackage string, resource extensionv1.ResourceGrant, retained ...bool) (context.Context, func(), error) { - if len(retained) > 0 && retained[0] && id > 0 { - installation, err := m.repo.GetByID(ctx, id) - if err != nil || !pluginDeclaresResource(installation, resource) { - return nil, nil, ErrExtensionOperationDisabled - } - if expectedPackage != "" && installation.PackageSHA256 != expectedPackage { - return nil, nil, ErrPluginUISessionChanged - } - return WithPluginExecution(ctx, installation), func() {}, nil - } - registry := m.extensions.Load() - if registry == nil || registry.unavailable != "" { - return nil, nil, ErrExtensionOperationUnavailable - } - var selected *PluginInstallation - for candidateID, installation := range registry.installations { - if id > 0 && candidateID != id { - continue - } - if !pluginDeclaresResource(installation, resource) { - continue - } - enabled := false - for _, binding := range installation.Bindings { - enabled = enabled || (binding.Enabled && binding.Capability == resource.Capability) - } - if !enabled { - continue - } - if selected != nil { - return nil, nil, ErrExtensionOperationUnavailable - } - selected = installation - } - if selected == nil { - return nil, nil, ErrExtensionOperationDisabled - } - current, err := m.repo.GetByID(ctx, selected.ID) - if err != nil { - return nil, nil, ErrExtensionOperationUnavailable - } - if expectedPackage != "" && current.PackageSHA256 != expectedPackage { - return nil, nil, ErrPluginUISessionChanged - } - if current.State == PluginStateUpdating || !samePluginRuntime(current, selected) { - return nil, nil, ErrExtensionOperationUnavailable - } - // Recheck persisted binding intent before a UI mutation, not just the - // process-local registry refreshed by the background reconciler. - enabled := false - for _, binding := range current.Bindings { - enabled = enabled || (binding.Enabled && binding.Capability == resource.Capability) - } - if !enabled { - return nil, nil, ErrExtensionOperationDisabled - } - runtime := registry.runtimes[selected.ID] - if runtime == nil || runtime.client == nil || runtime.client.Exited() || !pluginDependenciesHealthy(current, registry, map[int64]bool{}) { - return nil, nil, ErrExtensionOperationUnavailable - } - bound, release, err := m.bindHostPolicyContext(ctx, current, runtime) - if err != nil { - return nil, nil, err - } - return WithPluginExecution(bound, current), release, nil -} - -func pluginDeclaresResource(installation *PluginInstallation, resource extensionv1.ResourceGrant) bool { - if installation == nil { - return false - } - for _, grant := range installation.Manifest.Resources { - if grant.Name == resource.Name && grant.Capability == resource.Capability && grant.Permission == resource.Permission { - return true - } - } - return false -} - -func (m *PluginManager) ValidateResourceAccounts(ctx context.Context, capability string, ids []int64, filtered bool, fixedScope ...string) error { - execution, ok := PluginExecutionFromContext(ctx) - if !ok { - return ErrExtensionOperationUnavailable - } - installation, err := m.repo.GetByID(ctx, execution.ID) - if err != nil || installation.RuntimeGeneration != execution.Generation || installation.State == PluginStateUpdating { - return ErrExtensionOperationUnavailable - } - for _, id := range ids { - if id <= 0 { - return errors.New("invalid resource account identifier") - } - } - if filtered { - platform, kind := "*", "*" - if len(fixedScope) == 2 && fixedScope[0] != "" && fixedScope[1] != "" { - platform, kind = fixedScope[0], fixedScope[1] - } - for _, binding := range installation.Bindings { - if binding.Enabled && binding.Capability == capability && pluginScopeMatches(binding.Platform, platform) && pluginScopeMatches(binding.AccountType, kind) && binding.RolloutPercent == 100 { - return nil - } - } - return errors.New("filtered account operations require a binding covering all accounts; select explicit accounts instead") - } - if len(ids) == 0 { - return nil - } - directory, ok := m.accountDirectory.(PluginExtensionAccountDirectory) - if !ok { - return ErrExtensionOperationUnavailable - } - seen := map[int64]bool{} - for _, id := range ids { - if id <= 0 { - return errors.New("invalid resource account identifier") - } - if seen[id] { - continue - } - seen[id] = true - account, err := directory.ReadExtensionAccount(ctx, id) - if err != nil || account == nil || account.ID != id { - return errors.New("resource account unavailable") - } - allowed := false - for _, binding := range installation.Bindings { - if binding.Enabled && binding.Capability == capability && pluginScopeMatches(binding.Platform, account.Platform) && pluginScopeMatches(binding.AccountType, account.Type) && int(stablePluginBucket(id)) < binding.RolloutPercent { - allowed = true - break - } - } - if !allowed { - return errors.New("account is outside the enabled plugin scope") - } - } - return nil -} - -func (m *PluginManager) ResourceDescriptors(ctx context.Context, id int64, permission string, registered []extensionv1.ResourceDescriptor) ([]extensionv1.ResourceDescriptor, error) { - installation, err := m.repo.GetByID(ctx, id) - if err != nil { - return nil, err - } - if permission != "admin" && permission != "user" { - return nil, errors.New("invalid resource permission") - } - originAvailable := true - if identity, retained := RetainedAccountViewIdentity(ctx); retained { - _, release, err := m.BindAccountViewRequest(ctx, extensionv1.AccountViewContextV1{AccountViewIdentityV1: *identity}) - originAvailable = err == nil - if release != nil { - release() - } - } - out := make([]extensionv1.ResourceDescriptor, 0) - availability := map[string]bool{} - for _, descriptor := range registered { - if descriptor.Permission != permission || !pluginDeclaresResource(installation, descriptor.ResourceGrant) { - continue - } - policy, _ := json.Marshal(struct { - Capabilities []string - Retained, AllAccounts bool - Platform, AccountType, Owner string - }{resourceRequiredCapabilities(descriptor), descriptor.Retained, descriptor.AllAccounts, descriptor.FilterPlatform, descriptor.FilterAccountType, descriptor.OwnerPluginKey}) - cacheKey := string(policy) - ready, known := availability[cacheKey] - if !known { - bound, release, err := m.BindResourceContext(ctx, id, installation.PackageSHA256, descriptor.ResourceGrant, descriptor.Retained) - if err == nil && !descriptor.Retained { - err = m.ValidateResourcePolicy(bound, descriptor, nil, descriptor.AllAccounts) - } - ready = err == nil - if release != nil { - release() - } - availability[cacheKey] = ready - } - descriptor.Available = ready && (descriptor.Retained || originAvailable) - out = append(out, descriptor) - } - return out, nil -} - -func resourceRequiredCapabilities(descriptor extensionv1.ResourceDescriptor) []string { - capabilities := append([]string{descriptor.Capability}, descriptor.RequiredCapabilities...) - slices.Sort(capabilities) - return slices.Compact(capabilities) -} - -func (m *PluginManager) ValidateResourcePolicy(ctx context.Context, descriptor extensionv1.ResourceDescriptor, ids []int64, filtered bool) error { - execution, bound := PluginExecutionFromContext(ctx) - if !bound { - return ErrExtensionOperationUnavailable - } - installation, err := m.repo.GetByID(ctx, execution.ID) - if err != nil || installation == nil || installation.RuntimeGeneration != execution.Generation || installation.State != PluginStateEnabled || (descriptor.OwnerPluginKey != "" && installation.PluginKey != descriptor.OwnerPluginKey) { - return ErrExtensionOperationUnavailable - } - registry := m.extensions.Load() - if registry == nil || registry.unavailable != "" || !samePluginRuntime(installation, registry.installations[execution.ID]) { - return ErrExtensionOperationUnavailable - } - if _, applied := m.contributionAppliedRevision(installation, registry.runtimes[execution.ID]); !applied { - return ErrExtensionOperationUnavailable - } - for _, capability := range resourceRequiredCapabilities(descriptor) { - enabled := false - for _, binding := range installation.Bindings { - enabled = enabled || (binding.Enabled && binding.Capability == capability) - } - if !enabled { - return ErrExtensionOperationDisabled - } - if err := m.ValidateResourceAccounts(ctx, capability, ids, filtered, descriptor.FilterPlatform, descriptor.FilterAccountType); err != nil { - return err - } - } - for _, contribution := range installation.Manifest.Contributions { - if contribution.ResourceAction == nil || contribution.ResourceAction.Resource != descriptor.Name { - continue - } - directory, ok := m.accountDirectory.(accountViewAccountDirectory) - if !ok && len(ids) > 0 { - return ErrExtensionOperationUnavailable - } - for _, id := range ids { - account, err := directory.ReadAccountViewAccount(ctx, id) - if err != nil || account == nil || account.ID != id || !contributionAccountAllowed(installation, &contribution, *extensionAccount(account)) || (contribution.ResourceAction.RowPredicate != nil && !accountMatchesViewPredicate(account, *contribution.ResourceAction.RowPredicate)) { - return ErrAccountViewScope - } - } - } - return nil -} - -type boundResourcePolicyKey struct{} -type boundResourcePolicy struct { - manager *PluginManager - descriptor extensionv1.ResourceDescriptor -} - -func PluginResourceBound(ctx context.Context) bool { - _, bound := ctx.Value(boundResourcePolicyKey{}).(boundResourcePolicy) - return bound -} - -func (m *PluginManager) WithResourcePolicy(ctx context.Context, descriptor extensionv1.ResourceDescriptor) context.Context { - return context.WithValue(ctx, boundResourcePolicyKey{}, boundResourcePolicy{m, descriptor}) -} - -func ValidateBoundResourceTargets(ctx context.Context, ids []int64) error { - policy, bound := ctx.Value(boundResourcePolicyKey{}).(boundResourcePolicy) - if !bound { - return nil - } - return policy.manager.ValidateResourcePolicy(ctx, policy.descriptor, ids, policy.descriptor.AllAccounts) -} - -func (m *PluginManager) PinnedPluginOwner(ctx context.Context, key string) (int64, error) { - registry := m.extensions.Load() - if registry == nil || registry.unavailable != "" { - return 0, ErrExtensionOperationUnavailable - } - var owner int64 - for id, installation := range registry.installations { - if installation.PluginKey != key { - continue - } - if owner != 0 { - return 0, ErrExtensionOperationUnavailable - } - owner = id - } - if owner == 0 { - return 0, ErrExtensionOperationDisabled - } - current, err := m.repo.GetByID(ctx, owner) - if err != nil || current == nil || current.PluginKey != key || current.State != PluginStateEnabled || !samePluginRuntime(current, registry.installations[owner]) { - return 0, ErrExtensionOperationUnavailable - } - return owner, nil -} - -func CindyCleanupResourcePolicy() extensionv1.ResourceDescriptor { - return extensionv1.ResourceDescriptor{ResourceGrant: extensionv1.ResourceGrant{Capability: extensionv1.CapabilityProvider, Permission: "admin"}, RequiredCapabilities: []string{extensionv1.CapabilityAdmin}, OwnerPluginKey: CindyAccountViewPluginKey, AllAccounts: true, FilterPlatform: PlatformCindy, FilterAccountType: AccountTypeAPIKey, WholeAccountViewDomain: true} -} diff --git a/backend/internal/service/plugin_resources_test.go b/backend/internal/service/plugin_resources_test.go deleted file mode 100644 index 04b57549d2aa..000000000000 --- a/backend/internal/service/plugin_resources_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package service - -import ( - "context" - "strings" - "testing" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - hcplugin "github.com/hashicorp/go-plugin" - "github.com/stretchr/testify/require" -) - -func TestPluginResourcesBindDeclaredScopeAndRejectPersistedDisable(t *testing.T) { - grant := extensionv1.ResourceGrant{Name: "prompts.list", Capability: extensionv1.CapabilityRequest, Permission: "admin"} - installation := &PluginInstallation{ID: 7, PluginKey: "codexrip.prompt-skills", Version: "0.2.7", RuntimeGeneration: 1, PackageSHA256: strings.Repeat("a", 64), State: PluginStateEnabled, - Manifest: PluginManifest{Resources: []extensionv1.ResourceGrant{grant}}, Bindings: []PluginBinding{{Capability: grant.Capability, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth, Enabled: true, RolloutPercent: 100}}} - repo := &pluginTokenRepository{installation: installation} - manager := NewPluginManager(repo, pluginTokenEncryptor{}, nil, PluginHostInfo{}, nil) - runtime := &pluginRuntime{installation: installation, client: hcplugin.NewClient(&hcplugin.ClientConfig{}), done: make(chan struct{})} - manager.extensions.Store(&pluginExtensionRegistry{installations: map[int64]*PluginInstallation{7: installation}, runtimes: map[int64]*pluginRuntime{7: runtime}}) - ctx, release, err := manager.BindResourceContext(context.Background(), 0, "", grant) - require.NoError(t, err, "a global management resource may use an OAuth-only binding") - execution, ok := PluginExecutionFromContext(ctx) - require.True(t, ok) - require.Equal(t, installation.ID, execution.ID) - release() - require.Zero(t, runtime.inFlight.Load()) - _, _, err = manager.BindResourceContext(context.Background(), 7, strings.Repeat("b", 64), grant) - require.ErrorIs(t, err, ErrPluginUISessionChanged) - _, _, err = manager.BindResourceContext(context.Background(), 7, "", extensionv1.ResourceGrant{Name: "prompts.delete", Capability: grant.Capability, Permission: "admin"}) - require.ErrorIs(t, err, ErrExtensionOperationDisabled) - items, err := manager.ResourceDescriptors(context.Background(), 7, "user", []extensionv1.ResourceDescriptor{{ResourceGrant: grant, Method: "GET", Path: "/api/v1/admin/system-prompts"}}) - require.NoError(t, err) - require.Empty(t, items, "user metadata cannot expose an administrator resource") - updated := *installation - updated.Bindings = append([]PluginBinding(nil), installation.Bindings...) - updated.Bindings[0].Enabled = false - repo.installation = &updated - _, _, err = manager.BindResourceContext(context.Background(), 7, "", grant) - require.ErrorIs(t, err, ErrExtensionOperationDisabled, "a stale process registry cannot authorize a database-disabled binding") -} - -type resourceAccountDirectory struct { - PluginAccountDirectory - PluginExtensionAccountDirectory - accounts map[int64]extensionv1.Account -} - -func TestGlobalContributionAndResourceRequireFullBinding(t *testing.T) { - grant := extensionv1.ResourceGrant{Name: "prompt-audit.config", Capability: extensionv1.CapabilityUI, Permission: "admin"} - installation := &PluginInstallation{ID: 7, RuntimeGeneration: 1, State: PluginStateEnabled, - Manifest: PluginManifest{Resources: []extensionv1.ResourceGrant{grant}, Contributions: []extensionv1.Contribution{{ID: "prompt-audit", Slot: "surface", Permission: "admin", Capability: grant.Capability, AllAccounts: true}}}, - Bindings: []PluginBinding{{Capability: grant.Capability, Platform: "*", AccountType: "*", Enabled: true, RolloutPercent: 100}}} - manager := NewPluginManager(&pluginTokenRepository{installation: installation}, pluginTokenEncryptor{}, nil, PluginHostInfo{}, nil) - runtime := &pluginRuntime{installation: installation, client: hcplugin.NewClient(&hcplugin.ClientConfig{}), done: make(chan struct{})} - manager.extensions.Store(&pluginExtensionRegistry{installations: map[int64]*PluginInstallation{7: installation}, runtimes: map[int64]*pluginRuntime{7: runtime}}) - descriptors := []extensionv1.ResourceDescriptor{{ResourceGrant: grant, AllAccounts: true, Method: "GET", Path: "/fixture"}} - for _, scope := range []struct { - platform string - rollout int - allowed bool - }{{"*", 100, true}, {PlatformOpenAI, 100, false}, {"*", 99, false}} { - installation.Bindings[0].Platform, installation.Bindings[0].RolloutPercent = scope.platform, scope.rollout - items, err := manager.ResourceDescriptors(context.Background(), 7, "admin", descriptors) - require.NoError(t, err) - require.Len(t, items, 1) - require.Equal(t, scope.allowed, items[0].Available) - require.Equal(t, scope.allowed, len(manager.Contributions()) == 1) - bound, release, err := manager.BindResourceContext(context.Background(), 7, "", grant) - require.NoError(t, err) - require.Equal(t, scope.allowed, manager.ValidateResourceAccounts(bound, grant.Capability, nil, true) == nil) - release() - } - user, err := manager.ResourceDescriptors(context.Background(), 7, "user", descriptors) - require.NoError(t, err) - require.Empty(t, user) -} - -func (d *resourceAccountDirectory) ReadExtensionAccount(_ context.Context, id int64) (*extensionv1.Account, error) { - account := d.accounts[id] - return &account, nil -} - -func TestPluginResourceAccountSelectionsRespectScopeAndRollout(t *testing.T) { - installation := &PluginInstallation{ID: 7, RuntimeGeneration: 2, State: PluginStateEnabled, Bindings: []PluginBinding{{Capability: extensionv1.CapabilityAdmin, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth, Enabled: true, RolloutPercent: 100}}} - repo := &pluginTokenRepository{installation: installation} - manager := NewPluginManager(repo, pluginTokenEncryptor{}, nil, PluginHostInfo{}, nil) - manager.accountDirectory = &resourceAccountDirectory{accounts: map[int64]extensionv1.Account{ - 1: {ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth}, 2: {ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, - }} - ctx := WithPluginExecution(context.Background(), installation) - require.NoError(t, manager.ValidateResourceAccounts(ctx, extensionv1.CapabilityAdmin, []int64{1}, false)) - require.Error(t, manager.ValidateResourceAccounts(ctx, extensionv1.CapabilityAdmin, []int64{1, 2}, false)) - require.Error(t, manager.ValidateResourceAccounts(ctx, extensionv1.CapabilityAdmin, nil, true)) - installation.Bindings[0].RolloutPercent = int(stablePluginBucket(1)) - require.Error(t, manager.ValidateResourceAccounts(ctx, extensionv1.CapabilityAdmin, []int64{1}, false)) - installation.Bindings[0].Platform, installation.Bindings[0].AccountType, installation.Bindings[0].RolloutPercent = "*", "*", 100 - require.NoError(t, manager.ValidateResourceAccounts(ctx, extensionv1.CapabilityAdmin, nil, true)) - require.Error(t, manager.ValidateResourceAccounts(ctx, extensionv1.CapabilityAdmin, []int64{-1}, true)) -} - -func TestRetainedPluginResourcesDoNotRequireRunningBusinessCapability(t *testing.T) { - grant := extensionv1.ResourceGrant{Name: "image.job", Capability: extensionv1.CapabilityRequest, Permission: "user"} - installation := &PluginInstallation{ID: 7, PluginKey: "codexrip.image-tools", State: PluginStateDisabled, Manifest: PluginManifest{Resources: []extensionv1.ResourceGrant{grant}}} - manager := NewPluginManager(&pluginTokenRepository{installation: installation}, pluginTokenEncryptor{}, nil, PluginHostInfo{}, nil) - _, release, err := manager.BindResourceContext(context.Background(), 7, "", grant, true) - require.NoError(t, err) - release() - _, _, err = manager.BindResourceContext(context.Background(), 7, "", grant) - require.Error(t, err) - _, _, err = manager.BindResourceContext(context.Background(), 7, "", extensionv1.ResourceGrant{Name: "image.create", Capability: grant.Capability, Permission: "user"}, true) - require.Error(t, err) -} - -func TestPluginFilteredResourceUsesOnlyHostDeclaredFixedScope(t *testing.T) { - installation := &PluginInstallation{ID: 7, RuntimeGeneration: 2, State: PluginStateEnabled, Bindings: []PluginBinding{{Capability: extensionv1.CapabilityProvider, Platform: PlatformCindy, AccountType: AccountTypeAPIKey, Enabled: true, RolloutPercent: 100}}} - manager := NewPluginManager(&pluginTokenRepository{installation: installation}, pluginTokenEncryptor{}, nil, PluginHostInfo{}, nil) - ctx := WithPluginExecution(context.Background(), installation) - require.Error(t, manager.ValidateResourceAccounts(ctx, extensionv1.CapabilityProvider, nil, true)) - require.NoError(t, manager.ValidateResourceAccounts(ctx, extensionv1.CapabilityProvider, nil, true, PlatformCindy, AccountTypeAPIKey)) - require.Error(t, manager.ValidateResourceAccounts(ctx, extensionv1.CapabilityProvider, nil, true, PlatformOpenAI, AccountTypeAPIKey)) - installation.Bindings[0].RolloutPercent = 50 - require.Error(t, manager.ValidateResourceAccounts(ctx, extensionv1.CapabilityProvider, nil, true, PlatformCindy, AccountTypeAPIKey)) -} diff --git a/backend/internal/service/plugin_runtime.go b/backend/internal/service/plugin_runtime.go index 5c3ae6f346bd..eac0d7f0eae1 100644 --- a/backend/internal/service/plugin_runtime.go +++ b/backend/internal/service/plugin_runtime.go @@ -1,7 +1,6 @@ package service import ( - "bytes" "context" "crypto/sha256" "encoding/hex" @@ -18,7 +17,6 @@ import ( "sync/atomic" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" hclog "github.com/hashicorp/go-hclog" hcplugin "github.com/hashicorp/go-plugin" @@ -28,26 +26,13 @@ import ( ) type pluginRuntime struct { - installation *PluginInstallation - client *hcplugin.Client - api pluginv1.TransportPluginClient - extension *extensionv1.Client - scheduling atomic.Pointer[[]extensionv1.SchedulingRule] - configSnapshot atomic.Pointer[json.RawMessage] - configRevision atomic.Uint64 - catalogCache sync.Map - catalogCacheSize atomic.Int64 - inFlight atomic.Int64 - draining atomic.Bool - configuring atomic.Bool - policyMu sync.Mutex - policyLeaseID uint64 - policyLeases map[uint64]context.CancelFunc - done chan struct{} - doneOnce sync.Once - lease PluginRuntimeLease - leaseWatchDone <-chan struct{} - killOnce sync.Once + installation *PluginInstallation + client *hcplugin.Client + api pluginv1.TransportPluginClient + inFlight atomic.Int64 + draining atomic.Bool + done chan struct{} + doneOnce sync.Once } func startPluginRuntime(ctx context.Context, installation *PluginInstallation, startTimeout time.Duration, socketDir string, hostServices pluginv1.HostServiceServer) (*pluginRuntime, error) { @@ -95,7 +80,6 @@ func startPluginRuntime(ctx context.Context, installation *PluginInstallation, s installation: installation, client: client, api: api, - extension: extensionv1.NewClient(transportClient.Connection), done: make(chan struct{}), } infoCtx, cancel := context.WithTimeout(ctx, startTimeout) @@ -120,17 +104,14 @@ func startPluginRuntime(ctx context.Context, installation *PluginInstallation, s } // 可选地把宿主服务(HostService)反向暴露给插件。这是叠加在传输契约之上的能力: // 老插件不实现 InitHostServices(返回 Unimplemented),此处静默跳过,绝不阻断启动。 - if err := offerPluginHostServices(ctx, installation, api, transportClient.Broker, hostServices, startTimeout); err != nil && installation.Manifest.Requires.ExtensionAPI > 0 { - runtime.kill() - return nil, fmt.Errorf("扩展插件需要可用的宿主服务: %w", err) - } + offerPluginHostServices(ctx, installation, api, transportClient.Broker, hostServices, startTimeout) return runtime, nil } // offerPluginHostServices 在 go-plugin broker 上启动一个宿主服务实例,并通过 // InitHostServices 把 broker 流 id 交给插件。服务生命周期与插件进程绑定:client.Kill() -// 会关闭 broker,AcceptAndServe 随之 GracefulStop,无需手动清理。官方旧传输插件 -// 可以不使用此能力;声明扩展协议的插件必须成功握手,失败时禁止启用。 +// 会关闭 broker,AcceptAndServe 随之 GracefulStop,无需手动清理。整个过程尽力而为, +// 任何失败都只记录日志、不影响插件转发能力。 func offerPluginHostServices( ctx context.Context, installation *PluginInstallation, @@ -138,17 +119,14 @@ func offerPluginHostServices( broker *hcplugin.GRPCBroker, hostServices pluginv1.HostServiceServer, startTimeout time.Duration, -) error { +) { if broker == nil || hostServices == nil || api == nil { - return errors.New("host broker unavailable") + return } brokerID := broker.NextId() go broker.AcceptAndServe(brokerID, func(opts []grpc.ServerOption) *grpc.Server { server := grpc.NewServer(opts...) pluginv1.RegisterHostServiceServer(server, hostServices) - if registrar, ok := hostServices.(pluginv1.AdditionalServiceRegistrar); ok { - registrar.RegisterAdditionalServices(server) - } return server }) initCtx, cancel := context.WithTimeout(ctx, startTimeout) @@ -167,12 +145,11 @@ func offerPluginHostServices( } else { slog.Warn("plugin_host_services_init_failed", "plugin", pluginKey, "error", err) } - return err + return } - if resp == nil || !resp.Ready { - return errors.New("plugin did not initialize host services") + if resp != nil && !resp.Ready { + slog.Debug("plugin_host_services_declined", "plugin", pluginKey, "message", resp.Message) } - return nil } func (r *pluginRuntime) validateAndApplyConfig(ctx context.Context, configJSON []byte) error { @@ -181,14 +158,6 @@ func (r *pluginRuntime) validateAndApplyConfig(ctx context.Context, configJSON [ } func (r *pluginRuntime) validateAndApplyNormalizedConfig(ctx context.Context, configJSON []byte) ([]byte, error) { - configJSON, err := r.validateNormalizedConfig(ctx, configJSON) - if err != nil { - return nil, err - } - return r.applyNormalizedConfig(ctx, configJSON) -} - -func (r *pluginRuntime) validateNormalizedConfig(ctx context.Context, configJSON []byte) ([]byte, error) { validation, err := r.api.ValidateConfig(ctx, &pluginv1.ValidateConfigRequest{ConfigJson: configJSON}) if err != nil { return nil, fmt.Errorf("插件配置校验失败: %w", err) @@ -216,16 +185,6 @@ func (r *pluginRuntime) validateNormalizedConfig(ctx context.Context, configJSON if err != nil { return nil, fmt.Errorf("序列化插件规范化配置: %w", err) } - return configJSON, nil -} - -func (r *pluginRuntime) applyNormalizedConfig(ctx context.Context, configJSON []byte) ([]byte, error) { - if previous := r.configSnapshot.Load(); previous != nil && bytes.Equal(*previous, configJSON) { - return configJSON, nil - } - r.configuring.Store(true) - defer r.configuring.Store(false) - r.cancelPolicyContexts() applied, err := r.api.ApplyConfig(ctx, &pluginv1.ApplyConfigRequest{ConfigJson: configJSON}) if err != nil { return nil, fmt.Errorf("应用插件配置失败: %w", err) @@ -233,35 +192,6 @@ func (r *pluginRuntime) applyNormalizedConfig(ctx context.Context, configJSON [] if !applied.Applied { return nil, fmt.Errorf("插件拒绝应用配置: %s", applied.Message) } - var capabilities []PluginCapability - if r.installation != nil { - capabilities = r.installation.Manifest.Capabilities - } - for _, capability := range capabilities { - if capability.ID != extensionv1.CapabilityScheduling { - continue - } - result, err := r.extension.Invoke(ctx, extensionv1.Invocation{Capability: extensionv1.CapabilityScheduling, Operation: "describe", Payload: json.RawMessage(`{}`)}) - if err != nil { - return nil, fmt.Errorf("read plugin scheduling contract: %w", err) - } - var rules []extensionv1.SchedulingRule - if json.Unmarshal(result.Payload, &rules) != nil { - return nil, errors.New("invalid plugin scheduling contract") - } - for _, rule := range rules { - if (rule.Default != "allow" && rule.Default != "deny") || len(rule.Models) == 0 { - return nil, errors.New("invalid plugin scheduling rule") - } - } - r.scheduling.Store(&rules) - break - } - snapshot := json.RawMessage(append([]byte(nil), configJSON...)) - r.configSnapshot.Store(&snapshot) - r.configRevision.Add(1) - r.catalogCache.Clear() - r.catalogCacheSize.Store(0) return configJSON, nil } @@ -301,7 +231,7 @@ func (r *pluginRuntime) status(ctx context.Context) (*pluginv1.HealthResponse, e } func (r *pluginRuntime) beginRequest() bool { - if r == nil || r.draining.Load() || r.configuring.Load() { + if r == nil || r.draining.Load() { return false } r.inFlight.Add(1) @@ -322,7 +252,7 @@ func (r *pluginRuntime) drain(timeout time.Duration) { if r == nil { return } - r.beginDrain() + r.draining.Store(true) if r.inFlight.Load() == 0 { r.doneOnce.Do(func() { close(r.done) }) } @@ -336,37 +266,9 @@ func (r *pluginRuntime) drain(timeout time.Duration) { } func (r *pluginRuntime) kill() { - if r == nil { - return - } - r.beginDrain() - r.killOnce.Do(func() { - if r.client != nil { - r.client.Kill() - } - if r.lease != nil { - r.lease.Release() - } - }) -} - -func (r *pluginRuntime) observeLease(lease PluginRuntimeLease) error { - r.lease = lease - if lease == nil || lease.Done() == nil { - return nil + if r != nil && r.client != nil { + r.client.Kill() } - select { - case <-lease.Done(): - err := lease.Err() - r.kill() - if err == nil { - err = ErrPluginRuntimeLeaseLost - } - return err - default: - } - r.leaseWatchDone = watchPluginRuntimeLease(lease, r.kill) - return nil } func (r *pluginRuntime) roundTrip(ctx context.Context, request *http.Request, proxyURL string, account *Account) (*http.Response, error) { diff --git a/backend/internal/service/plugin_runtime_lease_loss_test.go b/backend/internal/service/plugin_runtime_lease_loss_test.go deleted file mode 100644 index 2d103760647d..000000000000 --- a/backend/internal/service/plugin_runtime_lease_loss_test.go +++ /dev/null @@ -1,190 +0,0 @@ -package service - -import ( - "context" - "errors" - "sync" - "sync/atomic" - "testing" - "time" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/stretchr/testify/require" -) - -type observedRuntimeLeaseFixture struct { - done chan struct{} - finishOnce sync.Once - releaseOnce sync.Once - mu sync.RWMutex - err error - onRelease func() - releases atomic.Int32 -} - -func newObservedRuntimeLeaseFixture() *observedRuntimeLeaseFixture { - return &observedRuntimeLeaseFixture{done: make(chan struct{})} -} -func (l *observedRuntimeLeaseFixture) Done() <-chan struct{} { return l.done } -func (l *observedRuntimeLeaseFixture) Err() error { - l.mu.RLock() - defer l.mu.RUnlock() - return l.err -} -func (l *observedRuntimeLeaseFixture) finish(err error) { - l.finishOnce.Do(func() { - l.mu.Lock() - l.err = err - close(l.done) - l.mu.Unlock() - }) -} -func (l *observedRuntimeLeaseFixture) Release() { - l.releaseOnce.Do(func() { - l.releases.Add(1) - if l.onRelease != nil { - l.onRelease() - } - l.finish(nil) - }) -} - -type observedRuntimeLeaseRepository struct { - *hostIOAdmissionRepository - hold func(context.Context, *PluginInstallation) (PluginRuntimeLease, error) - observedCalls int - legacyCalls int -} - -func (r *observedRuntimeLeaseRepository) HoldObservedPluginRuntime(ctx context.Context, installation *PluginInstallation) (PluginRuntimeLease, error) { - r.observedCalls++ - return r.hold(ctx, installation) -} -func (r *observedRuntimeLeaseRepository) HoldPluginRuntime(ctx context.Context, installation *PluginInstallation) (func(), error) { - r.legacyCalls++ - return r.hostIOAdmissionRepository.HoldPluginRuntime(ctx, installation) -} - -var _ PluginRepository = (*observedRuntimeLeaseRepository)(nil) -var _ ObservedPluginRuntimeLocker = (*observedRuntimeLeaseRepository)(nil) - -func waitRuntimeLeaseSignal(t *testing.T, done <-chan struct{}) { - t.Helper() - select { - case <-done: - case <-time.After(time.Second): - t.Fatal("lease notification listener did not finish") - } -} - -func TestObservedHostIOLeaseLossCancelsDetachedPolicyNotBilling(t *testing.T) { - manager, legacy, runtime := hostIOAdmissionManager(t) - lease := newObservedRuntimeLeaseFixture() - repo := &observedRuntimeLeaseRepository{hostIOAdmissionRepository: legacy} - repo.hold = func(ctx context.Context, installation *PluginInstallation) (PluginRuntimeLease, error) { - release, err := legacy.HoldPluginRuntime(ctx, installation) - lease.onRelease = release - return lease, err - } - manager.repo = repo - bound, release, err := manager.BindOperationContext(context.Background(), PlatformOpenAI, AccountTypeAPIKey, extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "host.io", AccountID: 42}) - require.NoError(t, err) - t.Cleanup(release) - detached, stopDetached := detachUpstreamContext(bound) - defer stopDetached() - billing, stopBilling := detachedBillingContext(bound) - defer stopBilling() - require.Equal(t, 1, legacy.businessHolds) - require.False(t, PluginBusinessIOLeaseRequired(bound)) - lease.finish(ErrPluginRuntimeLeaseLost) - waitRuntimeLeaseSignal(t, bound.Done()) - waitRuntimeLeaseSignal(t, detached.Done()) - require.ErrorIs(t, bound.Err(), context.Canceled) - require.ErrorIs(t, detached.Err(), context.Canceled) - require.NoError(t, billing.Err(), "already incurred usage still gets its detached billing cleanup") - require.Equal(t, 1, legacy.active, "loss notification cancels work but does not run its cleanup prematurely") - require.Equal(t, int64(1), runtime.inFlight.Load()) - release() - release() - require.Zero(t, legacy.active) - require.Zero(t, runtime.inFlight.Load()) - require.Equal(t, int32(1), lease.releases.Load()) - require.Equal(t, 1, repo.observedCalls) - require.Zero(t, repo.legacyCalls, "production observed capability is preferred over the old port") -} - -func TestObservedProcessLeaseLossDrainsBeforeRuntimeCleanup(t *testing.T) { - runtime := &pluginRuntime{done: make(chan struct{})} - policy, releasePolicy, err := runtime.bindPolicyContext(context.Background()) - require.NoError(t, err) - defer releasePolicy() - detached, stopDetached := detachUpstreamContext(policy) - defer stopDetached() - lease := newObservedRuntimeLeaseFixture() - var drainedBeforeRelease atomic.Bool - lease.onRelease = func() { - drainedBeforeRelease.Store(runtime.draining.Load() && policy.Err() != nil) - } - require.NoError(t, runtime.observeLease(lease)) - lease.finish(ErrPluginRuntimeLeaseLost) - waitRuntimeLeaseSignal(t, runtime.leaseWatchDone) - waitRuntimeLeaseSignal(t, detached.Done()) - require.True(t, drainedBeforeRelease.Load()) - require.True(t, runtime.draining.Load()) - require.False(t, runtime.beginRequest()) - runtime.kill() - require.Equal(t, int32(1), lease.releases.Load()) -} - -func TestObservedLeaseNormalReleaseEndsListenerWithoutFalseLoss(t *testing.T) { - lease := newObservedRuntimeLeaseFixture() - var lost atomic.Bool - finished := watchPluginRuntimeLease(lease, func() { lost.Store(true) }) - lease.Release() - waitRuntimeLeaseSignal(t, finished) - require.False(t, lost.Load()) - require.NoError(t, lease.Err()) -} - -func TestObservedProcessLeaseLostBeforeAttachmentRejectsRuntime(t *testing.T) { - lease := newObservedRuntimeLeaseFixture() - lease.finish(ErrPluginRuntimeLeaseLost) - runtime := &pluginRuntime{done: make(chan struct{})} - err := runtime.observeLease(lease) - require.ErrorIs(t, err, ErrPluginRuntimeLeaseLost) - require.True(t, runtime.draining.Load(), "a lease lost during process startup cannot certify publication") - require.False(t, runtime.beginRequest()) - require.Equal(t, int32(1), lease.releases.Load()) - require.Nil(t, runtime.leaseWatchDone, "no extra listener is retained after startup rejection") -} - -func TestObservedLeaseFailureNeverFallsBackToReleaseOnlyPort(t *testing.T) { - for _, mode := range []string{"error", "nil", "unobservable", "already_lost"} { - t.Run(mode, func(t *testing.T) { - _, legacy, _ := hostIOAdmissionManager(t) - fixture := newObservedRuntimeLeaseFixture() - repo := &observedRuntimeLeaseRepository{hostIOAdmissionRepository: legacy} - repo.hold = func(context.Context, *PluginInstallation) (PluginRuntimeLease, error) { - switch mode { - case "error": - return nil, errors.New("observer acquisition failed") - case "nil": - return nil, nil - case "unobservable": - return &pluginReleaseOnlyLease{release: fixture.Release}, nil - default: - fixture.finish(ErrPluginRuntimeLeaseLost) - return fixture, nil - } - } - lease, err := acquirePluginRuntimeLease(context.Background(), repo, legacy.current) - require.Error(t, err) - require.Nil(t, lease) - require.Equal(t, 1, repo.observedCalls) - require.Zero(t, repo.legacyCalls) - if mode == "unobservable" || mode == "already_lost" { - require.Equal(t, int32(1), fixture.releases.Load()) - } - }) - } -} diff --git a/backend/internal/service/plugin_scheduling.go b/backend/internal/service/plugin_scheduling.go deleted file mode 100644 index 275f5b05b08a..000000000000 --- a/backend/internal/service/plugin_scheduling.go +++ /dev/null @@ -1,102 +0,0 @@ -package service - -import ( - "encoding/json" - "path" - "time" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" -) - -const PluginAccountProjectionKey = "plugin_account_projections" - -type PluginAccountProjection struct { - Identity string `json:"identity"` - Scheduling map[string]extensionv1.SchedulingConstraint `json:"scheduling"` - Observations map[string]extensionv1.AccountObservation `json:"observations,omitempty"` -} - -// Scheduling uses a sanitized account projection and immutable plugin policy, -// so candidate filtering never launches a process RPC or reads ticket material. -func (m *PluginManager) SchedulingDecision(account *Account, model string, now time.Time) extensionv1.SchedulingDecision { - allow := extensionv1.SchedulingDecision{Allowed: true} - if m == nil || account == nil { - return allow - } - registry := m.extensions.Load() - if registry == nil || registry.unavailable != "" { - return extensionv1.SchedulingDecision{Reason: "plugin_registry_unavailable", Scope: model} - } - invocation := extensionv1.Invocation{Capability: extensionv1.CapabilityScheduling, AccountID: account.ID} - for id, installation := range registry.installations { - if !pluginHasInvocationCapability(installation, invocation, account.Platform, account.Type) { - continue - } - runtime := registry.runtimes[id] - if registry.unavailable != "" || runtime == nil || runtime.draining.Load() || runtime.client.Exited() || !pluginDependenciesHealthy(installation, registry, map[int64]bool{}) { - return extensionv1.SchedulingDecision{Reason: "plugin_unavailable", Scope: model} - } - rules := runtime.scheduling.Load() - if rules == nil { - return extensionv1.SchedulingDecision{Reason: "plugin_policy_unavailable", Scope: model} - } - projection := accountPluginProjection(account, installation.PluginKey) - for _, rule := range *rules { - if rule.ExcludeShadows && account.IsShadow() { - continue - } - matched := false - for _, pattern := range rule.Models { - if pattern == model { - matched = true - break - } - if ok, _ := path.Match(pattern, model); ok { - matched = true - break - } - } - if !matched { - continue - } - constraint, observed := projection.Scheduling[model] - if !observed { - constraint, observed = projection.Scheduling["*"] - } - if projection.Identity != CodexTicketAccountIdentity(account) { - observed = false - } - // Legacy length-only grants cannot survive a runtime cutover into the - // Cookie qualification policy, even before its background migration. - if installation.PluginKey == codexRuntimePluginKey && constraint.Effect == "allow" && constraint.Reason != "routing_verified" { - observed = false - } - if observed && constraint.Until != nil && !now.Before(*constraint.Until) { - observed = false - } - if observed { - if constraint.Effect == "deny" { - return extensionv1.SchedulingDecision{Reason: constraint.Reason, Until: constraint.Until, Scope: model} - } - continue - } - if rule.Default == "deny" { - return extensionv1.SchedulingDecision{Reason: rule.Reason, Scope: model} - } - } - } - return allow -} - -func accountPluginProjection(account *Account, key string) PluginAccountProjection { - var projection PluginAccountProjection - if account == nil { - return projection - } - if values, ok := account.Extra[PluginAccountProjectionKey].(map[string]any); ok { - if raw, err := json.Marshal(values[key]); err == nil { - _ = json.Unmarshal(raw, &projection) - } - } - return projection -} diff --git a/backend/internal/service/plugin_security_regression_test.go b/backend/internal/service/plugin_security_regression_test.go index e7212ec5b951..ae45fe44201d 100644 --- a/backend/internal/service/plugin_security_regression_test.go +++ b/backend/internal/service/plugin_security_regression_test.go @@ -87,18 +87,6 @@ func TestPluginUIAssetTokenRejectsOtherEncryptedPayloads(t *testing.T) { require.ErrorContains(t, err, "会话无效") } -func TestPluginUIAssetTokenCannotMixResourcesAcrossPackageUpdate(t *testing.T) { - repo := &pluginTokenRepository{installation: &PluginInstallation{ID: 42, PackageSHA256: strings.Repeat("a", 64)}} - manager := &PluginManager{repo: repo, encryptor: pluginTokenEncryptor{}} - token, _, err := manager.CreateUIAssetToken(context.Background(), 42, 30*time.Minute) - require.NoError(t, err) - repo.installation.PackageSHA256 = strings.Repeat("b", 64) - _, err = manager.ResolveUIAssetToken(token) - require.ErrorIs(t, err, ErrPluginUISessionChanged) - _, _, err = manager.ReadUIAssetForToken(context.Background(), token, "assets/app.js") - require.ErrorIs(t, err, ErrPluginUISessionChanged) -} - func TestPluginReconcileFailsClosedWhenDesiredStateCannotBeRead(t *testing.T) { manager := &PluginManager{ repo: &pluginTokenRepository{listErr: errors.New("数据库不可用")}, diff --git a/backend/internal/service/plugin_theme.go b/backend/internal/service/plugin_theme.go deleted file mode 100644 index 54b2c544a249..000000000000 --- a/backend/internal/service/plugin_theme.go +++ /dev/null @@ -1,66 +0,0 @@ -package service - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "os" - "slices" - "strings" -) - -func (m *PluginManager) installedByID(id int64) (*PluginInstallation, *pluginRuntime) { - registry := m.extensions.Load() - if registry == nil { - return nil, nil - } - return registry.installations[id], registry.runtimes[id] -} - -func pluginResourceRevision(installation *PluginInstallation) string { - raw, _ := json.Marshal(installation.Manifest.Files) - digest := sha256.Sum256(raw) - return hex.EncodeToString(digest[:]) -} - -func publicThemeAssetURL(installation *PluginInstallation, path string) string { - return fmt.Sprintf("/api/v1/settings/plugins/%d/theme/%s/%s", installation.ID, pluginResourceRevision(installation), strings.TrimPrefix(path, "ui/")) -} - -// Only an enabled public theme may publish its declared stylesheet and fonts. -// Configuration pages, scripts, and arbitrary package files stay private. -func (m *PluginManager) ReadPublicThemeAsset(ctx context.Context, id int64, revision, relative string) ([]byte, string, error) { - installation, _ := m.installedByID(id) - if installation == nil || revision != pluginResourceRevision(installation) || !safePluginRelativePath(relative) { - return nil, "", os.ErrNotExist - } - path := "ui/" + relative - allowed := false - for _, item := range m.Contributions() { - if item.PluginID != id || item.Permission != "public" || item.Slot != "theme" || !item.Available { - continue - } - if path == item.Entrypoint || slices.Contains(item.Assets, path) { - allowed = true - break - } - } - if !allowed { - return nil, "", os.ErrNotExist - } - expected := installation.Manifest.Files[path] - if expected == "" { - return nil, "", os.ErrNotExist - } - data, actualPath, err := m.ReadUIAsset(ctx, id, relative) - if err != nil { - return nil, "", err - } - digest := sha256.Sum256(data) - if actualPath != path || hex.EncodeToString(digest[:]) != expected { - return nil, "", os.ErrNotExist - } - return data, actualPath, nil -} diff --git a/backend/internal/service/plugin_theme_test.go b/backend/internal/service/plugin_theme_test.go deleted file mode 100644 index 5f812ba34020..000000000000 --- a/backend/internal/service/plugin_theme_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package service - -import ( - "bytes" - "context" - "encoding/json" - "os" - "path/filepath" - "testing" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - hcplugin "github.com/hashicorp/go-plugin" - "github.com/stretchr/testify/require" -) - -func TestPublicThemeAssetsRequireCurrentActivationDeclarationAndDigest(t *testing.T) { - files := map[string][]byte{"bin/plugin": []byte("binary"), "ui/index.html": []byte(""), "ui/assets/theme.css": []byte("binary"), "ui/assets/font.woff2": []byte("binary"), "ui/assets/private.js": []byte("binary")} - manifest := testPluginManifest(files) - manifest.Requires.ExtensionAPI = 1 - manifest.Capabilities = []PluginCapability{{ID: extensionv1.CapabilityUI, Platform: "*", AccountType: "*"}} - manifest.Contributions = []extensionv1.Contribution{{ID: "theme", Slot: "theme", Permission: "public", ConfigFlag: "theme_enabled", Entrypoint: "ui/assets/theme.css", Assets: []string{"ui/assets/font.woff2"}, Label: map[string]string{"en": "Theme"}}} - cfg := testPluginConfig(t.TempDir(), true) - info := PluginHostInfo{Version: "0.1.179"} - installer := NewPluginPackageInstaller(cfg, info) - installation, err := installer.Install(context.Background(), bytes.NewReader(buildPluginArchive(t, manifest, nil, "", nil)), nil) - require.NoError(t, err) - installation.ID, installation.State = 4, PluginStateEnabled - installation.Bindings = []PluginBinding{{Capability: extensionv1.CapabilityUI, Platform: "*", AccountType: "*", Enabled: true, RolloutPercent: 100}} - repo := &pluginTokenRepository{installation: installation} - manager := NewPluginManager(repo, pluginTokenEncryptor{}, cfg, info, nil) - runtime := &pluginRuntime{client: &hcplugin.Client{}} - configuration := json.RawMessage(`{"theme_enabled":true}`) - runtime.configSnapshot.Store(&configuration) - manager.runtimes[4] = runtime - manager.publishExtensionRegistryLocked([]*PluginInstallation{installation}, "") - revision := pluginResourceRevision(installation) - for _, percent := range []int{0, 50} { - installation.Bindings[0].RolloutPercent = percent - manager.publishExtensionRegistryLocked([]*PluginInstallation{installation}, "") - require.Empty(t, manager.PublicContributions(), "a partial theme cannot be published globally") - _, _, err := manager.ReadPublicThemeAsset(context.Background(), 4, revision, "assets/theme.css") - require.Error(t, err) - } - installation.Bindings[0].RolloutPercent = 100 - manager.publishExtensionRegistryLocked([]*PluginInstallation{installation}, "") - public := manager.PublicContributions() - require.Len(t, public, 1) - require.Contains(t, public[0].StylesheetURL, revision) - require.Empty(t, public[0].Assets) - for _, asset := range []string{"assets/theme.css", "assets/font.woff2"} { - data, _, err := manager.ReadPublicThemeAsset(context.Background(), 4, revision, asset) - require.NoError(t, err) - require.Equal(t, "binary", string(data)) - } - for _, asset := range []string{"index.html", "assets/private.js", "../bin/plugin"} { - _, _, err := manager.ReadPublicThemeAsset(context.Background(), 4, revision, asset) - require.Error(t, err) - } - _, _, err = manager.ReadPublicThemeAsset(context.Background(), 4, "old-revision", "assets/theme.css") - require.Error(t, err) - require.NoError(t, os.WriteFile(filepath.Join(installation.InstallPath, "ui", "assets", "theme.css"), []byte("changed"), 0600)) - _, _, err = manager.ReadPublicThemeAsset(context.Background(), 4, revision, "assets/theme.css") - require.Error(t, err) - installation.Bindings[0].Enabled = false - manager.publishExtensionRegistryLocked([]*PluginInstallation{installation}, "") - require.Empty(t, manager.PublicContributions()) - _, _, err = manager.ReadPublicThemeAsset(context.Background(), 4, revision, "assets/font.woff2") - require.Error(t, err) -} diff --git a/backend/internal/service/plugin_ui_session_test.go b/backend/internal/service/plugin_ui_session_test.go deleted file mode 100644 index 8d85bd63257b..000000000000 --- a/backend/internal/service/plugin_ui_session_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package service - -import ( - "context" - "testing" - "time" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/stretchr/testify/require" -) - -func TestPluginPageSessionCannotSelectAnotherRoleOrUndeclaredEntry(t *testing.T) { - installation := &PluginInstallation{ID: 7, Manifest: PluginManifest{UI: PluginUIManifest{Entrypoint: "ui/index.html"}, Contributions: []extensionv1.Contribution{ - {ID: "management", Permission: "admin", Entrypoint: "ui/compiled/index.html"}, - {ID: "studio", Permission: "user", Entrypoint: "ui/studio/index.html"}, - }}, Bindings: []PluginBinding{{Capability: extensionv1.CapabilityRequest, Platform: "*", AccountType: "*", Enabled: true}}} - manager := NewPluginManager(&pluginTokenRepository{installation: installation}, pluginTokenEncryptor{}, nil, PluginHostInfo{}, nil) - ctx := context.Background() - _, err := manager.CreateUIAssetSession(ctx, 7, "management", "user", time.Minute) - require.Error(t, err) - _, err = manager.CreateUIAssetSession(ctx, 7, "", "user", time.Minute) - require.Error(t, err) - _, err = manager.CreateUIAssetSession(ctx, 7, "undeclared", "admin", time.Minute) - require.Error(t, err) - session, err := manager.CreateUIAssetSession(ctx, 7, "studio", "admin", time.Minute) - require.NoError(t, err) - require.Equal(t, "user", session.Permission, "administrator browsing a user page still receives a user bridge") - claims, err := manager.parseUIAssetToken(session.Token) - require.NoError(t, err) - require.Equal(t, "ui/studio/index.html", claims.Entrypoint) - require.Equal(t, "user", claims.Permission) -} - -func TestPluginPageSessionRejectsDisabledContributionButAllowsFaultStaticPage(t *testing.T) { - installation := &PluginInstallation{ID: 8, State: PluginStateDisabled, Manifest: PluginManifest{UI: PluginUIManifest{Entrypoint: "ui/index.html"}, Contributions: []extensionv1.Contribution{{ID: "page", Permission: "user", Entrypoint: "ui/page/index.html"}}}, Bindings: []PluginBinding{{Capability: extensionv1.CapabilityRequest, Platform: "*", AccountType: "*", Enabled: false}}} - repo := &pluginTokenRepository{installation: installation} - manager := NewPluginManager(repo, pluginTokenEncryptor{}, nil, PluginHostInfo{}, nil) - _, err := manager.CreateUIAssetSession(context.Background(), 8, "page", "user", time.Minute) - require.ErrorIs(t, err, ErrExtensionOperationDisabled) - - installation.State = PluginStateError - installation.Bindings[0].Enabled = true - session, err := manager.CreateUIAssetSession(context.Background(), 8, "page", "user", time.Minute) - require.NoError(t, err) - require.NotEmpty(t, session.Token) -} - -func TestPluginConfigUISessionRemainsAvailableWhenBusinessBindingsAreDisabled(t *testing.T) { - installation := &PluginInstallation{ID: 9, State: PluginStateDisabled, Manifest: PluginManifest{UI: PluginUIManifest{Entrypoint: "ui/index.html"}}} - manager := NewPluginManager(&pluginTokenRepository{installation: installation}, pluginTokenEncryptor{}, nil, PluginHostInfo{}, nil) - - session, err := manager.CreateUIAssetSession(context.Background(), installation.ID, "", "admin", time.Minute) - require.NoError(t, err, "the administrator must be able to reopen a disabled plugin's configuration page") - require.Equal(t, "admin", session.Permission) - claims, err := manager.parseUIAssetToken(session.Token) - require.NoError(t, err) - require.Equal(t, "ui/index.html", claims.Entrypoint) - installation.Bindings = []PluginBinding{{Capability: extensionv1.CapabilityRequest, Platform: "*", AccountType: "*", Enabled: false}} - _, err = manager.CreateUIAssetSession(context.Background(), installation.ID, "", "admin", time.Minute) - require.NoError(t, err, "persisted disabled bindings must not prevent repairing the plugin configuration") - installation.State = PluginStateError - _, err = manager.CreateUIAssetSession(context.Background(), installation.ID, "", "admin", time.Minute) - require.NoError(t, err, "runtime health must not gate the static administrator configuration surface") -} - -func TestPluginBusinessUISessionRequiresItsCapabilityBinding(t *testing.T) { - installation := &PluginInstallation{ - ID: 10, - State: PluginStateEnabled, - Manifest: PluginManifest{UI: PluginUIManifest{Entrypoint: "ui/index.html"}, Contributions: []extensionv1.Contribution{{ - ID: "page", Permission: "admin", Capability: extensionv1.CapabilityRequest, Entrypoint: "ui/page/index.html", - }}}, - } - manager := NewPluginManager(&pluginTokenRepository{installation: installation}, pluginTokenEncryptor{}, nil, PluginHostInfo{}, nil) - - _, err := manager.CreateUIAssetSession(context.Background(), installation.ID, "page", "admin", time.Minute) - require.ErrorIs(t, err, ErrExtensionOperationDisabled, "an empty binding set must not authorize a new business page") - - installation.Bindings = []PluginBinding{{Capability: extensionv1.CapabilityAdmin, Platform: "*", AccountType: "*", Enabled: true}} - _, err = manager.CreateUIAssetSession(context.Background(), installation.ID, "page", "admin", time.Minute) - require.ErrorIs(t, err, ErrExtensionOperationDisabled, "an unrelated enabled capability must not authorize the page") - - installation.Bindings[0] = PluginBinding{Capability: extensionv1.CapabilityRequest, Platform: "openai", AccountType: "oauth", Enabled: true, RolloutPercent: 50} - session, err := manager.CreateUIAssetSession(context.Background(), installation.ID, "page", "admin", time.Minute) - require.NoError(t, err, "a scoped enabled binding may retain the static page for its rollout cohort") - require.NotEmpty(t, session.Token) -} - -func TestPluginGlobalBusinessUISessionRequiresFullWildcardBinding(t *testing.T) { - installation := &PluginInstallation{ - ID: 11, - State: PluginStateEnabled, - Manifest: PluginManifest{UI: PluginUIManifest{Entrypoint: "ui/index.html"}, Contributions: []extensionv1.Contribution{{ - ID: "global", Permission: "admin", Capability: extensionv1.CapabilityAdmin, AllAccounts: true, Entrypoint: "ui/global/index.html", - }}}, - Bindings: []PluginBinding{{Capability: extensionv1.CapabilityAdmin, Platform: "openai", AccountType: "*", Enabled: true, RolloutPercent: 100}}, - } - manager := NewPluginManager(&pluginTokenRepository{installation: installation}, pluginTokenEncryptor{}, nil, PluginHostInfo{}, nil) - - for _, tc := range []struct { - name string - platform string - kind string - rollout int - allowed bool - }{ - {name: "scoped platform", platform: "openai", kind: "*", rollout: 100}, - {name: "scoped account type", platform: "*", kind: "oauth", rollout: 100}, - {name: "partial rollout", platform: "*", kind: "*", rollout: 99}, - {name: "full wildcard", platform: "*", kind: "*", rollout: 100, allowed: true}, - } { - t.Run(tc.name, func(t *testing.T) { - installation.Bindings[0].Platform = tc.platform - installation.Bindings[0].AccountType = tc.kind - installation.Bindings[0].RolloutPercent = tc.rollout - session, err := manager.CreateUIAssetSession(context.Background(), installation.ID, "global", "admin", time.Minute) - if tc.allowed { - require.NoError(t, err) - require.NotEmpty(t, session.Token) - } else { - require.ErrorIs(t, err, ErrExtensionOperationDisabled) - } - }) - } -} - -func TestPluginBusinessUISessionDoesNotInvalidateStaticTokenOnDisable(t *testing.T) { - installation := &PluginInstallation{ID: 12, State: PluginStateEnabled, PackageSHA256: "package-v1", Manifest: PluginManifest{ - UI: PluginUIManifest{Entrypoint: "ui/index.html"}, - Contributions: []extensionv1.Contribution{{ID: "page", Permission: "admin", Capability: extensionv1.CapabilityRequest, Entrypoint: "ui/page/index.html"}}, - }, Bindings: []PluginBinding{{Capability: extensionv1.CapabilityRequest, Platform: "*", AccountType: "*", Enabled: true, RolloutPercent: 100}}} - repo := &pluginTokenRepository{installation: installation} - manager := NewPluginManager(repo, pluginTokenEncryptor{}, nil, PluginHostInfo{}, nil) - - session, err := manager.CreateUIAssetSession(context.Background(), installation.ID, "page", "admin", time.Minute) - require.NoError(t, err) - installation.State = PluginStateDisabled - installation.ConfigEncrypted = `{"revised":true}` - installation.Revision++ - installation.RuntimeGeneration++ - require.Equal(t, int64(installation.ID), mustResolvePluginUIToken(t, manager, session.Token)) -} - -func mustResolvePluginUIToken(t *testing.T, manager *PluginManager, token string) int64 { - t.Helper() - id, err := manager.ResolveUIAssetToken(token) - require.NoError(t, err) - return id -} - -func TestPluginManifestRejectsMissingContributedUI(t *testing.T) { - manifest := testPluginManifest(nil) - manifest.Contributions = []extensionv1.Contribution{{ID: "page", Slot: "surface", Permission: "admin", Label: map[string]string{"en": "Page"}, Entrypoint: "ui/compiled/index.html"}} - require.ErrorContains(t, manifest.Validate(), "未包含在签名资源") -} diff --git a/backend/internal/service/plugin_update.go b/backend/internal/service/plugin_update.go deleted file mode 100644 index 7b4a00e0f2a3..000000000000 --- a/backend/internal/service/plugin_update.go +++ /dev/null @@ -1,299 +0,0 @@ -package service - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "io" - "os" - "path/filepath" - "time" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" - "golang.org/x/mod/semver" -) - -func samePluginRuntime(a, b *PluginInstallation) bool { - return a != nil && b != nil && a.ID == b.ID && a.RuntimeGeneration == b.RuntimeGeneration && samePluginPackage(a, b) -} - -type stagedPluginHost struct{} - -func (stagedPluginHost) Call(context.Context, extensionv1.HostInvocation) (extensionv1.Result, error) { - return extensionv1.Result{}, errors.New("candidate validation does not grant host operations") -} - -// Validation runs without storage, credentials, jobs or an activation binding. -// ApplyConfig is intentionally deferred until after the atomic promotion. -func (m *PluginManager) validatePluginCandidate(ctx context.Context, candidate *PluginInstallation) error { - compatibility := EvaluatePluginCompatibility(candidate.Manifest, m.hostInfo) - if !compatibility.Compatible { - return errors.New(compatibility.Message) - } - socketDir := filepath.Join(m.installer.RootDir(), "runtime") - if err := os.MkdirAll(socketDir, 0700); err != nil { - return err - } - host := newPluginHostServiceServer(candidate.PluginKey, nil, nil, PluginAccountScope{}) - host.extension = stagedPluginHost{} - call, cancel := context.WithTimeout(ctx, 15*time.Second) - defer cancel() - runtime, err := startPluginRuntime(call, candidate, time.Duration(m.cfg.Plugins.StartTimeoutSeconds)*time.Second, socketDir, host) - if err != nil { - return err - } - defer runtime.kill() - config, err := m.decryptConfig(candidate) - if err != nil { - return err - } - result, err := runtime.api.ValidateConfig(call, &pluginv1.ValidateConfigRequest{ConfigJson: config}) - if err != nil || result == nil || !result.Valid { - return errors.New("replacement plugin does not accept the saved configuration") - } - if len(result.NormalizedConfigJson) > pluginConfigMaxBytes || (len(result.NormalizedConfigJson) > 0 && !json.Valid(result.NormalizedConfigJson)) { - return errors.New("invalid replacement configuration result") - } - return runtime.checkHealth(call) -} - -func (m *PluginManager) Update(ctx context.Context, id, revision int64, digest string, reader io.Reader, actor *int64) (*PluginInstallation, error) { - m.operationMu.Lock() - defer m.operationMu.Unlock() - previous, err := m.repo.GetByID(ctx, id) - if err != nil { - return nil, err - } - candidate, err := m.installer.Install(ctx, reader, actor) - if err != nil { - return nil, err - } - defer func() { _ = m.cleanupInstallationFiles(candidate) }() - if candidate.PluginKey != previous.PluginKey { - return nil, errors.New("replacement plugin identity differs") - } - if previous.State == PluginStateUpdating { - if store, ok := m.repo.(PluginUpdateRepository); ok { - pending, err := store.PendingPluginArtifact(ctx, id) - if err != nil { - return nil, err - } - sum := sha256.Sum256(pending) - if hex.EncodeToString(sum[:]) == candidate.PackageSHA256 { - return m.Get(ctx, id) - } - } - return nil, ErrPluginStateChanged - } - if previous.PackageSHA256 == candidate.PackageSHA256 { - // A retry after the first response was lost must not stage another update. - return m.Get(ctx, id) - } - if revision <= 0 || previous.Revision != revision || digest == "" || previous.PackageSHA256 != digest { - return nil, ErrPluginStateChanged - } - if semver.Compare(normalizeSemver(candidate.Version), normalizeSemver(previous.Version)) <= 0 { - return nil, errors.New("plugin update requires a newer immutable version") - } - if err = m.stagePluginReplacement(ctx, previous, candidate, PluginUpdatePinned); err != nil { - return nil, err - } - m.pausePluginForUpdate(id) - return m.Get(ctx, id) -} - -func (m *PluginManager) stagePluginReplacement(ctx context.Context, previous, candidate *PluginInstallation, policy string) error { - if candidate.Version == previous.Version && candidate.PackageSHA256 != previous.PackageSHA256 { - return errors.New("an immutable plugin version cannot contain different packages") - } - store, ok := m.repo.(PluginUpdateRepository) - if !ok { - return errors.New("independent plugin updates are unavailable") - } - if previous.State == PluginStateUpdating { - return ErrPluginStateChanged - } - if err := m.validatePluginReplacementRegistry(ctx, previous, candidate); err != nil { - return err - } - if err := m.validatePluginCandidate(ctx, candidate); err != nil { - return err - } - // Other hosts can change bindings while the candidate process is checked. - if err := m.validatePluginReplacementRegistry(ctx, previous, candidate); err != nil { - return err - } - return store.StagePluginUpdate(ctx, previous, candidate, policy) -} - -// Admission must use persisted desired bindings, not the local runtime or UI -// registry. Keep the caller's revision/config fence so a concurrent disable is -// retried with its new intent instead of silently enabling the replacement. -func (m *PluginManager) validatePluginReplacementRegistry(ctx context.Context, previous, candidate *PluginInstallation) error { - all, err := m.repo.List(ctx) - if err != nil { - return err - } - for index, installation := range all { - if installation.ID == previous.ID { - if installation.Revision != previous.Revision || installation.State != previous.State || - installation.ConfigEncrypted != previous.ConfigEncrypted || !samePluginRuntime(installation, previous) { - return ErrPluginStateChanged - } - candidate.ID, candidate.ConfigEncrypted = installation.ID, installation.ConfigEncrypted - candidate.Bindings = PluginReplacementBindings(installation.Bindings, candidate.Manifest) - all[index] = candidate - return validatePluginRegistry(all) - } - } - return ErrPluginStateChanged -} - -// Keep the desired contributions visible as unavailable while only this -// process drains. Other plugins and the host continue serving requests. -func (m *PluginManager) pausePluginForUpdate(id int64) { - m.mu.Lock() - runtime := m.runtimes[id] - delete(m.runtimes, id) - if registry := m.extensions.Load(); registry != nil { - if installation := registry.installations[id]; installation != nil { - m.replaceExtensionRegistrationLocked(installation, nil) - } - } - if route := m.route.Load(); route != nil && route.pluginID == id { - m.route.Store(&pluginRoute{pluginID: id, rolloutPercent: route.rolloutPercent, unavailable: "plugin update in progress"}) - } - if runtime != nil { - runtime.beginDrain() - } - m.mu.Unlock() - if runtime != nil { - go runtime.drain(10 * time.Second) - } -} - -func (m *PluginManager) resumePluginUpdate(ctx context.Context, previous *PluginInstallation) (*PluginInstallation, error) { - m.pausePluginForUpdate(previous.ID) - store, ok := m.repo.(PluginUpdateRepository) - if !ok { - return nil, errors.New("plugin update repository unavailable") - } - artifact, err := store.PendingPluginArtifact(ctx, previous.ID) - if err != nil { - return nil, err - } - candidate, err := m.installer.Install(ctx, bytes.NewReader(artifact), previous.InstalledBy) - if err != nil { - return nil, err - } - retained := false - defer func() { - if !retained { - _ = m.cleanupInstallationFiles(candidate) - } - }() - if candidate.PluginKey != previous.PluginKey { - return nil, errors.New("pending plugin identity differs") - } - if !candidate.Compatibility.Compatible { - return nil, errors.New(candidate.Compatibility.Message) - } - if err = m.validatePluginReplacementRegistry(ctx, previous, candidate); err != nil { - return nil, err - } - if err = m.validatePluginCandidate(ctx, candidate); err != nil { - return nil, err - } - if err = m.validatePluginReplacementRegistry(ctx, previous, candidate); err != nil { - return nil, err - } - if err = store.CommitPluginUpdate(ctx, previous, candidate); err != nil { - if errors.Is(err, ErrPluginUpdateWaiting) || errors.Is(err, ErrPluginStateChanged) { - return nil, nil - } - return nil, err - } - retained = true - current, err := m.repo.GetByID(ctx, previous.ID) - if err != nil { - return nil, err - } - m.mu.Lock() - m.localInstallations[current.ID] = mergeLocalInstallation(candidate, current) - m.mu.Unlock() - return current, nil -} - -// Following the image is explicit: selecting it may replace a newer pinned -// version with the image's compatible package. No account data is rolled back. -func (m *PluginManager) FollowBundledVersion(ctx context.Context, id, revision int64, digest string) (*PluginInstallation, error) { - m.operationMu.Lock() - defer m.operationMu.Unlock() - previous, err := m.repo.GetByID(ctx, id) - if err != nil { - return nil, err - } - if revision <= 0 || previous.Revision != revision || previous.PackageSHA256 != digest { - return nil, ErrPluginStateChanged - } - store, ok := m.repo.(PluginUpdateRepository) - if !ok { - return nil, errors.New("plugin updates unavailable") - } - path := m.bundlePath - if path == "" { - executable, err := os.Executable() - if err != nil { - return nil, err - } - path = filepath.Join(filepath.Dir(executable), "bundled-plugins", "lock.json") - } - raw, err := os.ReadFile(path) - if err != nil { - return nil, errors.New("this plugin has no bundled version") - } - var bundle PluginBundle - if json.Unmarshal(raw, &bundle) != nil || bundle.SchemaVersion != 1 || normalizeSemver(bundle.HostVersion) != normalizeSemver(m.hostInfo.Version) { - return nil, errors.New("invalid host bundle") - } - for _, entry := range bundle.Plugins { - if entry.ID != previous.PluginKey { - continue - } - if !safePluginRelativePath(entry.File) || filepath.Base(entry.File) != entry.File { - return nil, errors.New("invalid bundled package path") - } - artifact, err := os.ReadFile(filepath.Join(filepath.Dir(path), entry.File)) - if err != nil { - return nil, err - } - sum := sha256.Sum256(artifact) - if hex.EncodeToString(sum[:]) != entry.SHA256 { - return nil, errors.New("bundled package digest mismatch") - } - if previous.PackageSHA256 == entry.SHA256 { - if err = store.SetPluginUpdatePolicy(ctx, id, revision, PluginUpdateBundled); err != nil { - return nil, err - } - return m.Get(ctx, id) - } - candidate, err := m.installer.Install(ctx, bytes.NewReader(artifact), previous.InstalledBy) - if err != nil { - return nil, err - } - defer func() { _ = m.cleanupInstallationFiles(candidate) }() - if candidate.PluginKey != previous.PluginKey || candidate.Version != entry.Version { - return nil, errors.New("bundled plugin identity mismatch") - } - if err = m.stagePluginReplacement(ctx, previous, candidate, PluginUpdateBundled); err != nil { - return nil, err - } - m.pausePluginForUpdate(id) - return m.Get(ctx, id) - } - return nil, errors.New("this plugin has no bundled version") -} diff --git a/backend/internal/service/plugin_update_contract.go b/backend/internal/service/plugin_update_contract.go deleted file mode 100644 index 849403b8f02e..000000000000 --- a/backend/internal/service/plugin_update_contract.go +++ /dev/null @@ -1,164 +0,0 @@ -package service - -import ( - "context" - "errors" - "sync" -) - -const ( - PluginStateUpdating = "updating" - PluginUpdateBundled = "bundled" - PluginUpdatePinned = "pinned" -) - -var ErrPluginUpdateWaiting = errors.New("previous plugin processes are still draining") -var ErrPluginRuntimeLeaseLost = errors.New("plugin runtime lease session lost") - -// Separate from the original repository port so upstream implementations and -// contract fixtures do not accidentally claim support for independent updates. -type PluginUpdateRepository interface { - StagePluginUpdate(context.Context, *PluginInstallation, *PluginInstallation, string) error - PendingPluginArtifact(context.Context, int64) ([]byte, error) - CommitPluginUpdate(context.Context, *PluginInstallation, *PluginInstallation) error - SetPluginUpdatePolicy(context.Context, int64, int64, string) error -} - -type PluginRuntimeLocker interface { - HoldPluginRuntime(context.Context, *PluginInstallation) (func(), error) -} - -// Done closes on either deliberate release (Err is nil) or observed session -// loss. Release is idempotent and waits only for the connection owner's cleanup, -// never for callers that may themselves react to Done by calling Release. -type PluginRuntimeLease interface { - Done() <-chan struct{} - Err() error - Release() -} - -// Keep the legacy port for existing repositories/fixtures. Production provides -// this stronger optional port; its failure must never select the weaker port. -type ObservedPluginRuntimeLocker interface { - HoldObservedPluginRuntime(context.Context, *PluginInstallation) (PluginRuntimeLease, error) -} - -type pluginReleaseOnlyLease struct { - release func() - once sync.Once -} - -func (*pluginReleaseOnlyLease) Done() <-chan struct{} { return nil } -func (*pluginReleaseOnlyLease) Err() error { return nil } -func (l *pluginReleaseOnlyLease) Release() { l.once.Do(l.release) } - -func acquirePluginRuntimeLease(ctx context.Context, repo PluginRepository, installation *PluginInstallation) (PluginRuntimeLease, error) { - if observer, ok := repo.(ObservedPluginRuntimeLocker); ok { - lease, err := observer.HoldObservedPluginRuntime(ctx, installation) - if err != nil { - if lease != nil { - lease.Release() - } - return nil, err - } - if lease == nil || lease.Done() == nil { - if lease != nil { - lease.Release() - } - return nil, ErrPluginRuntimeLeaseLost - } - select { - case <-lease.Done(): - err = lease.Err() - lease.Release() - if err == nil { - err = ErrPluginRuntimeLeaseLost - } - return nil, err - default: - return lease, nil - } - } - if locker, ok := repo.(PluginRuntimeLocker); ok { - release, err := locker.HoldPluginRuntime(ctx, installation) - if err != nil { - return nil, err - } - if release == nil { - return nil, ErrPluginRuntimeLeaseLost - } - return &pluginReleaseOnlyLease{release: release}, nil - } - return nil, nil -} - -// The listener does not own or release the lease. In particular, onLoss may -// call runtime.kill/Release without a listener/connection-owner wait cycle. -func watchPluginRuntimeLease(lease PluginRuntimeLease, onLoss func()) <-chan struct{} { - finished := make(chan struct{}) - if lease == nil || lease.Done() == nil { - close(finished) - return finished - } - go func() { - defer close(finished) - <-lease.Done() - if lease.Err() != nil { - onLoss() - } - }() - return finished -} - -type pluginBusinessIOLeaseKey struct{} - -// WithPluginBusinessIOLease marks a host policy admission, not the lifecycle -// lease of a process used for startup or disabled-plugin config diagnostics. -// This private context key is never decoded from a plugin or HTTP payload. -func WithPluginBusinessIOLease(ctx context.Context) context.Context { - return context.WithValue(ctx, pluginBusinessIOLeaseKey{}, true) -} - -func PluginBusinessIOLeaseRequired(ctx context.Context) bool { - required, _ := ctx.Value(pluginBusinessIOLeaseKey{}).(bool) - return required -} - -type pluginExecutionKey struct{} -type PluginExecution struct{ ID, Generation int64 } - -// Only the authenticated host broker adds this fence. It is never decoded from -// a plugin payload, including when plugins use public storage operations. -func WithPluginExecution(ctx context.Context, installation *PluginInstallation) context.Context { - return context.WithValue(ctx, pluginExecutionKey{}, PluginExecution{installation.ID, installation.RuntimeGeneration}) -} -func PluginExecutionFromContext(ctx context.Context) (PluginExecution, bool) { - execution, ok := ctx.Value(pluginExecutionKey{}).(PluginExecution) - return execution, ok -} - -type pluginRevisionKey struct{} - -func WithPluginExpectedRevision(ctx context.Context, revision int64) context.Context { - return context.WithValue(ctx, pluginRevisionKey{}, revision) -} -func PluginExpectedRevision(ctx context.Context) int64 { - revision, _ := ctx.Value(pluginRevisionKey{}).(int64) - return revision -} - -// Preserve exact existing bindings; newly declared capabilities start disabled. -func PluginReplacementBindings(previous []PluginBinding, manifest PluginManifest) []PluginBinding { - bindings := make([]PluginBinding, 0, len(manifest.Capabilities)) - for _, capability := range manifest.SortedCapabilities() { - binding := PluginBinding{Capability: capability.ID, Platform: capability.Platform, AccountType: capability.AccountType, RolloutPercent: 100} - for _, old := range previous { - if old.Capability == binding.Capability && old.Platform == binding.Platform && old.AccountType == binding.AccountType { - binding.Enabled, binding.RolloutPercent = old.Enabled, old.RolloutPercent - break - } - } - bindings = append(bindings, binding) - } - return bindings -} diff --git a/backend/internal/service/plugin_update_test.go b/backend/internal/service/plugin_update_test.go deleted file mode 100644 index 7bfc56eab323..000000000000 --- a/backend/internal/service/plugin_update_test.go +++ /dev/null @@ -1,327 +0,0 @@ -package service - -import ( - "bytes" - "context" - "crypto/ed25519" - "crypto/rand" - "encoding/base64" - "errors" - "testing" - - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" - "github.com/stretchr/testify/require" -) - -func TestPluginUpdateRejectsChangedVersionAndSignatureBeforeExecution(t *testing.T) { - public, private, err := ed25519.GenerateKey(rand.Reader) - require.NoError(t, err) - cfg := testPluginConfig(t.TempDir(), false) - cfg.Plugins.TrustedPublishers["update-test"] = base64.StdEncoding.EncodeToString(public) - archive := buildTestPluginArchive(t, private, "update-test") - installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"}) - installed, err := installer.Install(context.Background(), bytes.NewReader(archive), nil) - require.NoError(t, err) - installed.ID, installed.Revision = 7, 4 - repo := &pluginTokenRepository{installation: installed} - manager := NewPluginManager(repo, pluginTokenEncryptor{}, cfg, PluginHostInfo{Version: "0.1.179"}, nil) - // Repeating exactly the accepted package is a read, even with an old revision. - result, err := manager.Update(context.Background(), 7, 3, "previous-digest", bytes.NewReader(archive), nil) - require.NoError(t, err) - require.Equal(t, installed.PackageSHA256, result.PackageSHA256) - manifest := installed.Manifest - manifest.Description = "changed content without a version increment" - changed := buildPluginArchive(t, manifest, private, "update-test", nil) - _, err = manager.Update(context.Background(), 7, 4, installed.PackageSHA256, bytes.NewReader(changed), nil) - require.ErrorContains(t, err, "newer immutable version") - manifest.Version = "1.0.1" - changed = buildPluginArchive(t, manifest, private, "update-test", nil) - _, err = manager.Update(context.Background(), 7, 3, installed.PackageSHA256, bytes.NewReader(changed), nil) - require.ErrorIs(t, err, ErrPluginStateChanged) - _, otherKey, err := ed25519.GenerateKey(rand.Reader) - require.NoError(t, err) - invalid := buildPluginArchive(t, manifest, otherKey, "update-test", nil) - _, err = manager.Update(context.Background(), 7, 4, installed.PackageSHA256, bytes.NewReader(invalid), nil) - require.ErrorContains(t, err, "签名校验失败") - require.Equal(t, installed, repo.installation) -} - -type conflictingPluginConfigRepository struct{ *pluginConfigRepository } - -func (*conflictingPluginConfigRepository) UpdateConfig(context.Context, int64, string, string) error { - return ErrPluginStateChanged -} - -func TestPluginConfigurationConflictDoesNotApplyOrCancelWork(t *testing.T) { - installation := &PluginInstallation{ID: 9, Revision: 3} - repo := &conflictingPluginConfigRepository{&pluginConfigRepository{installation: installation}} - client := &normalizingPluginClient{normalized: []byte(`{"enabled":true}`)} - runtime := &pluginRuntime{installation: installation, api: client} - work, release, err := runtime.bindPolicyContext(context.Background()) - require.NoError(t, err) - defer release() - manager := &PluginManager{repo: repo, encryptor: pluginTokenEncryptor{}, runtimes: map[int64]*pluginRuntime{9: runtime}} - _, err = manager.SaveConfig(context.Background(), 9, []byte(`{}`)) - require.ErrorIs(t, err, ErrPluginStateChanged) - require.Empty(t, client.applied) - require.NoError(t, work.Err()) -} - -type pendingResumePluginRepository struct { - *pluginTokenRepository - artifact []byte - commits int - pendingReads int -} - -func (r *pendingResumePluginRepository) PendingPluginArtifact(context.Context, int64) ([]byte, error) { - r.pendingReads++ - return r.artifact, nil -} - -func (*pendingResumePluginRepository) StagePluginUpdate(context.Context, *PluginInstallation, *PluginInstallation, string) error { - return errors.New("unexpected staging from a resume test") -} - -func (*pendingResumePluginRepository) SetPluginUpdatePolicy(context.Context, int64, int64, string) error { - return errors.New("unexpected policy change from a resume test") -} - -var _ PluginUpdateRepository = (*pendingResumePluginRepository)(nil) - -func (r *pendingResumePluginRepository) CommitPluginUpdate(context.Context, *PluginInstallation, *PluginInstallation) error { - r.commits++ - return nil -} - -func TestResumePluginUpdateRevalidatesCandidateBeforeCommit(t *testing.T) { - cfg := testPluginConfig(t.TempDir(), true) - installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"}) - oldArchive := buildTestPluginArchive(t, nil, "") - old, err := installer.Install(context.Background(), bytes.NewReader(oldArchive), nil) - require.NoError(t, err) - old.ID = 7 - old.State = PluginStateUpdating - old.Revision = 4 - old.RuntimeGeneration = 9 - old.ConfigEncrypted = "ENC:{}" - old.Bindings = []PluginBinding{{Capability: extensionv1.CapabilityAdmin, Platform: "*", AccountType: "*", Enabled: true, RolloutPercent: 100}} - - manifest := testPluginManifest(nil) - manifest.Version = "0.1.1" - pending := buildPluginArchive(t, manifest, nil, "", nil) - repo := &pendingResumePluginRepository{ - pluginTokenRepository: &pluginTokenRepository{installation: old}, - artifact: pending, - } - manager := NewPluginManager(repo, pluginTokenEncryptor{}, cfg, PluginHostInfo{Version: "0.1.179"}, nil) - - _, err = manager.resumePluginUpdate(context.Background(), old) - require.ErrorContains(t, err, "启动插件进程", "a real candidate-start failure, not a missing update interface, must block promotion") - require.Equal(t, 1, repo.pendingReads) - require.Zero(t, repo.commits) - require.Equal(t, old.PackageSHA256, repo.installation.PackageSHA256) - require.Equal(t, old.ConfigEncrypted, repo.installation.ConfigEncrypted) - require.Equal(t, old.Bindings, repo.installation.Bindings) -} - -type pluginUpdateAdmissionRepository struct { - *pluginTokenRepository - artifact []byte - peers []*PluginInstallation - lists int - pendingReads int - stages int - commits int -} - -func (r *pluginUpdateAdmissionRepository) List(ctx context.Context) ([]*PluginInstallation, error) { - r.lists++ - all, err := r.pluginTokenRepository.List(ctx) - if err != nil { - return nil, err - } - for _, peer := range r.peers { - copy := *peer - copy.Bindings = append([]PluginBinding(nil), peer.Bindings...) - all = append(all, ©) - } - return all, nil -} - -func (r *pluginUpdateAdmissionRepository) StagePluginUpdate(context.Context, *PluginInstallation, *PluginInstallation, string) error { - r.stages++ - return errors.New("unexpected plugin update stage") -} - -func (r *pluginUpdateAdmissionRepository) PendingPluginArtifact(context.Context, int64) ([]byte, error) { - r.pendingReads++ - return append([]byte(nil), r.artifact...), nil -} - -func (r *pluginUpdateAdmissionRepository) CommitPluginUpdate(context.Context, *PluginInstallation, *PluginInstallation) error { - r.commits++ - return errors.New("unexpected plugin update commit") -} - -func (*pluginUpdateAdmissionRepository) SetPluginUpdatePolicy(context.Context, int64, int64, string) error { - return errors.New("unexpected plugin update policy change") -} - -var _ PluginUpdateRepository = (*pluginUpdateAdmissionRepository)(nil) - -func newPluginUpdateAdmissionFixture(t *testing.T, state string) (*PluginManager, *pluginUpdateAdmissionRepository, *PluginInstallation, *PluginInstallation) { - t.Helper() - cfg := testPluginConfig(t.TempDir(), true) - host := PluginHostInfo{Version: "0.1.179"} - installer := NewPluginPackageInstaller(cfg, host) - manifest := testPluginManifest(nil) - manifest.Requires.ExtensionAPI = extensionv1.Version - manifest.Capabilities = []PluginCapability{{ID: extensionv1.CapabilityAdmin, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth}} - manifest.Operations = map[string][]string{extensionv1.CapabilityAdmin: {"admin.original"}} - old, err := installer.Install(context.Background(), bytes.NewReader(buildPluginArchive(t, manifest, nil, "", nil)), nil) - require.NoError(t, err) - old.ID, old.Revision, old.RuntimeGeneration = 7, 4, 9 - old.State, old.ConfigEncrypted = state, `ENC:{"saved":true}` - old.Bindings = []PluginBinding{{Capability: extensionv1.CapabilityAdmin, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth, Enabled: true, RolloutPercent: 37}} - - manifest.Version = "0.1.1" - manifest.Capabilities = append(manifest.Capabilities, PluginCapability{ID: extensionv1.CapabilityRecovery, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth}) - manifest.Dependencies = []extensionv1.Dependency{{Capability: extensionv1.CapabilityProvider, Platform: "cindy", AccountType: "apikey"}} - manifest.Operations = map[string][]string{extensionv1.CapabilityAdmin: {"admin.replacement"}} - candidate, err := installer.Install(context.Background(), bytes.NewReader(buildPluginArchive(t, manifest, nil, "", nil)), nil) - require.NoError(t, err) - repo := &pluginUpdateAdmissionRepository{ - pluginTokenRepository: &pluginTokenRepository{installation: old}, - artifact: append([]byte(nil), candidate.ArtifactData...), - peers: []*PluginInstallation{ - {ID: 8, PluginKey: "com.example.provider", State: PluginStateEnabled, Bindings: []PluginBinding{{Capability: extensionv1.CapabilityProvider, Platform: "cindy", AccountType: "apikey", Enabled: true, RolloutPercent: 100}}}, - {ID: 9, PluginKey: "com.example.admin", State: PluginStateEnabled, - Manifest: PluginManifest{Operations: map[string][]string{extensionv1.CapabilityAdmin: {"admin.other"}}}, - Bindings: []PluginBinding{{Capability: extensionv1.CapabilityAdmin, Platform: "*", AccountType: "*", Enabled: true, RolloutPercent: 100}}}, - }, - } - previous := *old - previous.Bindings = append([]PluginBinding(nil), old.Bindings...) - return NewPluginManager(repo, pluginTokenEncryptor{}, cfg, host, nil), repo, &previous, candidate -} - -func TestPluginUpdateAdmissionRejectsChangedRegistry(t *testing.T) { - for _, mode := range []string{"stage", "resume", "restart"} { - t.Run(mode, func(t *testing.T) { - for _, test := range []struct { - name string - change func(*pluginUpdateAdmissionRepository) - want string - }{ - { - name: "dependency_disabled", - change: func(repo *pluginUpdateAdmissionRepository) { - repo.peers[0].State = PluginStateDisabled - repo.peers[0].Bindings[0].Enabled = false - }, - want: "missing enabled plugin dependency", - }, - { - name: "operation_owner_added", - change: func(repo *pluginUpdateAdmissionRepository) { - repo.peers[1].Manifest.Operations = map[string][]string{extensionv1.CapabilityAdmin: {"admin.replacement"}} - }, - want: "extension operation has overlapping enabled owners", - }, - { - name: "registry_unavailable", - change: func(repo *pluginUpdateAdmissionRepository) { - repo.listErr = errors.New("current plugin registry unavailable") - }, - want: "current plugin registry unavailable", - }, - } { - t.Run(test.name, func(t *testing.T) { - state := PluginStateUpdating - if mode == "stage" { - state = PluginStateEnabled - } - manager, repo, previous, candidate := newPluginUpdateAdmissionFixture(t, state) - require.NoError(t, manager.validatePluginReplacementRegistry(context.Background(), previous, candidate)) - require.Equal(t, 1, repo.lists) - pending := append([]byte(nil), repo.artifact...) - // Leave both local projections at the admitted state. Neither is - // evidence that the database still permits this replacement. - cached := &pluginExtensionRegistry{installations: map[int64]*PluginInstallation{previous.ID: previous}, runtimes: map[int64]*pluginRuntime{}} - for _, peer := range repo.peers { - copy := *peer - copy.Bindings = append([]PluginBinding(nil), peer.Bindings...) - cached.installations[copy.ID] = © - manager.localInstallations[copy.ID] = © - } - manager.extensions.Store(cached) - test.change(repo) - if mode == "restart" { - manager = NewPluginManager(repo, pluginTokenEncryptor{}, manager.cfg, manager.hostInfo, nil) - } - var err error - if mode == "stage" { - err = manager.stagePluginReplacement(context.Background(), previous, candidate, PluginUpdatePinned) - require.Zero(t, repo.pendingReads) - } else { - var result *PluginInstallation - result, err = manager.resumePluginUpdate(context.Background(), previous) - require.Nil(t, result) - require.Equal(t, 1, repo.pendingReads) - } - // The archive contains no runnable program. A matching admission - // error proves the registry was checked before process startup. - require.ErrorContains(t, err, test.want) - require.Equal(t, 2, repo.lists) - require.Zero(t, repo.stages) - require.Zero(t, repo.commits) - require.Equal(t, pending, repo.artifact) - require.Equal(t, previous, repo.installation) - require.FileExists(t, previous.ArtifactPath) - require.FileExists(t, previous.BinaryPath) - }) - } - }) - } -} - -func TestResumePluginUpdateAdmissionPreservesCurrentDisableIntent(t *testing.T) { - manager, repo, previous, candidate := newPluginUpdateAdmissionFixture(t, PluginStateUpdating) - require.NoError(t, manager.validatePluginReplacementRegistry(context.Background(), previous, candidate)) - require.Equal(t, previous.ID, candidate.ID) - require.Equal(t, previous.ConfigEncrypted, candidate.ConfigEncrypted) - bindings := make(map[string]PluginBinding) - for _, binding := range candidate.Bindings { - bindings[binding.Capability] = binding - } - require.True(t, bindings[extensionv1.CapabilityAdmin].Enabled) - require.Equal(t, 37, bindings[extensionv1.CapabilityAdmin].RolloutPercent) - require.False(t, bindings[extensionv1.CapabilityRecovery].Enabled, "new capabilities remain disabled") - - // A disable during draining changes the revision but leaves the pending - // package in place. An older reconcile snapshot must not commit over it. - repo.installation.Revision++ - repo.installation.Bindings[0].Enabled = false - result, err := manager.resumePluginUpdate(context.Background(), previous) - require.Nil(t, result) - require.ErrorIs(t, err, ErrPluginStateChanged) - require.Zero(t, repo.commits) - require.Equal(t, previous.PackageSHA256, repo.installation.PackageSHA256) - require.Equal(t, previous.ConfigEncrypted, repo.installation.ConfigEncrypted) - require.Equal(t, previous.RuntimeGeneration, repo.installation.RuntimeGeneration) - require.Equal(t, PluginStateUpdating, repo.installation.State) - require.Equal(t, candidate.ArtifactData, repo.artifact) - - current, err := repo.GetByID(context.Background(), previous.ID) - require.NoError(t, err) - repo.peers[0].Bindings[0].Enabled = false - repo.peers[1].Manifest.Operations = map[string][]string{extensionv1.CapabilityAdmin: {"admin.replacement"}} - require.NoError(t, manager.validatePluginReplacementRegistry(context.Background(), current, candidate)) - require.False(t, hasEnabledPluginBinding(candidate.Bindings), "fresh admission must preserve the administrator's disable") - require.Equal(t, current.ConfigEncrypted, candidate.ConfigEncrypted) - require.True(t, previous.Bindings[0].Enabled, "the earlier snapshot is not rewritten") - require.Equal(t, int64(5), repo.installation.Revision) - require.FileExists(t, previous.ArtifactPath) - require.FileExists(t, previous.BinaryPath) -} diff --git a/backend/internal/service/policy_context.go b/backend/internal/service/policy_context.go new file mode 100644 index 000000000000..747892b47123 --- /dev/null +++ b/backend/internal/service/policy_context.go @@ -0,0 +1,29 @@ +package service + +import "context" + +type policyCancellationSignalsKey struct{} + +// Policy cancellation is independent from a client disconnect. Upstream IO +// may detach from that disconnect, but must still stop on native policy replacement. +func detachPolicyContext(parent context.Context) (context.Context, context.CancelFunc) { + signals, _ := parent.Value(policyCancellationSignalsKey{}).([]context.Context) + signals = append(append([]context.Context(nil), signals...), nativeCodexPolicySignals(parent)...) + if len(signals) == 0 { + return context.WithoutCancel(parent), func() {} + } + ctx, cancel := context.WithCancel(context.WithoutCancel(parent)) + stops := make([]func() bool, 0, len(signals)) + for _, signal := range signals { + if signal.Err() != nil { + cancel() + } + stops = append(stops, context.AfterFunc(signal, cancel)) + } + return ctx, func() { + for _, stop := range stops { + stop() + } + cancel() + } +} diff --git a/backend/internal/service/policy_context_test.go b/backend/internal/service/policy_context_test.go new file mode 100644 index 000000000000..72e895972ec2 --- /dev/null +++ b/backend/internal/service/policy_context_test.go @@ -0,0 +1,31 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestNativePolicyCancellationSurvivesDetachedClientContext(t *testing.T) { + for _, native := range []bool{false, true} { + client, cancelClient := context.WithCancel(context.Background()) + policy, cancelPolicy := context.WithCancel(context.Background()) + parent := context.WithValue(client, policyCancellationSignalsKey{}, []context.Context{policy}) + if native { + parent = context.WithValue(client, nativeCodexPolicySignalsKey{}, []context.Context{policy}) + } + detached, release := detachPolicyContext(parent) + cancelClient() + require.NoError(t, detached.Err(), "a client disconnect does not terminate detached work") + cancelPolicy() + select { + case <-detached.Done(): + case <-time.After(time.Second): + t.Fatal("native policy cancellation did not reach detached work") + } + require.ErrorIs(t, detached.Err(), context.Canceled) + release() + } +} diff --git a/backend/internal/service/prompt_domain_runtime.go b/backend/internal/service/prompt_domain_runtime.go index 0773b5257861..8f24598abeff 100644 --- a/backend/internal/service/prompt_domain_runtime.go +++ b/backend/internal/service/prompt_domain_runtime.go @@ -7,11 +7,11 @@ import ( "sync" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) -// Initialize domain storage only after the signed policy process is ready. -// Existing state stays available for history while the plugin is disabled. +// Initialize domain storage only after the embedded skill registry is ready. +// Existing state stays available for history otherwise. type PromptDomainRuntime struct { registry *RemoteSkillRegistryService prompts *BusinessSystemPromptService @@ -30,7 +30,7 @@ func NewPromptDomainRuntime(registry *RemoteSkillRegistryService, prompts *Busin func promptPolicyAvailability(ctx context.Context) error { call, cancel := context.WithTimeout(ctx, time.Second) defer cancel() - result, err := invokeProcessDomainExtension(call, extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "prompt.availability", Payload: []byte(`{}`)}, true) + result, err := invokePromptSkills(call, extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "prompt.availability", Payload: []byte(`{}`)}) if err != nil { return err } diff --git a/backend/internal/service/prompt_domain_runtime_test.go b/backend/internal/service/prompt_domain_runtime_test.go index b5e6107a71bc..b0a570389794 100644 --- a/backend/internal/service/prompt_domain_runtime_test.go +++ b/backend/internal/service/prompt_domain_runtime_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" ) @@ -25,99 +25,12 @@ func (failedPromptProcess) InvokeOperation(context.Context, string, string, exte return extensionv1.Result{}, ErrExtensionOperationUnavailable } -type boundPromptFixture struct { - promptPolicyFixture - runtime *pluginRuntime -} - -func (f boundPromptFixture) BindOperationContext(ctx context.Context, _, _ string, _ extensionv1.Invocation) (context.Context, context.CancelFunc, error) { - return f.runtime.bindPolicyContext(ctx) -} - -type promptSourceFunc func(context.Context) (BusinessSystemPromptSourceCandidate, error) - -func (f promptSourceFunc) Fetch(ctx context.Context) (BusinessSystemPromptSourceCandidate, error) { - return f(ctx) -} - -func TestPromptSourceDisableDiscardsLateCandidateBeforePersistence(t *testing.T) { - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - runtime := &pluginRuntime{} - processExtensionOperations.Store(&extensionOperationProvider{invoker: boundPromptFixture{runtime: runtime}}) - store := &fakeBusinessSystemPromptStore{} - prompts := NewBusinessSystemPromptService(store, nil) - prompts.SetBusinessSystemPromptSource(promptSourceFunc(func(ctx context.Context) (BusinessSystemPromptSourceCandidate, error) { - runtime.beginDrain() - return BusinessSystemPromptSourceCandidate{Body: "late"}, nil - })) - _, err := prompts.SyncManagedSource(context.Background(), 2, 42, 1, 3) - require.ErrorIs(t, err, context.Canceled) - require.Zero(t, store.sourceSyncCalls) -} - -func TestPromptDomainInitializesAfterActivationAndPreservesDataOnDisable(t *testing.T) { - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - registry, registryStore, files := testRemoteSkillRegistry(t, testRemoteSkillCandidate(t, 1, 1, "seed")) - defer registry.Stop() - files.installed = false - store := &fakeBusinessSystemPromptStore{loaded: BusinessSystemPromptSnapshot{Revision: 3, Enabled: true, Body: "server"}} - prompts := NewBusinessSystemPromptService(store, nil) - defer prompts.Stop() - runtime := NewPromptDomainRuntime(registry, prompts) - processExtensionOperations.Store(nil) - runtime.reconcile(context.Background(), time.Now()) - require.False(t, files.installed) - require.False(t, store.seedCalled) - processExtensionOperations.Store(previous) - runtime.reconcile(context.Background(), time.Now()) - require.True(t, runtime.ready) - require.True(t, files.installed) - require.True(t, store.seedCalled) - _, err := registry.LoadPublishedFile(context.Background(), "SKILL.md") - require.NoError(t, err) - processExtensionOperations.Store(nil) - runtime.reconcile(context.Background(), time.Now()) - require.False(t, runtime.ready) - registry.runMu.Lock() - require.False(t, registry.started) - registry.runMu.Unlock() - prompts.lifecycleMu.Lock() - require.False(t, prompts.started) - prompts.lifecycleMu.Unlock() - require.Equal(t, "server", prompts.snapshot.Load().Body) - require.NotNil(t, registry.publication.Load()) - require.False(t, files.cleaned) - require.False(t, registryStore.cleaned) - _, err = registry.LoadPublishedFile(context.Background(), "SKILL.md") - require.ErrorIs(t, err, ErrRemoteSkillPublicFileNotFound) - body := []byte(`{"input":"client"}`) - gateway := &OpenAIGatewayService{businessPromptService: prompts} - out, applied, err := gateway.applyBusinessSystemPrompt(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, BusinessSystemPromptProtocolResponses, false) - require.NoError(t, err) - require.Equal(t, body, out) - require.False(t, applied.Applied) - processExtensionOperations.Store(previous) - runtime.reconcile(context.Background(), time.Now()) - require.True(t, runtime.ready) - registry.runMu.Lock() - require.True(t, registry.started) - registry.runMu.Unlock() - prompts.lifecycleMu.Lock() - require.True(t, prompts.started) - prompts.lifecycleMu.Unlock() - processExtensionOperations.Store(&extensionOperationProvider{invoker: failedPromptProcess{}}) - _, err = registry.LoadPublishedFile(context.Background(), "SKILL.md") - require.ErrorIs(t, err, ErrBusinessSystemPromptBundleUnavailable) - _, _, err = gateway.applyBusinessSystemPrompt(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, BusinessSystemPromptProtocolResponses, false) - require.ErrorIs(t, err, ErrBusinessSystemPromptUnavailable, "failure of an enabled policy must not silently remove a configured prompt") -} - func TestPromptDomainHealthyStartReloadsPromptOnce(t *testing.T) { - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) - processExtensionOperations.Store(&extensionOperationProvider{invoker: promptPolicyFixture{}}) + previous := invokePromptSkills + t.Cleanup(func() { invokePromptSkills = previous }) + invokePromptSkills = func(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { + return (promptPolicyFixture{}).InvokeOperation(ctx, "", "", in) + } registry, _, _ := testRemoteSkillRegistry(t, testRemoteSkillCandidate(t, 1, 1, "seed")) store := &fakeBusinessSystemPromptStore{loaded: BusinessSystemPromptSnapshot{Revision: 1, Body: embeddedBusinessSystemPrompt}} prompts := NewBusinessSystemPromptService(store, nil) diff --git a/backend/internal/service/plugin_prompt_management.go b/backend/internal/service/prompt_management.go similarity index 88% rename from backend/internal/service/plugin_prompt_management.go rename to backend/internal/service/prompt_management.go index c9b1815a8681..15ff43d756e7 100644 --- a/backend/internal/service/plugin_prompt_management.go +++ b/backend/internal/service/prompt_management.go @@ -10,14 +10,10 @@ import ( "time" "unicode/utf8" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func invokePromptManagement(ctx context.Context, operation string, input, output any) error { - return invokePromptManagementPolicy(ctx, operation, input, output, false) -} - -func invokePromptManagementPolicy(ctx context.Context, operation string, input, output any, cached bool) error { raw, err := json.Marshal(input) if err != nil { return err @@ -27,7 +23,7 @@ func invokePromptManagementPolicy(ctx context.Context, operation string, input, if operation == "prompt.source.fetch" { call = ctx } - result, err := invokeProcessDomainExtension(call, extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: operation, Payload: raw}, cached) + result, err := invokePromptSkills(call, extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: operation, Payload: raw}) if err != nil { if strings.HasPrefix(operation, "prompt.source.") { return ErrBusinessSystemPromptSourceUnavailable @@ -100,7 +96,7 @@ func planSkillPublication(ctx context.Context, request extensionv1.SkillPublicat func LoadRemoteSkillRegistryProfile(ctx context.Context) (extensionv1.SkillRegistryPolicyProfile, error) { var profile extensionv1.SkillRegistryPolicyProfile - if err := invokePromptManagementPolicy(ctx, "skills.profile", struct{}{}, &profile, true); err != nil { + if err := invokePromptManagement(ctx, "skills.profile", struct{}{}, &profile); err != nil { return profile, err } if err := validateRemoteSkillRegistryProfile(profile); err != nil { @@ -136,10 +132,10 @@ func planRemoteSkillStorage(ctx context.Context, candidate RemoteSkillCandidate) return extensionv1.SkillStorageLayoutPlan{}, ErrBusinessSystemPromptBundleInvalid } var plan extensionv1.SkillStorageLayoutPlan - err := invokePromptManagementPolicy(ctx, "skills.storage.plan", extensionv1.SkillStorageLayoutRequest{ + err := invokePromptManagement(ctx, "skills.storage.plan", extensionv1.SkillStorageLayoutRequest{ BundleVersionID: candidate.Version.ID, PromptVersionID: candidate.Prompt.ID, EffectiveTreeSHA256: candidate.Version.EffectiveTreeSHA256, EffectivePromptSHA256: candidate.Prompt.EffectiveSHA256, - }, &plan, true) + }, &plan) if err != nil { return plan, err } @@ -166,7 +162,7 @@ func planRemoteSkillStorage(ctx context.Context, candidate RemoteSkillCandidate) func remoteSkillFilePlan(ctx context.Context, name string, data []byte) (extensionv1.SkillFilePlan, error) { var plan extensionv1.SkillFilePlan - err := invokePromptManagementPolicy(ctx, "skills.file.plan", extensionv1.SkillFileInspection{Path: name, Prefix: data[:min(2, len(data))], UTF8: utf8.Valid(data)}, &plan, true) + err := invokePromptManagement(ctx, "skills.file.plan", extensionv1.SkillFileInspection{Path: name, Prefix: data[:min(2, len(data))], UTF8: utf8.Valid(data)}, &plan) if err == nil && (plan.Kind != "text" && plan.Kind != "script" && plan.Kind != "binary" || len(plan.ReplaceFrom) > 2048 || len(plan.ReplaceTo) > 2048) { err = ErrBusinessSystemPromptBundleInvalid } diff --git a/backend/internal/service/plugin_prompt_profile_test.go b/backend/internal/service/prompt_profile_test.go similarity index 68% rename from backend/internal/service/plugin_prompt_profile_test.go rename to backend/internal/service/prompt_profile_test.go index 7ff97d6d0f91..aa64c252ce8a 100644 --- a/backend/internal/service/plugin_prompt_profile_test.go +++ b/backend/internal/service/prompt_profile_test.go @@ -5,7 +5,7 @@ import ( "encoding/json" "testing" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" ) @@ -31,36 +31,34 @@ func TestSkillStorageRejectsNonHashPathBeforeCallingThePlugin(t *testing.T) { require.ErrorIs(t, err, ErrBusinessSystemPromptBundleInvalid) } -type restrictedSkillProfileFixture struct { - promptPolicyFixture - bytesOnly bool -} - -func (f restrictedSkillProfileFixture) InvokeOperation(ctx context.Context, platform, kind string, in extensionv1.Invocation) (extensionv1.Result, error) { - result, err := f.promptPolicyFixture.InvokeOperation(ctx, platform, kind, in) - if err != nil || in.Operation != "skills.profile" { +// restrictedSkillProfile lowers one registry profile limit returned by invoke. +func restrictedSkillProfile(invoke func(context.Context, extensionv1.Invocation) (extensionv1.Result, error), bytesOnly bool) func(context.Context, extensionv1.Invocation) (extensionv1.Result, error) { + return func(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { + result, err := invoke(ctx, in) + if err != nil || in.Operation != "skills.profile" { + return result, err + } + var profile extensionv1.SkillRegistryPolicyProfile + if err = json.Unmarshal(result.Payload, &profile); err != nil { + return result, err + } + if bytesOnly { + profile.MaxTotalBytes = 1 + } else { + profile.MaxFileCount = 1 + } + result.Payload, err = json.Marshal(profile) return result, err } - var profile extensionv1.SkillRegistryPolicyProfile - if err = json.Unmarshal(result.Payload, &profile); err != nil { - return result, err - } - if f.bytesOnly { - profile.MaxTotalBytes = 1 - } else { - profile.MaxFileCount = 1 - } - result.Payload, err = json.Marshal(profile) - return result, err } func TestSkillProfileLimitsStopDownloadsBeforeAnyNetworkRequest(t *testing.T) { prompt, err := buildRemoteSkillPromptCapture([]byte(modelGangPromptCaptureFixture)) require.NoError(t, err) - previous := processExtensionOperations.Load() - t.Cleanup(func() { processExtensionOperations.Store(previous) }) + previous := invokePromptSkills + t.Cleanup(func() { invokePromptSkills = previous }) for _, bytesOnly := range []bool{false, true} { - processExtensionOperations.Store(&extensionOperationProvider{invoker: restrictedSkillProfileFixture{bytesOnly: bytesOnly}}) + invokePromptSkills = restrictedSkillProfile(previous, bytesOnly) client := &fakeRemoteSkillHTTPClient{} _, err := NewMoxinggangRemoteSkillCandidateSource(client).Build(context.Background(), prompt, nil) require.ErrorIs(t, err, ErrBusinessSystemPromptBundleInvalid) diff --git a/backend/internal/service/plugin_prompt_request_context.go b/backend/internal/service/prompt_request_context.go similarity index 100% rename from backend/internal/service/plugin_prompt_request_context.go rename to backend/internal/service/prompt_request_context.go diff --git a/backend/internal/service/prompt_skills_module.go b/backend/internal/service/prompt_skills_module.go new file mode 100644 index 000000000000..2d6701b912da --- /dev/null +++ b/backend/internal/service/prompt_skills_module.go @@ -0,0 +1,25 @@ +package service + +import ( + "context" + "sync" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + promptpolicy "github.com/Wei-Shaw/sub2api/internal/promptskills/policy" +) + +// promptSkills answers the prompt application and skill registry policy +// operations in process. The prompt-skills plugin served them before. +var promptSkills = sync.OnceValue(promptpolicy.New) + +// invokePromptSkills runs one prompt or skill registry operation. Tests replace +// it to model per-account decisions or restricted registry profiles. +var invokePromptSkills = func(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { + return promptSkills().Invoke(ctx, in) +} + +// promptPlanInvoke adapts invokePromptSkills to the account-scoped invoker of +// the prompt application path. +func promptPlanInvoke(ctx context.Context, _, _ string, in extensionv1.Invocation) (extensionv1.Result, error) { + return invokePromptSkills(ctx, in) +} diff --git a/backend/internal/service/remote_skill_prompt.go b/backend/internal/service/remote_skill_prompt.go index 8aeb7274beff..57a0d756d0b4 100644 --- a/backend/internal/service/remote_skill_prompt.go +++ b/backend/internal/service/remote_skill_prompt.go @@ -6,7 +6,7 @@ import ( "fmt" "strings" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/pmezard/go-difflib/difflib" ) diff --git a/backend/internal/service/remote_skill_registry.go b/backend/internal/service/remote_skill_registry.go index 805897ff89be..a7985b98e99f 100644 --- a/backend/internal/service/remote_skill_registry.go +++ b/backend/internal/service/remote_skill_registry.go @@ -9,7 +9,7 @@ import ( "sync/atomic" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) const ( @@ -355,13 +355,6 @@ func (s *RemoteSkillRegistryService) runSyncJob(ctx context.Context, job RemoteS s.failSyncJob(ctx, job.ID, "sync_expired") return } - bound, release, err := bindProcessDomainExtensionContext(ctx, extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "skills.tree.check"}) - if err != nil { - s.failSyncJob(ctx, job.ID, "service_stopped") - return - } - defer release() - ctx = bound if err := s.store.UpdateRemoteSkillSyncJobStage(ctx, job.ID, "fetching_source"); err != nil { s.failSyncJob(ctx, job.ID, "storage_error") return diff --git a/backend/internal/service/remote_skill_registry_filesystem.go b/backend/internal/service/remote_skill_registry_filesystem.go index 5bb9114b4cb5..8a1184468f3a 100644 --- a/backend/internal/service/remote_skill_registry_filesystem.go +++ b/backend/internal/service/remote_skill_registry_filesystem.go @@ -12,7 +12,7 @@ import ( "strings" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) const ( @@ -78,7 +78,7 @@ func (f *RemoteSkillRegistryFilesystem) LoadSeed(ctx context.Context) (RemoteSki return RemoteSkillCandidate{}, err } var promptBody string - if err := invokePromptManagementPolicy(ctx, "skills.prompt.seed", struct{}{}, &promptBody, true); err != nil { + if err := invokePromptManagement(ctx, "skills.prompt.seed", struct{}{}, &promptBody); err != nil { return RemoteSkillCandidate{}, err } prompt, err := buildRemoteSkillPromptCapture([]byte(promptBody)) diff --git a/backend/internal/service/remote_skill_registry_manifest.go b/backend/internal/service/remote_skill_registry_manifest.go index 23d4c0d60cc6..e687b48672cd 100644 --- a/backend/internal/service/remote_skill_registry_manifest.go +++ b/backend/internal/service/remote_skill_registry_manifest.go @@ -6,7 +6,7 @@ import ( "sort" "unicode/utf8" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) type remoteSkillManifest = extensionv1.SkillManifest @@ -17,7 +17,7 @@ func loadRemoteSkillManifest() (remoteSkillManifest, error) { } func loadRemoteSkillManifestContext(ctx context.Context) (remoteSkillManifest, error) { var manifest remoteSkillManifest - err := invokePromptManagementPolicy(ctx, "skills.seed.index", struct{}{}, &manifest, true) + err := invokePromptManagement(ctx, "skills.seed.index", struct{}{}, &manifest) if err != nil { return manifest, err } @@ -67,7 +67,7 @@ func readRemoteSkillSeedFile(ctx context.Context, entry remoteSkillManifestEntry body := make([]byte, 0, entry.ByteLength) for len(body) < entry.ByteLength { var chunk extensionv1.SkillSeedChunk - err := invokePromptManagementPolicy(ctx, "skills.seed.chunk", extensionv1.SkillSeedChunkRequest{Path: entry.Path, Offset: len(body), Limit: 256 << 10}, &chunk, true) + err := invokePromptManagement(ctx, "skills.seed.chunk", extensionv1.SkillSeedChunkRequest{Path: entry.Path, Offset: len(body), Limit: 256 << 10}, &chunk) if err != nil { return nil, err } diff --git a/backend/internal/service/remote_skill_registry_source.go b/backend/internal/service/remote_skill_registry_source.go index 135e7cf661d6..0c313f7130e7 100644 --- a/backend/internal/service/remote_skill_registry_source.go +++ b/backend/internal/service/remote_skill_registry_source.go @@ -17,7 +17,7 @@ import ( "golang.org/x/sync/errgroup" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) const ( diff --git a/backend/internal/service/remote_skill_registry_test.go b/backend/internal/service/remote_skill_registry_test.go index 9915504215bf..99027aea536a 100644 --- a/backend/internal/service/remote_skill_registry_test.go +++ b/backend/internal/service/remote_skill_registry_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" ) diff --git a/backend/internal/service/remote_skill_registry_validation.go b/backend/internal/service/remote_skill_registry_validation.go index 6102ce8abfb3..b532987c2aa9 100644 --- a/backend/internal/service/remote_skill_registry_validation.go +++ b/backend/internal/service/remote_skill_registry_validation.go @@ -4,7 +4,7 @@ import ( "context" "unicode/utf8" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) func validateCurrentRemoteSkillTree(files map[string][]byte) error { diff --git a/backend/internal/service/remote_skill_seed_fixture_test.go b/backend/internal/service/remote_skill_seed_fixture_test.go index 0b7acdb9035e..c9f50dce5bd9 100644 --- a/backend/internal/service/remote_skill_seed_fixture_test.go +++ b/backend/internal/service/remote_skill_seed_fixture_test.go @@ -6,8 +6,8 @@ import ( "io/fs" "strings" - registry "github.com/HTExplicit/sub2api-plugins/promptskills/registry" - promptsource "github.com/HTExplicit/sub2api-plugins/promptskills/source" + registry "github.com/Wei-Shaw/sub2api/internal/promptskills/registry" + promptsource "github.com/Wei-Shaw/sub2api/internal/promptskills/source" ) const ( diff --git a/backend/internal/service/setting_admin_observability.go b/backend/internal/service/setting_admin_observability.go new file mode 100644 index 000000000000..2e123dba2c60 --- /dev/null +++ b/backend/internal/service/setting_admin_observability.go @@ -0,0 +1,50 @@ +package service + +import ( + "context" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" +) + +// SettingKeyAdminObservabilityConfig stores the account traffic telemetry and +// flat theme switches formerly kept in the admin-observability plugin +// configuration. +const SettingKeyAdminObservabilityConfig = "admin_observability_config" + +// EffectiveAdminObservabilityConfig returns the switches this process applies. +func EffectiveAdminObservabilityConfig() extensionv1.AdminObservabilityConfig { + return currentAdminObservabilityConfig() +} + +// LoadAdminObservabilityConfig installs the effective switches for this process +// at startup: the stored value, or the deploy-time default when none is stored. +func (s *SettingService) LoadAdminObservabilityConfig(ctx context.Context) error { + config := extensionv1.AdminObservabilityConfig{TelemetryEnabled: true, ThemeEnabled: true} + found, err := s.readNativeSwitchSetting(ctx, SettingKeyAdminObservabilityConfig, &config, "telemetry_enabled", "theme_enabled") + if err != nil { + return err + } + if !found { + if s == nil { + config = LegacyAdminObservabilityConfig(nil) + } else { + config = LegacyAdminObservabilityConfig(s.cfg) + } + } + ConfigureAdminObservability(&config) + return nil +} + +// UpdateAdminObservabilityConfig persists the switches and applies them to this +// process. +func (s *SettingService) UpdateAdminObservabilityConfig(ctx context.Context, config extensionv1.AdminObservabilityConfig) error { + if err := s.writeJSONSetting(ctx, SettingKeyAdminObservabilityConfig, config); err != nil { + return err + } + ConfigureAdminObservability(&config) + // flat_theme_enabled is a public setting embedded in the served HTML. + if s.onUpdate != nil { + s.onUpdate() + } + return nil +} diff --git a/backend/internal/service/setting_cindy_provider.go b/backend/internal/service/setting_cindy_provider.go new file mode 100644 index 000000000000..277d943deed9 --- /dev/null +++ b/backend/internal/service/setting_cindy_provider.go @@ -0,0 +1,46 @@ +package service + +import ( + "context" + "sync" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" +) + +// SettingKeyCindyProviderConfig stores the Cindy balance detection, catalog and +// search switches formerly kept in the cindy-provider plugin configuration. +const SettingKeyCindyProviderConfig = "cindy_provider_config" + +var cindyProviderSettingsMu sync.Mutex + +// EffectiveCindyProviderConfig returns the switches this process applies. +func EffectiveCindyProviderConfig() extensionv1.CindyProviderConfig { + config, _ := currentCindyProviderConfig() + return config +} + +// LoadCindyProviderConfig installs the stored switches for this process at +// startup. Without a stored value the deploy-time rollout flags stay in force. +func (s *SettingService) LoadCindyProviderConfig(ctx context.Context) error { + config := extensionv1.CindyProviderConfig{BalanceDetection: true} + found, err := s.readNativeSwitchSetting(ctx, SettingKeyCindyProviderConfig, &config, "balance_detection", "catalog_enabled", "search_enabled") + if err != nil { + return err + } + if found { + ConfigureCindyProvider(&config) + } + return nil +} + +// UpdateCindyProviderConfig persists the switches and applies them to this +// process. Existing account health records are kept. +func (s *SettingService) UpdateCindyProviderConfig(ctx context.Context, config extensionv1.CindyProviderConfig) error { + cindyProviderSettingsMu.Lock() + defer cindyProviderSettingsMu.Unlock() + if err := s.writeJSONSetting(ctx, SettingKeyCindyProviderConfig, config); err != nil { + return err + } + ConfigureCindyProvider(&config) + return nil +} diff --git a/backend/internal/service/setting_image_tools.go b/backend/internal/service/setting_image_tools.go new file mode 100644 index 000000000000..836af0cfd54d --- /dev/null +++ b/backend/internal/service/setting_image_tools.go @@ -0,0 +1,45 @@ +package service + +import ( + "context" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" +) + +// SettingKeyImageToolsConfig stores the Image Studio and Responses image bridge +// switches formerly kept in the image-tools plugin configuration. +const SettingKeyImageToolsConfig = "image_tools_config" + +// EffectiveImageToolsConfig returns the switches this process applies. +func EffectiveImageToolsConfig() extensionv1.ImageToolsConfig { + config, _ := currentImageToolsConfig() + return config +} + +// LoadImageToolsConfig installs the stored switches for this process at +// startup. Without a stored value the deploy-time rollout flags stay in force. +func (s *SettingService) LoadImageToolsConfig(ctx context.Context) error { + var stored extensionv1.ImageToolsConfig + found, err := s.readNativeSwitchSetting(ctx, SettingKeyImageToolsConfig, &stored, "studio_enabled", "responses_image_enabled") + if err != nil { + return err + } + if found { + ConfigureImageTools(&stored) + } + return nil +} + +// UpdateImageToolsConfig persists the switches and applies them to this process, +// starting the Image Studio runtime when it is switched on. +func (s *SettingService) UpdateImageToolsConfig(ctx context.Context, config extensionv1.ImageToolsConfig) error { + if err := s.writeJSONSetting(ctx, SettingKeyImageToolsConfig, config); err != nil { + return err + } + ConfigureImageTools(&config) + // image_studio_enabled is a public setting embedded in the served HTML. + if s.onUpdate != nil { + s.onUpdate() + } + return nil +} diff --git a/backend/internal/service/setting_json.go b/backend/internal/service/setting_json.go new file mode 100644 index 000000000000..d2ebcd03a99f --- /dev/null +++ b/backend/internal/service/setting_json.go @@ -0,0 +1,63 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" +) + +// Startup must distinguish a missing key from a failed read, so a stored false +// switch cannot silently become an enabled deployment default. +func (s *SettingService) readNativeSwitchSetting(ctx context.Context, key string, target any, allowed ...string) (bool, error) { + if s == nil || s.settingRepo == nil { + return false, errors.New("native settings repository is unavailable") + } + dbCtx, cancel := context.WithTimeout(ctx, gatewayForwardingDBTimeout) + defer cancel() + raw, err := s.settingRepo.GetValue(dbCtx, key) + if errors.Is(err, ErrSettingNotFound) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("cannot read native setting %s", key) + } + if err := DecodeSwitchSettings([]byte(raw), target, allowed...); err != nil { + return false, fmt.Errorf("invalid native setting %s", key) + } + return true, nil +} + +// writeJSONSetting stores value as a JSON-valued setting. +func (s *SettingService) writeJSONSetting(ctx context.Context, key string, value any) error { + if s == nil || s.settingRepo == nil { + return errors.New("settings are unavailable") + } + raw, err := json.Marshal(value) + if err != nil { + return err + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), gatewayForwardingDBTimeout) + defer cancel() + return s.settingRepo.Set(dbCtx, key, string(raw)) +} + +// 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. +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 { + return errors.New("settings must be a JSON object") + } + for key, value := range fields { + known := false + for _, name := range allowed { + known = known || key == name + } + if !known || (string(value) != "true" && string(value) != "false") { + return errors.New("unknown or invalid switch setting") + } + } + return json.Unmarshal(raw, target) +} diff --git a/backend/internal/service/setting_public.go b/backend/internal/service/setting_public.go index d76ce1a30d39..397c9def8fed 100644 --- a/backend/internal/service/setting_public.go +++ b/backend/internal/service/setting_public.go @@ -366,6 +366,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true", ImageStudioEnabled: CindyImageStudioFeatureEnabled(), + FlatThemeEnabled: FlatThemeEnabled(), SubscriptionEnabled: !isFalseSettingValue(settings[SettingKeySubscriptionEnabled]), @@ -638,6 +639,7 @@ type PublicSettingsInjectionPayload struct { ChannelMonitorHideUserRanking bool `json:"channel_monitor_hide_user_ranking"` AvailableChannelsEnabled bool `json:"available_channels_enabled"` ImageStudioEnabled bool `json:"image_studio_enabled"` + FlatThemeEnabled bool `json:"flat_theme_enabled"` SubscriptionEnabled bool `json:"subscription_enabled"` ModelPlazaEnabled bool `json:"model_plaza_enabled"` ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` @@ -723,6 +725,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any ChannelMonitorHideUserRanking: settings.ChannelMonitorHideUserRanking, AvailableChannelsEnabled: settings.AvailableChannelsEnabled, ImageStudioEnabled: settings.ImageStudioEnabled, + FlatThemeEnabled: settings.FlatThemeEnabled, SubscriptionEnabled: settings.SubscriptionEnabled, ModelPlazaEnabled: settings.ModelPlazaEnabled, ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth, diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index c7157f04a75e..78e608b63620 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -410,6 +410,7 @@ type PublicSettings struct { // Available Channels feature (user-facing aggregate view) AvailableChannelsEnabled bool `json:"available_channels_enabled"` ImageStudioEnabled bool `json:"image_studio_enabled"` + FlatThemeEnabled bool `json:"flat_theme_enabled"` // Subscription feature switch (see SystemSettings.SubscriptionEnabled) SubscriptionEnabled bool `json:"subscription_enabled"` diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 79de84f3009e..acf87a88af78 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -918,8 +918,15 @@ 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) *SettingService { +func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupRepository, proxyRepo ProxyRepository, cfg *config.Config, _ *NativeFeatureBootstrap) (*SettingService, error) { svc := NewSettingService(settingRepo, cfg) + // Image tool, observability and Cindy provider switches are read by + // package-level gates before Image Studio and the gateway start. + for _, load := range []func(context.Context) error{svc.LoadImageToolsConfig, svc.LoadAdminObservabilityConfig, svc.LoadCindyProviderConfig} { + if err := load(context.Background()); err != nil { + return nil, err + } + } svc.SetDefaultSubscriptionGroupReader(groupRepo) svc.SetProxyRepository(proxyRepo) if err := svc.LoadForwardedClientIPSettings(context.Background()); err != nil { @@ -945,7 +952,7 @@ func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupReposit claude.SetCLIVersionResolver(func() string { return svc.GetClaudeCodeClientVersion(context.Background()) }) - return svc + return svc, nil } // ProvideBillingCacheService wires BillingCacheService with its RPM dependencies. @@ -982,6 +989,8 @@ func ProvideAPIKeyService( // ProviderSet is the Wire provider set for all services var ProviderSet = wire.NewSet( + ProvideNativeFeatureBootstrap, + ProvideNativeCodexRuntime, // Core services ProvideAuthService, NewPasskeyService, diff --git a/backend/internal/testextensions/fixtures.go b/backend/internal/testextensions/fixtures.go index ac3d5e18fb35..9dbc87f99520 100644 --- a/backend/internal/testextensions/fixtures.go +++ b/backend/internal/testextensions/fixtures.go @@ -1,6 +1,6 @@ // Package testextensions installs actual independent modules as deterministic // contract fixtures. It is imported only by host tests; production composes -// signed plugin processes through ProvidePluginManager. +// native modules; the remaining plugin manager serves third-party packages. package testextensions import ( @@ -8,24 +8,21 @@ import ( "encoding/json" "strings" - cindy "github.com/HTExplicit/sub2api-plugins/cindyprovider/catalog" - codexprofile "github.com/HTExplicit/sub2api-plugins/codexruntime/profile" - codexrecovery "github.com/HTExplicit/sub2api-plugins/codexruntime/recovery" + cindy "github.com/Wei-Shaw/sub2api/internal/cindyprovider/catalog" + codexprofile "github.com/Wei-Shaw/sub2api/internal/codexruntime/profile" + codexrecovery "github.com/Wei-Shaw/sub2api/internal/codexruntime/recovery" - accounttools "github.com/HTExplicit/sub2api-plugins/accounttools/policy" - observability "github.com/HTExplicit/sub2api-plugins/adminobservability/policy" - imagetools "github.com/HTExplicit/sub2api-plugins/imagetools/policy" - catalog "github.com/HTExplicit/sub2api-plugins/modelpolicy/catalog" - prompt "github.com/HTExplicit/sub2api-plugins/promptskills/policy" + accounttools "github.com/Wei-Shaw/sub2api/internal/accounttools/policy" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + prompt "github.com/Wei-Shaw/sub2api/internal/promptskills/policy" "github.com/Wei-Shaw/sub2api/internal/service" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" ) -type operations struct{ imageConfig *extensionv1.ImageToolsConfig } +type operations struct{} var promptFixtureModule = prompt.New() -func (fixture operations) InvokeOperation(ctx context.Context, _, _ string, in extensionv1.Invocation) (extensionv1.Result, error) { +func (operations) InvokeOperation(ctx context.Context, _, _ string, in extensionv1.Invocation) (extensionv1.Result, error) { if in.Capability == extensionv1.CapabilityRecovery { return codexrecovery.Invoke(ctx, in) } @@ -40,21 +37,6 @@ func (fixture operations) InvokeOperation(ctx context.Context, _, _ string, in e raw, err := json.Marshal(codexprofile.TransportPlan(query, false)) return extensionv1.Result{Payload: raw}, err } - if strings.HasPrefix(in.Operation, "observability.") { - return observability.New().Invoke(ctx, in) - } - if strings.HasPrefix(in.Operation, "image.") { - module := imagetools.New() - config := service.LegacyImageToolsConfig() - if fixture.imageConfig != nil { - config = *fixture.imageConfig - } - raw, _ := json.Marshal(config) - if err := module.ApplyConfig(ctx, raw); err != nil { - return extensionv1.Result{}, err - } - return module.Invoke(ctx, in) - } if strings.HasPrefix(in.Operation, "cindy.") { module := cindy.New() raw, _ := json.Marshal(service.LegacyCindyProviderConfig()) @@ -71,8 +53,13 @@ func (fixture operations) InvokeOperation(ctx context.Context, _, _ string, in e } return extensionv1.Result{}, service.ErrExtensionOperationDisabled } -func Install() { service.ConfigureProcessExtensionServices(catalog.New(), operations{}) } +func Install() { + service.ConfigureNativePolicyOperations(operations{}) + service.ConfigureImageTools(nil) + service.ConfigureAdminObservability(nil) +} func InstallImageTools(config extensionv1.ImageToolsConfig) { - service.ConfigureProcessExtensionServices(catalog.New(), operations{imageConfig: &config}) + service.ConfigureNativePolicyOperations(operations{}) + service.ConfigureImageTools(&config) } diff --git a/backend/migrations/254_plugin_job_action_digest.sql b/backend/migrations/254_plugin_job_action_digest.sql new file mode 100644 index 000000000000..a9a9783240f6 --- /dev/null +++ b/backend/migrations/254_plugin_job_action_digest.sql @@ -0,0 +1,4 @@ +-- Plugin jobs use a complete SHA-256 hex digest as the per-target payload key. +-- Preserve existing actions while allowing all 64 hexadecimal characters. +ALTER TABLE admin_account_job_items + ALTER COLUMN action TYPE VARCHAR(64); diff --git a/backend/migrations/remote_skill_sources_migration_integration_test.go b/backend/migrations/remote_skill_sources_migration_integration_test.go index aef202121711..85c4f1d7f012 100644 --- a/backend/migrations/remote_skill_sources_migration_integration_test.go +++ b/backend/migrations/remote_skill_sources_migration_integration_test.go @@ -74,10 +74,10 @@ func TestMain(m *testing.M) { func TestRemoteSkillPairedMigrationAndStartupPreserveEightGitHubVersions(t *testing.T) { ctx := context.Background() - // Production initializes this domain only after its plugin is ready. Use - // the existing actual module fixture through the same public SDK boundary. + // Exercise the actual native domain fixture without starting the server + // or connecting any model transport. testextensions.Install() - t.Cleanup(func() { service.ConfigureProcessExtensionServices(nil, nil) }) + t.Cleanup(func() { service.ConfigureNativePolicyOperations(nil) }) db, schema := remoteSkillMigrationTestDatabase(t) require.NoError(t, execRemoteSkillSQL(ctx, db, remoteSkillPost200FixtureSQL)) require.NoError(t, insertEightLegacyRemoteSkillVersions(ctx, db)) diff --git a/backend/pkg/extensionapi/ui/account-view.ts b/backend/pkg/extensionapi/ui/account-view.ts deleted file mode 100644 index 19373e1b0fe8..000000000000 --- a/backend/pkg/extensionapi/ui/account-view.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** Versioned data-only account view declarations. Private host UI is not exported. */ -export type AccountViewLabels = Record - -export interface AccountViewPredicate { - platforms?: string[] - types?: string[] - statuses?: string[] - plans?: string[] - privacy_mode?: string - cindy_only?: boolean - cindy_balance_status?: 'insufficient' - cindy_health_status?: 'banned' -} - -export interface AccountViewPreset { - id: string - label: AccountViewLabels - query: AccountViewPredicate - counter: string -} - -export interface AccountViewDefinitionV1 { - version: 1 - source: 'accounts.console.v1' - base_query: AccountViewPredicate - default_preset: string - extends_core_filters?: boolean - core_filter_surface_refs?: string[] - presets: AccountViewPreset[] - layout: Array<{ kind: 'account_table' } | { kind: 'surface'; surface_ref: string }> - table: { layouts: Array<'table' | 'compact' | 'cards'>; column_refs: string[]; action_refs: string[] } - navigation?: { section: 'admin.extensions'; target_view: string; icon: 'accounts'; order?: number } - legacy_query_aliases?: Array<{ match: Record; preset: string; priority: number }> -} - -export interface AccountResourceActionV1 { - version: 1 - resource: string - account_parameter: 'id' - account_source: 'row.id' - row_predicate?: AccountViewPredicate - effect: 'refresh_current_account_view' -} - -/** Non-secret identity; it never replaces the operation plugin's own identity. */ -export interface AccountViewIdentityV1 { - version: 1 - plugin_id: number - plugin_key: string - package_sha256: string - view_id: string - preset_id: string - view_definition_digest: string -} - -/** Only the user filter layer. Manifest predicates cannot be overridden here. */ -export interface AccountViewQueryV1 { - platforms?: string[] - types?: string[] - statuses?: string[] - plans?: string[] - proxies?: string[] - folders?: string[] - tags?: number[] - account_ids?: number[] - group_id?: number - privacy_mode?: string - search?: string - sort_by?: string - sort_order?: 'asc' | 'desc' -} - -export interface AccountViewContextV1 extends AccountViewIdentityV1 { - query: AccountViewQueryV1 -} - -/** Explicit host projection for owned surfaces; never a native Account DTO. */ -export interface AccountViewStateV1 { - identity: AccountViewIdentityV1 - base_query: AccountViewPredicate - preset: AccountViewPreset - query: AccountViewQueryV1 - selected_ids: number[] - preset_counts: Record - available: boolean -} - -export interface AccountViewRecoveryAck { account_id: number; recovered: boolean } -export const ACCOUNT_VIEW_REFRESH_EVENT = 'refresh_current_account_view' as const diff --git a/backend/pkg/extensionapi/ui/base.css b/backend/pkg/extensionapi/ui/base.css deleted file mode 100644 index 02a8c65571dc..000000000000 --- a/backend/pkg/extensionapi/ui/base.css +++ /dev/null @@ -1,812 +0,0 @@ -@import './theme.css'; - -@tailwind base; -@tailwind components; -@tailwind utilities; - -@layer base { - * { - @apply border-gray-200 dark:border-dark-700; - } - - html { - @apply scroll-smooth antialiased; - color-scheme: light; - } - - .dark { - color-scheme: dark; - } - - body { - @apply bg-canvas text-ink font-sans; - @apply min-h-screen; - } - - /* 自定义滚动条 - 仅针对 Firefox,避免 Chrome 取消 webkit 的全局定制 */ - @supports (-moz-appearance:none) { - * { - scrollbar-width: thin; - scrollbar-color: transparent transparent; - } - - *:hover, - *:focus-within { - scrollbar-color: rgba(156, 163, 175, 0.5) transparent; - } - - .dark *:hover, - .dark *:focus-within { - scrollbar-color: rgba(75, 85, 99, 0.5) transparent; - } - } - - ::-webkit-scrollbar { - @apply h-2 w-2; - } - - ::-webkit-scrollbar-track { - @apply bg-transparent; - } - - ::-webkit-scrollbar-thumb { - @apply rounded-full bg-transparent; - transition: background-color 0.2s ease; - } - - *:hover::-webkit-scrollbar-thumb { - @apply bg-gray-300/50 dark:bg-dark-600; - } - - *:hover::-webkit-scrollbar-thumb:hover { - @apply bg-gray-400 dark:bg-dark-500; - } - - /* 选中文本样式 */ - ::selection { - @apply bg-primary-500/20 text-primary-900 dark:text-primary-100; - } - - -} - -@layer components { - /* ============ 按钮样式 ============ */ - .btn { - @apply inline-flex items-center justify-center gap-2; - @apply rounded-xl px-4 py-2.5 text-sm font-medium; - @apply transition-all duration-200 ease-out; - @apply focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:ring-offset-2; - @apply disabled:transform-none disabled:cursor-not-allowed disabled:opacity-50; - - } - - .btn-primary { - @apply bg-primary-500; - @apply text-white; - @apply hover:bg-primary-600; - } - - .btn-secondary { - @apply bg-white dark:bg-dark-800; - @apply text-gray-700 dark:text-gray-200; - @apply border border-gray-200 dark:border-dark-600; - @apply shadow-sm hover:bg-gray-50 dark:hover:bg-dark-700; - @apply hover:border-gray-300 dark:hover:border-dark-500; - } - - .btn-ghost { - @apply bg-transparent text-gray-600 dark:text-gray-300; - @apply hover:bg-gray-100 dark:hover:bg-dark-800; - } - - .btn-danger { - @apply bg-red-500; - @apply text-white; - @apply hover:bg-red-600; - } - - .btn-success { - @apply bg-emerald-500; - @apply text-white; - @apply hover:bg-emerald-600; - } - - .btn-warning { - @apply bg-amber-500; - @apply text-white; - @apply hover:bg-amber-600; - } - - .btn-stripe { - @apply bg-[#635bff] text-white; - @apply hover:bg-[#5851ea]; - @apply dark:bg-[#7a73ff]; - @apply dark:hover:bg-[#635bff]; - } - - .btn-airwallex { - @apply bg-[#14171A] text-white; - @apply hover:bg-[#20252a]; - @apply dark:bg-[#7AF0C4] dark:text-gray-950; - @apply dark:hover:bg-[#62d9ad]; - } - - .btn-alipay { - @apply bg-[#00AEEF] text-white; - @apply hover:bg-[#009dd6]; - @apply active:bg-[#008cbe]; - - } - - .btn-wxpay { - @apply bg-[#2BB741] text-white; - @apply hover:bg-[#24a038]; - @apply active:bg-[#1d8a2f]; - - } - - .btn-sm { - @apply rounded-lg px-3 py-1.5 text-xs; - } - - .btn-md { - @apply rounded-xl px-4 py-2 text-sm; - } - - .btn-lg { - @apply rounded-2xl px-6 py-3 text-base; - } - - .btn-icon { - @apply rounded-xl p-2.5; - } - - /* ============ 输入框样式 ============ */ - .input { - @apply w-full rounded-xl px-4 py-2.5 text-sm; - @apply bg-white dark:bg-dark-800; - @apply border border-gray-200 dark:border-dark-600; - @apply text-gray-900 dark:text-gray-100; - @apply placeholder:text-gray-400 dark:placeholder:text-dark-400; - @apply transition-all duration-200; - @apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30; - @apply disabled:cursor-not-allowed disabled:bg-gray-100 dark:disabled:bg-dark-900; - } - - .dark .input[type='date'], - .dark .input[type='time'], - .dark .input[type='datetime-local'], - .dark .input[type='month'], - .dark .input[type='week'] { - color-scheme: dark; - } - - .input-error { - @apply border-red-500 focus:border-red-500 focus:ring-red-500/30; - } - - .input-label { - @apply mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300; - } - - .input-hint { - @apply mt-1 text-xs text-gray-500 dark:text-dark-400; - } - - .input-error-text { - @apply mt-1 text-xs text-red-500; - } - - /* Hide number input spinner buttons for cleaner UI */ - input[type='number']::-webkit-inner-spin-button, - input[type='number']::-webkit-outer-spin-button { - -webkit-appearance: none; - margin: 0; - } - - input[type='number'] { - -moz-appearance: textfield; - } - - /* ============ 平面面板 ============ */ - .glass { - @apply bg-white dark:bg-dark-800; - } - - .glass-card { - @apply bg-white dark:bg-dark-800; - @apply rounded-2xl; - @apply border border-line; - @apply transition-all duration-300; - } - - /* ============ 卡片样式 ============ */ - .card { - @apply bg-white dark:bg-dark-800; - @apply rounded-2xl; - @apply border border-line; - @apply transition-all duration-300; - } - - .card-hover { - @apply hover:border-line-strong; - } - - .card-glass { - @apply bg-white dark:bg-dark-800; - @apply border border-line; - } - - .card-header { - @apply border-b border-line; - @apply px-6 py-4; - } - - .card-body { - @apply p-6; - } - - .card-footer { - @apply border-t border-line; - @apply px-6 py-4; - } - - /* ============ 统计卡片 ============ */ - .stat-card { - @apply card p-5; - @apply flex items-start gap-4; - } - - .stat-icon { - @apply h-12 w-12 rounded-xl; - @apply flex items-center justify-center; - @apply text-xl; - } - - .stat-icon-primary { - @apply bg-primary-100 text-primary-600 dark:bg-primary-900/30 dark:text-primary-400; - } - - .stat-icon-success { - @apply bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400; - } - - .stat-icon-warning { - @apply bg-amber-100 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400; - } - - .stat-icon-danger { - @apply bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400; - } - - .stat-value { - @apply text-2xl font-bold text-gray-900 dark:text-white truncate; - } - - .stat-label { - @apply text-sm text-gray-500 dark:text-dark-400; - } - - .stat-trend { - @apply mt-1 flex items-center gap-1 text-xs font-medium; - } - - .stat-trend-up { - @apply text-emerald-600 dark:text-emerald-400; - } - - .stat-trend-down { - @apply text-red-600 dark:text-red-400; - } - - /* ============ 表格样式 ============ */ - .table-container { - @apply overflow-x-auto rounded-xl border border-gray-200 dark:border-dark-700; - } - - .table { - @apply w-full text-sm; - } - - .table th { - @apply px-4 py-3 text-left font-medium; - @apply text-gray-600 dark:text-dark-300; - @apply bg-gray-50 dark:bg-dark-800; - @apply border-b border-gray-200 dark:border-dark-700; - } - - .table td { - @apply px-4 py-3; - @apply text-gray-700 dark:text-gray-300; - @apply border-b border-gray-100 dark:border-dark-800; - } - - .table tr:last-child td { - @apply border-b-0; - } - - .table tbody tr { - @apply transition-colors duration-150; - @apply hover:bg-gray-50 dark:hover:bg-dark-800; - } - - /* ============ 徽章样式 ============ */ - .badge { - @apply inline-flex items-center gap-1; - @apply rounded-full px-2.5 py-0.5 text-xs font-medium; - } - - .badge-primary { - @apply bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400; - } - - .badge-success { - @apply bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400; - } - - .badge-warning { - @apply bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400; - } - - .badge-danger { - @apply bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400; - } - - .badge-gray { - @apply bg-gray-100 text-gray-700 dark:bg-dark-700 dark:text-dark-300; - } - - .badge-purple { - @apply bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400; - } - - /* ============ 下拉菜单 ============ */ - .dropdown { - @apply absolute z-50; - @apply bg-white dark:bg-dark-800; - @apply rounded-xl; - @apply border border-gray-200 dark:border-dark-700; - @apply py-1; - @apply origin-top-right animate-scale-in; - } - - .dropdown-item { - @apply px-4 py-2 text-sm; - @apply text-gray-700 dark:text-gray-300; - @apply hover:bg-gray-100 dark:hover:bg-dark-700; - @apply cursor-pointer transition-colors; - @apply flex items-center gap-2; - } - - /* ============ 模态框 ============ */ - .modal-overlay { - @apply fixed inset-0 z-50; - @apply bg-black/50; - @apply flex items-center justify-center p-2 sm:p-4; - } - - .modal-content { - @apply w-full; - @apply max-h-[95vh] sm:max-h-[90vh]; - @apply bg-white dark:bg-dark-800; - @apply rounded-none; - @apply border border-gray-200 dark:border-dark-700; - @apply flex flex-col; - } - - .modal-header { - @apply border-b border-gray-200 px-4 py-3 dark:border-dark-700; - @apply sm:px-6 sm:py-4; - @apply flex items-center justify-between; - @apply flex-shrink-0; - } - - .modal-title { - @apply text-lg font-semibold text-gray-900 dark:text-white; - } - - .modal-body { - @apply px-4 py-3; - @apply sm:px-6 sm:py-4; - @apply flex-1 overflow-y-auto; - } - - .modal-footer { - @apply border-t border-gray-200 px-4 py-3 dark:border-dark-700; - @apply sm:px-6 sm:py-4; - @apply flex items-center justify-end gap-3; - @apply flex-shrink-0; - } - - /* 防止body滚动的工具类 */ - body.modal-open { - overflow: hidden; - } - - .modal-enter-active { - transition: opacity 250ms ease-out; - } - - .modal-leave-active { - transition: opacity 200ms ease-in; - } - - .modal-enter-from, - .modal-leave-to { - opacity: 0; - } - - .modal-enter-active .modal-content { - transition: transform 250ms ease-out, opacity 250ms ease-out; - } - - .modal-leave-active .modal-content { - transition: transform 200ms ease-in, opacity 200ms ease-in; - } - - .modal-enter-from .modal-content, - .modal-leave-to .modal-content { - transform: scale(0.95); - opacity: 0; - } - - .modal-enter-to .modal-content, - .modal-leave-from .modal-content { - transform: scale(1); - opacity: 1; - } - - @media (prefers-reduced-motion: reduce) { - .modal-enter-active, - .modal-leave-active, - .modal-enter-active .modal-content, - .modal-leave-active .modal-content { - transition-duration: 1ms; - transition-delay: 0ms; - } - - .modal-enter-from .modal-content, - .modal-leave-to .modal-content { - transform: none; - } - } - - /* ============ Dialog ============ */ - .dialog-overlay { - @apply fixed inset-0 z-50; - @apply bg-black/40 dark:bg-black/60; - @apply flex items-center justify-center p-4; - } - - .dialog-container { - @apply flex w-full flex-col; - @apply max-h-[90vh]; - @apply rounded-2xl bg-white dark:bg-dark-800; - @apply shadow-xl; - } - - .dialog-header { - @apply border-b border-gray-100 px-6 py-4 dark:border-dark-700; - @apply flex items-center justify-between; - } - - .dialog-body { - @apply overflow-y-auto px-6 py-4; - } - - .dialog-footer { - @apply border-t border-gray-100 px-6 py-4 dark:border-dark-700; - @apply bg-gray-50/60 dark:bg-dark-900; - @apply flex items-center justify-end gap-3; - } - - /* ============ Toast 通知 ============ */ - .toast { - @apply fixed right-4 top-4 z-[100]; - @apply min-w-[320px] max-w-md; - @apply bg-white dark:bg-dark-800; - @apply rounded-none shadow-outline; - @apply border-l-4; - @apply p-4; - @apply animate-slide-in-right; - } - - .toast-success { - @apply border-l-emerald-500; - } - - .toast-error { - @apply border-l-red-500; - } - - .toast-warning { - @apply border-l-amber-500; - } - - .toast-info { - @apply border-l-primary-500; - } - - /* ============ 侧边栏 ============ */ - .sidebar { - @apply fixed inset-y-0 left-0 z-40; - @apply w-64 bg-white dark:bg-dark-900; - @apply border-r border-gray-200 dark:border-dark-800; - @apply flex flex-col; - @apply transition-transform duration-300; - transition-property: width, transform; - } - - .sidebar-header { - @apply h-16 px-6; - @apply flex items-center gap-3; - @apply border-b border-gray-100 dark:border-dark-800; - transition: - padding 0.2s ease, - gap 0.2s ease; - } - - .sidebar-nav { - @apply flex-1 overflow-y-auto px-3 py-4; - } - - .sidebar-link { - @apply flex items-center gap-3 rounded-xl py-2.5; - @apply overflow-hidden; - @apply text-sm font-medium; - @apply text-gray-600 dark:text-dark-300; - @apply transition-all duration-200; - @apply hover:bg-gray-100 dark:hover:bg-dark-800; - @apply hover:text-gray-900 dark:hover:text-white; - padding-left: 1.0625rem; - padding-right: 0.875rem; - } - - .sidebar-link-active { - @apply bg-primary-50 dark:bg-primary-900/20; - @apply text-primary-600 dark:text-primary-400; - @apply hover:bg-primary-100 dark:hover:bg-primary-900/30; - } - - .sidebar-section { - @apply mb-6; - } - - .sidebar-section-title { - @apply mb-2 px-3; - @apply text-xs font-semibold uppercase tracking-wider; - @apply text-muted; - } - - /* ============ 页面头部 ============ */ - .page-header { - @apply mb-6; - } - - .page-title { - @apply text-2xl font-bold text-gray-900 dark:text-white; - } - - .page-description { - @apply mt-1 text-sm text-gray-500 dark:text-dark-400; - } - - /* ============ 空状态 ============ */ - .empty-state { - @apply flex flex-col items-center justify-center px-4 py-12; - @apply text-center; - } - - .empty-state-icon { - @apply mb-4 h-16 w-16; - @apply text-muted; - } - - .empty-state-title { - @apply mb-1 text-lg font-medium text-gray-900 dark:text-white; - } - - .empty-state-description { - @apply max-w-sm text-sm text-gray-500 dark:text-dark-400; - } - - /* ============ 加载状态 ============ */ - .spinner { - @apply h-5 w-5 rounded-full border-2 border-current border-t-transparent; - @apply animate-spin; - } - - .skeleton { - @apply animate-pulse rounded bg-gray-200 dark:bg-dark-700; - } - - /* ============ 分隔线 ============ */ - .divider { - @apply my-4 h-px bg-gray-200 dark:bg-dark-700; - } - - /* ============ 标签页 ============ */ - .tabs { - @apply flex gap-1 p-1; - @apply rounded-xl bg-gray-100 dark:bg-dark-800; - } - - .tab { - @apply rounded-lg px-4 py-2 text-sm font-medium; - @apply text-gray-600 dark:text-dark-400; - @apply transition-all duration-200; - @apply hover:text-gray-900 dark:hover:text-white; - } - - .tab-active { - @apply bg-white dark:bg-dark-700; - @apply text-gray-900 dark:text-white; - @apply shadow-sm; - } - - /* ============ 进度条 ============ */ - .progress { - @apply h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-dark-700; - } - - .progress-bar { - @apply h-full bg-primary-500; - @apply transition-all duration-300; - } - - /* ============ 开关 ============ */ - .switch { - @apply relative h-6 w-11 cursor-pointer rounded-full; - @apply bg-gray-300 dark:bg-dark-600; - @apply transition-colors duration-200; - } - - .switch-active { - @apply bg-primary-500; - } - - .switch-thumb { - @apply absolute left-0.5 top-0.5 h-5 w-5 rounded-full; - @apply bg-white shadow-sm; - @apply transition-transform duration-200; - } - - .switch-active .switch-thumb { - @apply translate-x-5; - } - - /* ============ 代码块 ============ */ - .code { - @apply font-mono text-sm; - @apply bg-gray-100 dark:bg-dark-800; - @apply rounded px-1.5 py-0.5; - @apply text-primary-600 dark:text-primary-400; - } - - .code-block { - @apply font-mono text-sm; - @apply bg-gray-900 text-gray-100; - @apply overflow-x-auto rounded-xl p-4; - } - - /* ============ Tour Description ============ */ - .tour-step-description { - @apply space-y-3 text-sm leading-relaxed text-gray-700 dark:text-gray-200; - } - - .tour-step-description ul { - @apply list-disc pl-5; - } - - .tour-step-description ol { - @apply list-decimal pl-5; - } - - .tour-step-description li + li { - @apply mt-1; - } - - .tour-info-box { - @apply rounded-md border-l-4 border-blue-500 bg-blue-50 px-3 py-2 text-xs text-blue-900; - @apply dark:border-blue-400 dark:bg-blue-950/40 dark:text-blue-200; - } - - .tour-success-box { - @apply rounded-md border-l-4 border-emerald-500 bg-emerald-50 px-3 py-2 text-xs text-emerald-900; - @apply dark:border-emerald-400 dark:bg-emerald-950/40 dark:text-emerald-200; - } - - .tour-warning-box { - @apply rounded-md border-l-4 border-amber-500 bg-amber-50 px-3 py-2 text-xs text-amber-900; - @apply dark:border-amber-400 dark:bg-amber-950/40 dark:text-amber-200; - } - - .tour-error-box { - @apply rounded-md border-l-4 border-red-500 bg-red-50 px-3 py-2 text-xs text-red-900; - @apply dark:border-red-400 dark:bg-red-950/40 dark:text-red-200; - } - - /* ============ 表格页面布局优化 ============ */ - /* 表格容器 - 默认仅支持水平滚动 */ - .table-wrapper { - overflow-x: auto; - } - - /* 表头固定时添加底部阴影,增强视觉层次 */ - .table-wrapper thead.sticky { - box-shadow: inset 0 -1px 0 rgb(var(--ui-line)); - } -} - -@layer utilities { - /* Solid accent text; legacy class kept for compatibility. */ - .text-gradient { - @apply text-primary-600 dark:text-primary-400; - } - - /* 隐藏滚动条 */ - .scrollbar-hide { - -ms-overflow-style: none; - scrollbar-width: none; - } - .scrollbar-hide::-webkit-scrollbar { - display: none; - } - - /* 安全区域 */ - .safe-top { - padding-top: env(safe-area-inset-top); - } - - .safe-bottom { - padding-bottom: env(safe-area-inset-bottom); - } -} - -/* -------------------------------------------------------------------------- - * Shared list surfaces - * - * A number of admin and user list views render small native tables alongside - * the shared DataTable component. Keep those tables on the same neutral - * editorial surface so a page cannot fall back to the old blue-gray utility - * palette when it is switched between themes. - * -------------------------------------------------------------------------- */ -@layer components { - table thead[class*='bg-gray-50'], - table thead[class*='bg-gray-100'], - table thead[class*='dark:bg-dark-700'], - table thead[class*='dark:bg-dark-800'], - table thead[class*='dark:bg-dark-900'] { - background-color: rgb(var(--ui-raised)) !important; - } - - table tbody[class*='bg-white'], - table tbody[class*='dark:bg-dark-700'], - table tbody[class*='dark:bg-dark-800'], - table tbody[class*='dark:bg-dark-900'] { - background-color: rgb(var(--ui-canvas)) !important; - } - - table thead, - table tbody, - table tr, - table td, - table th { - border-color: rgb(var(--ui-line)) !important; - } - - table tbody tr:hover { - background-color: rgb(var(--ui-raised) / 0.72) !important; - } - - .list-surface { - background-color: rgb(var(--ui-canvas)); - border-color: rgb(var(--ui-line)); - } - - .list-surface-raised { - background-color: rgb(var(--ui-raised)); - border-color: rgb(var(--ui-line)); - } -} diff --git a/backend/pkg/extensionapi/ui/bridge.js b/backend/pkg/extensionapi/ui/bridge.js deleted file mode 100644 index b0adfec96e7e..000000000000 --- a/backend/pkg/extensionapi/ui/bridge.js +++ /dev/null @@ -1,80 +0,0 @@ -// Public UI SDK. The sandbox receives a short-lived bridge token, never the -// administrator's session credentials. Network actions are performed by the host. -class PluginBridge { - constructor() { - this.token = new URLSearchParams(location.hash.slice(1)).get('bridge_token') || '' - this.sequence = 0 - this.pending = new Map() - this.contextListeners = new Set() - this.preferenceListeners = new Set() - this.listener = event => { - const message = event.data - if (event.source !== parent || !message || message.source !== 'sub2api-plugin-host' || message.bridge_token !== this.token) return - if (message.type === 'extension.context.updated') { for (const listener of this.contextListeners) listener(message.context); return } - if (message.type === 'preference.updated') { for (const listener of this.preferenceListeners) listener(message.key, message.value); return } - const request = this.pending.get(message.request_id) - if (!request) return - this.pending.delete(message.request_id) - clearTimeout(request.timer) - request.cleanup?.() - if (message.ok) request.resolve(message) - else { - const error = new Error(message.error || 'Operation failed') - error.code = message.code - error.status = message.status - error.response = { status: message.status, data: { code: message.code, message: error.message } } - request.reject(error) - } - } - window.addEventListener('message', this.listener) - this.notify('sub2api.plugin.ready') - } - - notify(type, fields = {}) { - parent.postMessage({ source: 'sub2api-plugin-ui', bridge_token: this.token, type, ...fields }, '*') - } - - request(type, fields = {}, signal) { - const request_id = `extension-${++this.sequence}` - return new Promise((resolve, reject) => { - if (signal?.aborted) { reject(new DOMException('Request canceled', 'AbortError')); return } - const abort = () => { - const request = this.pending.get(request_id) - if (!request) return - clearTimeout(request.timer); this.pending.delete(request_id) - this.notify('extension.cancel', { target_request_id: request_id }) - reject(new DOMException('Request canceled', 'AbortError')) - } - const timer = setTimeout(() => { this.pending.get(request_id)?.cleanup?.(); this.pending.delete(request_id); reject(new Error('Operation timed out')) }, 30000) - this.pending.set(request_id, { resolve, reject, timer, cleanup: () => signal?.removeEventListener('abort', abort) }) - signal?.addEventListener('abort', abort, { once: true }) - try { this.notify(type, { ...fields, request_id }) } - catch (error) { clearTimeout(timer); this.pending.get(request_id)?.cleanup?.(); this.pending.delete(request_id); reject(error) } - }) - } - - async context() { return (await this.request('extension.context')).context } - async config() { return (await this.request('config.load')).config } - async status() { return (await this.request('plugin.status')).result } - async save(config) { return (await this.request('config.save', { config })).config } - async invoke(operation, payload = {}) { return (await this.request('extension.invoke', { operation, payload })).result } - async submit(operation, items, operation_key) { return (await this.request('extension.job.submit', { operation, items, operation_key })).job } - async job(job_id) { return (await this.request('extension.job.get', { job_id })).job } - async resource(operation, input = {}, signal) { return (await this.request('extension.resource', { operation, input }, signal)).result } - async event(name, payload) { return this.request('extension.event', { name, payload }) } - async openJob(job_id) { return this.request('extension.job.open', { job_id }) } - async preference(key) { return (await this.request('preference.read', { key })).value } - async savePreference(key, value) { return this.request('preference.write', { key, value }) } - onContextChange(listener) { this.contextListeners.add(listener); return () => this.contextListeners.delete(listener) } - onPreferenceChange(listener) { this.preferenceListeners.add(listener); return () => this.preferenceListeners.delete(listener) } - resize(height) { this.notify('ui.resize', { height }) } - dispose() { - window.removeEventListener('message', this.listener) - for (const request of this.pending.values()) { clearTimeout(request.timer); request.cleanup?.(); request.reject(new Error('Plugin view closed')) } - this.pending.clear() - this.contextListeners.clear() - this.preferenceListeners.clear() - } -} - -globalThis.Sub2APIPluginBridge = PluginBridge diff --git a/backend/pkg/extensionapi/ui/build.mjs b/backend/pkg/extensionapi/ui/build.mjs deleted file mode 100644 index 4915aa677a2d..000000000000 --- a/backend/pkg/extensionapi/ui/build.mjs +++ /dev/null @@ -1,55 +0,0 @@ -// Builds only plugin-owned source and the public SDK. The existing frozen -// frontend lock supplies build dependencies, never private host components. -import fs from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { createRequire } from 'node:module' -import preset from './tailwind.preset.mjs' - -const sdk = path.dirname(fileURLToPath(import.meta.url)) -const root = path.resolve(sdk, '../../../..') -const require = createRequire(path.join(root, 'frontend/package.json')) -const { build } = require('vite') -const vue = require('@vitejs/plugin-vue') -const tailwind = require('tailwindcss') -const autoprefixer = require('autoprefixer') -const inventory = JSON.parse(fs.readFileSync(path.join(root, 'plugins/bundle.source.json'), 'utf8')) -const selected = process.argv[2] -if (selected && !inventory.plugins.some(item => item.directory === selected)) throw new Error('Unknown plugin domain') -for (const { directory } of inventory.plugins) { - if (selected && selected !== directory) continue - if (!/^[a-z][a-z0-9-]*$/.test(directory)) throw new Error('Invalid plugin directory') - const ui = path.join(root, 'plugins', directory, 'ui') - const source = path.join(ui, 'src') - if (!fs.existsSync(path.join(source, 'index.html'))) continue - await build({ - root: source, configFile: false, base: './', - plugins: [vue(), { - name: 'public-plugin-source-boundary', - async resolveId(id, importer) { - if (id.startsWith('@/')) throw new Error('Plugins cannot import private host frontend modules') - if (importer && !id.startsWith('.') && !id.startsWith('/') && !id.startsWith('\0') && !path.isAbsolute(id) && !id.startsWith('@sub2api/')) { - const resolved = await this.resolve(id, path.join(root, 'frontend/src/main.ts'), { skipSelf: true }) - return resolved || require.resolve(id) - } - }, - load(id) { - const file = id.replaceAll('\\', '/') - if (file.startsWith(root.replaceAll('\\', '/') + '/frontend/src/') || file.startsWith(root.replaceAll('\\', '/') + '/backend/internal/')) { - throw new Error('Plugins may import only their own source and the public SDK') - } - } - }], - resolve: { - dedupe: ['vue', 'vue-i18n'], - alias: { - '@sub2api/plugin-ui': sdk, - vue: path.join(root, 'frontend/node_modules/vue/dist/vue.runtime.esm-bundler.js'), - 'vue-i18n': path.join(root, 'frontend/node_modules/vue-i18n/dist/vue-i18n.runtime.esm-bundler.js') - } - }, - define: { __INTLIFY_JIT_COMPILATION__: true, __VUE_OPTIONS_API__: true, __VUE_PROD_DEVTOOLS__: false }, - css: { postcss: { plugins: [tailwind({ ...preset, content: [source + '/**/*.{html,vue,js,ts}', sdk + '/components/**/*.{vue,js,ts}'] }), autoprefixer()] } }, - build: { outDir: path.join(ui, 'compiled'), emptyOutDir: true, target: 'es2020', sourcemap: false } - }) -} diff --git a/backend/pkg/extensionapi/ui/client.ts b/backend/pkg/extensionapi/ui/client.ts deleted file mode 100644 index 90afea14f9ac..000000000000 --- a/backend/pkg/extensionapi/ui/client.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { computed, createApp, ref, readonly, watch, onScopeDispose, toRaw, type Component } from 'vue' -import { createI18n } from 'vue-i18n' -import { extensionAvailabilityKey, extensionUnavailableMessageKey } from './context' -import { createPluginPresentation } from './presentation' -import { createPluginSizing } from './sizing' -import './bridge.js' - -export interface UIContext { - locale?: string - theme?: string - available?: boolean - contribution_id?: string - theme_tokens?: Record - theme_stylesheets?: string[] - [key: string]: unknown -} - -export interface ResourceInput { - local_data?: unknown - operation_key?: string - params?: Record - query?: Record - body?: unknown - form?: Array<[string, string | Blob]> -} - -export interface ResourceAvailability { - name: string - available: boolean -} - -export interface TranslationMessages { - [key: string]: string | TranslationMessages -} - -interface Bridge { - request(type: string, fields?: Record): Promise> - event(name: string, payload?: unknown): Promise - openJob(id: number): Promise - preference(key: string): Promise - savePreference(key: string, value: string): Promise - onPreferenceChange(listener: (key: string, value: string) => void): () => void - context(): Promise - resource(operation: string, input: ResourceInput, signal?: AbortSignal): Promise - notify(type: string, fields?: Record): void - resize(height: number): void - onContextChange(listener: (context: UIContext) => void): () => void - dispose(): void -} - -let connection: Bridge | null = null -const sharedContext = ref({}) -export const usePluginContext = () => readonly(sharedContext) -const jsonValue = (value: T): T => value === undefined ? value : JSON.parse(JSON.stringify(value)) as T -export const emitHostEvent = (name: string, payload?: unknown) => bridge().event(name, jsonValue(payload)) -export const openHostJob = (id: number) => bridge().openJob(id) -export async function confirmAction(message: string) { return (await bridge().request('ui.confirm', { message })).confirmed === true } -export async function downloadBlob(blob: Blob, filename: string) { await bridge().request('ui.download', { blob, filename }) } - -function localValue(value: unknown, ancestors = new Set(), depth = 0): unknown { - if (depth > 32) throw new Error('Local record is too deeply nested') - if (value === null || typeof value !== 'object') return value - const raw = toRaw(value) - if (raw instanceof Blob || raw instanceof ArrayBuffer) return raw - if (ancestors.has(raw)) throw new Error('Local record contains a cycle') - ancestors.add(raw) - const next = Array.isArray(raw) ? raw.map(item => localValue(item, ancestors, depth + 1)) : Object.fromEntries(Object.entries(raw).map(([key, item]) => [key, localValue(item, ancestors, depth + 1)])) - ancestors.delete(raw) - return next -} - -export function usePersistentDraft(key: string) { - const value = ref('') - let edited = false, receiving = false, disposed = false - const apply = (next: string) => { receiving = true; value.value = next; receiving = false } - void bridge().preference(key).then(saved => { if (!disposed && !edited && saved !== null) apply(saved) }).catch(() => {}) - const stop = bridge().onPreferenceChange((changed, next) => { if (changed === key && typeof next === 'string') apply(next) }) - watch(value, next => { if (!receiving) { edited = true; void bridge().savePreference(key, next).catch(() => {}) } }, { flush: 'sync' }) - onScopeDispose(() => { disposed = true; stop() }) - return value -} -function bridge(): Bridge { - if (!connection) { - const Constructor = (globalThis as unknown as { Sub2APIPluginBridge: new () => Bridge }).Sub2APIPluginBridge - connection = new Constructor() - } - return connection -} - -export async function resource(operation: string, input: ResourceInput = {}, signal?: AbortSignal): Promise { - // Vue proxies cannot be cloned by postMessage. JSON fields use the same - // representation as HTTP JSON; File/Blob entries remain native objects. - const payload = { ...jsonValue({ ...input, form: undefined, local_data: undefined }), - ...(input.local_data === undefined ? {} : { local_data: localValue(input.local_data) }), - ...(input.form ? { form: Array.from(input.form, ([key, value]) => [key, value] as [string, string | Blob]) } : {}) } - return await bridge().resource(operation, payload, signal) as T -} - -export async function resourceAvailability(): Promise { - const { resources } = await bridge().request('extension.resources') - if (!Array.isArray(resources) || resources.some(item => !item || typeof item.name !== 'string' || typeof item.available !== 'boolean')) { - throw new Error('Invalid resource availability') - } - return resources.map(({ name, available }) => ({ name, available })) -} - -export function useNotifications() { - return { - showError: (message: string) => bridge().notify('ui.notify', { level: 'error', message }), - showSuccess: (message: string) => bridge().notify('ui.notify', { level: 'success', message }), - showWarning: (message: string) => bridge().notify('ui.notify', { level: 'warning', message }), - showInfo: (message: string) => bridge().notify('ui.notify', { level: 'info', message }) - } -} - -// A plugin owns its page and locale messages. The public SDK supplies only -// rendering, presentation context and named host operations. -export async function mountPlugin(App: Component, messages: Record) { - const client = bridge() - const context = sharedContext - context.value = await client.context() - const scheduleSizing = { run: () => {} } - const presentation = createPluginPresentation(document, () => scheduleSizing.run()) - presentation.update(context.value) - const i18n = createI18n({ legacy: false, locale: String(context.value.locale || 'zh').startsWith('zh') ? 'zh' : 'en', fallbackLocale: 'en', messages }) - const app = createApp(App) - app.use(i18n) - app.provide(extensionAvailabilityKey, computed(() => context.value.retained_controls === true || context.value.available !== false)) - app.provide(extensionUnavailableMessageKey, computed(() => typeof context.value.unavailable_message === 'string' ? context.value.unavailable_message : String(context.value.locale).startsWith('zh') ? '插件暂不可用,已保留输入' : 'Plugin unavailable; your input is retained')) - app.mount('#app') - const root = document.getElementById('app')! - root.inert = context.value.retained_controls !== true && context.value.available === false - const stop = client.onContextChange(next => { context.value = next; root.inert = next.retained_controls !== true && next.available === false; presentation.update(next); i18n.global.locale.value = String(next.locale).startsWith('zh') ? 'zh' : 'en' }) - if (context.value.layout === 'inline') document.body.style.minHeight = '0' - const sizing = createPluginSizing(root, height => client.resize(height), () => context.value.layout === 'inline') - scheduleSizing.run = sizing.schedule - window.addEventListener('pagehide', () => { stop(); sizing.dispose(); presentation.dispose(); app.unmount(); client.dispose() }, { once: true }) -} diff --git a/backend/pkg/extensionapi/ui/components/BaseDialog.vue b/backend/pkg/extensionapi/ui/components/BaseDialog.vue deleted file mode 100644 index f24be21ef8fb..000000000000 --- a/backend/pkg/extensionapi/ui/components/BaseDialog.vue +++ /dev/null @@ -1,208 +0,0 @@ - - - diff --git a/backend/pkg/extensionapi/ui/components/ConfirmDialog.vue b/backend/pkg/extensionapi/ui/components/ConfirmDialog.vue deleted file mode 100644 index 3a2e4d3c2415..000000000000 --- a/backend/pkg/extensionapi/ui/components/ConfirmDialog.vue +++ /dev/null @@ -1,71 +0,0 @@ - - - diff --git a/backend/pkg/extensionapi/ui/components/Icon.vue b/backend/pkg/extensionapi/ui/components/Icon.vue deleted file mode 100644 index 7859adee7012..000000000000 --- a/backend/pkg/extensionapi/ui/components/Icon.vue +++ /dev/null @@ -1,147 +0,0 @@ - - - diff --git a/backend/pkg/extensionapi/ui/components/Pagination.vue b/backend/pkg/extensionapi/ui/components/Pagination.vue deleted file mode 100644 index 3cd836364f10..000000000000 --- a/backend/pkg/extensionapi/ui/components/Pagination.vue +++ /dev/null @@ -1,253 +0,0 @@ - - - - - diff --git a/backend/pkg/extensionapi/ui/components/Select.vue b/backend/pkg/extensionapi/ui/components/Select.vue deleted file mode 100644 index 7fa608446ea1..000000000000 --- a/backend/pkg/extensionapi/ui/components/Select.vue +++ /dev/null @@ -1,644 +0,0 @@ - - - - - - - diff --git a/backend/pkg/extensionapi/ui/components/Toggle.vue b/backend/pkg/extensionapi/ui/components/Toggle.vue deleted file mode 100644 index 9e9b4333092c..000000000000 --- a/backend/pkg/extensionapi/ui/components/Toggle.vue +++ /dev/null @@ -1,29 +0,0 @@ - - - diff --git a/backend/pkg/extensionapi/ui/components/dialogStack.ts b/backend/pkg/extensionapi/ui/components/dialogStack.ts deleted file mode 100644 index a3da051abf7b..000000000000 --- a/backend/pkg/extensionapi/ui/components/dialogStack.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { shallowReactive } from 'vue' - -/** - * Shared registry of every shown BaseDialog. - * - * - Keyboard handling (Escape, Tab cycling) and auto-focus only act on the top-most dialog, - * so a confirm nested inside an editor no longer closes the editor as well. - * - The `body.modal-open` scroll lock is reference counted: added on the first registration, - * removed on the last unregistration, instead of every dialog toggling it on its own. - * - `dialogLayer` stacks nested dialogs ten z-index steps above the previous one so a child - * always paints above its parent even when both use the default z-index. - */ - -interface DialogEntry { - id: string - zIndex: number -} - -/** Matches `.modal-overlay` (`z-50`) in style.css; BaseDialog emits no inline style at this layer. */ -export const DEFAULT_DIALOG_Z_INDEX = 50 - -/** - * A dialog opened with an explicit z-index at or above this value is a gate - * (AdminComplianceDialog uses 80): it always paints at exactly that z-index and - * dialogs opened later are capped beneath it instead of being raised above it. - */ -export const GATE_DIALOG_Z_INDEX = 80 - -const LAYER_STEP = 10 -const BODY_LOCK_CLASS = 'modal-open' - -const dialogs = shallowReactive([]) -let sequence = 0 - -/** Module-scope counter so nested dialogs never share a `modal-title-N` id. */ -export function nextDialogId(): string { - return `modal-title-${++sequence}` -} - -export function registerDialog(id: string, zIndex = DEFAULT_DIALOG_Z_INDEX): void { - const index = dialogs.findIndex((entry) => entry.id === id) - if (index >= 0) { - // Replace rather than mutate: the array is shallowReactive, so only splice/push notify. - dialogs.splice(index, 1, { id, zIndex }) - } else { - dialogs.push({ id, zIndex }) - } - document.body.classList.add(BODY_LOCK_CLASS) -} - -export function unregisterDialog(id: string): void { - const index = dialogs.findIndex((entry) => entry.id === id) - if (index >= 0) { - dialogs.splice(index, 1) - } - if (dialogs.length === 0) { - document.body.classList.remove(BODY_LOCK_CLASS) - } -} - -const isGate = (entry: DialogEntry): boolean => entry.zIndex >= GATE_DIALOG_Z_INDEX - -/** Resolved z-index of every registered dialog, in registration order. */ -function resolveLayers(): Map { - const gates = dialogs.filter(isGate).map((entry) => entry.zIndex) - const ceiling = gates.length > 0 ? Math.min(...gates) - 1 : Number.POSITIVE_INFINITY - const layers = new Map() - let previous = DEFAULT_DIALOG_Z_INDEX - LAYER_STEP - for (const entry of dialogs) { - if (isGate(entry)) { - layers.set(entry.id, entry.zIndex) - continue - } - previous = Math.min(Math.max(previous + LAYER_STEP, entry.zIndex), ceiling) - layers.set(entry.id, previous) - } - return layers -} - -/** - * z-index for a shown dialog: max(previous dialog + 10, explicit), never above a gate. - * Falls back to `explicit` while the dialog is not registered (hidden). - */ -export function dialogLayer(id: string, explicit = DEFAULT_DIALOG_Z_INDEX): number { - return resolveLayers().get(id) ?? explicit -} - -/** Whether `id` is the dialog painted on top: highest layer, later registration winning ties. */ -export function isTopDialog(id: string): boolean { - let topId: string | undefined - let topLayer = Number.NEGATIVE_INFINITY - for (const [entryId, layer] of resolveLayers()) { - if (layer >= topLayer) { - topId = entryId - topLayer = layer - } - } - return topId === id -} diff --git a/backend/pkg/extensionapi/ui/context.ts b/backend/pkg/extensionapi/ui/context.ts deleted file mode 100644 index 75ea4e29a527..000000000000 --- a/backend/pkg/extensionapi/ui/context.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { ComputedRef, InjectionKey } from 'vue' - -export const extensionAvailabilityKey: InjectionKey> = Symbol('extensionAvailability') -export const extensionUnavailableMessageKey: InjectionKey> = Symbol('extensionUnavailableMessage') diff --git a/backend/pkg/extensionapi/ui/errors.ts b/backend/pkg/extensionapi/ui/errors.ts deleted file mode 100644 index 07a17aca139a..000000000000 --- a/backend/pkg/extensionapi/ui/errors.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * Centralized API error message extraction - * - * The API client interceptor rejects with a plain object: { status, code, message, error } - * This utility extracts the user-facing message from any error shape. - */ - -interface ApiErrorLike { - status?: number - code?: number | string - message?: string - error?: string - reason?: string - metadata?: Record - response?: { - data?: { - detail?: string - message?: string - code?: number | string - } - } -} - -/** - * Extract the error code from an API error object. - * - * Prefers the string `reason` (e.g. "PAYMENT_PROVIDER_MISCONFIGURED") over the - * numeric HTTP `code`, because reason is granular enough to drive i18n lookup - * while HTTP code is not. - */ -export function extractApiErrorCode(err: unknown): string | undefined { - if (!err || typeof err !== 'object') return undefined - const e = err as ApiErrorLike - const code = e.reason ?? e.code ?? e.response?.data?.code - return code != null ? String(code) : undefined -} - -/** - * Extract metadata (interpolation params) from an API error object. - * Backend errors carry `metadata` with template variables that fill i18n placeholders. - */ -export function extractApiErrorMetadata(err: unknown): Record | undefined { - if (!err || typeof err !== 'object') return undefined - const e = err as ApiErrorLike - return e.metadata -} - -type TranslateFn = (key: string, params?: Record) => string -type TranslateWithExistsFn = TranslateFn & { te?: (key: string) => boolean } - -/** - * Translate a value via i18n if a matching key exists, otherwise return the original. - * Example: "certSerial" → t('admin.settings.payment.field_certSerial') → "证书序列号". - */ -function tryTranslate(t: TranslateFn, key: string, fallback: string): string { - const translated = t(key) - if (translated === key) return fallback - const te = (t as TranslateWithExistsFn).te - if (te && !te(key)) return fallback - return translated -} - -/** - * Replace raw config field names in metadata (e.g. "certSerial") with their - * localized UI labels (e.g. "证书序列号"), using the provider-config field i18n namespace. - * Handles both single `key` and `/`-joined `keys` patterns used by wxpay errors. - */ -function localizeMetadata(metadata: Record, t: TranslateFn): Record { - const out: Record = { ...metadata } - if (typeof out.key === 'string') { - out.key = tryTranslate(t, `admin.settings.payment.field_${out.key}`, out.key) - } - if (typeof out.keys === 'string') { - out.keys = out.keys - .split('/') - .map(k => tryTranslate(t, `admin.settings.payment.field_${k}`, k)) - .join(' / ') - } - return out -} - -/** - * Extract a localized error message from an API error by looking up - * `.` in i18n and substituting metadata as placeholders. - * - * Config-field names in metadata (`key` / `keys`) are automatically translated - * to their UI labels before substitution, so error messages read like - * "缺少必填项:证书序列号" instead of "缺少必填项:certSerial". - * - * @param err - The caught error - * @param t - Vue i18n translate function - * @param namespace- i18n key prefix, e.g. "payment.errors" - * @param fallback - Fallback key or plain string if no localized mapping exists - */ -export function extractI18nErrorMessage( - err: unknown, - t: TranslateFn, - namespace: string, - fallback: string, -): string { - const code = extractApiErrorCode(err) - if (code) { - const key = `${namespace}.${code}` - const rawMetadata = extractApiErrorMetadata(err) ?? {} - const metadata = localizeMetadata(rawMetadata, t) - const translated = t(key, metadata) - // Vue i18n returns the key itself when missing; detect that and fall back. - if (translated !== key) return translated - // If the framework exposes `te`, use it to double-check. - const te = (t as TranslateWithExistsFn).te - if (te && te(key)) return translated - } - return extractApiErrorMessage(err, fallback) -} - -/** - * Extract a displayable error message from an API error. - * - * @param err - The caught error (unknown type) - * @param fallback - Fallback message if none can be extracted (use t('common.error') or similar) - * @param i18nMap - Optional map of error codes to i18n translated strings - */ -export function extractApiErrorMessage( - err: unknown, - fallback = 'Unknown error', - i18nMap?: Record, -): string { - if (!err) return fallback - - // Try i18n mapping by error code first - if (i18nMap) { - const code = extractApiErrorCode(err) - if (code && i18nMap[code]) return i18nMap[code] - } - - // Plain object from API client interceptor (most common case) - if (typeof err === 'object' && err !== null) { - const e = err as ApiErrorLike - // Interceptor shape: { message, error } - if (e.message) return e.message - if (e.error) return e.error - // Legacy axios shape: { response.data.detail } - if (e.response?.data?.detail) return e.response.data.detail - if (e.response?.data?.message) return e.response.data.message - } - - // Standard Error - if (err instanceof Error) return err.message - - // Last resort - const str = String(err) - return str === '[object Object]' ? fallback : str -} diff --git a/backend/pkg/extensionapi/ui/index.ts b/backend/pkg/extensionapi/ui/index.ts deleted file mode 100644 index d98c1cee9162..000000000000 --- a/backend/pkg/extensionapi/ui/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { default as BaseDialog } from './components/BaseDialog.vue' -export { default as ConfirmDialog } from './components/ConfirmDialog.vue' -export { default as Toggle } from './components/Toggle.vue' -export { default as Select } from './components/Select.vue' -export { default as Icon } from './components/Icon.vue' -export { default as Pagination } from './components/Pagination.vue' -export { resource, resourceAvailability, useNotifications, mountPlugin, usePluginContext, emitHostEvent, openHostJob, usePersistentDraft, confirmAction, downloadBlob } from './client' -export type { ResourceInput, ResourceAvailability, UIContext } from './client' -export * from './errors' -export * from './account-view' -export * from './account-create' -export * from './account-edit' diff --git a/backend/pkg/extensionapi/ui/presentation.ts b/backend/pkg/extensionapi/ui/presentation.ts deleted file mode 100644 index f1c6dac56d68..000000000000 --- a/backend/pkg/extensionapi/ui/presentation.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { createStylesheetController, pluginThemeURL } from './stylesheets' - -interface Presentation { - theme?: string - theme_tokens?: Record - theme_stylesheets?: string[] -} - -export function createPluginPresentation(doc: Document, changed: () => void) { - const ownedTokens = new Set() - const stylesheets = createStylesheetController(doc, changed) - function update(context: Presentation) { - doc.documentElement.classList.toggle('dark', context.theme === 'dark') - const style = doc.documentElement.style - const next = new Map(Object.entries(context.theme_tokens || {}).filter(([key, value]) => - /^--(?:ui|theme)-[a-z0-9-]+$/.test(key) && typeof value === 'string' && value.length <= 512 && !/url\s*\(/i.test(value))) - for (const key of ownedTokens) if (!next.has(key)) { style.removeProperty(key); ownedTokens.delete(key) } - for (const [key, value] of next) { - if (style.getPropertyValue(key) !== value) style.setProperty(key, value) - ownedTokens.add(key) - } - const desired = new Map() - for (const value of context.theme_stylesheets || []) { - const href = pluginThemeURL(value) - if (!href) continue - // A new digest replaces the same plugin asset, rather than removing its - // old stylesheet before the replacement has loaded. - const key = href.replace(/\/theme\/[a-f0-9]{64}\//, '/theme/') - desired.set(key, href) - } - void stylesheets.sync(desired) - changed() - } - return { update, dispose: stylesheets.dispose } -} diff --git a/backend/pkg/extensionapi/ui/sizing.ts b/backend/pkg/extensionapi/ui/sizing.ts deleted file mode 100644 index 9c61eaf78ea7..000000000000 --- a/backend/pkg/extensionapi/ui/sizing.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** Intrinsic iframe content sizing; viewport height must never be its input. */ -export function createPluginSizing(root: HTMLElement, send: (height: number) => void, inline: () => boolean) { - const doc = root.ownerDocument - const view = doc.defaultView! - const panels = new Set() - let frame: number | null = null - let stopped = false - let previous: number | undefined - - function measure() { - frame = null - if (stopped) return - const rootTop = Math.max(0, root.getBoundingClientRect().top + view.scrollY) - let bottom = rootTop + Math.max(root.offsetHeight, root.scrollHeight) - for (const menu of doc.querySelectorAll('[role="listbox"], [role="menu"]')) { - const box = menu.getBoundingClientRect() - if (box.height) bottom = Math.max(bottom, box.bottom + view.scrollY) - } - const present = new Set(doc.querySelectorAll('[role="dialog"] .modal-content')) - for (const panel of panels) if (!present.has(panel)) { observer.unobserve(panel); panels.delete(panel) } - for (const panel of present) { - if (!panels.has(panel)) { panels.add(panel); observer.observe(panel) } - if (!panel.getBoundingClientRect().height) continue - const body = panel.querySelector('.modal-body') - const natural = panel.scrollHeight + (body ? Math.max(0, body.scrollHeight - body.clientHeight) : 0) - bottom = Math.max(bottom, natural + 32) - } - const height = Math.max(inline() ? 0 : 120, Math.min(1200, Math.ceil(bottom) + (inline() ? 0 : 24))) - if (height !== previous) { previous = height; send(height) } - } - function schedule() { - if (!stopped && frame === null) frame = view.requestAnimationFrame(measure) - } - const observer = new ResizeObserver(schedule) - const mutations = new MutationObserver(schedule) - observer.observe(root) - mutations.observe(doc.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class', 'style', 'hidden', 'open'] }) - view.addEventListener('resize', schedule) - doc.fonts?.addEventListener('loadingdone', schedule) - void doc.fonts?.ready.then(schedule) - schedule() - return { - schedule, - dispose() { - stopped = true - if (frame !== null) view.cancelAnimationFrame(frame) - observer.disconnect() - mutations.disconnect() - view.removeEventListener('resize', schedule) - doc.fonts?.removeEventListener('loadingdone', schedule) - panels.clear() - } - } -} diff --git a/backend/pkg/extensionapi/ui/stylesheets.ts b/backend/pkg/extensionapi/ui/stylesheets.ts deleted file mode 100644 index 9e31091c7f4c..000000000000 --- a/backend/pkg/extensionapi/ui/stylesheets.ts +++ /dev/null @@ -1,113 +0,0 @@ -interface PendingStylesheet { - href: string - promise: Promise - cancel: () => void -} - -interface StylesheetEntry { - active?: HTMLLinkElement - pending?: PendingStylesheet -} - -export function pluginThemeURL(value: unknown): string | null { - if (typeof value !== 'string' || !/^\/api\/v1\/settings\/plugins\/[1-9]\d*\/theme\/[a-f0-9]{64}\/[a-zA-Z0-9_./-]+\.css$/.test(value) || value.split('/').includes('..')) return null - return value -} - -async function preloadThemeFonts(link: HTMLLinkElement): Promise { - if (typeof FontFace === 'undefined' || !link.sheet) return - const base = new URL(link.href) - const directory = new URL('.', base).pathname - const fonts: Promise[] = [] - for (const rule of Array.from(link.sheet.cssRules)) { - if (rule.type !== CSSRule.FONT_FACE_RULE) continue - const style = (rule as CSSFontFaceRule).style - let valid = true - const source = style.getPropertyValue('src').replace(/url\(\s*(['"]?)(.*?)\1\s*\)/g, (_match, _quote, path: string) => { - const url = new URL(path, base) - if (url.origin !== base.origin || !url.pathname.startsWith(directory) || !url.pathname.endsWith('.woff2')) valid = false - return `url("${url.href}")` - }) - if (!valid || !source.includes('url(')) continue - const family = style.getPropertyValue('font-family').replace(/^['"]|['"]$/g, '') - const face = new FontFace(family, source, { weight: style.getPropertyValue('font-weight') || 'normal', style: style.getPropertyValue('font-style') || 'normal' }) - fonts.push(face.load()) - } - await Promise.all(fonts) -} - -/** Keep the last loaded appearance until its replacement is ready. */ -export function createStylesheetController(doc: Document, changed: () => void = () => {}) { - const entries = new Map() - let disposed = false - - function sync(desired: ReadonlyMap): Promise { - if (disposed) return Promise.resolve() - for (const [key, entry] of entries) { - if (desired.has(key)) continue - entry.pending?.cancel() - entry.active?.remove() - entries.delete(key) - changed() - } - const loading: Promise[] = [] - for (const [key, href] of desired) { - let entry = entries.get(key) - if (!entry) { entry = {}; entries.set(key, entry) } - if (entry.active && !entry.active.isConnected) entry.active = undefined - if (entry.pending?.href === href) { loading.push(entry.pending.promise); continue } - entry.pending?.cancel() - if (entry.active?.getAttribute('href') === href) continue - - const link = doc.createElement('link') - link.rel = 'stylesheet' - link.crossOrigin = 'anonymous' - link.href = href - link.dataset.pluginTheme = key - // Fetch without applying a partially replaced theme to the current page. - link.media = 'not all' - const current = entry - let complete!: (success: boolean) => void - let loaded!: () => void - const promise = new Promise(resolve => { - let timer = setTimeout(() => complete(false), 1500) - let finished = false - loaded = () => { - if (finished) return - clearTimeout(timer) - // Fonts are substantially larger than CSS. Their asynchronous load - // has its own deadline while the current theme remains usable. - timer = setTimeout(() => complete(false), 15000) - void preloadThemeFonts(link).then(() => complete(true), () => complete(false)) - } - complete = success => { - if (finished) return - finished = true - clearTimeout(timer) - link.onload = link.onerror = null - if (success && !disposed && entries.get(key) === current) { - link.media = '' - current.active?.remove() - current.active = link - changed() - } else link.remove() - current.pending = undefined - resolve() - } - }) - current.pending = { href, promise, cancel: () => complete(false) } - link.onload = loaded - link.onerror = () => complete(false) - doc.head.append(link) - loading.push(promise) - } - return Promise.all(loading).then(() => {}) - } - - function dispose() { - disposed = true - for (const entry of entries.values()) { entry.pending?.cancel(); entry.active?.remove() } - entries.clear() - } - return { sync, dispose } -} diff --git a/backend/pkg/extensionapi/ui/tailwind.preset.mjs b/backend/pkg/extensionapi/ui/tailwind.preset.mjs deleted file mode 100644 index 8fc0e743040e..000000000000 --- a/backend/pkg/extensionapi/ui/tailwind.preset.mjs +++ /dev/null @@ -1,225 +0,0 @@ -/** Public UI defaults with optional plugin-owned presentation tokens. */ -export default { - "content": [ - "./index.html", - "./src/**/*.{vue,js,ts,jsx,tsx}" - ], - "darkMode": "class", - "theme": { - "extend": { - "colors": { - "primary": { - "50": "rgb(var(--theme-color-primary-50, 240 253 250) / )", - "100": "rgb(var(--theme-color-primary-100, 204 251 241) / )", - "200": "rgb(var(--theme-color-primary-200, 153 246 228) / )", - "300": "rgb(var(--theme-color-primary-300, 94 234 212) / )", - "400": "rgb(var(--theme-color-primary-400, 45 212 191) / )", - "500": "rgb(var(--theme-color-primary-500, 20 184 166) / )", - "600": "rgb(var(--theme-color-primary-600, 13 148 136) / )", - "700": "rgb(var(--theme-color-primary-700, 15 118 110) / )", - "800": "rgb(var(--theme-color-primary-800, 17 94 89) / )", - "900": "rgb(var(--theme-color-primary-900, 19 78 74) / )", - "950": "rgb(var(--theme-color-primary-950, 4 47 46) / )" - }, - "accent": { - "50": "rgb(var(--theme-color-accent-50, 248 250 252) / )", - "100": "rgb(var(--theme-color-accent-100, 241 245 249) / )", - "200": "rgb(var(--theme-color-accent-200, 226 232 240) / )", - "300": "rgb(var(--theme-color-accent-300, 203 213 225) / )", - "400": "rgb(var(--theme-color-accent-400, 148 163 184) / )", - "500": "rgb(var(--theme-color-accent-500, 100 116 139) / )", - "600": "rgb(var(--theme-color-accent-600, 71 85 105) / )", - "700": "rgb(var(--theme-color-accent-700, 51 65 85) / )", - "800": "rgb(var(--theme-color-accent-800, 30 41 59) / )", - "900": "rgb(var(--theme-color-accent-900, 15 23 42) / )", - "950": "rgb(var(--theme-color-accent-950, 2 6 23) / )" - }, - "dark": { - "50": "rgb(var(--theme-color-dark-50, 248 250 252) / )", - "100": "rgb(var(--theme-color-dark-100, 241 245 249) / )", - "200": "rgb(var(--theme-color-dark-200, 226 232 240) / )", - "300": "rgb(var(--theme-color-dark-300, 203 213 225) / )", - "400": "rgb(var(--theme-color-dark-400, 148 163 184) / )", - "500": "rgb(var(--theme-color-dark-500, 100 116 139) / )", - "600": "rgb(var(--theme-color-dark-600, 71 85 105) / )", - "700": "rgb(var(--theme-color-dark-700, 51 65 85) / )", - "800": "rgb(var(--theme-color-dark-800, 30 41 59) / )", - "900": "rgb(var(--theme-color-dark-900, 15 23 42) / )", - "950": "rgb(var(--theme-color-dark-950, 2 6 23) / )" - }, - "gray": { - "50": "rgb(var(--theme-color-gray-50, 249 250 251) / )", - "100": "rgb(var(--theme-color-gray-100, 243 244 246) / )", - "200": "rgb(var(--theme-color-gray-200, 229 231 235) / )", - "300": "rgb(var(--theme-color-gray-300, 209 213 219) / )", - "400": "rgb(var(--theme-color-gray-400, 156 163 175) / )", - "500": "rgb(var(--theme-color-gray-500, 107 114 128) / )", - "600": "rgb(var(--theme-color-gray-600, 75 85 99) / )", - "700": "rgb(var(--theme-color-gray-700, 55 65 81) / )", - "800": "rgb(var(--theme-color-gray-800, 31 41 55) / )", - "900": "rgb(var(--theme-color-gray-900, 17 24 39) / )", - "950": "rgb(var(--theme-color-gray-950, 3 7 18) / )" - }, - "slate": { - "50": "rgb(var(--theme-color-slate-50, 248 250 252) / )", - "100": "rgb(var(--theme-color-slate-100, 241 245 249) / )", - "200": "rgb(var(--theme-color-slate-200, 226 232 240) / )", - "300": "rgb(var(--theme-color-slate-300, 203 213 225) / )", - "400": "rgb(var(--theme-color-slate-400, 148 163 184) / )", - "500": "rgb(var(--theme-color-slate-500, 100 116 139) / )", - "600": "rgb(var(--theme-color-slate-600, 71 85 105) / )", - "700": "rgb(var(--theme-color-slate-700, 51 65 85) / )", - "800": "rgb(var(--theme-color-slate-800, 30 41 59) / )", - "900": "rgb(var(--theme-color-slate-900, 15 23 42) / )", - "950": "rgb(var(--theme-color-slate-950, 2 6 23) / )" - }, - "canvas": "rgb(var(--ui-canvas) / )", - "surface": "rgb(var(--ui-surface) / )", - "raised": "rgb(var(--ui-raised) / )", - "line": "rgb(var(--ui-line) / )", - "line-strong": "rgb(var(--ui-line-strong) / )", - "ink": "rgb(var(--ui-ink) / )", - "muted": "rgb(var(--ui-muted) / )" - }, - "fontFamily": { - "sans": [ - "var(--ui-font)" - ], - "mono": [ - "ui-monospace", - "SFMono-Regular", - "Menlo", - "Monaco", - "Consolas", - "monospace" - ] - }, - "boxShadow": { - "sm": "var(--theme-shadow-sm, 0 1px 2px 0 rgb(0 0 0 / 0.05))", - "DEFAULT": "var(--theme-shadow-default, 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1))", - "md": "var(--theme-shadow-md, 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1))", - "lg": "var(--theme-shadow-lg, 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1))", - "xl": "var(--theme-shadow-xl, 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1))", - "2xl": "var(--theme-shadow-2xl, 0 25px 50px -12px rgb(0 0 0 / 0.25))", - "inner": "var(--theme-shadow-inner, inset 0 2px 4px 0 rgb(0 0 0 / 0.05))", - "none": "var(--theme-shadow-none, none)", - "glass": "var(--theme-shadow-glass, 0 8px 32px rgba(0, 0, 0, 0.08))", - "glass-sm": "var(--theme-shadow-glass-sm, 0 4px 16px rgba(0, 0, 0, 0.06))", - "glow": "var(--theme-shadow-glow, 0 0 20px rgba(20, 184, 166, 0.25))", - "glow-lg": "var(--theme-shadow-glow-lg, 0 0 40px rgba(20, 184, 166, 0.35))", - "card": "var(--theme-shadow-card, 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.06))", - "card-hover": "var(--theme-shadow-card-hover, 0 10px 40px rgba(0, 0, 0, 0.08))", - "inner-glow": "var(--theme-shadow-inner-glow, inset 0 1px 0 rgba(255, 255, 255, 0.1))", - "outline": "var(--theme-shadow-outline, 0 0 0 1px rgb(var(--ui-line)))" - }, - "backgroundImage": { - "none": "none", - "gradient-to-t": "var(--theme-background-gradient-to-t, linear-gradient(to top, var(--tw-gradient-stops)))", - "gradient-to-tr": "var(--theme-background-gradient-to-tr, linear-gradient(to top right, var(--tw-gradient-stops)))", - "gradient-to-r": "var(--theme-background-gradient-to-r, linear-gradient(to right, var(--tw-gradient-stops)))", - "gradient-to-br": "var(--theme-background-gradient-to-br, linear-gradient(to bottom right, var(--tw-gradient-stops)))", - "gradient-to-b": "var(--theme-background-gradient-to-b, linear-gradient(to bottom, var(--tw-gradient-stops)))", - "gradient-to-bl": "var(--theme-background-gradient-to-bl, linear-gradient(to bottom left, var(--tw-gradient-stops)))", - "gradient-to-l": "var(--theme-background-gradient-to-l, linear-gradient(to left, var(--tw-gradient-stops)))", - "gradient-to-tl": "var(--theme-background-gradient-to-tl, linear-gradient(to top left, var(--tw-gradient-stops)))", - "gradient-radial": "var(--theme-background-gradient-radial, radial-gradient(var(--tw-gradient-stops)))", - "gradient-primary": "var(--theme-background-gradient-primary, linear-gradient(135deg, #14b8a6 0%, #0d9488 100%))", - "gradient-dark": "var(--theme-background-gradient-dark, linear-gradient(135deg, #1e293b 0%, #0f172a 100%))", - "gradient-glass": "var(--theme-background-gradient-glass, linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%))", - "mesh-gradient": "var(--theme-background-mesh-gradient, radial-gradient(at 40% 20%, rgba(20, 184, 166, 0.12) 0px, transparent 50%), radial-gradient(at 80% 0%, rgba(6, 182, 212, 0.08) 0px, transparent 50%), radial-gradient(at 0% 50%, rgba(20, 184, 166, 0.08) 0px, transparent 50%))" - }, - "animation": { - "fade-in": "var(--theme-animation-fade-in, fadeIn 0.3s ease-out)", - "slide-up": "var(--theme-animation-slide-up, slideUp 0.3s ease-out)", - "slide-down": "var(--theme-animation-slide-down, slideDown 0.3s ease-out)", - "slide-in-right": "var(--theme-animation-slide-in-right, slideInRight 0.3s ease-out)", - "scale-in": "var(--theme-animation-scale-in, scaleIn 0.2s ease-out)", - "pulse-slow": "var(--theme-animation-pulse-slow, pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite)", - "shimmer": "var(--theme-animation-shimmer, shimmer 2s linear infinite)", - "glow": "var(--theme-animation-glow, glow 2s ease-in-out infinite alternate)" - }, - "keyframes": { - "fadeIn": { - "0%": { - "opacity": "0" - }, - "100%": { - "opacity": "1" - } - }, - "slideUp": { - "0%": { - "opacity": "0", - "transform": "translateY(10px)" - }, - "100%": { - "opacity": "1", - "transform": "translateY(0)" - } - }, - "slideDown": { - "0%": { - "opacity": "0", - "transform": "translateY(-10px)" - }, - "100%": { - "opacity": "1", - "transform": "translateY(0)" - } - }, - "slideInRight": { - "0%": { - "opacity": "0", - "transform": "translateX(20px)" - }, - "100%": { - "opacity": "1", - "transform": "translateX(0)" - } - }, - "scaleIn": { - "0%": { - "opacity": "0", - "transform": "scale(0.95)" - }, - "100%": { - "opacity": "1", - "transform": "scale(1)" - } - }, - "shimmer": { - "0%": { - "backgroundPosition": "-200% 0" - }, - "100%": { - "backgroundPosition": "200% 0" - } - }, - "glow": { - "0%": { - "boxShadow": "0 0 20px rgba(20, 184, 166, 0.25)" - }, - "100%": { - "boxShadow": "0 0 30px rgba(20, 184, 166, 0.4)" - } - } - }, - "backdropBlur": { - "xs": "2px" - }, - "borderRadius": { - "none": "0px", - "sm": "var(--theme-radius-sm, 0.125rem)", - "DEFAULT": "var(--theme-radius-default, 0.25rem)", - "md": "var(--theme-radius-md, 0.375rem)", - "lg": "var(--theme-radius-lg, 0.5rem)", - "xl": "var(--theme-radius-xl, 0.75rem)", - "2xl": "var(--theme-radius-2xl, 1rem)", - "3xl": "var(--theme-radius-3xl, 1.5rem)", - "full": "9999px", - "4xl": "var(--theme-radius-4xl, 2rem)" - } - } - }, - "plugins": [] -} diff --git a/backend/pkg/extensionapi/ui/theme.css b/backend/pkg/extensionapi/ui/theme.css deleted file mode 100644 index b67e8e1a4990..000000000000 --- a/backend/pkg/extensionapi/ui/theme.css +++ /dev/null @@ -1,34 +0,0 @@ -/* Public semantic defaults. Optional themes override these through signed assets. */ -:root { - --ui-canvas: 249 250 251; - --ui-surface: 255 255 255; - --ui-raised: 243 244 246; - --ui-line: 229 231 235; - --ui-line-strong: 209 213 219; - --ui-ink: 17 24 39; - --ui-muted: 107 114 128; - --ui-accent: #14b8a6; - --ui-link: #0d9488; - --ui-font: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; -} -:root.dark { - --ui-canvas: 2 6 23; - --ui-surface: 15 23 42; - --ui-raised: 30 41 59; - --ui-line: 51 65 85; - --ui-line-strong: 71 85 105; - --ui-ink: 243 244 246; - --ui-muted: 148 163 184; - --ui-link: #2dd4bf; -} - -@media (prefers-reduced-motion: reduce) { - html { scroll-behavior: auto; } - *, *::before, *::after { - animation-duration: 0.01ms !important; - animation-delay: 0ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - transition-delay: 0ms !important; - } -} diff --git a/backend/pkg/extensionapi/ui/tsconfig.json b/backend/pkg/extensionapi/ui/tsconfig.json deleted file mode 100644 index 065587a2f6e3..000000000000 --- a/backend/pkg/extensionapi/ui/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../../../frontend/tsconfig.json", - "compilerOptions": { "types": [] }, - "files": ["../../../../frontend/node_modules/vite/client.d.ts"], - "include": ["./**/*.ts", "./**/*.vue", "../../../../plugins/*/ui/src/**/*.ts", "../../../../plugins/*/ui/src/**/*.vue"], - "exclude": ["../../../../plugins/**/__tests__/**", "../../../../plugins/**/*.spec.ts", "../../../../plugins/**/*.test.ts"], - "references": [] -} diff --git a/backend/pkg/extensionapi/v1/publisher.go b/backend/pkg/extensionapi/v1/publisher.go deleted file mode 100644 index fd66387dccff..000000000000 --- a/backend/pkg/extensionapi/v1/publisher.go +++ /dev/null @@ -1,36 +0,0 @@ -package extensionv1 - -import ( - _ "embed" - "encoding/json" - "strings" -) - -//go:embed publisher.json -var publisherJSON []byte - -type Publisher struct { - KeyID string `json:"key_id"` - PublicKey string `json:"public_key"` -} - -// Publisher metadata has a single source shared by the host and release jobs. -// The matching private key exists only in the repository's signing secret. -func FirstPartyPublisher() Publisher { - var publisher Publisher - if err := json.Unmarshal(publisherJSON, &publisher); err != nil { - panic("invalid embedded publisher metadata") - } - return publisher -} - -func FirstPartyPublisherKey(keyID, pluginID string) (string, bool) { - publisher := FirstPartyPublisher() - if keyID != publisher.KeyID { - return "", false - } - if !strings.HasPrefix(pluginID, "codexrip.") { - return "", true - } - return publisher.PublicKey, true -} diff --git a/backend/pkg/extensionapi/v1/publisher.json b/backend/pkg/extensionapi/v1/publisher.json deleted file mode 100644 index 393a9c330542..000000000000 --- a/backend/pkg/extensionapi/v1/publisher.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "key_id": "codexrip-plugins-v1", - "public_key": "9foT52Masryks+hrKf7Vg9e4I+xbN3FOTuac2Sgk3aw=" -} diff --git a/backend/pkg/extensionapi/v1/rpc.go b/backend/pkg/extensionapi/v1/rpc.go deleted file mode 100644 index 5c526fea4098..000000000000 --- a/backend/pkg/extensionapi/v1/rpc.go +++ /dev/null @@ -1,126 +0,0 @@ -package extensionv1 - -import ( - "context" - "encoding/json" - - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/wrapperspb" -) - -const MaxPayloadBytes = 4 * 1024 * 1024 -const pluginService = "sub2api.extensions.v1.Plugin" -const hostService = "sub2api.extensions.v1.Host" - -// The RPC envelope is protobuf BytesValue. Versioned JSON contracts inside it -// keep optional extensions independent from the official transport proto. -type Client struct{ conn grpc.ClientConnInterface } - -func NewClient(conn grpc.ClientConnInterface) *Client { return &Client{conn: conn} } - -func (c *Client) Invoke(ctx context.Context, in Invocation) (Result, error) { - return c.call(ctx, "/"+pluginService+"/Invoke", in) -} - -func (c *Client) Call(ctx context.Context, in HostInvocation) (Result, error) { - return c.call(ctx, "/"+hostService+"/Call", in) -} - -func (c *Client) call(ctx context.Context, method string, in any) (Result, error) { - var result Result - if c == nil || c.conn == nil { - return result, status.Error(codes.Unavailable, "extension connection unavailable") - } - raw, err := json.Marshal(in) - if err != nil { - return result, err - } - if len(raw) > MaxPayloadBytes { - return result, status.Error(codes.ResourceExhausted, "extension payload too large") - } - out := &wrapperspb.BytesValue{} - if err := c.conn.Invoke(ctx, method, wrapperspb.Bytes(raw), out); err != nil { - return result, err - } - if len(out.Value) > MaxPayloadBytes { - return result, status.Error(codes.ResourceExhausted, "extension result too large") - } - if err := json.Unmarshal(out.Value, &result); err != nil { - return result, status.Error(codes.DataLoss, "invalid extension result") - } - return result, nil -} - -type rpcServer interface { - dispatch(context.Context, *wrapperspb.BytesValue) (*wrapperspb.BytesValue, error) -} -type rpcAdapter struct { - invoke func(context.Context, []byte) (Result, error) -} - -func (a *rpcAdapter) dispatch(ctx context.Context, in *wrapperspb.BytesValue) (*wrapperspb.BytesValue, error) { - if in == nil || len(in.Value) > MaxPayloadBytes { - return nil, status.Error(codes.ResourceExhausted, "extension payload too large") - } - result, err := a.invoke(ctx, in.Value) - if err != nil { - return nil, err - } - raw, err := json.Marshal(result) - if err != nil { - return nil, status.Error(codes.Internal, "extension result encoding failed") - } - if len(raw) > MaxPayloadBytes { - return nil, status.Error(codes.ResourceExhausted, "extension result too large") - } - return wrapperspb.Bytes(raw), nil -} - -func RegisterPlugin(server grpc.ServiceRegistrar, handler Handler) { - register(server, pluginService, "Invoke", &rpcAdapter{invoke: func(ctx context.Context, raw []byte) (Result, error) { - var in Invocation - if json.Unmarshal(raw, &in) != nil || !IsCapability(in.Capability) || in.Operation == "" { - return Result{}, status.Error(codes.InvalidArgument, "invalid extension invocation") - } - return handler.Invoke(ctx, in) - }}) -} - -func RegisterHost(server grpc.ServiceRegistrar, handler HostHandler) { - register(server, hostService, "Call", &rpcAdapter{invoke: func(ctx context.Context, raw []byte) (Result, error) { - var in HostInvocation - if json.Unmarshal(raw, &in) != nil || in.Operation == "" { - return Result{}, status.Error(codes.InvalidArgument, "invalid extension host invocation") - } - return handler.Call(ctx, in) - }}) -} - -func register(server grpc.ServiceRegistrar, service, method string, adapter *rpcAdapter) { - server.RegisterService(&grpc.ServiceDesc{ - ServiceName: service, HandlerType: (*rpcServer)(nil), - Methods: []grpc.MethodDesc{{MethodName: method, Handler: func(srv any, ctx context.Context, decode func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { - in := &wrapperspb.BytesValue{} - if err := decode(in); err != nil { - return nil, err - } - call := func(ctx context.Context, req any) (any, error) { - target, ok := srv.(rpcServer) - if !ok { - return nil, status.Error(codes.Internal, "invalid extension RPC handler") - } - request, ok := req.(*wrapperspb.BytesValue) - if !ok || request == nil { - return nil, status.Error(codes.InvalidArgument, "invalid extension RPC request") - } - return target.dispatch(ctx, request) - } - if interceptor == nil { - return call(ctx, in) - } - return interceptor(ctx, in, &grpc.UnaryServerInfo{Server: srv, FullMethod: "/" + service + "/" + method}, call) - }}}, - }, adapter) -} diff --git a/backend/pkg/extensionapi/v1/rpc_test.go b/backend/pkg/extensionapi/v1/rpc_test.go deleted file mode 100644 index 0c78114bf707..000000000000 --- a/backend/pkg/extensionapi/v1/rpc_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package extensionv1 - -import ( - "context" - "encoding/json" - "net" - "testing" - "time" - - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/credentials/insecure" - "google.golang.org/grpc/status" - "google.golang.org/grpc/test/bufconn" - "google.golang.org/protobuf/types/known/wrapperspb" -) - -type capturedRPCRegistration struct { - description *grpc.ServiceDesc - implementation any -} - -func (r *capturedRPCRegistration) RegisterService(description *grpc.ServiceDesc, implementation any) { - r.description, r.implementation = description, implementation -} - -func TestExtensionRPCRejectsInvalidDispatchTypes(t *testing.T) { - registration := &capturedRPCRegistration{} - RegisterPlugin(registration, testHandler{}) - handler := registration.description.Methods[0].Handler - decode := func(any) error { return nil } - _, err := handler(struct{}{}, context.Background(), decode, nil) - if status.Code(err) != codes.Internal { - t.Fatalf("invalid handler did not fail closed: %v", err) - } - for _, request := range []any{struct{}{}, (*wrapperspb.BytesValue)(nil)} { - interceptor := func(ctx context.Context, _ any, _ *grpc.UnaryServerInfo, next grpc.UnaryHandler) (any, error) { - return next(ctx, request) - } - _, err := handler(registration.implementation, context.Background(), decode, interceptor) - if status.Code(err) != codes.InvalidArgument { - t.Fatalf("invalid interceptor request did not fail closed: %v", err) - } - } -} - -type testHandler struct{} - -func (testHandler) Invoke(ctx context.Context, in Invocation) (Result, error) { - if in.Operation == "wait" { - <-ctx.Done() - return Result{}, status.FromContextError(ctx.Err()).Err() - } - var req SchedulingRequest - if err := Decode(in.Payload, &req); err != nil { - return Result{}, err - } - raw, err := Encode(SchedulingDecision{Allowed: false, Reason: "ticket_missing", Scope: req.Model}) - return Result{Payload: raw}, err -} - -func TestExtensionRPCContractAndCancellation(t *testing.T) { - listener := bufconn.Listen(1024 * 1024) - server := grpc.NewServer() - RegisterPlugin(server, testHandler{}) - go func() { _ = server.Serve(listener) }() - t.Cleanup(server.Stop) - connection, err := grpc.NewClient("passthrough:///extension", grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() })) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = connection.Close() }) - client := NewClient(connection) - raw, _ := Encode(SchedulingRequest{Account: Account{ID: 17, Platform: "openai", Type: "oauth"}, Model: "model-a"}) - out, err := client.Invoke(context.Background(), Invocation{Capability: CapabilityScheduling, Operation: "admit", Payload: raw}) - if err != nil { - t.Fatal(err) - } - var decision SchedulingDecision - if err := json.Unmarshal(out.Payload, &decision); err != nil { - t.Fatal(err) - } - if decision.Allowed || decision.Reason != "ticket_missing" || decision.Scope != "model-a" { - t.Fatalf("unexpected decision: %+v", decision) - } - _, err = client.Invoke(context.Background(), Invocation{Capability: "shell.exec", Operation: "run", Payload: raw}) - if status.Code(err) != codes.InvalidArgument { - t.Fatalf("unknown capability accepted: %v", err) - } - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) - defer cancel() - _, err = client.Invoke(ctx, Invocation{Capability: CapabilityScheduling, Operation: "wait", Payload: raw}) - if status.Code(err) != codes.DeadlineExceeded { - t.Fatalf("cancellation not propagated: %v", err) - } -} diff --git a/backend/pkg/extensionapi/v1/runtime.go b/backend/pkg/extensionapi/v1/runtime.go deleted file mode 100644 index 14a932e8faa2..000000000000 --- a/backend/pkg/extensionapi/v1/runtime.go +++ /dev/null @@ -1,140 +0,0 @@ -package extensionv1 - -import ( - "context" - "encoding/json" - "sync" - "time" - - pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" - hcplugin "github.com/hashicorp/go-plugin" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -// Module is the only domain implementation loaded into a plugin process. Its -// configuration and policy never execute inside the host process. -type Module interface { - Handler - ValidateConfig(context.Context, json.RawMessage) (json.RawMessage, error) - ApplyConfig(context.Context, json.RawMessage) error - Status(context.Context) (json.RawMessage, error) -} - -type HostReceiver interface{ SetHost(*Client) } - -type Definition struct { - ID string - Version string - Capabilities []string -} - -type Runtime struct { - pluginv1.UnimplementedTransportPluginServer - definition Definition - module Module - mu sync.Mutex - broker *hcplugin.GRPCBroker - hostConn *grpc.ClientConn -} - -func NewRuntime(definition Definition, module Module) *Runtime { - return &Runtime{definition: definition, module: module} -} -func Serve(definition Definition, module Module) { pluginv1.Serve(NewRuntime(definition, module)) } -func (r *Runtime) SetHostBroker(broker *hcplugin.GRPCBroker) { r.broker = broker } -func (r *Runtime) RegisterAdditionalServices(server grpc.ServiceRegistrar) { - RegisterPlugin(server, r.module) -} - -func (r *Runtime) GetInfo(context.Context, *pluginv1.GetInfoRequest) (*pluginv1.GetInfoResponse, error) { - return &pluginv1.GetInfoResponse{PluginId: r.definition.ID, PluginVersion: r.definition.Version, - ProtocolVersion: pluginv1.ProtocolVersion, TransportApiVersion: pluginv1.TransportAPIVersion, Capabilities: r.definition.Capabilities}, nil -} - -func (r *Runtime) Health(ctx context.Context, _ *pluginv1.HealthRequest) (*pluginv1.HealthResponse, error) { - raw, err := r.module.Status(ctx) - if err != nil { - return &pluginv1.HealthResponse{Healthy: false, Message: "extension status unavailable"}, nil - } - return &pluginv1.HealthResponse{Healthy: true, StatusJson: string(raw)}, nil -} - -func (r *Runtime) ValidateConfig(ctx context.Context, in *pluginv1.ValidateConfigRequest) (*pluginv1.ValidateConfigResponse, error) { - if in == nil || len(in.ConfigJson) > MaxPayloadBytes { - return nil, status.Error(codes.InvalidArgument, "invalid configuration") - } - raw, err := r.module.ValidateConfig(ctx, in.ConfigJson) - if err != nil { - return &pluginv1.ValidateConfigResponse{Valid: false, Message: err.Error()}, nil - } - return &pluginv1.ValidateConfigResponse{Valid: true, NormalizedConfigJson: raw}, nil -} - -func (r *Runtime) ApplyConfig(ctx context.Context, in *pluginv1.ApplyConfigRequest) (*pluginv1.ApplyConfigResponse, error) { - if in == nil || len(in.ConfigJson) > MaxPayloadBytes { - return nil, status.Error(codes.InvalidArgument, "invalid configuration") - } - raw, err := r.module.ValidateConfig(ctx, in.ConfigJson) - if err != nil { - return &pluginv1.ApplyConfigResponse{Applied: false, Message: err.Error()}, nil - } - if err := r.module.ApplyConfig(ctx, raw); err != nil { - return &pluginv1.ApplyConfigResponse{Applied: false, Message: err.Error()}, nil - } - return &pluginv1.ApplyConfigResponse{Applied: true}, nil -} - -// Policy modules use the official TestConfig method for local diagnostics. -// This default checks configuration and passive status only; domain-specific -// upstream probes remain explicit named admin operations, never hidden here. -func (r *Runtime) TestConfig(ctx context.Context, in *pluginv1.TestConfigRequest) (*pluginv1.TestConfigResponse, error) { - if err := ctx.Err(); err != nil { - return nil, status.FromContextError(err).Err() - } - if in == nil || len(in.ConfigJson) > MaxPayloadBytes { - return nil, status.Error(codes.InvalidArgument, "invalid configuration") - } - started := time.Now() - raw, err := r.module.ValidateConfig(ctx, in.ConfigJson) - if err := ctx.Err(); err != nil { - return nil, status.FromContextError(err).Err() - } - var normalized map[string]json.RawMessage - if err != nil || len(raw) > MaxPayloadBytes || json.Unmarshal(raw, &normalized) != nil || normalized == nil { - return &pluginv1.TestConfigResponse{Message: "Extension configuration is invalid."}, nil - } - localStatus, err := r.module.Status(ctx) - if err := ctx.Err(); err != nil { - return nil, status.FromContextError(err).Err() - } - if err != nil || len(localStatus) > MaxPayloadBytes || !json.Valid(localStatus) { - return &pluginv1.TestConfigResponse{Message: "Extension status is unavailable."}, nil - } - return &pluginv1.TestConfigResponse{ - Success: true, Message: "Configuration and local status are valid; no upstream connection was tested.", - LatencyMs: time.Since(started).Milliseconds(), StatusJson: string(localStatus), - }, nil -} - -func (r *Runtime) InitHostServices(_ context.Context, in *pluginv1.InitHostServicesRequest) (*pluginv1.InitHostServicesResponse, error) { - r.mu.Lock() - defer r.mu.Unlock() - if r.broker == nil || in == nil || in.HostServiceApiVersion != pluginv1.HostServiceAPIVersion { - return nil, status.Error(codes.FailedPrecondition, "compatible host broker required") - } - connection, err := r.broker.Dial(in.HostServiceId) - if err != nil { - return nil, status.Error(codes.Unavailable, "host broker unavailable") - } - if receiver, ok := r.module.(HostReceiver); ok { - receiver.SetHost(NewClient(connection)) - } - previous := r.hostConn - r.hostConn = connection - if previous != nil { - _ = previous.Close() - } - return &pluginv1.InitHostServicesResponse{Ready: true}, nil -} diff --git a/backend/pkg/extensionapi/v1/runtime_config_test.go b/backend/pkg/extensionapi/v1/runtime_config_test.go deleted file mode 100644 index bcd9285757e7..000000000000 --- a/backend/pkg/extensionapi/v1/runtime_config_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package extensionv1 - -import ( - "context" - "encoding/json" - "errors" - "testing" - - pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -type staticConfigModule struct { - validations, statuses, applies, invocations int - validationErr, statusErr error -} - -func (m *staticConfigModule) ValidateConfig(context.Context, json.RawMessage) (json.RawMessage, error) { - m.validations++ - return json.RawMessage(`{"enabled":true}`), m.validationErr -} -func (m *staticConfigModule) ApplyConfig(context.Context, json.RawMessage) error { - m.applies++ - return nil -} -func (m *staticConfigModule) Invoke(context.Context, Invocation) (Result, error) { - m.invocations++ - return Result{}, errors.New("business operation must not run") -} -func (m *staticConfigModule) Status(context.Context) (json.RawMessage, error) { - m.statuses++ - return json.RawMessage(`{"ready":true}`), m.statusErr -} - -func TestRuntimeConfigTestReportsOnlyLocalValidation(t *testing.T) { - module := &staticConfigModule{} - runtime := NewRuntime(Definition{ID: "fixture.config", Version: "1.0.0"}, module) - result, err := runtime.TestConfig(context.Background(), &pluginv1.TestConfigRequest{ConfigJson: []byte(`{}`)}) - if err != nil || result == nil || !result.Success { - t.Fatalf("valid independent configuration did not get a diagnostic result: %v, %v", result, err) - } - if result.StatusJson != `{"ready":true}` || result.Message != "Configuration and local status are valid; no upstream connection was tested." { - t.Fatalf("diagnostic scope is not explicit: %+v", result) - } - if module.validations != 1 || module.statuses != 1 || module.applies != 0 || module.invocations != 0 { - t.Fatalf("configuration diagnostic performed unexpected work: %+v", module) - } -} - -func TestRuntimeConfigTestRejectsInvalidOrCanceledWithoutBusinessWork(t *testing.T) { - for _, scenario := range []string{"missing", "oversized", "canceled", "invalid-config", "failed-status"} { - t.Run(scenario, func(t *testing.T) { - module := &staticConfigModule{} - request := &pluginv1.TestConfigRequest{ConfigJson: []byte(`{}`)} - ctx := context.Background() - switch scenario { - case "missing": - request = nil - case "oversized": - request.ConfigJson = make([]byte, MaxPayloadBytes+1) - case "canceled": - canceled, cancel := context.WithCancel(ctx) - cancel() - ctx = canceled - case "invalid-config": - module.validationErr = errors.New("synthetic-private-config-detail") - case "failed-status": - module.statusErr = errors.New("synthetic-private-status-detail") - } - result, err := NewRuntime(Definition{}, module).TestConfig(ctx, request) - if scenario == "invalid-config" || scenario == "failed-status" { - if err != nil || result == nil || result.Success || result.StatusJson != "" { - t.Fatalf("failed local check must not report success: %+v, %v", result, err) - } - } else { - want := codes.InvalidArgument - if scenario == "canceled" { - want = codes.Canceled - } - if status.Code(err) != want || module.validations != 0 || module.statuses != 0 { - t.Fatalf("invalid admission should stop before module calls: %+v, %v", module, err) - } - } - if module.applies != 0 || module.invocations != 0 { - t.Fatal("configuration check performed a business action") - } - }) - } -} diff --git a/backend/scripts/build-development-plugins.sh b/backend/scripts/build-development-plugins.sh deleted file mode 100644 index 276c640fe931..000000000000 --- a/backend/scripts/build-development-plugins.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -plugin_output=${1:?output directory required} -host_version=${2:?host version required} -runtime_platform=${3:?runtime platform required} -source_revision=${4:-} -[[ -z ${SUB2API_PLUGIN_SIGNING_KEY:-} ]] || { printf '%s\n' 'Development builds cannot use the release signing secret' >&2; exit 1; } -plugin_build=$(mktemp -d) -trap 'rm -rf "$plugin_build"' EXIT - -go build -o "$plugin_build/package-plugin" ./cmd/package-plugin -"$plugin_build/package-plugin" -build-binaries -bundle-source ../plugins/bundle.source.json -binary-dir "$plugin_build/binaries" -platform "$runtime_platform" -"$plugin_build/package-plugin" -generate-key "$plugin_build/development.key" >"$plugin_build/public.json" -source_args=() -if [[ $source_revision =~ ^[a-f0-9]{40}$ ]]; then source_args=(-source-revision "$source_revision"); fi -"$plugin_build/package-plugin" -bundle-source ../plugins/bundle.source.json -binary-dir "$plugin_build/binaries" \ - -platform "$runtime_platform" -tested-host-version "$host_version" -output "$plugin_output" \ - -signing-key-file "$plugin_build/development.key" -key-id codexrip-development "${source_args[@]}" -"$plugin_build/package-plugin" -verify-bundle "$plugin_output/lock.json" -tested-host-version "$host_version" -platform "$runtime_platform" -development diff --git a/docs/PLUGIN_DEVELOPMENT.md b/docs/PLUGIN_DEVELOPMENT.md index 84dcef1f888a..1b7b24894746 100644 --- a/docs/PLUGIN_DEVELOPMENT.md +++ b/docs/PLUGIN_DEVELOPMENT.md @@ -1,162 +1,233 @@ -# Sub2API 插件开发 +# Sub2API 插件开发教程 -本页描述当前 `v0.2.7` 下游分支的实现,尚不代表生产已升级。迁移与交付状态见[实施状态](../.downstream/implementation-v0.2.7.md)。插件以独立模块、独立进程和签名资源包运行;宿主保留鉴权、凭据刷新、事务、金额提交及通用请求生命周期。 +本文面向希望为 Sub2API 开发、打包和发布插件的团队。插件是独立进程和静态 UI 组成的 `.s2plugin` 包,宿主通过稳定的 gRPC 协议调用它。本文以当前宿主已经定义的 `openai.oauth.outbound_transport.v1` 能力作为协议示例,说明开发者需要准备什么、哪些职责属于插件、哪些职责仍由 Sub2API 负责。 -## 从现有模块开始 +本文不是一个可直接安装的完整插件,也不代表 Sub2API 已经发布对应的官方插件包。当前文档主要描述公开协议、宿主边界和开发流程。后续是否发布可安装包、支持哪些 Provider,以及如何提供示例仓库,都需要另行公告。 -使用与 `backend/go.mod` 一致的 Go 工具链。插件只导入公开的 `backend/pkg/extensionapi/v1` 或旧传输 `backend/pkg/pluginapi/v1`,不能导入宿主 `internal` 包。 +## 1. 准备开发环境 -| 模块 | 已迁入的主要职责 | -|---|---| -| `plugins/codex-runtime` | 292采集、校验/注入、有限续期、身份/传输、推理恢复/回放规则及诊断界面 | -| `plugins/model-policy` | 静态容量参考、精确别名与供应商范围 | -| `plugins/prompt-skills` | 提示词范围、协议载体、回显、发布/回滚、技能来源/存储计划及管理界面 | -| `plugins/account-tools` | 分类策略、逐账号测试计划和推理选项 | -| `plugins/cindy-provider` | 目录、价格参考、协议能力及健康错误分类 | -| `plugins/image-tools` | 图像生成/编辑计划、模型投影和图像功能配置 | -| `plugins/admin-observability` | 流量规则、指标展示、提示词审计管理界面和可停用主题资源 | +建议使用以下环境: -这些模块可作为实现示例;剩余混合职责以实施状态中的未完成项为准。`plugins/bundle.source.json` 是镜像内置集合及首次迁移配置的唯一清单。 +- Go 1.21 或更高版本; +- Node.js(仅在插件 UI 使用 JavaScript 时需要); +- Git; +- 与目标部署环境一致的构建工具链。 -每个模块维护自己的 `go.mod`、`cmd/plugin/main.go`、业务包、`manifest.source.json` 和 `ui/`。入口调用 `extensionv1.Serve`,业务模块实现 `Invoke`、`ValidateConfig`、`ApplyConfig` 和 `Status`。需要宿主数据的模块实现 `SetHost(*extensionv1.Client)`。 +协议定义和通用说明位于: -## 声明能力与生命周期 +- `backend/pkg/pluginapi/v1/plugin.proto`:进程间消息和流式请求定义; +- `backend/pkg/pluginapi/v1/runtime.go`:插件进程启动入口; +- `backend/pkg/pluginapi/v1/manifest.schema.json`:包清单 JSON Schema; +- `backend/pkg/pluginapi/docs/`:开发、UI Bridge、包格式和安全边界说明。 -manifest 的身份、版本、协议版本和能力必须与程序 `GetInfo` 一致。当前扩展 API 为 1;JSON 请求通过有界 gRPC 信封传送,单次输入和输出上限均为 4 MiB。 +目前暂未提供可直接复制的官方示例源码。开发者可以按照本文的目录和协议说明创建自己的插件工程;示例仓库发布后,会在本文补充正式的获取地址、目录说明和版本要求。公开协议始终以 `backend/pkg/pluginapi/` 为准。 -| 能力 | 边界 | -|---|---| -| `extensions.provider.v1` | 供应商策略,按平台和账号类型限定 | -| `extensions.catalog.v1` | 模型目录参考 | -| `extensions.request.v1` | 请求计划和前置条件,不自动授予凭据权限 | -| `extensions.scheduling.v1` | 调度约束及持久摘要 | -| `extensions.jobs.v1` | 宿主持久任务 | -| `extensions.admin.v1` | 已声明、经管理员授权的操作 | -| `extensions.observability.v1` | 有范围限制的账号计数器,不包含凭据和请求正文 | -| `extensions.recovery.v1` | 有账号范围的恢复/回放规则及脱敏诊断,不提供原始客户历史 | -| `extensions.credentials.v1` | 显式声明范围内的敏感出站身份解析 | -| `extensions.ui.v1` | 界面贡献;公开主题使用通配平台和账号类型绑定 | +## 2. 创建插件工程 -命名操作通过 manifest 的 `operations` 按能力声明。作用范围重叠的两个已启用插件不能拥有同名操作。宿主在调用前检查实际绑定、依赖和健康状态;结果 `plugin_id` 由宿主写入。旧 `openai.oauth.outbound_transport.v1` 仍支持原有流式契约,参考 `backend/pkg/pluginapi/docs/`;其 `request_sent` 必须准确,防止重复上游请求。 +在示例仓库发布前,可以先创建一个独立的 Go 工程,目录建议如下: -声明 `requires.extension_api` 的进程只使用带账号范围和执行代次的具名宿主端口,不能同时使用旧 `HostService` 的 KV 或账号目录绕过这些限制;旧 transport-only 插件未声明扩展 API 时仍保留原协议。具名策略即使命中缓存,也会重新核验当前持久状态、绑定和已应用配置,调用期间的配置变化或取消会使该次结果失效。需要后续宿主出站的流程仍须保留自身操作上下文,不能把一次策略查询当作无限期授权。 - -安装、绑定启停、卸载、更新提交及内置初始化的最终写入共享 PostgreSQL `SERIALIZABLE` 事务内的依赖图校验。健康故障不擦除期望启用状态;校验失败会回滚本次图修改和同事务任务取消。序列化冲突返回既有状态冲突,不自动重放业务。这只约束采用该协议的新写入路径,不保证旧版本写入器或绕过仓库的直接 SQL 安全。 - -不要把完整客户历史、图片或大文件复制进 RPC。传送有界事实与计划,由宿主处理原始载荷。价格和观测规则等需要跨异步边界的结果,在请求开始时冻结,新请求重新检查插件状态。 - -`ValidateConfig` 拒绝未知字段和错误类型,返回完整规范化对象。宿主加密保存配置,历史环境变量仅用于首次迁移。停用撤下入口并阻止新调用;启用但故障保留入口和不可用原因。已有历史、已停止续期状态、账本和结果保留。 - -公共扩展运行时也实现官方 `TestConfig` 方法,默认只检查配置和读取被动状态,并明确返回“未测试上游连接”。它不再次应用配置,也不调用业务操作;真正的代理/上游诊断应通过已声明的领域管理操作显式触发,不能把这个本地检查结果当作模型连通性证明。 - -宿主任务可绑定插件操作上下文,在停用、替换或实际配置变化时取消 IO。保存相同配置不会取消现有任务。已经开始计数或产生费用的请求使用冻结快照收尾。缓存必须有界,按配置版本隔离,使用前仍检查启停和健康;不可用时不得恢复旧宿主业务作为隐式回退。 - -具名调用的 `account_id` 由宿主从实际授权目标写入;权限与灰度在缓存和执行前检查。账号任务元数据中的 `plugin_id`、`plugin_generation` 也由宿主写入;创建事务检查当前启停与代次。失败重试保留原插件,显式重新校验当前版本与账号范围,不能通过重试扩大已收窄的绑定。旧代次任务不能在新进程上自动继续。 - -实际账号目录查询也必须传 `CatalogQuery.account_id`;省略或零值仅保留无账号的共享参考查询兼容性。账号型 broker 在读凭据、指标或写账号投影前读取当前安装范围,不能把 payload 里的账号元数据当作调用准入。已经发出的观测仍可按原归属收尾,不因此开始新业务请求。 - -## 页面和公开主题 - -配置页面位于 `ui/`,在受限 iframe 中运行。打包器自动加入 `backend/pkg/extensionapi/ui/bridge.js`,页面使用 `Sub2APIPluginBridge`。 - -复杂页面位于插件自己的 `ui/src`,只导入 Vue 等公开依赖和 `@sub2api/plugin-ui`。公共 UI SDK 提供对话框、图标、错误处理、基础样式和渲染工具;宿主旧组件路径仅作转出适配,插件不引用宿主内部组件。`node backend/pkg/extensionapi/ui/build.mjs prompt-skills` 编译所选域,省略域名编译所有已声明源码入口。构建产物进入 `ui/compiled`,不提交;打包跳过源码和测试目录。贡献的入口必须实际包含在签名包里。 - -| 方法 | 用途 | -|---|---| -| `context()` | 挂载模式、语言、主题与已验证账号上下文 | -| `config()` / `save(config)` | 配置读取、校验和保存 | -| `status()` | 被动状态,不执行模型测试 | -| `invoke(action, payload)` | 贡献中声明的管理操作 | -| `submit(action, items, key)` | 带稳定幂等键的宿主持久任务 | -| `resource(name, input)` | 具名宿主数据操作;`params`、`query`、`body` 或 `form` 提供原接口的参数 | -| UI SDK `resourceAvailability()` | 只读刷新当前会话角色下的具名资源 `{name, available}`;不返回任意 URL 或请求头权限 | -| `event(name, payload)` | 只发送当前贡献在 `events` 声明的界面事件 | -| `openJob(id)` | 宿主重新读取并验证归属后展示通用任务进度 | -| `preference(key)` / `savePreference(key, value)` | 按用户和插件隔离的浏览器偏好,不开放任意存储键 | -| `resize(height)` / `dispose()` | 尺寸和卸载清理 | - -账号操作、详情、测试字段、设置页和宿主表面通过 manifest 贡献接入。`config_flag` 引用一个布尔配置字段,关闭时撤下入口,读取失败时显示不可用。持续任务的进度、取消和历史由宿主管理。 - -配置页面须先调用 `config()` 再 `save()` 或测试。Bridge 仍传原始配置对象;宿主保留该次读取的安装修订号和包摘要,不让页面载荷伪造版本。冲突不自动读取新版覆盖草稿;用户明确重新加载后才采用新快照。配置请求中名为 `code`、`message` 或 `data` 的字段仍是配置,不按普通 API envelope 解包。 - -全局管理界面可声明 `all_accounts: true`,同时指定管理员角色与所需能力;只有平台/账号类型均为通配、灰度为100%的绑定才提供该入口。对应资源也声明完整范围,描述、提交与执行使用相同条件。分页组件接受宿主提供的选项;具名 `table-page-size` 偏好沿用原有非敏感浏览器分页设置,其他偏好继续按用户/插件隔离。 - -第一方贡献都显式声明 `capability`;带管理员动作的贡献还必须满足固有Admin权限。例如票据采集同时需要Credentials与Admin,计数器查看同时需要Observability与Admin。宿主的 `account_scope` 是当前有效绑定交集的只读投影,不是插件可提交的授权字段;界面和提交对整组选中账号使用同一作用范围与灰度。缺少任一账号资料时拒绝整批,不静默剔除目标。旧第三方清单缺能力字段的页面兼容规则保留,执行仍要验证原有管理权限。 - -数据资源通过 manifest 的 `resources` 显式声明名称、所需能力和用户角色。HTTP 方法与路径由宿主注册,前端桥接只接受名称,不能传任意 URL 或请求头。实际操作仍经过原接口的认证、审计、合规与二次验证链;资源守卫再次检查持久启停状态、包摘要及运行周期。兼容 API 调用同样不能绕过停用状态。 +```text +my-plugin/ +├── cmd//main.go +├── internal/pluginconfig/ +├── internal/transport/ +├── ui/index.html +├── ui/assets/ +├── tools/ +├── manifest.source.json +└── build.sh +``` -页面可以按资源可用性分别禁用具体动作,并在提交前重新查询;查询失败不得启用动作,界面结果也不能替代后端实时门禁。例如全局文件夹/标签删除需要全账号权限,但局部分类赋值不因此升级为全局操作。管理员配置页在插件停用或故障时仍可打开用于修复,它不授予业务执行权限。 +开发时至少准备以下部分: + +1. `manifest.source.json`:插件 ID、名称、版本、作者、能力和兼容的 Sub2API 版本; +2. `cmd//main.go`:启动入口和运行时版本注入,并同步打包器中的构建目标和二进制名称; +3. `internal/pluginconfig/`:配置结构、默认值、严格校验和规范化; +4. `internal/transport/`:HTTP 客户端、代理、请求头、请求体、网络连接参数、响应流和资源回收; +5. `ui/index.html` 与 `ui/assets/`:插件自己的配置界面; +6. 单元测试、进程集成测试和目标平台构建配置。 + +入口文件应保持很小,只负责调用 `pluginv1.Serve`。实际逻辑放在可独立测试的包中,避免把配置解析、网络请求和协议组装全部写在 `main.go`。 + +## 3. 编写运行时 + +运行时实现 `TransportPlugin` 服务,必须满足以下约定: + +| 方法 | 要求 | +| --- | --- | +| `GetInfo` | 返回的插件 ID、版本、协议版本、传输 API 版本和能力必须与清单一致。 | +| `Health` | 快速返回进程是否可以接收新请求,不执行长时间网络探测。 | +| `ValidateConfig` | 严格解析 JSON,拒绝未知字段和非法范围,并返回完整的规范化配置。 | +| `ApplyConfig` | 成功后原子切换配置;失败时保留旧配置和旧连接。 | +| `TestConfig` | 针对已保存配置进行快速诊断,返回简短、可展示的结果。 | +| `Forward` | 按协议接收请求流,发出上游请求,再按顺序返回响应流。 | + +请求帧顺序为 `start`、零到多个 `body_chunk`、`body_end`;响应帧顺序为 `start`、零到多个 `body_chunk`、`end`。不能继续处理时发送 `error` 帧。 + +`ForwardResponseError.request_sent` 必须准确:只有在能够确认尚未调用上游 HTTP Transport 时才返回 `false`;一旦已经调用,或无法确认上游是否收到请求,就返回 `true`。宿主会据此决定是否允许切换账号重试,避免重复执行同一个请求。 + +资源管理也属于运行时契约:复用 HTTP Transport 和连接池,配置切换时关闭旧空闲连接,沿用 gRPC stream 的 context 取消 DNS、连接、上传和响应读取,并始终关闭上游响应体。日志和错误消息不能包含 Token、代理凭据、完整请求体或敏感响应头。 + +## 4. 设计插件配置 + +插件配置由插件定义,由 Sub2API 加密保存。推荐流程是: + +1. 在 `internal/pluginconfig.Config` 中定义字段和默认值; +2. 使用 `json.Decoder.DisallowUnknownFields` 等严格方式解析; +3. 将空对象规范化为完整默认配置; +4. 在 `ValidateConfig` 和 `ApplyConfig` 中复用同一套校验; +5. 配置应用成功后再让宿主保存,保存失败时允许恢复旧配置。 + +JSON 字段统一使用 `snake_case`。敏感配置不要放入 URL、UI 通知、诊断结果或日志。插件不应从 UI 读取、刷新或持久化 OAuth Token;宿主只在运行时调用需要的网络转发接口。 + +## 5. 实现插件自己的配置 UI + +UI 是插件包内的静态页面,不需要修改 Sub2API 前端源码。宿主会在受限 iframe 中加载 `ui/index.html`,并通过 UI Bridge 提供配置读写和测试能力。 + +页面初始化流程: + +1. 加载包内 HTML、CSS 和 JavaScript; +2. 创建 Bridge 并注册 `message` 监听; +3. 发送 `sub2api.plugin.ready`; +4. 调用 `config.load` 渲染表单; +5. 编辑后调用 `config.save`; +6. 测试前先保存,再调用 `config.test`; +7. 页面卸载时调用 `dispose()`。 + +当前 Bridge 支持: + +| 消息 | 用途 | +| --- | --- | +| `config.load` | 读取当前配置。 | +| `config.save` | 提交配置,由运行时校验、应用并加密保存。 | +| `config.test` | 运行已保存配置的诊断。 | +| `ui.resize` | 调整配置 iframe 高度。 | +| `ui.notify` | 显示成功、错误或提示消息。 | + +每条消息都必须带 `request_id`,并校验 `event.source`、消息来源标识和 Bridge Token。不要依赖 CDN、远程脚本、Cookie 或本地存储。页面需要兼容窄屏和明暗主题,并正确处理加载、保存、测试、超时和未保存状态。 + +详细信封格式见 `backend/pkg/pluginapi/docs/ui-bridge.md`。如果后续示例仓库提供可复用的 Bridge SDK,本文会在示例仓库章节补充对应路径和使用方式。 + +## 6. 编写包清单 + +只维护 `manifest.source.json`,不要手工编辑构建目录中的 `manifest.json`。至少需要填写: + +```json +{ + "schema_version": 1, + "id": "example.openai.transport", + "name": "Example OpenAI Transport", + "version": "0.1.0", + "requires": { + "sub2api": ">=0.1.179 <0.2.0", + "recommended_sub2api_version": "0.1.179", + "tested_sub2api_versions": ["0.1.179"], + "plugin_protocol": 1, + "transport_api": 1, + "ui_bridge": 1 + }, + "capabilities": [ + { + "id": "openai.oauth.outbound_transport.v1", + "platform": "openai", + "account_type": "oauth" + } + ], + "runtimes": {}, + "ui": { "entrypoint": "ui/index.html" }, + "files": {} +} +``` -账号资源还核验目标账号及灰度范围,筛选批量操作要求覆盖完整账号范围;没有资格的目标不会被静默过滤。事务使用原接口校验,界面上下文传入领域调用时绑定已验证的插件所有者。分类保存面向插件仅返回账号 ID,不返回完整账号或凭据。创建任务使用 `operation_key` 映射到既有幂等键,任务记录和取消仍由宿主负责。 +打包器会自动填充目标平台运行时、UI 和运行时文件的 SHA-256。清单中的 `requires.sub2api` 是硬兼容范围;`tested_sub2api_versions` 应只填写真实验证过的版本;`recommended_sub2api_version` 用于管理页面展示。当前宿主仅处理 `openai.oauth.outbound_transport.v1`,声明其他能力不会自动产生新路由。后续增加 Provider 支持时,会在协议、能力清单和宿主路由完成适配后,再补充对应的清单示例。 -内联界面用 `ExtensionWidget`,操作弹窗用 `ExtensionModal`;原入口可保留轻量参数适配。SDK 对 JSON 参数去除 Vue 响应式代理,保留原生上传文件;取消或关闭页面会终止仍在执行的资源读取。通用字段支持模型选项和受限文本区域,标签、提示与字符上限由插件声明。 +## 7. 生成密钥并签名 -只读表格使用 `display_fields` 声明文本、徽标或日期字段;宿主仅渲染挂载处明确给出的标量,不执行表达式或遍历账号对象。插件上传解析可以使用包内内联 Blob Worker,仍无直接网络权限。浏览器本地媒体使用具名 `LOCAL` 资源与 `local_data`,Blob 保留原生类型,实际所有者由宿主登录用户决定;历史与取消等 `Retained` 资源只由宿主登记,插件声明不能自行取得这一特权。 +生产包应始终签名,宿主默认拒绝未签名包。可以使用插件工程中的密钥生成工具生成一对 Ed25519 密钥;示例仓库发布后会提供标准工具和完整命令: -页面会话按贡献选择签名入口,并限定管理员或普通用户角色;普通用户不能选择管理页面。模块脚本使用隔离来源所需的 CORS 资源响应,iframe 使用 `sandbox="allow-scripts allow-forms"` 以支持表单事件和原生校验,保持同源隔离;CSP 的 `form-action 'none'` 与 `connect-src 'none'` 阻止直接网络提交。主题、语言及可用状态通过桥接更新,故障时保留输入并禁用动作,升级后的旧页面需重新打开。 +```bash +go run ./tools/keygen -out build/keys/my-publisher +``` -普通用户通过 `/api/v1/settings/plugins` 获得公开表面元数据,不获得管理员动作、iframe 入口或配置字段。公开主题声明 `slot: "theme"`、`permission: "public"`、CSS `entrypoint` 和最多 32 个 WOFF2 `assets`。宿主仅公开这些已声明且哈希匹配的样式与字体,不公开脚本或其他包文件。资源 URL 绑定资源清单摘要;停用或故障后移除样式,宿主基本样式及可访问性规则继续工作。字体许可证随包分发。 +生成的 `my-publisher.private` 只保存在受控的开发机或 CI Secret 中,不能提交到源码仓库、插件包或部署服务器。公钥是 Base64 文本,可以提供给部署者。 -Canvas图表通过宿主通用颜色读取器使用 `--theme-chart-*` 参数,主题加载、撤下及深浅色变化会更新已挂载的图表。数据和统计逻辑不属于主题。技能来源使用已注册的网络与公开根,profile不能自行授予新URL;存储计划只选择受支持布局和槽位,候选键须是内容摘要,宿主执行固定根内的完整性检查和原子发布。 +插件工程的 `build.sh` 应调用标准打包器。自定义发布者密钥时必须同时提供 `-signing-key` 和 `-key-id`: -## 构建与签名 +```bash +./build.sh \ + -signing-key /安全目录/my-publisher.private \ + -key-id my-publisher-v1 \ + -output dist/my-openai-plugin.s2plugin +``` -只编辑 `manifest.source.json`。打包器生成运行时路径、文件 SHA-256、已测试宿主版本和签名。签名后不再改写 manifest;私钥不进入源码、镜像或插件包。 +签名覆盖最终 `manifest.json` 的精确字节;清单中的文件哈希再覆盖运行时和 UI 文件。签名完成后不要重新格式化 `manifest.json`。 -以下 PowerShell 示例从仓库根目录构建现有模型目录模块,生成的密钥仅供本地开发验证: +部署者在 Sub2API 配置文件中追加公钥: -```powershell -$pluginBuild = Join-Path ([IO.Path]::GetTempPath()) ('sub2api-plugin-' + [guid]::NewGuid().ToString('N')) -New-Item -ItemType Directory -Path $pluginBuild | Out-Null -go -C plugins/model-policy build -trimpath -o (Join-Path $pluginBuild 'model-policy.exe') ./cmd/plugin -go -C backend run ./cmd/package-plugin -generate-key (Join-Path $pluginBuild 'publisher.key') -go -C backend run ./cmd/package-plugin ` - -source ../plugins/model-policy ` - -binary (Join-Path $pluginBuild 'model-policy.exe') ` - -platform windows-amd64 -tested-host-version 0.2.7 ` - -output (Join-Path $pluginBuild 'model-policy.s2plugin') ` - -signing-key-file (Join-Path $pluginBuild 'publisher.key') ` - -key-id local-model-policy-test +```yaml +plugins: + allow_unsigned: false + trusted_publishers: + my-publisher-v1: "BASE64_ED25519_PUBLIC_KEY" ``` -第三方开发包在 `plugins.trusted_publishers` 中配置自己的 `key_id` 和公钥。正式 Codexrip 公钥唯一保存在 `backend/pkg/extensionapi/v1/publisher.json`,宿主内置该信任身份,配置不能覆盖;私钥位于 GitHub Secret `SUB2API_PLUGIN_SIGNING_KEY`。`go run .github/scripts/initialize_plugin_publisher.go` 只检查当前配置,显式 `-initialize` 也拒绝替换已经存在的密钥。 +`trusted_publishers` 是在宿主内置官方公钥之外追加的信任来源,不能覆盖内置公钥。`signature.json` 中的 `key_id` 必须与配置键完全一致。密钥轮换时先发布包含新公钥的宿主配置或版本,再发布新签名包,最后再停用旧密钥。 -集合打包使用 `-bundle-source`、`-binary-dir` 和新的空输出目录;`-plugin-domain` 只选择清单中的一个域。`-build-binaries` 从各 manifest 读取独立版本,并注入程序版本;`-verify-bundle` 复用生产签名、文件和目标平台校验,不执行程序。`-source-revision` 把完整源码提交写入签名清单。构建程序时不传签名密钥,签名步骤只运行已构建的打包器。 +开发阶段如需使用未签名包,只应在隔离的本地环境临时设置 `plugins.allow_unsigned: true`,测试完成后立即恢复为 `false`。 -Downstream Release 先生成 `deploy/plugin-bundle/current`,正式 Docker 阶段校验后原样复制这些包;同一份包与锁清单作为 Release 附件发布。默认候选镜像使用合成开发签名,正式 Codexrip 宿主拒绝开发发布者。开发目录中的旧二进制不用于发布。 +## 8. 构建、测试和安装 -Independent Plugin Release 接受不可变 `plugins/<域名>/v<版本>` 标签和已发布宿主标签,只构建所选域。它要求已通过主分支检查的宿主实现与所选宿主 Release 相同(测试源码除外),才能复用其验证结果;需要新宿主接口时先发布宿主。发布不覆盖同名 Release,安装也拒绝同版本不同包。 +在插件目录执行: -## 更新单个插件 +```bash +go test ./... -count=1 +node --check ui/assets/bridge-v1.js +node --check ui/assets/app.js +./build.sh +unzip -t dist/*.s2plugin +``` -普通上传只用于首次安装。已有相同插件身份时,无论是否停用,都返回 `409 / PLUGIN_ALREADY_INSTALLED`;不能通过旧上传覆盖包、重置灰度或绕过下列更新契约。明确卸载后的重新安装保留既有迁移/诊断账本,不等于升级回滚。 +回到 Sub2API 仓库根目录后,再使用真实构建包运行宿主集成测试: -插件管理页的“更新插件包”保持配置、逐能力启停和灰度,接受同 ID 的更高版本签名包。先验证签名、版本、依赖和保存的配置,再提交持久更新记录;候选校验不获得宿主存储、凭据或任务权限。相同包重复提交不重复执行,同版本不同内容拒绝。 +```bash +cd ../.. +SUB2API_TEST_PLUGIN_PACKAGE=plugins/my-openai-plugin/dist/my-openai-plugin.s2plugin \ + go test ./backend/internal/service -run '^TestPluginRuntimeIntegration$' -count=1 +``` -| 接口 | 请求 | 结果 | -|---|---|---| -| `POST /api/v1/admin/plugins/:id/update` | multipart:`plugin`、`expected_revision`、`expected_package_sha256` | 返回安装记录;`updating` 表示等待旧进程排空 | -| `POST /api/v1/admin/plugins/:id/follow-bundled` | JSON:`expected_revision`、`expected_package_sha256` | 明确恢复跟随当前镜像内置包 | +最低测试集应覆盖配置默认值和边界值、插件身份、请求和响应分块、流式响应、上下文取消、插件退出、代理开关、包哈希、签名、路径安全、目标平台运行时以及 UI Bridge 的加载、保存、测试、错误和超时。 -两个接口沿用管理员授权和现有二次认证。安装记录增加 `revision`、`package_sha256` 和 `update_policy`。独立更新设置为 `pinned`,宿主升级保留该版本;`bundled` 才跟随内置包。恢复跟随可能选择较旧的内置版本,但不会回滚账号或业务数据。 +安装后先保持停用,确认清单兼容性、签名和诊断结果,再按账号灰度启用。API Key 账号和未命中灰度的 OAuth 账号继续走 Sub2API 原有路径。 -每个运行进程和绑定的宿主操作持有独立 PostgreSQL 会话锁,不占用业务查询池。正常切换先排空旧进程、取消并收尾绑定的宿主 IO,再提交新的执行代次;数据库会话异常失联的限制见下段。上游 IO 可以脱离客户端断线,但仍保留插件取消信号,已经产生的计费则独立提交。更新中的版本与意图保存在数据库,重启继续处理;期间仍可明确停用,新能力默认关闭。旧代次的状态写入、任务创建和租约领取被拒绝。更新失败不切回旧宿主业务,候选校验失败不改变当前版本。 +## 9. 发布前检查清单 -专用会话现在由宿主观察器检测失联(1秒探测间隔、1秒探测超时),首次失败即排空/取消并关闭原连接,不自动重连。正常释放不报告故障,取消不提前代替宿主任务清理。数据库可能先释放失联会话的锁、再由本机检测到;这不是跨实例原子撤销,不能撤回已被上游接受的IO。持久任务租约、调用前已花费阶段和事务幂等仍是防重复的独立保障。依赖图事务准入与这种在途 IO 的撤销是两项不同保证,不能互相替代。 +- 插件版本与 `GetInfo` 返回值一致; +- `requires.sub2api` 覆盖范围经过验证,没有未经测试的破坏性版本; +- `tested_sub2api_versions` 与实际测试记录一致; +- 每个支持的平台和架构都有运行时文件; +- 生产包存在有效 `signature.json`,公钥已交付部署者; +- 包中没有私钥、源映射、测试数据、日志和临时文件; +- UI 不依赖外部资源,也不保存宿主会话信息; +- 配置切换、请求取消、响应关闭和错误重试语义经过测试; +- 发布说明包含升级、停用、回滚和兼容版本信息。 -配置保存、启用、停用、测试及删除的 HTTP 请求必须同时携带 `X-Sub2API-Plugin-Revision` 和 `X-Sub2API-Plugin-Package`;动作和新任务提交必须携带包摘要。缺失、非法及过期条件分别返回 428、400、409。读取配置从同一持久快照返回 raw JSON 和上述响应头,并禁止缓存;保存回执使用条件写入的 `RETURNING revision`,不通过后续读取借用另一位管理员的新版本。相同配置仍做 CAS,但不增加修订、不重复应用或取消工作。写入 CAS 冲突不改变现有工作;实际配置变更沿用原有取消和应用机制。应用前后若有另一次并发配置变更,旧进程在当前配置/代次门禁下不可执行,等待协调更新,不声称跨宿主 RPC 与数据库写入原子化。 +## 10. 常见问题 -插件资源会话绑定整包摘要,升级后的旧会话返回 410,重新打开界面取得当前版本资源;旧页面的动作/新任务还受上述包版本检查。历史、已完成结果和取消入口不因旧页面版本而被一并封锁。 +| 现象 | 排查方向 | +| --- | --- | +| 安装提示签名不受信任 | 检查 `signature.json.key_id`、Base64 公钥和配置键是否完全一致。 | +| 插件显示不兼容 | 检查 `requires.sub2api`、`plugin_protocol`、`transport_api` 和 `ui_bridge`。 | +| 插件进程无法启动 | 检查目标系统和架构对应的运行时路径、可执行权限和运行用户权限。 | +| 配置页无法加载 | 检查 `ui.entrypoint`、UI 文件哈希、Bridge Token 校验和 iframe 消息来源。 | +| 保存后配置未生效 | 查看 `ValidateConfig`、`ApplyConfig` 返回的规范化配置和诊断信息。 | +| 请求失败后重复执行 | 检查 `ForwardResponseError.request_sent` 是否准确反映请求是否可能已发出。 | -## 按实际改动验证 +## 11. 需要扩展能力时 -独立模块验证自身策略;宿主验证范围、取消、事务和真实进程通信;签名包验证清单、哈希及资源边界。选择与改动风险对应的用例,复用相同代码的已有结果,保留正常 PR 必需检查。 +如果新插件需要支持其他 Provider、其他账号类型或新的消息字段,应先扩展并版本化公开协议,再由宿主增加能力匹配和生命周期处理。不要仅通过清单声明一个宿主尚未实现的能力。这样可以让旧插件继续运行,也能让新宿主明确拒绝不兼容的插件。 -本地模型目录进程契约可使用上面构建的程序: +Sub2API 后续会持续补充更多 Provider 的插件适配说明,包括能力标识、请求和响应契约、配置字段、UI Bridge 使用方式、版本兼容要求以及测试清单。本文会随着这些能力的落地继续更新,Provider 专属章节会放在本节之后。 -```powershell -$env:SUB2API_MODEL_POLICY_TEST_BINARY = Join-Path $pluginBuild 'model-policy.exe' -go -C backend test ./internal/service -run '^TestCatalogExtensionRuntimeUsesIndependentProcess$' -count=1 -``` +## 12. 示例仓库预留 -这些契约使用合成数据,不需要真实模型请求。主题离线浏览器脚本为 `plugins/admin-observability/scripts/check-theme.py`,票据界面脚本为 `plugins/codex-runtime/scripts/check-ui.py`;已验证范围及未完成项见实施状态。 +后续计划提供独立的插件示例仓库,用于存放可复用的运行时骨架、UI 组件、打包工具和各 Provider 的最小实现。目前示例仓库尚未准备完成,因此暂不提供地址;正式发布后会在这里补充仓库地址、适用的 Sub2API 版本、示例插件版本和构建说明。 diff --git a/frontend/public/fonts/misans/NOTICE.md b/frontend/public/fonts/misans/NOTICE.md index 7596ff7ca8ce..d3f1d89f4925 100644 --- a/frontend/public/fonts/misans/NOTICE.md +++ b/frontend/public/fonts/misans/NOTICE.md @@ -4,7 +4,7 @@ Copyright Xiaomi. Distributed under the MiSans Font Intellectual Property Licens Original, unmodified WOFF2 files extracted from the official [MiSans download](https://hyperos.mi.com/font-download/MiSans.zip). No subsetting, conversion or glyph modifications were performed. -The official license is included as [MiSans-Font-Intellectual-Property-License.pdf](MiSans-Font-Intellectual-Property-License.pdf). Fonts are included in the signed admin-observability plugin and loaded with `font-display: swap` while its theme is enabled. +The official license is included as [MiSans-Font-Intellectual-Property-License.pdf](MiSans-Font-Intellectual-Property-License.pdf). The fonts live in `frontend/src/assets/fonts/misans/` and are loaded with `font-display: swap` while the flat theme is enabled. ## Original font checksums diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ffec86af6010..c7d37a8a5ec1 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -11,7 +11,6 @@ import { getSetupStatus } from '@/api/setup' import { updateFavicon } from '@/utils/branding' import { FeatureFlags, isFeatureFlagEnabled } from '@/utils/featureFlags' import { resolveSiteBillingMode } from '@/utils/siteBillingMode' -import { startPluginThemeRefresh } from '@/components/plugins/theme' const router = useRouter() const route = useRoute() @@ -23,7 +22,6 @@ const adminComplianceStore = useAdminComplianceStore() const adminSettingsStore = useAdminSettingsStore() const accountJobsStore = useAccountJobsStore() let accountJobsSessionKey: string | null = null -let stopPluginThemeRefresh: (() => void) | undefined watch( [ @@ -155,14 +153,12 @@ router.afterEach(() => { }) onBeforeUnmount(() => { - stopPluginThemeRefresh?.() accountJobsStore.clear() document.removeEventListener('visibilitychange', onVisibilityChange) window.removeEventListener('admin-compliance-required', onAdminComplianceRequired) }) onMounted(async () => { - stopPluginThemeRefresh = startPluginThemeRefresh() window.addEventListener('admin-compliance-required', onAdminComplianceRequired) // Check if setup is needed diff --git a/frontend/src/__tests__/fixtures/accountEdit.ts b/frontend/src/__tests__/fixtures/accountEdit.ts index 70a261da09e4..da600e11600f 100644 --- a/frontend/src/__tests__/fixtures/accountEdit.ts +++ b/frontend/src/__tests__/fixtures/accountEdit.ts @@ -1,15 +1,14 @@ import { createHash } from 'node:crypto' -import manifest from '../../../../plugins/cindy-provider/manifest.source.json' +import { cindyEditDefinition } from '@/features/cindy/accountForm' import type { Account, AccountEditFieldState, ProviderAccountEditContext } from '@/types' -import type { AccountEditContribution } from '@/composables/useAccountEditProfile' +import type { AccountEditDefinitionV1 } from '@/types/accountEdit' import { accountEditModes, captureAccountEditState, mappingInputFromStored, type AccountEditInput, type ReadyAccountEditCatalog } from '@/utils/accountEditCodec' import { resolveOpenAIWSModeFromExtra } from '@/utils/openaiWsMode' -export function editContribution(overrides: Partial = {}): AccountEditContribution { +interface NativeEditFixture { account_edit: AccountEditDefinitionV1; available: boolean } +export function editContribution(overrides: Partial = {}): NativeEditFixture { return { - ...JSON.parse(JSON.stringify(manifest.contributions.find(item => item.slot === 'account.edit.v1'))), - plugin_id: 7, plugin_key: 'codexrip.cindy-provider', package_sha256: 'a'.repeat(64), edit_definition_digest: 'b'.repeat(64), runtime_generation: 1, - available: true, account_scope: { version: 1, bindings: [{ platform: 'cindy', account_type: 'apikey', rollout_percent: 100 }] }, ...overrides + account_edit: structuredClone(cindyEditDefinition), available: true, ...overrides } } export function editAccount(overrides: Partial = {}): Account { @@ -50,8 +49,7 @@ export function editContext(account = editAccount(), contribution = editContribu } return { schema_version: 1, kind: 'provider', account_id: account.id, - profile: { plugin_id: contribution.plugin_id, plugin_key: contribution.plugin_key, contribution_id: contribution.id, - package_sha256: contribution.package_sha256, definition_sha256: contribution.edit_definition_digest, runtime_generation: contribution.runtime_generation, available: contribution.available }, + profile: { native: true, policy_sha256: 'b'.repeat(64), available: contribution.available }, edit_state_sha256: editFixtureStateDigest(account), values: { responses_mode: field(accountEditModes.responses_mode.key, input.responses_mode, accountEditModes.responses_mode.values), diff --git a/frontend/src/__tests__/fixtures/cindyAccount.ts b/frontend/src/__tests__/fixtures/cindyAccount.ts new file mode 100644 index 000000000000..ae612927105c --- /dev/null +++ b/frontend/src/__tests__/fixtures/cindyAccount.ts @@ -0,0 +1,10 @@ +import type { Account } from '@/types' + +export function cindyAccount(id = 1): Account { + return { + id, name: `Cindy ${id}`, platform: 'cindy', type: 'apikey', status: 'active', schedulable: false, + concurrency: 1, priority: 0, rate_multiplier: 1, credentials: {}, extra: { privacy_mode: 'private' }, groups: [], tags: [], + cindy_balance_insufficient: true, cindy_balance_probe_job_id: 321, cindy_balance_probe_outcome: 'healthy', + cindy_balance_probe_checked_at: '2032-08-16T00:02:00Z' + } as Account +} diff --git a/plugins/account-tools/ui/src/__tests__/data-import.spec.ts b/frontend/src/__tests__/integration/data-import.spec.ts similarity index 95% rename from plugins/account-tools/ui/src/__tests__/data-import.spec.ts rename to frontend/src/__tests__/integration/data-import.spec.ts index 6ee07ede230b..b746f8405085 100644 --- a/plugins/account-tools/ui/src/__tests__/data-import.spec.ts +++ b/frontend/src/__tests__/integration/data-import.spec.ts @@ -1,10 +1,11 @@ +vi.mock('@/components/admin/account-jobs/AccountOperationDialog.vue', () => ({ default: { props: ['job', 'show'], template: '
' } })) import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' -import ImportDataModal from '../ImportDataModal.vue' -import { readAccountImportFiles } from '../accountImportWorker' +import ImportDataModal from '@/components/admin/account/ImportDataModal.vue' +import { readAccountImportFiles } from '@/utils/accountImportWorker' import AccountImportSettingsEditor, { type AccountImportSettingsDraft, -} from '../AccountImportSettingsEditor.vue' +} from '@/components/admin/account/AccountImportSettingsEditor.vue' const { importData, previewImportData, showError, showSuccess, showWarning } = vi.hoisted(() => ({ importData: vi.fn(), @@ -14,10 +15,12 @@ const { importData, previewImportData, showError, showSuccess, showWarning } = v showWarning: vi.fn(), })) -vi.mock('../api', () => ({ adminAPI: { accounts: { importData, previewImportData } } })) -vi.mock('@sub2api/plugin-ui', async () => ({ ...await vi.importActual('@sub2api/plugin-ui'), useNotifications: () => ({ showError, showSuccess, showWarning, showInfo: vi.fn() }) })) -vi.mock('../accountImportWorker', async () => { - const { parseAccountImportFiles } = await vi.importActual('../accountImportParser') +vi.mock('@/api/admin', () => ({ adminAPI: { accounts: { importData, previewImportData } } })) +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ showError, showSuccess, showWarning, showInfo: vi.fn() }), +})) +vi.mock('@/utils/accountImportWorker', async () => { + const { parseAccountImportFiles } = await vi.importActual('@/utils/accountImportParser') return { readAccountImportFiles: vi.fn((files: File[]) => parseAccountImportFiles(files)) } }) vi.mock('vue-i18n', () => ({ diff --git a/frontend/src/api/__tests__/admin.accounts.editContext.spec.ts b/frontend/src/api/__tests__/admin.accounts.editContext.spec.ts index 5ae95f4806d3..60dd07b697e1 100644 --- a/frontend/src/api/__tests__/admin.accounts.editContext.spec.ts +++ b/frontend/src/api/__tests__/admin.accounts.editContext.spec.ts @@ -4,11 +4,9 @@ import type { CapturedAccountView } from '@/composables/useAccountViewContext' const { get, request } = vi.hoisted(() => ({ get: vi.fn(), request: vi.fn() })) vi.mock('@/api/client', () => ({ apiClient: { get, request } })) import { accountAPIForView, getEditContext } from '@/api/admin/accounts' -import { ACCOUNT_VIEW_HEADER, accountViewIdentityHeader } from '@/components/plugins/accountView' function view(assertCurrent = vi.fn()): CapturedAccountView { - return { actorID: 9, assertCurrent, context: { version: 1, plugin_id: 7, plugin_key: 'codexrip.cindy-provider', package_sha256: 'a'.repeat(64), - view_id: 'cindy-accounts', preset_id: 'cindy', view_definition_digest: 'b'.repeat(64), query: { cindy_only: true } } } + return { actorID: 9, assertCurrent } } describe('fixed account edit-context API', () => { beforeEach(() => { get.mockReset(); request.mockReset() }) @@ -21,13 +19,12 @@ describe('fixed account edit-context API', () => { expect(request).not.toHaveBeenCalled() }) - it('captures the origin view on the fixed read through the existing native facade', async () => { + it('retains the native operation read guard without plugin identity headers', async () => { const scope = view(), signal = new AbortController().signal request.mockResolvedValue({ data: { schema_version: 1, kind: 'core', account_id: 41 } }) await accountAPIForView(scope).getEditContext(41, signal) - const { query: _query, ...identity } = scope.context - expect(request).toHaveBeenCalledWith(expect.objectContaining({ url: '/admin/accounts/41/edit-context', method: 'GET', signal, - headers: { [ACCOUNT_VIEW_HEADER]: accountViewIdentityHeader(identity) } })) + expect(request).toHaveBeenCalledWith({ url: '/admin/accounts/41/edit-context', method: 'GET', signal }) + expect(scope.assertCurrent).toHaveBeenCalled() expect(get).not.toHaveBeenCalled() }) @@ -43,11 +40,11 @@ describe('fixed account edit-context API', () => { expect(get).not.toHaveBeenCalled() }) - it('keeps the existing view context on a basic-only native update', async () => { + it('keeps a basic-only native update free of a plugin view context', async () => { const scope = view() request.mockResolvedValue({ data: { id: 41, notes: 'basic edit' } }) await accountAPIForView(scope).update(41, { notes: 'basic edit' }) expect(request).toHaveBeenCalledWith(expect.objectContaining({ url: '/admin/accounts/41', method: 'PUT', - data: { notes: 'basic edit', view_context: scope.context } })) + data: { notes: 'basic edit' } })) }) }) diff --git a/frontend/src/api/admin/__tests__/accountJobs.spec.ts b/frontend/src/api/admin/__tests__/accountJobs.spec.ts index 5a8eeda5ca06..61072ff4ffb2 100644 --- a/frontend/src/api/admin/__tests__/accountJobs.spec.ts +++ b/frontend/src/api/admin/__tests__/accountJobs.spec.ts @@ -51,7 +51,7 @@ describe('accountJobsAPI', () => { post.mockResolvedValueOnce({ data: { items: [{ account_id: 7, models: [] }] } }) expect(await accountJobsAPI.batchTestModels([7], signal)).toEqual([{ account_id: 7, models: [] }]) await accountJobsAPI.batchTest([{ account_id: 7, model_id: 'raw-alias' }]) - expect(post).toHaveBeenNthCalledWith(1, '/admin/accounts/batch-test-models', { account_ids: [7] }, { signal }) + expect(post).toHaveBeenNthCalledWith(1, '/admin/accounts/batch-test-models', { account_ids: [7] }, { params: { view: 'account-test-plan-v1' }, signal }) expect(post).toHaveBeenNthCalledWith(2, '/admin/accounts/batch-test', { items: [{ account_id: 7, model_id: 'raw-alias' }], prompt: '' }, { headers: { 'Idempotency-Key': expect.stringMatching(/^account_batch_test-/) } }) }) diff --git a/frontend/src/api/admin/__tests__/plugins.precondition.spec.ts b/frontend/src/api/admin/__tests__/plugins.precondition.spec.ts deleted file mode 100644 index c1d96239d035..000000000000 --- a/frontend/src/api/admin/__tests__/plugins.precondition.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import plugins from '../plugins' - -const client = vi.hoisted(() => ({ get: vi.fn(), put: vi.fn(), post: vi.fn(), delete: vi.fn() })) -vi.mock('@/api/client', () => ({ apiClient: client })) -const digest = 'a'.repeat(64) -const version = { revision: 4, package_sha256: digest } -beforeEach(() => vi.clearAllMocks()) - -describe('plugin version wire contract', () => { - it('saves raw JSON with its read version and preserves a canonical header receipt', async () => { - const raw = { code: 17, enabled: true } - client.put.mockResolvedValue({ data: raw, headers: { 'x-sub2api-plugin-revision': '5', 'x-sub2api-plugin-package': digest } }) - expect(await plugins.saveConfig(7, raw, version)).toEqual({ config: raw, revision: 5, package_sha256: digest }) - expect(client.put).toHaveBeenCalledWith('/admin/plugins/7/config', raw, { rawPluginConfig: true, headers: { 'X-Sub2API-Plugin-Revision': '4', 'X-Sub2API-Plugin-Package': digest } }) - expect(client.get).not.toHaveBeenCalled() - }) - - it('rejects missing or unrelated response revision instead of inventing a newer receipt', async () => { - client.put.mockResolvedValue({ data: {}, headers: { 'x-sub2api-plugin-revision': '9', 'x-sub2api-plugin-package': digest } }) - await expect(plugins.saveConfig(7, {}, version)).rejects.toThrow('receipt') - client.get.mockResolvedValue({ data: {}, headers: {} }) - await expect(plugins.getConfig(7, digest)).rejects.toThrow('Reload') - }) - - it('sends lifecycle snapshot headers without a new read', async () => { - client.post.mockResolvedValue({ data: {} }) - client.delete.mockResolvedValue({}) - const plugin = { id: 7, ...version } - await plugins.enable(plugin, 37, false) - await plugins.disable(plugin) - await plugins.test(plugin) - await plugins.remove(plugin) - for (const call of client.post.mock.calls) expect(call[2]).toEqual({ headers: { 'X-Sub2API-Plugin-Revision': '4', 'X-Sub2API-Plugin-Package': digest } }) - expect(client.delete).toHaveBeenCalledWith('/admin/plugins/7', { headers: { 'X-Sub2API-Plugin-Revision': '4', 'X-Sub2API-Plugin-Package': digest } }) - expect(client.get).not.toHaveBeenCalled() - }) -}) diff --git a/frontend/src/api/admin/accountJobs.ts b/frontend/src/api/admin/accountJobs.ts index a1f37aba527a..f51acb9d63e2 100644 --- a/frontend/src/api/admin/accountJobs.ts +++ b/frontend/src/api/admin/accountJobs.ts @@ -1,8 +1,7 @@ import { apiClient } from '../client' import { accountViewClient } from './accountViewClient' import type { CapturedAccountView } from '@/composables/useAccountViewContext' -import { pluginDispatchClient, pluginDispatchHeaders, type PluginDispatchContext } from './pluginDispatch' -import type { AccountAvailableModel } from '@/types' +import type { AccountAvailableModel, AccountTestPlanView } from '@/types' const BASE_PATH = '/admin/account-jobs' @@ -125,10 +124,9 @@ async function list( return data } -async function get(jobID: number, options: { signal?: AbortSignal } = {}, dispatch?: PluginDispatchContext): Promise { - const { data } = await pluginDispatchClient(dispatch).get(`${BASE_PATH}/${jobID}`, { +async function get(jobID: number, options: { signal?: AbortSignal } = {}): Promise { + const { data } = await apiClient.get(`${BASE_PATH}/${jobID}`, { signal: options.signal, - ...(dispatch ? { headers: pluginDispatchHeaders(dispatch) } : {}), }) return data } @@ -186,6 +184,8 @@ export interface BatchTestModelRow { is_cindy: boolean models: AccountAvailableModel[] error_code?: string + // Present when the account-test-plan-v1 view is requested. + test_plan?: AccountTestPlanView } const accountJobsAPI = { @@ -196,7 +196,7 @@ const accountJobsAPI = { }, async batchTestModels(accountIDs: number[], signal?: AbortSignal): Promise { const { data } = await apiClient.post<{ items: BatchTestModelRow[] }>('/admin/accounts/batch-test-models', - { account_ids: accountIDs }, { signal }) + { account_ids: accountIDs }, { params: { view: 'account-test-plan-v1' }, signal }) return data.items }, async resultAccountIDs(jobID: number): Promise { diff --git a/frontend/src/api/admin/accountViewClient.ts b/frontend/src/api/admin/accountViewClient.ts index 7c9d80651324..9316c6f4a08b 100644 --- a/frontend/src/api/admin/accountViewClient.ts +++ b/frontend/src/api/admin/accountViewClient.ts @@ -1,7 +1,6 @@ import type { AxiosRequestConfig, AxiosResponse } from 'axios' import { apiClient } from '../client' import type { CapturedAccountView } from '@/composables/useAccountViewContext' -import { ACCOUNT_VIEW_HEADER, accountViewIdentityHeader, cloneViewContext } from '@/components/plugins/accountView' export interface AccountViewRequestOptions { view?: CapturedAccountView; signal?: AbortSignal; etag?: string | null } export interface AccountHTTPClient { @@ -12,26 +11,18 @@ export interface AccountHTTPClient { delete(url: string, config?: AxiosRequestConfig): Promise> } +/** Streaming callers share the same native read guard without adding view metadata. */ export function accountViewRequestConfig(scope: CapturedAccountView, config: AxiosRequestConfig): AxiosRequestConfig { scope.assertCurrent() - const { query: _query, ...identity } = scope.context - const method = String(config.method || 'GET').toUpperCase() - let data = config.data - if (!['GET', 'HEAD'].includes(method)) { - if (data instanceof FormData || (data !== undefined && data !== null && (typeof data !== 'object' || Array.isArray(data)))) { - throw new Error('ACCOUNT_VIEW_INVALID_REQUEST') - } - // The captured host context is authoritative, never an iframe/body override. - data = { ...(data || {}), view_context: cloneViewContext(scope.context) } - } - return { ...config, data, headers: { ...config.headers, [ACCOUNT_VIEW_HEADER]: accountViewIdentityHeader(identity) } } + return config } /** Per-call client only: no route/global Axios interceptor or mutable authority. */ export function accountViewClient(scope?: CapturedAccountView): AccountHTTPClient { if (!scope) return apiClient const request = async (config: AxiosRequestConfig): Promise> => { - const response = await apiClient.request(accountViewRequestConfig(scope, config)) + scope.assertCurrent() + const response = await apiClient.request(config) scope.assertCurrent() return response } diff --git a/frontend/src/api/admin/cindyProvider.ts b/frontend/src/api/admin/cindyProvider.ts new file mode 100644 index 000000000000..be11165bc2e8 --- /dev/null +++ b/frontend/src/api/admin/cindyProvider.ts @@ -0,0 +1,30 @@ +import { apiClient } from '../client' + +export interface CindyProviderSettings { + balance_detection: boolean + catalog_enabled: boolean + search_enabled: boolean +} + +export interface CindyProviderCatalogEntry { + public_id?: string + id?: string + model_id?: string + live_upstream_id?: string + upstream_model?: string + endpoints?: string[] +} + +export async function getCindyProviderSettings(signal?: AbortSignal): Promise { + return (await apiClient.get('/admin/settings/cindy-provider', { signal })).data +} + +export async function updateCindyProviderSettings(settings: CindyProviderSettings): Promise { + return (await apiClient.put('/admin/settings/cindy-provider', settings)).data +} + +export async function getCindyProviderCatalog(signal?: AbortSignal): Promise { + const { data } = await apiClient.get('/admin/settings/cindy-provider/catalog', { signal }) + if (!Array.isArray(data)) throw new Error('Invalid Cindy catalog response') + return data +} diff --git a/frontend/src/api/admin/codexRuntime.ts b/frontend/src/api/admin/codexRuntime.ts new file mode 100644 index 000000000000..159d0a333251 --- /dev/null +++ b/frontend/src/api/admin/codexRuntime.ts @@ -0,0 +1,20 @@ +import { apiClient } from '../client' + +export interface CodexRuntimeConfig extends Record { + enabled?: boolean + request_zstd?: boolean + proxy_url?: string + proxy_protocol?: string +} + +export const codexRuntimeAPI = { + async getConfig(): Promise { + return (await apiClient.get('/admin/settings/codex-runtime', { rawPluginConfig: true })).data + }, + async saveConfig(config: CodexRuntimeConfig): Promise { + return (await apiClient.put('/admin/settings/codex-runtime', config, { rawPluginConfig: true })).data + }, + async diagnostics(errorId: number, signal?: AbortSignal): Promise { + return (await apiClient.get(`/admin/ops/errors/${errorId}/diagnostics`, { signal })).data + } +} diff --git a/frontend/src/api/admin/codexTickets.ts b/frontend/src/api/admin/codexTickets.ts index 5443188f28cd..8ca38b68f09e 100644 --- a/frontend/src/api/admin/codexTickets.ts +++ b/frontend/src/api/admin/codexTickets.ts @@ -23,7 +23,12 @@ export const codexTicketsAPI = { async stop(id: number, models: string[]) { await apiClient.post(`/admin/accounts/${id}/codex-tickets/stop`, { models }) }, - async testProxy(proxy_url: string) { - return (await apiClient.post('/admin/settings/openai-codex-ticket/proxy-test', { proxy_url }, { timeout: 30000 })).data + async stopJob(ids: number[], models: string[], key = accountJobIdempotencyHeaders('codex_ticket_stop')) { + const path = ids.length === 1 ? `/admin/accounts/${ids[0]}/codex-tickets/stop-job` : '/admin/accounts/codex-tickets/batch-stop' + return (await apiClient.post(path, { account_ids: ids, models }, key)).data + }, + async testProxy(proxy_url: string, protocol?: string) { + const body = protocol ? { proxy_url, protocol } : { proxy_url } + return (await apiClient.post('/admin/settings/openai-codex-ticket/proxy-test', body, { timeout: 30000 })).data } } diff --git a/frontend/src/api/admin/pluginDispatch.ts b/frontend/src/api/admin/pluginDispatch.ts deleted file mode 100644 index 0dcac0369e1c..000000000000 --- a/frontend/src/api/admin/pluginDispatch.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { CapturedAccountView } from '@/composables/useAccountViewContext' -import { accountViewClient } from './accountViewClient' - -export interface PluginDispatchContext { - view?: CapturedAccountView - requestBinding?: string - retained?: boolean - pluginID?: number - packageSHA?: string -} -export function pluginDispatchHeaders(context?: PluginDispatchContext): Record { - return { - ...(context?.requestBinding ? { 'X-Sub2API-Plugin-UI-Session': context.requestBinding } : {}), - ...(context?.pluginID ? { 'X-Sub2API-Plugin': String(context.pluginID) } : {}), - ...(context?.packageSHA ? { 'X-Sub2API-Plugin-Package': context.packageSHA } : {}) - } -} -export function pluginDispatchClient(context?: PluginDispatchContext) { - const view = context?.view - return accountViewClient(view && context?.retained ? { ...view, assertCurrent: () => (view.assertRetained || view.assertCurrent)() } : view) -} diff --git a/frontend/src/api/admin/plugins.ts b/frontend/src/api/admin/plugins.ts index 915dec02431e..1bdd7a0e83a3 100644 --- a/frontend/src/api/admin/plugins.ts +++ b/frontend/src/api/admin/plugins.ts @@ -1,41 +1,9 @@ import { apiClient } from '../client' -import { accountJobIdempotencyHeaders, type AccountJob } from './accountJobs' -import type { PluginResourceDescriptor } from '@/components/plugins/resourceClient' -import type { AccountResourceActionV1, AccountViewDefinitionV1, AccountViewIdentityV1 } from '@sub2api/plugin-ui/account-view' -import type { AccountCreateDefinitionV1 } from '@sub2api/plugin-ui/account-create' -import type { AccountEditDefinitionV1 } from '@sub2api/plugin-ui/account-edit' -import type { CapturedAccountView } from '@/composables/useAccountViewContext' -import { accountViewClient } from './accountViewClient' -import { pluginDispatchClient, pluginDispatchHeaders, type PluginDispatchContext } from './pluginDispatch' -export { pluginDispatchClient, pluginDispatchHeaders, type PluginDispatchContext } from './pluginDispatch' declare module 'axios' { interface AxiosRequestConfig { rawPluginConfig?: boolean } } -export interface PluginVersionSnapshot { revision: number; package_sha256: string } -export interface PluginConfigSnapshot extends PluginVersionSnapshot { config: Record } -const revisionHeader = 'X-Sub2API-Plugin-Revision' -const packageHeader = 'X-Sub2API-Plugin-Package' - -function packageHeaders(digest: string) { - if (!/^[a-f0-9]{64}$/.test(digest)) throw new Error('Reload the plugin view before submitting this operation') - return { [packageHeader]: digest } -} - -function versionHeaders(snapshot: PluginVersionSnapshot) { - if (!Number.isSafeInteger(snapshot.revision) || snapshot.revision <= 0) throw new Error('Reload the plugin configuration before submitting this operation') - return { ...packageHeaders(snapshot.package_sha256), [revisionHeader]: String(snapshot.revision) } -} - -function configSnapshot(data: Record, headers: Record): PluginConfigSnapshot { - const revision = Number(headers[revisionHeader.toLowerCase()]) - const digest = String(headers[packageHeader.toLowerCase()] || '') - versionHeaders({ revision, package_sha256: digest }) - if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('Invalid plugin configuration response') - return { config: data, revision, package_sha256: digest } -} - export interface PluginCapability { id: string platform: string @@ -43,7 +11,6 @@ export interface PluginCapability { } export interface PluginRequirements { - extension_api?: number sub2api: string recommended_sub2api_version?: string tested_sub2api_versions?: string[] @@ -53,9 +20,6 @@ export interface PluginRequirements { } export interface PluginManifest { - source_revision?: string - dependencies?: Array<{ capability: string; platform?: string; account_type?: string }> - contributions?: Array> schema_version: number id: string name: string @@ -91,10 +55,6 @@ export interface PluginBinding { } export interface PluginInstallation { - revision: number - package_sha256: string - update_policy: 'bundled' | 'pinned' - desired_enabled?: boolean id: number plugin_key: string name: string @@ -104,7 +64,7 @@ export interface PluginInstallation { manifest: PluginManifest binary_sha256: string signature_status: 'trusted' | 'unsigned' - state: 'disabled' | 'starting' | 'enabled' | 'error' | 'incompatible' | 'updating' + state: 'disabled' | 'starting' | 'enabled' | 'error' | 'incompatible' last_error: string installed_at: string enabled_at?: string @@ -115,84 +75,6 @@ export interface PluginInstallation { runtime_message: string } -export interface PluginContribution { - plugin_key?: string - account_edit?: AccountEditDefinitionV1 - edit_definition_digest?: string - account_create?: AccountCreateDefinitionV1 - create_definition_digest?: string - runtime_generation?: number - view_definition_digest?: string - account_view?: AccountViewDefinitionV1 - value_bindings?: Record - resource_action?: AccountResourceActionV1 - all_accounts?: boolean - retained_controls?: boolean - events?: string[] - package_sha256?: string - capability?: string - stylesheet_url?: string - config_flag?: string - fields?: PluginFormField[] - display_fields?: PluginDisplayField[] - account_filter?: { platforms?: string[]; types?: string[]; statuses?: string[]; exclude_shadows?: boolean } - account_scope?: { version: 1; bindings: Array<{ platform: string; account_type: string; rollout_percent: number }> } - id: string - slot: string - label: Record - permission: string - action?: string - entrypoint?: string - order?: number - plugin_id: number - available: boolean - reason?: string -} - -export interface PluginDisplayField { - key: string - kind: 'text' | 'badge' | 'datetime' - prefix?: string - new_line?: boolean - values?: Record; tone?: 'neutral' | 'success' | 'warning' | 'danger' | 'info' }> -} - -export interface PluginFormField { - key: string - kind: 'select' | 'textarea' | 'text' - label: Record - options_source?: string - default_label?: Record - default_source?: string - placeholder?: string - rows?: number - max_length?: number - hint?: Record - reset_label?: Record - limit_message?: Record -} - -export async function contributions(): Promise { - const { data } = await apiClient.get('/admin/plugins/contributions') - return data || [] -} - -export async function publicContributions(): Promise { - const { data } = await apiClient.get('/settings/plugins', { timeout: 5000 }) - return data || [] -} - -export async function invokeAdmin(id: number, operation: string, accountID: number | undefined, payload: Record, expectedPackage: string, dispatch?: PluginDispatchContext): Promise<{ payload?: unknown; code?: string; message?: string }> { - const { data } = await pluginDispatchClient(dispatch).post<{ payload?: unknown; code?: string; message?: string }>(`/admin/plugins/${id}/actions`, { operation, account_id: accountID, payload }, { timeout: 30000, headers: { ...packageHeaders(expectedPackage), ...pluginDispatchHeaders(dispatch) } }) - return data -} - -export async function submitJob(id: number, operation: string, items: Array<{ account_id: number; payload: Record; label?: string }>, expectedPackage: string, key?: string, dispatch?: PluginDispatchContext): Promise { - const config = key ? { headers: { 'Idempotency-Key': key } } : accountJobIdempotencyHeaders('plugin_operation') - const { data } = await pluginDispatchClient(dispatch).post(`/admin/plugins/${id}/jobs`, { operation, items }, { ...config, headers: { ...config.headers, ...packageHeaders(expectedPackage), ...pluginDispatchHeaders(dispatch) } }) - return data -} - export interface PluginTestResult { success: boolean message: string @@ -207,11 +89,6 @@ export interface PluginStatusResult { } export interface PluginUISession { - view_context?: AccountViewIdentityV1 - request_binding?: string - plugin_key?: string - package_sha256?: string - permission?: 'admin' | 'user' url: string bridge_token: string ui_bridge_version: number @@ -233,71 +110,42 @@ export async function upload(file: File): Promise { return data } -export async function update(plugin: PluginInstallation, file: File): Promise { - const form = new FormData() - form.append('plugin', file) - form.append('expected_revision', String(plugin.revision)) - form.append('expected_package_sha256', plugin.package_sha256) - const { data } = await apiClient.post(`/admin/plugins/${plugin.id}/update`, form, { - headers: { 'Content-Type': 'multipart/form-data' }, timeout: 120000 - }) - changed() - return data -} - -export async function followBundled(plugin: PluginInstallation): Promise { - const { data } = await apiClient.post(`/admin/plugins/${plugin.id}/follow-bundled`, { - expected_revision: plugin.revision, expected_package_sha256: plugin.package_sha256 - }, { timeout: 120000 }) - changed() - return data -} - export async function enable( - plugin: PluginVersionSnapshot & { id: number }, + id: number, rolloutPercent: number, acceptUntested: boolean ): Promise { - const { data } = await apiClient.post(`/admin/plugins/${plugin.id}/enable`, { + const { data } = await apiClient.post(`/admin/plugins/${id}/enable`, { rollout_percent: rolloutPercent, accept_untested: acceptUntested - }, { headers: versionHeaders(plugin) }) - changed() + }) return data } -export async function disable(plugin: PluginVersionSnapshot & { id: number }): Promise { - const { data } = await apiClient.post(`/admin/plugins/${plugin.id}/disable`, undefined, { headers: versionHeaders(plugin) }) - changed() +export async function disable(id: number): Promise { + const { data } = await apiClient.post(`/admin/plugins/${id}/disable`) return data } -export async function remove(plugin: PluginVersionSnapshot & { id: number }): Promise { - await apiClient.delete(`/admin/plugins/${plugin.id}`, { headers: versionHeaders(plugin) }) - changed() +export async function remove(id: number): Promise { + await apiClient.delete(`/admin/plugins/${id}`) } -export async function getConfig(id: number, expectedPackage: string): Promise { - const { data, headers } = await apiClient.get>(`/admin/plugins/${id}/config`, { rawPluginConfig: true, headers: packageHeaders(expectedPackage) }) - const snapshot = configSnapshot(data, headers) - if (snapshot.package_sha256 !== expectedPackage) throw new Error('Plugin package changed; reload the view') - return snapshot +export async function getConfig(id: number): Promise> { + const { data } = await apiClient.get>(`/admin/plugins/${id}/config`, { rawPluginConfig: true }) + return data } export async function saveConfig( id: number, - config: Record, - expected: PluginVersionSnapshot -): Promise { - const { data, headers } = await apiClient.put>(`/admin/plugins/${id}/config`, config, { rawPluginConfig: true, headers: versionHeaders(expected) }) - const snapshot = configSnapshot(data, headers) - if (snapshot.package_sha256 !== expected.package_sha256 || snapshot.revision < expected.revision || snapshot.revision > expected.revision + 1) throw new Error('Plugin save receipt does not match the submitted version') - changed() - return snapshot + config: Record +): Promise> { + const { data } = await apiClient.put>(`/admin/plugins/${id}/config`, config, { rawPluginConfig: true }) + return data } -export async function test(plugin: PluginVersionSnapshot & { id: number }): Promise { - const { data } = await apiClient.post(`/admin/plugins/${plugin.id}/test`, undefined, { headers: versionHeaders(plugin) }) +export async function test(id: number): Promise { + const { data } = await apiClient.post(`/admin/plugins/${id}/test`) return data } @@ -306,30 +154,14 @@ export async function status(id: number): Promise { return data } -export async function createUISession(id: number, contributionID?: string, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).post(`/admin/plugins/${id}/ui-session`, { contribution_id: contributionID }) - return data -} - -export async function createUserUISession(id: number, contributionID?: string): Promise { - const { data } = await apiClient.post(`/plugins/${id}/ui-session`, { contribution_id: contributionID }) - return data -} - -export async function resources(id: number, permission: 'admin' | 'user' = 'admin', dispatch?: PluginDispatchContext): Promise { - const prefix = permission === 'admin' ? '/admin' : '' - const { data } = await pluginDispatchClient(dispatch).get(`${prefix}/plugins/${id}/resources`, { headers: pluginDispatchHeaders(dispatch) }) +export async function createUISession(id: number): Promise { + const { data } = await apiClient.post(`/admin/plugins/${id}/ui-session`) return data } export default { - contributions, - invokeAdmin, - submitJob, list, upload, - update, - followBundled, enable, disable, remove, @@ -337,9 +169,5 @@ export default { saveConfig, test, status, - createUISession, - createUserUISession, - resources + createUISession } - -function changed() { if (typeof window !== 'undefined') window.dispatchEvent(new Event('sub2api:plugins-changed')) } diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 03d28030b20d..ad985a0ab12c 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -1119,6 +1119,73 @@ export interface UpdateSettingsRequest { allow_user_view_error_requests?: boolean; } +export interface OfficialModelCapacityReference { + product: string + source_url: string + release: string + verified_at: string + context_window: number + max_context_window: number +} + +export interface OfficialModelCapacityEntry { + model_id: string + aliases?: string[] + provider: string + product: string + context_window?: number + max_context_window?: number + max_input_tokens?: number + max_output_tokens?: number + source_url: string + verified_at: string + conditions?: string + reference?: OfficialModelCapacityReference +} + +export interface OfficialModelCapacityCatalog { + reference_release: string + entries: OfficialModelCapacityEntry[] +} + +export interface ImageToolsSettings { + studio_enabled: boolean + responses_image_enabled: boolean +} + +/** Image Studio and Cindy Responses image bridge switches. */ +export async function getImageToolsSettings(): Promise { + const { data } = await apiClient.get("/admin/settings/image-tools"); + return data; +} + +export async function updateImageToolsSettings(settings: ImageToolsSettings): Promise { + const { data } = await apiClient.put("/admin/settings/image-tools", settings); + return data; +} + +export interface ObservabilitySettings { + telemetry_enabled: boolean + theme_enabled: boolean +} + +/** Account traffic telemetry and flat site theme switches. */ +export async function getObservabilitySettings(): Promise { + const { data } = await apiClient.get("/admin/settings/observability"); + return data; +} + +export async function updateObservabilitySettings(settings: ObservabilitySettings): Promise { + const { data } = await apiClient.put("/admin/settings/observability", settings); + return data; +} + +/** Read-only release-pinned official model capacity catalog. */ +export async function getOfficialModelCapacityCatalog(): Promise { + const { data } = await apiClient.get("/admin/settings/model-context-catalog"); + return data; +} + /** * Get all system settings * @returns System settings @@ -1635,6 +1702,11 @@ export async function resetWebSearchUsage(payload: { export const settingsAPI = { getSettings, + getOfficialModelCapacityCatalog, + getImageToolsSettings, + updateImageToolsSettings, + getObservabilitySettings, + updateObservabilitySettings, updateSettings, testSmtpConnection, sendTestEmail, diff --git a/plugins/prompt-skills/ui/src/rules-api.ts b/frontend/src/api/admin/systemPromptRules.ts similarity index 64% rename from plugins/prompt-skills/ui/src/rules-api.ts rename to frontend/src/api/admin/systemPromptRules.ts index edf40e53f774..e0b2b4425294 100644 --- a/plugins/prompt-skills/ui/src/rules-api.ts +++ b/frontend/src/api/admin/systemPromptRules.ts @@ -1,4 +1,4 @@ -import { resource } from '@sub2api/plugin-ui' +import { apiClient } from '../client' export type PromptDelivery = 'native_control' | 'system' | 'developer' export type PromptPosition = 'control_prepend' | 'control_append' | 'conversation_head' | 'conversation_tail' @@ -36,10 +36,16 @@ export interface PromptPreview { } } export const legalPromptPosition = (delivery: PromptDelivery, position: PromptPosition) => delivery !== 'native_control' || position.startsWith('control_') +const basePath = '/admin/system-prompts' + export const rulesAPI = { - read: () => resource('prompts.rules.read'), - save: (policy: PromptRulePolicy, revision: number) => resource('prompts.rules.update', { body: { policy, expected_revision: revision } }), - accounts: (ids: number[]) => resource('prompts.bindings.read', { body: { account_ids: ids } }), - bind: (updates: Array<{ account_id: number; expected_updated_at: string; binding: PromptBinding }>, revision: number) => resource('prompts.bindings.update', { body: { updates, expected_revision: revision } }), - preview: (accountId: number, body: { protocol: string; transport: string; compact: boolean; body: unknown; policy?: PromptRulePolicy; binding?: PromptBinding; simulate_enabled?: boolean }) => resource('prompts.rules.preview', { params: { account_id: accountId }, body }) + read: async () => (await apiClient.get(`${basePath}/rules`)).data, + save: async (policy: PromptRulePolicy, revision: number) => + (await apiClient.put(`${basePath}/rules`, { policy, expected_revision: revision })).data, + accounts: async (ids: number[]) => + (await apiClient.post(`${basePath}/accounts/resolve`, { account_ids: ids })).data, + bind: async (updates: Array<{ account_id: number; expected_updated_at: string; binding: PromptBinding }>, revision: number) => + (await apiClient.post(`${basePath}/accounts/bindings`, { updates, expected_revision: revision })).data, + preview: async (accountId: number, body: { protocol: string; transport: string; compact: boolean; body: unknown; policy?: PromptRulePolicy; binding?: PromptBinding; simulate_enabled?: boolean }) => + (await apiClient.post(`${basePath}/rules/preview/${encodeURIComponent(String(accountId))}`, body)).data, } diff --git a/plugins/admin-observability/ui/assets/fonts/MiSans-Bold.woff2 b/frontend/src/assets/fonts/misans/MiSans-Bold.woff2 similarity index 100% rename from plugins/admin-observability/ui/assets/fonts/MiSans-Bold.woff2 rename to frontend/src/assets/fonts/misans/MiSans-Bold.woff2 diff --git a/plugins/admin-observability/ui/assets/fonts/MiSans-Medium.woff2 b/frontend/src/assets/fonts/misans/MiSans-Medium.woff2 similarity index 100% rename from plugins/admin-observability/ui/assets/fonts/MiSans-Medium.woff2 rename to frontend/src/assets/fonts/misans/MiSans-Medium.woff2 diff --git a/plugins/admin-observability/ui/assets/fonts/MiSans-Regular.woff2 b/frontend/src/assets/fonts/misans/MiSans-Regular.woff2 similarity index 100% rename from plugins/admin-observability/ui/assets/fonts/MiSans-Regular.woff2 rename to frontend/src/assets/fonts/misans/MiSans-Regular.woff2 diff --git a/plugins/admin-observability/ui/assets/fonts/MiSans-Semibold.woff2 b/frontend/src/assets/fonts/misans/MiSans-Semibold.woff2 similarity index 100% rename from plugins/admin-observability/ui/assets/fonts/MiSans-Semibold.woff2 rename to frontend/src/assets/fonts/misans/MiSans-Semibold.woff2 diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 235068f7c360..ccf60ce13817 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -217,10 +217,9 @@ @@ -1463,8 +1462,11 @@

{{ apiKeyHint }}

- +
+ + +

{{ profileLabel(createProfile.fields[0]?.hint || {}) }}

+
[ { value: 'force_responses', label: t('admin.accounts.openai.responsesModeForceResponses') }, { value: 'force_chat_completions', label: t('admin.accounts.openai.responsesModeForceChatCompletions') } ]) -const providerCreate = useAccountCreateProfile({ +const providerCreate = useCindyAccountCreate({ platform: () => form.platform, accountType: () => form.type, baseURL: () => apiKeyBaseUrl.value, actorID: () => authStore.user?.id, loadCatalog: platform => adminAPI.groups.getModelAllowlistCandidates(0, platform) @@ -5966,7 +5966,7 @@ const handleVertexServiceAccountDrop = async (event: DragEvent) => { const handleSubmit = async () => { if (providerCreationBlocked.value) { - appStore.showError(t(providerCreate.available.value ? 'common.error' : 'admin.plugins.extensionUnavailable')) + appStore.showError(t(providerCreate.available.value ? 'common.error' : 'admin.accounts.providerEdit.unavailable')) return } if (!capacityReady.value) { diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 7dcb8905e934..c4549e29abb1 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -3254,13 +3254,13 @@ -
-
- +
+
@@ -97,8 +97,7 @@ import AccountIdentityBadges from '@/components/account/AccountIdentityBadges.vu import AccountSelectionCheckbox from '@/components/account/AccountSelectionCheckbox.vue' import AccountGroupsCell from '@/components/account/AccountGroupsCell.vue' import Icon from '@/components/icons/Icon.vue' -import AccountColumnDisplay from '@/components/plugins/AccountColumnDisplay.vue' -import type { PluginContribution } from '@/api/admin/plugins' +import CindyBalanceProbeSummary from '@/features/cindy-balance-probe/CindyBalanceProbeSummary.vue' import type { Account, WindowStats } from '@/types' const props = withDefaults(defineProps<{ @@ -111,9 +110,9 @@ const props = withDefaults(defineProps<{ todayStatsUpdatedAt: number | null manualRefreshToken: number statusNow: number - extensionColumns?: PluginContribution[] + showCindyProbe?: boolean }>(), { - extensionColumns: () => [], + showCindyProbe: false, }) const emit = defineEmits<{ diff --git a/frontend/src/components/admin/account/AccountCompactList.vue b/frontend/src/components/admin/account/AccountCompactList.vue index 6d3cdd05bc3d..acea981d5600 100644 --- a/frontend/src/components/admin/account/AccountCompactList.vue +++ b/frontend/src/components/admin/account/AccountCompactList.vue @@ -49,10 +49,8 @@
- {{ t('admin.accounts.routing') }}{{ account.proxy?.name || t('admin.accounts.directConnection') }}
- +
@@ -92,8 +90,7 @@ import AccountIdentityBadges from '@/components/account/AccountIdentityBadges.vu import AccountSelectionCheckbox from '@/components/account/AccountSelectionCheckbox.vue' import AccountGroupsCell from '@/components/account/AccountGroupsCell.vue' import Icon from '@/components/icons/Icon.vue' -import AccountColumnDisplay from '@/components/plugins/AccountColumnDisplay.vue' -import type { PluginContribution } from '@/api/admin/plugins' +import CindyBalanceProbeSummary from '@/features/cindy-balance-probe/CindyBalanceProbeSummary.vue' import type { Account, WindowStats } from '@/types' const props = withDefaults(defineProps<{ @@ -106,9 +103,9 @@ const props = withDefaults(defineProps<{ todayStatsUpdatedAt: number | null manualRefreshToken: number statusNow: number - extensionColumns?: PluginContribution[] + showCindyProbe?: boolean }>(), { - extensionColumns: () => [], + showCindyProbe: false, }) const emit = defineEmits<{ diff --git a/frontend/src/components/admin/account/AccountDetailsDrawer.vue b/frontend/src/components/admin/account/AccountDetailsDrawer.vue index f60f3a1e54d0..e1cedf835a1a 100644 --- a/frontend/src/components/admin/account/AccountDetailsDrawer.vue +++ b/frontend/src/components/admin/account/AccountDetailsDrawer.vue @@ -73,9 +73,8 @@

- + @@ -162,7 +161,7 @@ diff --git a/plugins/account-tools/ui/src/AccountImportSettingsEditor.vue b/frontend/src/components/admin/account/AccountImportSettingsEditor.vue similarity index 99% rename from plugins/account-tools/ui/src/AccountImportSettingsEditor.vue rename to frontend/src/components/admin/account/AccountImportSettingsEditor.vue index 843acc76a0c6..d602dfcdde9c 100644 --- a/plugins/account-tools/ui/src/AccountImportSettingsEditor.vue +++ b/frontend/src/components/admin/account/AccountImportSettingsEditor.vue @@ -93,7 +93,7 @@ diff --git a/plugins/account-tools/ui/src/AccountTaxonomyEditor.vue b/frontend/src/components/admin/account/AccountTaxonomyEditor.vue similarity index 84% rename from plugins/account-tools/ui/src/AccountTaxonomyEditor.vue rename to frontend/src/components/admin/account/AccountTaxonomyEditor.vue index d64643e4eb0c..9d134fd86ca4 100644 --- a/plugins/account-tools/ui/src/AccountTaxonomyEditor.vue +++ b/frontend/src/components/admin/account/AccountTaxonomyEditor.vue @@ -21,19 +21,20 @@ diff --git a/frontend/src/components/admin/account/AccountTestModal.vue b/frontend/src/components/admin/account/AccountTestModal.vue index 1f82dd0741c8..f8b83248f558 100644 --- a/frontend/src/components/admin/account/AccountTestModal.vue +++ b/frontend/src/components/admin/account/AccountTestModal.vue @@ -375,8 +375,6 @@ import { accountViewRequestConfig } from '@/api/admin/accountViewClient' import AccountTestReasoningSelect from './AccountTestReasoningSelect.vue' import AccountTextTestPrompt from './AccountTextTestPrompt.vue' import { useAccountTestPrompt } from '@/composables/useAccountTestPrompt' -import { usePluginExtensions } from '@/stores/pluginExtensions' -import { contributionAdmission } from '@/components/plugins/contributionAdmission' import { computed, ref, watch, nextTick, onBeforeUnmount, onMounted } from 'vue' import { useI18n } from 'vue-i18n' import BaseDialog from '@/components/common/BaseDialog.vue' @@ -428,11 +426,9 @@ let modelLoadRevision = 0 let modelLoadController: AbortController | null = null const selectedModelId = ref('') const { prompt: textPrompt, valid: textPromptValid } = useAccountTestPrompt() -const promptExtensions = usePluginExtensions() -const textPromptEnabled = computed(() => promptExtensions.items.some(item => item.slot === 'account.test.prompt' && item.permission === 'admin' && contributionAdmission(item, { account: props.account }).allowed)) const textPromptPolicyValid = ref(true) const mediaTestPrompt = ref('') -const testPrompt = computed({ get: () => supportsTextPrompt.value ? (textPromptEnabled.value ? textPrompt.value : '') : mediaTestPrompt.value, +const testPrompt = computed({ get: () => supportsTextPrompt.value ? textPrompt.value : mediaTestPrompt.value, set: value => { if (supportsTextPrompt.value) textPrompt.value = value; else mediaTestPrompt.value = value } }) const loadingModels = ref(false) let abortController: AbortController | null = null @@ -444,9 +440,8 @@ const testMode = ref<'default' | 'compact'>('default') const reasoningEffort = ref('') const reasoningValid = ref(true) const effectiveReasoningEffort = computed(() => { - const enabled = promptExtensions.items.some(item => item.slot === 'account.test' && item.permission === 'admin' && contributionAdmission(item, { account: props.account }).allowed) const levels = modelOptionsForMode.value.find(model => model.id === selectedModelId.value)?.reasoning_efforts || [] - return enabled && levels.includes(reasoningEffort.value) ? reasoningEffort.value : '' + return levels.includes(reasoningEffort.value) ? reasoningEffort.value : '' }) const grokTestMode = ref<'text' | 'image' | 'video' | 'search' | 'tts' | 'stt' | 'realtime'>('text') const uploadImageDataURL = ref('') @@ -688,7 +683,7 @@ const canStartTest = computed(() => { if (!accountViewOperation.available.value) return false if (!props.show || !currentModelPlan.value || loadingModels.value) return false if (effectiveReasoningEffort.value && !reasoningValid.value) return false - if (supportsTextPrompt.value && textPromptEnabled.value && (!textPromptValid.value || !textPromptPolicyValid.value)) return false + if (supportsTextPrompt.value && (!textPromptValid.value || !textPromptPolicyValid.value)) return false if (status.value === 'connecting') return false if (isGrokAccount.value) { if ( diff --git a/frontend/src/components/admin/account/AccountTestReasoningSelect.vue b/frontend/src/components/admin/account/AccountTestReasoningSelect.vue index bff81bbe314a..a17542e60419 100644 --- a/frontend/src/components/admin/account/AccountTestReasoningSelect.vue +++ b/frontend/src/components/admin/account/AccountTestReasoningSelect.vue @@ -1,16 +1,23 @@ diff --git a/frontend/src/components/admin/account/AccountTextTestPrompt.vue b/frontend/src/components/admin/account/AccountTextTestPrompt.vue index 6407e3c52bf6..370804f995c3 100644 --- a/frontend/src/components/admin/account/AccountTextTestPrompt.vue +++ b/frontend/src/components/admin/account/AccountTextTestPrompt.vue @@ -1,10 +1,25 @@ + +
+ +
+
+ + + + + + + + + + + +
{{ t('admin.systemPrompts.history.version') }}{{ t('admin.systemPrompts.history.status') }}{{ t('admin.systemPrompts.history.created') }}{{ t('admin.systemPrompts.history.actions') }}
{{ version.id === runtimeVersionId ? t('admin.systemPrompts.history.active') : t('admin.systemPrompts.history.candidate') }}{{ formatDate(version.created_at) }} + + +
{{ t('admin.systemPrompts.history.empty') }}
+
+
+ + +
{{ t('admin.systemPrompts.templates.select') }}
+ + + + + + + +
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+ + +
+
{{ t('admin.systemPrompts.source.repository') }}: {{ versionDetails.source_repository }}
{{ t('admin.systemPrompts.source.version') }}: {{ versionDetails.source_version }}
{{ t('admin.systemPrompts.source.commit') }}: {{ versionDetails.source_commit }}
{{ t('admin.systemPrompts.source.artifact') }}: {{ versionDetails.source_artifact }}
{{ versionDetails.source_artifact_sha256 }}
{{ versionDetails.source_license_sha256 }}
+
{{ t('admin.systemPrompts.source.inline') }}
+
+
+ + +
+
+
+
+
+
+ + + diff --git a/frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts index fe697c6e9c17..066940c090a5 100644 --- a/frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts +++ b/frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts @@ -1,4 +1,4 @@ -vi.mock('@/stores/pluginExtensions', () => ({ usePluginExtensions: () => ({ loaded: true, items: [], refresh: vi.fn() }) })) + import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' diff --git a/frontend/src/views/admin/__tests__/AccountsView.console.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.console.spec.ts index 208105b316c7..4ea5c9c508e6 100644 --- a/frontend/src/views/admin/__tests__/AccountsView.console.spec.ts +++ b/frontend/src/views/admin/__tests__/AccountsView.console.spec.ts @@ -1,7 +1,5 @@ -import { cindyAccount, cindyView, viewContributions } from '@/components/plugins/__tests__/accountView.fixtures' -import type { PluginContribution } from '@/api/admin/plugins' -const viewRegistry = vi.hoisted(() => ({ loaded: true, actorID: 41, items: [] as PluginContribution[], refresh: vi.fn() })) -vi.mock('@/stores/pluginExtensions', () => ({ usePluginExtensions: () => viewRegistry })) +import { cindyAccount } from '@/__tests__/fixtures/cindyAccount' + vi.mock('@/components/admin/account-jobs/AccountOperationDialog.vue', () => ({ default: { name: 'AccountOperationDialog', props: ['job', 'show'], template: '
' } })) import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' @@ -165,7 +163,7 @@ const DataTableStub = { - + ` @@ -204,16 +202,15 @@ const AccountTestModalStub = { const commonStubs = { AppLayout: { template: '
' }, - ExtensionWidget: { name: 'ExtensionWidget', props: ['name', 'context', 'originView'], template: '
' }, TablePageLayout: { template: '
' }, DataTable: DataTableStub, AccountCompactList: { - props: ['accounts', 'todayStats', 'todayStatsLoading', 'todayStatsError', 'manualRefreshToken', 'extensionColumns'], - template: '
{{ accounts.length }}
' + props: ['accounts', 'todayStats', 'todayStatsLoading', 'todayStatsError', 'manualRefreshToken', 'showCindyProbe'], + template: '
{{ accounts.length }}
' }, AccountCardGrid: { - props: ['accounts', 'todayStats', 'todayStatsLoading', 'todayStatsError', 'manualRefreshToken', 'extensionColumns'], - template: '
{{ accounts.length }}
' + props: ['accounts', 'todayStats', 'todayStatsLoading', 'todayStatsError', 'manualRefreshToken', 'showCindyProbe'], + template: '
{{ accounts.length }}
' }, AccountViewModeSwitcher: ViewModeStub, AccountConsoleFilters: { props: ['modelValue'], template: '
{{ modelValue.account_ids.join(\',\') }}
' }, @@ -259,7 +256,7 @@ const commonStubs = { const mountView = ( plugins: any[] = [], - props: { viewContribution?: PluginContribution } = {}, + props: { scope?: 'all' | 'cindy' } = {}, slots: Record = {}, ) => mount(AccountsView, { props, @@ -287,7 +284,7 @@ describe('admin AccountsView Cockpit console', () => { beforeEach(() => { localStorage.clear() sessionStorage.clear() - viewRegistry.items = viewContributions() + listAccounts.mockReset().mockResolvedValue({ items: [account], total: 1, page: 1, page_size: 20, pages: 1 }) getById.mockReset().mockResolvedValue(account) getAvailableModels.mockReset().mockResolvedValue(structuredClone(modelDisplayContract.expected)) @@ -516,47 +513,48 @@ describe('admin AccountsView Cockpit console', () => { wrapper.unmount() }) - it('account.view.v1 binds plugin presets and keeps core controls only while a plugin preset is selected', async () => { - getFacets.mockResolvedValue({ total: 4, uncategorized_count: 4, view_preset_counts: { cindy: 4, insufficient: 2, banned: 1 }, platforms: [], types: [], statuses: [], plans: [], proxies: [], folders: [], tags: [] }) + it('binds native Cindy filters and only shows cleanup controls while their matching preset is selected', async () => { + getFacets.mockResolvedValue({ total: 4, uncategorized_count: 4, cindy_total: 4, cindy_insufficient_count: 2, cindy_banned_count: 1, platforms: [], types: [], statuses: [], plans: [], proxies: [], folders: [], tags: [] }) const wrapper = mountView() await flushPromises() - expect(wrapper.find('[data-surface="cindy-account-controls"]').exists()).toBe(false) - const buttons = () => wrapper.get('[data-test="account-view-presets"]').findAll('button') + expect(wrapper.find('[data-test="delete-cindy-insufficient"]').exists()).toBe(false) + const buttons = () => wrapper.get('[data-test="cindy-account-view"]').findAll('button') expect(buttons()).toHaveLength(4) await buttons()[2].trigger('click'); await flushPromises() - expect(listAccounts.mock.calls.some(call => call[4]?.context.preset_id === 'insufficient')).toBe(true) - expect(wrapper.find('[data-surface="cindy-account-controls"]').exists()).toBe(true) - expect(wrapper.find('[data-surface="cindy-balance-probe"]').exists()).toBe(false) - expect(wrapper.find('[data-surface="cindy-duplicate-inventory"]').exists()).toBe(false) + expect(listAccounts.mock.lastCall?.[2]).toMatchObject({ cindy_only: 'true', cindy_balance_status: 'insufficient' }) + expect(wrapper.find('[data-test="delete-cindy-insufficient"]').exists()).toBe(true) + expect(wrapper.findComponent({ name: 'CindyBalanceProbePanel' }).exists()).toBe(false) + expect(wrapper.findComponent({ name: 'CindyDuplicateInventoryPanel' }).exists()).toBe(false) await buttons()[0].trigger('click'); await flushPromises() - expect(wrapper.find('[data-surface="cindy-account-controls"]').exists()).toBe(false) + expect(wrapper.find('[data-test="delete-cindy-insufficient"]').exists()).toBe(false) + expect(listAccounts.mock.lastCall?.[2]).toMatchObject({ cindy_only: undefined, cindy_balance_status: undefined }) expect(listAccounts.mock.lastCall?.[4]).toBeUndefined() wrapper.unmount() }) - it('account.view.v1 keeps the dedicated base trusted even when legacy query tries to disable it', async () => { + it('keeps native Cindy scope even when the legacy query tries to disable it', async () => { const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/admin/cindy-accounts', component: { template: '
' } }] }) await router.push('/admin/cindy-accounts?cindy_only=false'); await router.isReady() - const wrapper = mountView([router], { viewContribution: cindyView() }) + const wrapper = mountView([router], { scope: 'cindy' }) await flushPromises() - expect(wrapper.get('[data-test="account-view-presets"]').findAll('button')).toHaveLength(3) - expect(listAccounts.mock.calls.every(call => call[4]?.context.view_id === 'cindy-accounts')).toBe(true) - expect(listAccounts.mock.calls.every(call => call[4]?.context.preset_id === 'cindy')).toBe(true) + expect(wrapper.get('[data-test="cindy-account-view"]').findAll('button')).toHaveLength(3) + expect(listAccounts.mock.calls.every(call => call[2]?.cindy_only === 'true')).toBe(true) + expect(listAccounts.mock.calls.every(call => call[4] === undefined)).toBe(true) expect(router.currentRoute.value.query.cindy_only).toBe('true') await router.push('/admin/cindy-accounts?cindy_only=false&cindy_balance_status=insufficient'); await flushPromises() - expect(listAccounts.mock.lastCall?.[4]?.context.preset_id).toBe('insufficient') + expect(listAccounts.mock.lastCall?.[2]?.cindy_balance_status).toBe('insufficient') expect(router.currentRoute.value.query.cindy_only).toBe('true') wrapper.unmount() }) - it('account.view.v1 renders declared probe columns in all layouts while core remains unchanged', async () => { + it('renders native Cindy probe columns in all layouts while core remains unchanged', async () => { const ordinary = mountView(); await flushPromises() - expect(ordinary.get('[data-test="view-table"]').attributes('data-columns')).not.toContain('extension_') + expect(ordinary.get('[data-test="view-table"]').attributes('data-columns')).not.toContain('cindy_probe') ordinary.unmount() listAccounts.mockResolvedValue({ items: [cindyAccount()], total: 1, page: 1, page_size: 20, pages: 1 }) - const scoped = mountView([], { viewContribution: cindyView() }); await flushPromises() - expect(scoped.get('[data-test="view-table"]').attributes('data-columns')).toContain('extension_7_cindy-probe-summary') - expect(scoped.get('[data-extension-display="cindy-probe-summary"]').text()).toContain('#321') + const scoped = mountView([], { scope: 'cindy' }); await flushPromises() + expect(scoped.get('[data-test="view-table"]').attributes('data-columns')).toContain('cindy_probe') + expect(scoped.get('[data-test="cindy-probe-summary"]').text()).toContain('#321') await scoped.get('[data-test="mode-compact"]').trigger('click') expect(scoped.get('[data-test="view-compact"]').attributes('data-show-cindy-probe')).toBe('true') await scoped.get('[data-test="mode-cards"]').trigger('click') @@ -564,33 +562,6 @@ describe('admin AccountsView Cockpit console', () => { scoped.unmount() }) - it('account.view.v1 projects safe selection and typed filters to same-owner surfaces', async () => { - const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/admin/cindy-accounts', component: { template: '
' } }] }) - await router.push('/admin/cindy-accounts?proxies=direct,3&folder=uncategorized&tags=2&group_id=ungrouped&privacy_mode=private&cindy_balance_status=insufficient') - await router.isReady() - listAccounts.mockResolvedValue({ items: [cindyAccount()], total: 1, page: 1, page_size: 20, pages: 1 }) - listTags.mockResolvedValue([{ id: 2, name: 'Tag' }]) - const wrapper = mountView([router], { viewContribution: cindyView() }); await flushPromises() - await wrapper.get('input[type="checkbox"]').setValue(true) - const widget = wrapper.findAllComponents({ name: 'ExtensionWidget' }).find(component => component.props('name') === 'cindy-balance-probe')! - expect(widget.props('context').account_view_state).toMatchObject({ - identity: { plugin_key: 'codexrip.cindy-provider', preset_id: 'insufficient' }, - selected_ids: [1], query: { proxies: ['3', 'direct'], folders: ['uncategorized'], tags: [2], group_id: -1, privacy_mode: 'private' } - }) - expect(widget.props('context').account_view_state).not.toHaveProperty('accounts') - wrapper.unmount() - }) - - it('account.view.v1 denies a legacy core preset when its owner is missing instead of showing all accounts', async () => { - viewRegistry.items = [] - const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/admin/accounts', component: { template: '
' } }] }) - await router.push('/admin/accounts?cindy_balance_status=insufficient'); await router.isReady() - const wrapper = mountView([router]); await flushPromises() - expect(listAccounts).not.toHaveBeenCalled() - expect(wrapper.find('[data-test="account-view-unavailable"]').exists()).toBe(true) - wrapper.unmount() - }) - // Cleanup fingerprints/409/no-op and banned-vs-insufficient behavior moved // with their UI to CindyAccountControls.spec.ts. They are not host handlers. diff --git a/frontend/src/views/admin/__tests__/AccountsView.lite.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.lite.spec.ts index 39ebe960d879..2069affa87f7 100644 --- a/frontend/src/views/admin/__tests__/AccountsView.lite.spec.ts +++ b/frontend/src/views/admin/__tests__/AccountsView.lite.spec.ts @@ -1,7 +1,5 @@ -import { cindyAccount, cindyView, viewContributions } from '@/components/plugins/__tests__/accountView.fixtures' -import type { PluginContribution } from '@/api/admin/plugins' -const viewRegistry = vi.hoisted(() => ({ loaded: true, items: [] as PluginContribution[], refresh: vi.fn() })) -vi.mock('@/stores/pluginExtensions', () => ({ usePluginExtensions: () => viewRegistry })) +import { cindyAccount } from '@/__tests__/fixtures/cindyAccount' + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { DOMWrapper, flushPromises, mount } from '@vue/test-utils' import { defineComponent } from 'vue' @@ -108,7 +106,7 @@ const AccountStatsModalStub = defineComponent({ template: '
{{ show ? account?.name : "" }}
' }) -function mountView(props: { viewContribution?: PluginContribution } = {}, stubActionMenu = true) { +function mountView(props: { scope?: 'all' | 'cindy' } = {}, stubActionMenu = true) { return mount(AccountsView, { props, attachTo: document.body, @@ -178,7 +176,7 @@ describe('admin AccountsView lite account list', () => { beforeEach(() => { setActivePinia(createPinia()) localStorage.clear() - viewRegistry.items = [] + listAccounts.mockReset().mockResolvedValue({ items: [listRow], total: 1, page: 1, page_size: 20, pages: 1 }) listWithEtag.mockReset().mockResolvedValue({ notModified: true, etag: 'compact-etag', data: null }) getById.mockReset().mockResolvedValue(fullAccount) @@ -235,20 +233,17 @@ describe('admin AccountsView lite account list', () => { }) it('keeps Cindy scope filters on compact requests', async () => { - viewRegistry.items = viewContributions() + listAccounts.mockResolvedValue({ items: [cindyAccount(42)], total: 1, page: 1, page_size: 20, pages: 1 }) - const wrapper = mountView({ viewContribution: cindyView() }) + const wrapper = mountView({ scope: 'cindy' }) await flushPromises() expect(listAccounts).toHaveBeenCalledWith( 1, 20, - expect.objectContaining({ lite: '1' }), - expect.objectContaining({ signal: expect.any(AbortSignal) }), - expect.objectContaining({ context: expect.objectContaining({ - plugin_key: 'codexrip.cindy-provider', view_id: 'cindy-accounts', preset_id: 'cindy' - }) }) + expect.objectContaining({ lite: '1', cindy_only: 'true' }), + expect.objectContaining({ signal: expect.any(AbortSignal) }) ) - expect(listAccounts.mock.calls.every(call => call[4]?.context.preset_id === 'cindy')).toBe(true) + expect(listAccounts.mock.calls.every(call => call[4] === undefined)).toBe(true) wrapper.unmount() }) diff --git a/frontend/src/views/admin/__tests__/AccountsView.priorityColumn.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.priorityColumn.spec.ts index 90f6dfbfb9f0..dbd6dbe6fa6e 100644 --- a/frontend/src/views/admin/__tests__/AccountsView.priorityColumn.spec.ts +++ b/frontend/src/views/admin/__tests__/AccountsView.priorityColumn.spec.ts @@ -1,4 +1,4 @@ -vi.mock('@/stores/pluginExtensions', () => ({ usePluginExtensions: () => ({ loaded: true, items: [], refresh: vi.fn() }) })) + import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' diff --git a/frontend/src/views/admin/__tests__/AccountsView.schedulerScore.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.schedulerScore.spec.ts index 98b1d32da8ec..7b94237aeaba 100644 --- a/frontend/src/views/admin/__tests__/AccountsView.schedulerScore.spec.ts +++ b/frontend/src/views/admin/__tests__/AccountsView.schedulerScore.spec.ts @@ -1,4 +1,4 @@ -vi.mock('@/stores/pluginExtensions', () => ({ usePluginExtensions: () => ({ loaded: true, items: [], refresh: vi.fn() }) })) + import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' diff --git a/frontend/src/views/admin/__tests__/AccountsView.selectAllResults.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.selectAllResults.spec.ts index 640bd616e246..74286eda2b2c 100644 --- a/frontend/src/views/admin/__tests__/AccountsView.selectAllResults.spec.ts +++ b/frontend/src/views/admin/__tests__/AccountsView.selectAllResults.spec.ts @@ -1,4 +1,4 @@ -vi.mock('@/stores/pluginExtensions', () => ({ usePluginExtensions: () => ({ loaded: true, items: [], refresh: vi.fn() }) })) + vi.mock('@/components/admin/account-jobs/AccountOperationDialog.vue', () => ({ default: { name: 'AccountOperationDialog', props: ['job', 'show'], template: '
' } })) import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' diff --git a/frontend/src/views/admin/__tests__/AccountsView.selectionScope.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.selectionScope.spec.ts new file mode 100644 index 000000000000..2065008ea986 --- /dev/null +++ b/frontend/src/views/admin/__tests__/AccountsView.selectionScope.spec.ts @@ -0,0 +1,235 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import AccountsView from '../AccountsView.vue' +import AccountBulkActionsBar from '@/components/admin/account/AccountBulkActionsBar.vue' +import BulkEditAccountModal from '@/components/account/BulkEditAccountModal.vue' + +const { listAccounts, wireGet, wirePost, showError, trackJob } = vi.hoisted(() => ({ + listAccounts: vi.fn(), + wireGet: vi.fn(), + wirePost: vi.fn(), + showError: vi.fn(), + trackJob: vi.fn() +})) + +vi.mock('@/api/client', () => ({ + apiClient: { get: wireGet, post: wirePost }, + default: { get: wireGet, post: wirePost } +})) +vi.mock('@/api/admin/accounts', async () => ({ + ...await vi.importActual('@/api/admin/accounts'), + list: listAccounts +})) +vi.mock('@/api/admin', async () => { + const { bulkUpdate } = await vi.importActual('@/api/admin/accounts') + return { + adminAPI: { + accounts: { + list: listAccounts, + bulkUpdate, + listWithEtag: vi.fn().mockResolvedValue({ notModified: true, etag: null, data: null }), + getUpstreamBillingRatesWithEtag: vi.fn().mockResolvedValue({ notModified: true, etag: null, data: null }), + getFacets: vi.fn().mockResolvedValue({ total: 3, uncategorized_count: 3, platforms: [], types: [], statuses: [], plans: [], proxies: [], folders: [], tags: [] }), + listFolders: vi.fn().mockResolvedValue([]), + listTags: vi.fn().mockResolvedValue([]), + getBatchTodayStats: vi.fn().mockResolvedValue({ stats: {} }), + getUpstreamBillingProbeSettings: vi.fn().mockResolvedValue({ enabled: false, interval_minutes: 30 }), + getAPIKeyVisibility: vi.fn().mockResolvedValue({ enabled: false }) + }, + proxies: { getAll: vi.fn().mockResolvedValue([]) }, + groups: { getAll: vi.fn().mockResolvedValue([]) } + } + } +}) + +vi.mock('@/stores/accountJobs', () => ({ + useAccountJobsStore: () => ({ track: trackJob, reviewDuplicates: vi.fn() }) +})) +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ showError, showSuccess: vi.fn(), showInfo: vi.fn() }) +})) +vi.mock('@/stores/auth', () => ({ + useAuthStore: () => ({ token: 'test-token', user: { id: 1 } }) +})) +vi.mock('vue-i18n', async () => ({ + ...await vi.importActual('vue-i18n'), + useI18n: () => ({ t: (key: string) => key, locale: { value: 'en' } }) +})) +vi.mock('@/components/admin/account-jobs/AccountOperationDialog.vue', () => ({ + default: { props: ['show', 'job'], template: '
' } +})) + +const makeAccounts = (ids = [7, 11, 99]) => ids.map(id => ({ + id, name: `account-${id}`, platform: 'grok', type: 'oauth', status: 'active', schedulable: true, + created_at: '2026-09-24T00:00:00Z', updated_at: '2026-09-24T00:00:00Z' +})) +const page = (ids = [7, 11, 99], total = ids.length, pages = 1) => ({ + items: makeAccounts(ids), total, page: 1, page_size: 20, pages +}) +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: Error) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +let wrapper: VueWrapper | undefined +function mountView() { + wrapper = mount(AccountsView, { + global: { + stubs: { + AppLayout: { template: '
' }, + TablePageLayout: { template: '
' }, + DataTable: { + props: ['data'], + template: '
' + }, + Pagination: { emits: ['update:page'], template: '' }, + BaseDialog: { props: ['show'], template: '
' }, + ConfirmDialog: true, + ExtensionSlot: true, + AccountFolderBar: true, + AccountActionMenu: true, + AccountDetailsDrawer: true, + AccountTaxonomyManager: true, + AccountBulkTaxonomyModal: true, + AccountTaskDrawer: true, + AccountTestModal: true, + BatchTestAccountModal: true, + AccountStatsModal: true, + ScheduledTestsPanel: true, + ImportDataModal: true, + ReAuthAccountModal: true, + SyncFromCrsModal: true, + TempUnschedStatusModal: true, + ErrorPassthroughRulesModal: true, + TLSFingerprintProfilesModal: true, + CreateAccountModal: true, + EditAccountModal: true, + ModelWhitelistSelector: true, + ProxySelector: true, + GroupSelector: true, + Icon: true + } + } + }) + return wrapper +} + +function toolbarButton(view: VueWrapper, key: string) { + const button = view.getComponent(AccountBulkActionsBar).findAll('button').find(item => item.text() === key) + if (!button) throw new Error(`Missing real toolbar action: ${key}`) + return button +} +async function savePriority(view: VueWrapper) { + const modal = view.getComponent(BulkEditAccountModal) + await modal.get('#bulk-edit-priority-enabled').setValue(true) + await modal.get('#bulk-edit-priority').setValue(3) + await modal.get('#bulk-edit-account-form').trigger('submit') + await flushPromises() +} + +describe('AccountsView selection scope through real bulk controls', () => { + beforeEach(() => { + localStorage.clear() + sessionStorage.clear() + vi.clearAllMocks() + listAccounts.mockReset().mockResolvedValue(page()) + wireGet.mockReset().mockResolvedValue({ data: { items: [], total: 0 } }) + wirePost.mockReset().mockResolvedValue({ data: { id: 31, kind: 'account_bulk_update', status: 'pending' } }) + }) + afterEach(() => { + wrapper?.unmount() + wrapper = undefined + }) + + it.each(['admin.accounts.bulkActions.edit', 'admin.accounts.bulkEdit.submit'])( + 'submits only the frozen cross-page selection from %s', async (action) => { + listAccounts.mockImplementation(async (currentPage: number) => currentPage === 1 ? page([7, 11], 3, 2) : page([99], 3, 2)) + const view = mountView() + await flushPromises() + await view.get('[data-account="7"] input').setValue(true) + await view.get('[data-test="page-two"]').trigger('click') + await flushPromises() + await view.get('[data-account="99"] input').setValue(true) + await toolbarButton(view, action).trigger('click') + await flushPromises() + await view.get('[data-account="99"] input').setValue(false) + await savePriority(view) + + expect(wirePost).toHaveBeenCalledTimes(1) + expect(wirePost).toHaveBeenCalledWith('/admin/accounts/bulk-update', { account_ids: [7, 99], priority: 3 }, expect.any(Object)) + expect(listAccounts.mock.calls.some(([, pageSize]) => pageSize === 100)).toBe(false) + expect(trackJob).toHaveBeenCalledWith(expect.objectContaining({ id: 31 }), { open: false }) + } + ) + + it('uses a selection made while the filtered preview is loading', async () => { + const preview = deferred>() + listAccounts.mockImplementation((_currentPage: number, pageSize: number) => pageSize === 100 ? preview.promise : Promise.resolve(page())) + const view = mountView() + await flushPromises() + await toolbarButton(view, 'admin.accounts.bulkEdit.submit').trigger('click') + await view.get('[data-account="11"] input').setValue(true) + preview.resolve(page()) + await flushPromises() + await savePriority(view) + + expect(wirePost).toHaveBeenCalledTimes(1) + expect(wirePost).toHaveBeenCalledWith('/admin/accounts/bulk-update', { account_ids: [11], priority: 3 }, expect.any(Object)) + }) + + it('retains the filtered request contract when no accounts are selected', async () => { + const view = mountView() + await flushPromises() + await toolbarButton(view, 'admin.accounts.bulkEdit.submit').trigger('click') + await flushPromises() + await savePriority(view) + + expect(wirePost).toHaveBeenCalledTimes(1) + const [url, payload] = wirePost.mock.calls[0] + expect(url).toBe('/admin/accounts/bulk-update') + expect(payload).toEqual({ filters: expect.any(Object), priority: 3 }) + expect(payload).not.toHaveProperty('account_ids') + }) + + it.each(['complete', 'next-page', 'error'] as const)( + 'keeps a later manual deselection when select-all ends with %s', async outcome => { + const allResults = deferred>() + listAccounts.mockImplementation((_currentPage: number, pageSize: number) => pageSize === 1000 ? allResults.promise : Promise.resolve(page())) + const view = mountView() + await flushPromises() + await view.get('[data-account="7"] input').setValue(true) + await view.get('[data-account="11"] input').setValue(true) + await toolbarButton(view, 'admin.accounts.bulkActions.selectAllResults').trigger('click') + await view.get('[data-account="11"] input').setValue(false) + if (outcome === 'error') allResults.reject(new Error('synthetic select-all failure')) + else allResults.resolve(page([7, 11, 99], outcome === 'next-page' ? 1001 : 3, outcome === 'next-page' ? 2 : 1)) + await flushPromises() + + const toolbar = view.getComponent(AccountBulkActionsBar) + expect(toolbar.props('selectedIds')).toEqual([7]) + expect(toolbar.props('selectingAll')).toBe(false) + expect(toolbar.props('allResultsSelected')).toBe(false) + expect(listAccounts.mock.calls.filter(([, pageSize]) => pageSize === 1000)).toHaveLength(1) + expect(showError).not.toHaveBeenCalled() + expect(wirePost).not.toHaveBeenCalled() + } + ) + + it('finishes select-all normally when the selection has not changed', async () => { + const view = mountView() + await flushPromises() + await toolbarButton(view, 'admin.accounts.bulkActions.selectAllResults').trigger('click') + await flushPromises() + + const toolbar = view.getComponent(AccountBulkActionsBar) + expect(toolbar.props('selectedIds')).toEqual([7, 11, 99]) + expect(toolbar.props('selectingAll')).toBe(false) + expect(toolbar.props('allResultsSelected')).toBe(true) + expect(showError).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/views/admin/__tests__/AccountsView.sparkShadow.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.sparkShadow.spec.ts index f487e9079c36..c2bc79647aff 100644 --- a/frontend/src/views/admin/__tests__/AccountsView.sparkShadow.spec.ts +++ b/frontend/src/views/admin/__tests__/AccountsView.sparkShadow.spec.ts @@ -1,4 +1,4 @@ -vi.mock('@/stores/pluginExtensions', () => ({ usePluginExtensions: () => ({ loaded: true, items: [], refresh: vi.fn() }) })) + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' diff --git a/frontend/src/views/admin/__tests__/AccountsView.usageWindowsHint.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.usageWindowsHint.spec.ts index b3800d96520b..cc260db6c20d 100644 --- a/frontend/src/views/admin/__tests__/AccountsView.usageWindowsHint.spec.ts +++ b/frontend/src/views/admin/__tests__/AccountsView.usageWindowsHint.spec.ts @@ -1,4 +1,4 @@ -vi.mock('@/stores/pluginExtensions', () => ({ usePluginExtensions: () => ({ loaded: true, items: [], refresh: vi.fn() }) })) + import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' diff --git a/frontend/src/views/admin/__tests__/GroupsView.columnSettings.spec.ts b/frontend/src/views/admin/__tests__/GroupsView.columnSettings.spec.ts index 9653425f492e..4eb5393f6a48 100644 --- a/frontend/src/views/admin/__tests__/GroupsView.columnSettings.spec.ts +++ b/frontend/src/views/admin/__tests__/GroupsView.columnSettings.spec.ts @@ -51,7 +51,6 @@ const messages: Record = { 'admin.groups.usageTotal': 'Total', } -vi.mock('@/components/plugins/ExtensionSurface.vue', () => ({ default: { template: '
' } })) vi.mock('@/api/admin', () => ({ adminAPI: { groups: { diff --git a/frontend/src/views/admin/__tests__/GroupsView.duplicate.spec.ts b/frontend/src/views/admin/__tests__/GroupsView.duplicate.spec.ts index a00b839e3618..8ff81f58c579 100644 --- a/frontend/src/views/admin/__tests__/GroupsView.duplicate.spec.ts +++ b/frontend/src/views/admin/__tests__/GroupsView.duplicate.spec.ts @@ -31,7 +31,6 @@ const { const authState = vi.hoisted(() => ({ isSimpleMode: false })) -vi.mock('@/components/plugins/ExtensionSurface.vue', () => ({ default: { template: '
' } })) vi.mock('@/api/admin', () => ({ adminAPI: { groups: { diff --git a/frontend/src/views/admin/__tests__/PluginsView.spec.ts b/frontend/src/views/admin/__tests__/PluginsView.spec.ts index a1b2504347ab..3fa4a7ed294d 100644 --- a/frontend/src/views/admin/__tests__/PluginsView.spec.ts +++ b/frontend/src/views/admin/__tests__/PluginsView.spec.ts @@ -6,8 +6,6 @@ import PluginsView from '../PluginsView.vue' const { listPlugins, uploadPlugin, - updatePlugin, - followBundled, enablePlugin, savePluginConfig, createUISession, @@ -15,8 +13,6 @@ const { } = vi.hoisted(() => ({ listPlugins: vi.fn(), uploadPlugin: vi.fn(), - updatePlugin: vi.fn(), - followBundled: vi.fn(), enablePlugin: vi.fn(), savePluginConfig: vi.fn(), createUISession: vi.fn(), @@ -28,8 +24,6 @@ vi.mock('@/api/admin', () => ({ plugins: { list: listPlugins, upload: uploadPlugin, - update: updatePlugin, - followBundled, enable: enablePlugin, disable: vi.fn(), remove: vi.fn(), @@ -62,9 +56,6 @@ vi.mock('vue-i18n', async (importOriginal) => ({ })) const plugin = { - revision: 4, - package_sha256: 'b'.repeat(64), - update_policy: 'pinned' as const, id: 7, plugin_key: 'local.test.transport', name: 'Test Transport', @@ -137,8 +128,6 @@ describe('管理员插件页二次验证', () => { stepUpRun.mockImplementation((action: () => Promise) => action()) listPlugins.mockResolvedValue([plugin]) uploadPlugin.mockResolvedValue(plugin) - updatePlugin.mockResolvedValue(plugin) - followBundled.mockResolvedValue(plugin) enablePlugin.mockResolvedValue(plugin) savePluginConfig.mockResolvedValue({ enabled: true }) createUISession.mockResolvedValue({ @@ -159,7 +148,7 @@ describe('管理员插件页二次验证', () => { await flushPromises() expect(stepUpRun).toHaveBeenCalledTimes(1) - expect(enablePlugin).toHaveBeenCalledWith(plugin, 100, false) + expect(enablePlugin).toHaveBeenCalledWith(7, 100, false) }) it('上传插件通过 step-up 控制器执行', async () => { @@ -177,54 +166,4 @@ describe('管理员插件页二次验证', () => { expect(stepUpRun).toHaveBeenCalledTimes(1) expect(uploadPlugin).toHaveBeenCalledTimes(1) }) - - it('step-up期间不把已读取的操作快照换成新revision', async () => { - const displayed = { ...plugin } - listPlugins.mockResolvedValue([displayed]) - let delayed: (() => Promise) | undefined - let finish: (() => void) | undefined - stepUpRun.mockImplementation((action: () => Promise) => { - delayed = action - return new Promise(resolve => { finish = resolve }) - }) - const wrapper = mountView() - await flushPromises() - await wrapper.findAll('button').find(item => item.text().includes('admin.plugins.enable'))!.trigger('click') - displayed.revision = 99 - await delayed!() - expect(enablePlugin).toHaveBeenCalledWith(expect.objectContaining({ revision: 4, package_sha256: plugin.package_sha256 }), 100, false) - finish!() - await flushPromises() - wrapper.unmount() - }) - - it('更新携带当前版本快照且不先停用插件', async () => { - const enabled = { ...plugin, state: 'enabled', bindings: [{ ...plugin.bindings[0], enabled: true }] } - listPlugins.mockResolvedValue([enabled]) - const wrapper = mountView() - await flushPromises() - await wrapper.findAll('button').find(button => button.text().includes('admin.plugins.updatePackage'))!.trigger('click') - const input = wrapper.findAll('input[type="file"]')[1]! - const file = new File(['new signed package'], 'updated.s2plugin') - Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }) - await input.trigger('change') - await flushPromises() - expect(stepUpRun).toHaveBeenCalledTimes(1) - expect(updatePlugin).toHaveBeenCalledWith(enabled, file) - expect(uploadPlugin).not.toHaveBeenCalled() - wrapper.unmount() - }) - - it('固定版本仅在明确选择后恢复跟随内置版', async () => { - const pinned = { ...plugin, plugin_key: 'codexrip.account-tools' } - listPlugins.mockResolvedValue([pinned]) - const wrapper = mountView() - await flushPromises() - expect(followBundled).not.toHaveBeenCalled() - await wrapper.findAll('button').find(button => button.text().includes('admin.plugins.followBundle'))!.trigger('click') - await flushPromises() - expect(stepUpRun).toHaveBeenCalledTimes(1) - expect(followBundled).toHaveBeenCalledWith(pinned) - wrapper.unmount() - }) }) diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index 48f5a693c68f..53b98a5c3a0c 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -8,10 +8,6 @@ import zhCommon from "@/i18n/locales/zh/common"; import zhSettings from "@/i18n/locales/zh/admin/settings"; import SettingsView from "../SettingsView.vue"; -vi.mock('@/components/plugins/ExtensionSlot.vue', () => ({ - default: { props: ['name'], template: '
' }, -})); - const { getSettings, updateSettings, @@ -85,6 +81,35 @@ const { const localeRef = vi.hoisted(() => ({ value: "zh-CN" })); +const nativeSettings = vi.hoisted(() => ({ + image: { studio_enabled: false, responses_image_enabled: false }, + observability: { telemetry_enabled: true, theme_enabled: true }, + cindy: { balance_detection: true, catalog_enabled: true, search_enabled: true }, + updateCindy: vi.fn(async (value) => value), +})); + +vi.mock("@/stores/auth", () => ({ + useAuthStore: () => ({ user: { id: 41, role: "admin" }, isAuthenticated: true, isAdmin: true }), +})); + +vi.mock("@/api/admin/settings", async () => { + const actual = await vi.importActual("@/api/admin/settings"); + const native = { + getImageToolsSettings: vi.fn(async () => ({ ...nativeSettings.image })), + updateImageToolsSettings: vi.fn(async (value) => value), + getObservabilitySettings: vi.fn(async () => ({ ...nativeSettings.observability })), + updateObservabilitySettings: vi.fn(async (value) => value), + getOfficialModelCapacityCatalog: vi.fn(async () => ({ entries: [] })), + }; + return { ...actual, ...native, settingsAPI: { ...actual.settingsAPI, ...native }, default: { ...actual.default, ...native } }; +}); + +vi.mock("@/api/admin/cindyProvider", () => ({ + getCindyProviderSettings: vi.fn(async () => ({ ...nativeSettings.cindy })), + updateCindyProviderSettings: nativeSettings.updateCindy, + getCindyProviderCatalog: vi.fn(async () => []), +})); + vi.mock("@/api", () => ({ adminAPI: { settings: { @@ -267,6 +292,7 @@ vi.mock("vue-i18n", async () => { }); const AppLayoutStub = { template: "
" }; +const RouterLinkStub = { props: ["to"], template: '' }; const ToggleStub = defineComponent({ props: { modelValue: { @@ -583,6 +609,7 @@ function mountView() { global: { stubs: { AppLayout: AppLayoutStub, + RouterLink: RouterLinkStub, Select: SelectStub, Toggle: ToggleStub, Icon: true, @@ -779,17 +806,24 @@ describe("admin SettingsView payment visible method controls", () => { wrapper.unmount(); }); - it("mounts plugin settings without resubmitting retired ticket configuration", async () => { + it("mounts native settings without resubmitting their configuration through the general settings form", async () => { const wrapper = mountView(); await flushPromises(); await openGatewayTab(wrapper); - expect(wrapper.find('[data-test-extension-slot="admin.settings"]').exists()).toBe(true); + expect(wrapper.get('[data-testid="image-tools-settings"]').exists()).toBe(true); + expect(wrapper.get('[data-testid="observability-settings"]').exists()).toBe(true); + expect(wrapper.get('[data-testid="cindy-provider-settings"]').exists()).toBe(true); + expect(wrapper.get('[data-testid="official-model-catalog"]').exists()).toBe(true); + expect(wrapper.get('[data-testid="image-tools-studio"]').element.checked).toBe(false); + expect(wrapper.get('[data-testid="cindy-provider-balance_detection"]').element.checked).toBe(true); + expect(wrapper.find('[data-test-extension-slot="admin.settings"]').exists()).toBe(false); expect(wrapper.find('#codex-ticket-enabled').exists()).toBe(false); expect(wrapper.find('#codex-ticket-harvest-proxy').exists()).toBe(false); await wrapper.find("form").trigger("submit.prevent"); await flushPromises(); expect(updateSettings).toHaveBeenCalledOnce(); expect(Object.keys(updateSettings.mock.calls[0][0]).some(key => key.startsWith('openai_codex_ticket_'))).toBe(false); + expect(nativeSettings.updateCindy).not.toHaveBeenCalled(); wrapper.unmount(); }); @@ -1536,6 +1570,7 @@ describe("admin SettingsView payment visible method controls", () => { global: { stubs: { AppLayout: AppLayoutStub, + RouterLink: RouterLinkStub, Select: SelectStub, Toggle: ToggleStub, Icon: true, @@ -1882,6 +1917,7 @@ describe("admin SettingsView payment visible method controls", () => { global: { stubs: { AppLayout: AppLayoutStub, + RouterLink: RouterLinkStub, Select: SelectStub, Toggle: ToggleStub, Icon: true, diff --git a/plugins/prompt-skills/ui/src/__tests__/locales.spec.ts b/frontend/src/views/admin/__tests__/SystemPromptsView.locale.spec.ts similarity index 93% rename from plugins/prompt-skills/ui/src/__tests__/locales.spec.ts rename to frontend/src/views/admin/__tests__/SystemPromptsView.locale.spec.ts index 12b08c60d84e..095ca6a60cc8 100644 --- a/plugins/prompt-skills/ui/src/__tests__/locales.spec.ts +++ b/frontend/src/views/admin/__tests__/SystemPromptsView.locale.spec.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { createI18n } from 'vue-i18n' -import en from '../locales/en' -import zh from '../locales/zh' +import en from '@/i18n/locales/en/admin/systemPrompts' +import zh from '@/i18n/locales/zh/admin/systemPrompts' const api = vi.hoisted(() => ({ list: vi.fn(), get: vi.fn(), create: vi.fn(), saveDraft: vi.fn(), updateMetadata: vi.fn(), @@ -10,13 +10,10 @@ const api = vi.hoisted(() => ({ getSkillRegistry: vi.fn(), startSkillSync: vi.fn(), publishSkillVersion: vi.fn(), })) const notifications = vi.hoisted(() => ({ showError: vi.fn(), showSuccess: vi.fn(), showWarning: vi.fn() })) -vi.mock('../api', () => ({ default: api })) -vi.mock('@sub2api/plugin-ui', async () => ({ - ...await vi.importActual('@sub2api/plugin-ui'), - useNotifications: () => notifications, -})) +vi.mock('@/api/admin/systemPrompts', () => ({ default: api })) +vi.mock('@/stores/app', () => ({ useAppStore: () => notifications })) -import App from '../App.vue' +import App from '../SystemPromptsView.vue' function messages(value: object, prefix = ''): Record { return Object.fromEntries(Object.entries(value).flatMap(([key, child]) => { @@ -63,6 +60,7 @@ describe('prompt-skills site language', () => { zh: { admin: zh, common: { confirm: '确认', cancel: '取消', close: '关闭' } }, } }) const wrapper = mount(App, { global: { plugins: [i18n], stubs: { + AppLayout: { template: '
' }, Icon: true, Toggle: true, SystemPromptAdvancedDrawer: true, BaseDialog: true, ConfirmDialog: true, } } }) diff --git a/plugins/prompt-skills/ui/src/__tests__/App.spec.ts b/frontend/src/views/admin/__tests__/SystemPromptsView.spec.ts similarity index 98% rename from plugins/prompt-skills/ui/src/__tests__/App.spec.ts rename to frontend/src/views/admin/__tests__/SystemPromptsView.spec.ts index e2a2b0c2f508..06e6050d9d33 100644 --- a/plugins/prompt-skills/ui/src/__tests__/App.spec.ts +++ b/frontend/src/views/admin/__tests__/SystemPromptsView.spec.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { defineComponent } from 'vue' import { flushPromises, mount } from '@vue/test-utils' -import SystemPromptsView from '../App.vue' -import SystemPromptAdvancedDrawer from '../SystemPromptAdvancedDrawer.vue' +import SystemPromptsView from '../SystemPromptsView.vue' +import SystemPromptAdvancedDrawer from '@/components/admin/systemPrompt/SystemPromptAdvancedDrawer.vue' const mocks = vi.hoisted(() => ({ list: vi.fn(), get: vi.fn(), create: vi.fn(), updateMetadata: vi.fn(), saveDraft: vi.fn(), @@ -11,13 +11,13 @@ const mocks = vi.hoisted(() => ({ showSuccess: vi.fn(), showError: vi.fn(), showWarning: vi.fn(), })) -vi.mock('../api', () => ({ default: mocks })) -vi.mock('@sub2api/plugin-ui', async () => ({ ...await vi.importActual('@sub2api/plugin-ui'), useNotifications: () => ({ +vi.mock('@/api/admin/systemPrompts', () => ({ default: mocks })) +vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showSuccess: mocks.showSuccess, showError: mocks.showError, showWarning: mocks.showWarning, }) })) vi.mock('vue-i18n', async () => { const actual = await vi.importActual('vue-i18n') - const { default: messages } = await import('../locales/en') + const { default: messages } = await import('@/i18n/locales/en/admin/systemPrompts') const en = { admin: messages, common: { confirm: 'Confirm', cancel: 'Cancel' } } const resolveMessage = (key: string) => key.split('.').reduce((value, segment) => { if (!value || typeof value !== 'object') return undefined diff --git a/frontend/src/views/admin/__tests__/systemPromptsIntegrationSurface.spec.ts b/frontend/src/views/admin/__tests__/systemPromptsIntegrationSurface.spec.ts index ba23c96b0bee..7bae7af10758 100644 --- a/frontend/src/views/admin/__tests__/systemPromptsIntegrationSurface.spec.ts +++ b/frontend/src/views/admin/__tests__/systemPromptsIntegrationSurface.spec.ts @@ -2,8 +2,6 @@ import { readFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -import en from '@/i18n/locales/en' -import zh from '@/i18n/locales/zh' const here = dirname(fileURLToPath(import.meta.url)) const read = (path: string) => readFileSync(resolve(here, path), 'utf8') @@ -24,14 +22,11 @@ describe('System Prompts integration surface', () => { expect(sidebar).toContain("t('nav.systemPrompts')") }) - it('keeps the page Chinese under the English locale and removes legacy surfaces', () => { - expect(en.admin.systemPrompts).toEqual(zh.admin.systemPrompts) + it('renders the native management page without legacy surfaces', () => { const view = read('../SystemPromptsView.vue') - expect(view).toContain('ExtensionPage') - expect(view).not.toContain('systemPromptsAPI') - const page = read('../../../../../plugins/prompt-skills/ui/src/App.vue') + expect(view).not.toContain('ExtensionPage') for (const marker of ['saveVersion', 'setCurrent', 'rollback', 'syncManagedSource', 'SystemPromptAdvancedDrawer']) { - expect(page).toContain(marker) + expect(view).toContain(marker) } expect(view).not.toContain('previewMerge') expect(view).not.toContain('previewUpstream') @@ -39,7 +34,7 @@ describe('System Prompts integration surface', () => { expect(view).not.toContain('isLegacyComposition') expect(view).not.toContain('copyInstallCommand') - const drawer = read('../../../../../plugins/prompt-skills/ui/src/SystemPromptAdvancedDrawer.vue') + const drawer = read('../../../components/admin/systemPrompt/SystemPromptAdvancedDrawer.vue') expect(drawer).toContain('compact_enabled') expect(drawer).toContain('expose_server_prompt') expect(drawer).toContain('data-test="system-prompt-advanced-drawer"') diff --git a/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue b/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue index e2905610bc70..1f4111c6e5b2 100644 --- a/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue +++ b/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue @@ -130,7 +130,7 @@
{{ rootCauseMessage }}
- +

{{ t('admin.ops.errorDetail.diagnosticPayloads') }}

@@ -231,7 +231,7 @@ import { computed, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import BaseDialog from '@/components/common/BaseDialog.vue' import Icon from '@/components/icons/Icon.vue' -import ExtensionWidget from '@/components/plugins/ExtensionWidget.vue' +import CodexContinuationDiagnostics from '@/components/admin/codex/CodexContinuationDiagnostics.vue' import { useAppStore } from '@/stores' import { opsAPI, type OpsErrorDetail } from '@/api/admin/ops' import { formatDateTime } from '@/utils/format' diff --git a/frontend/src/views/admin/ops/components/__tests__/OpsErrorDetailModal.spec.ts b/frontend/src/views/admin/ops/components/__tests__/OpsErrorDetailModal.spec.ts index b3f1cadac879..1770b87bc350 100644 --- a/frontend/src/views/admin/ops/components/__tests__/OpsErrorDetailModal.spec.ts +++ b/frontend/src/views/admin/ops/components/__tests__/OpsErrorDetailModal.spec.ts @@ -19,8 +19,8 @@ vi.mock('@/stores', () => ({ useAppStore: () => ({ showError: vi.fn() }) })) -vi.mock('@/components/plugins/ExtensionWidget.vue', () => ({ - default: { name: 'ExtensionWidget', props: ['name', 'context'], template: '
' } +vi.mock('@/components/admin/codex/CodexContinuationDiagnostics.vue', () => ({ + default: { name: 'CodexContinuationDiagnostics', props: ['errorId'], template: '
' } })) vi.mock('vue-i18n', async (importOriginal) => { diff --git a/frontend/src/views/user/ImageStudioView.vue b/frontend/src/views/user/ImageStudioView.vue index 691a2bc85061..2648c1ea4fd3 100644 --- a/frontend/src/views/user/ImageStudioView.vue +++ b/frontend/src/views/user/ImageStudioView.vue @@ -1,5 +1,6 @@ - + + diff --git a/plugins/account-tools/ui/src/accountImport.worker.ts b/frontend/src/workers/accountImport.worker.ts similarity index 92% rename from plugins/account-tools/ui/src/accountImport.worker.ts rename to frontend/src/workers/accountImport.worker.ts index 2c3bf9f38a87..3fe798c55296 100644 --- a/plugins/account-tools/ui/src/accountImport.worker.ts +++ b/frontend/src/workers/accountImport.worker.ts @@ -1,4 +1,4 @@ -import { AccountImportParseError, parseAccountImportFiles } from './accountImportParser' +import { AccountImportParseError, parseAccountImportFiles } from '@/utils/accountImportParser' self.onmessage = async (event: MessageEvent) => { try { diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 9d650c4e3a24..4bdfd7b840b8 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -1,3 +1,225 @@ -import preset from '../backend/pkg/extensionapi/ui/tailwind.preset.mjs' - -export default { ...preset, content: [...preset.content, '../backend/pkg/extensionapi/ui/components/**/*.{vue,js,ts}'] } +/** Shared UI defaults with site presentation tokens. */ +export default { + "content": [ + "./index.html", + "./src/**/*.{vue,js,ts,jsx,tsx}" + ], + "darkMode": "class", + "theme": { + "extend": { + "colors": { + "primary": { + "50": "rgb(var(--theme-color-primary-50, 240 253 250) / )", + "100": "rgb(var(--theme-color-primary-100, 204 251 241) / )", + "200": "rgb(var(--theme-color-primary-200, 153 246 228) / )", + "300": "rgb(var(--theme-color-primary-300, 94 234 212) / )", + "400": "rgb(var(--theme-color-primary-400, 45 212 191) / )", + "500": "rgb(var(--theme-color-primary-500, 20 184 166) / )", + "600": "rgb(var(--theme-color-primary-600, 13 148 136) / )", + "700": "rgb(var(--theme-color-primary-700, 15 118 110) / )", + "800": "rgb(var(--theme-color-primary-800, 17 94 89) / )", + "900": "rgb(var(--theme-color-primary-900, 19 78 74) / )", + "950": "rgb(var(--theme-color-primary-950, 4 47 46) / )" + }, + "accent": { + "50": "rgb(var(--theme-color-accent-50, 248 250 252) / )", + "100": "rgb(var(--theme-color-accent-100, 241 245 249) / )", + "200": "rgb(var(--theme-color-accent-200, 226 232 240) / )", + "300": "rgb(var(--theme-color-accent-300, 203 213 225) / )", + "400": "rgb(var(--theme-color-accent-400, 148 163 184) / )", + "500": "rgb(var(--theme-color-accent-500, 100 116 139) / )", + "600": "rgb(var(--theme-color-accent-600, 71 85 105) / )", + "700": "rgb(var(--theme-color-accent-700, 51 65 85) / )", + "800": "rgb(var(--theme-color-accent-800, 30 41 59) / )", + "900": "rgb(var(--theme-color-accent-900, 15 23 42) / )", + "950": "rgb(var(--theme-color-accent-950, 2 6 23) / )" + }, + "dark": { + "50": "rgb(var(--theme-color-dark-50, 248 250 252) / )", + "100": "rgb(var(--theme-color-dark-100, 241 245 249) / )", + "200": "rgb(var(--theme-color-dark-200, 226 232 240) / )", + "300": "rgb(var(--theme-color-dark-300, 203 213 225) / )", + "400": "rgb(var(--theme-color-dark-400, 148 163 184) / )", + "500": "rgb(var(--theme-color-dark-500, 100 116 139) / )", + "600": "rgb(var(--theme-color-dark-600, 71 85 105) / )", + "700": "rgb(var(--theme-color-dark-700, 51 65 85) / )", + "800": "rgb(var(--theme-color-dark-800, 30 41 59) / )", + "900": "rgb(var(--theme-color-dark-900, 15 23 42) / )", + "950": "rgb(var(--theme-color-dark-950, 2 6 23) / )" + }, + "gray": { + "50": "rgb(var(--theme-color-gray-50, 249 250 251) / )", + "100": "rgb(var(--theme-color-gray-100, 243 244 246) / )", + "200": "rgb(var(--theme-color-gray-200, 229 231 235) / )", + "300": "rgb(var(--theme-color-gray-300, 209 213 219) / )", + "400": "rgb(var(--theme-color-gray-400, 156 163 175) / )", + "500": "rgb(var(--theme-color-gray-500, 107 114 128) / )", + "600": "rgb(var(--theme-color-gray-600, 75 85 99) / )", + "700": "rgb(var(--theme-color-gray-700, 55 65 81) / )", + "800": "rgb(var(--theme-color-gray-800, 31 41 55) / )", + "900": "rgb(var(--theme-color-gray-900, 17 24 39) / )", + "950": "rgb(var(--theme-color-gray-950, 3 7 18) / )" + }, + "slate": { + "50": "rgb(var(--theme-color-slate-50, 248 250 252) / )", + "100": "rgb(var(--theme-color-slate-100, 241 245 249) / )", + "200": "rgb(var(--theme-color-slate-200, 226 232 240) / )", + "300": "rgb(var(--theme-color-slate-300, 203 213 225) / )", + "400": "rgb(var(--theme-color-slate-400, 148 163 184) / )", + "500": "rgb(var(--theme-color-slate-500, 100 116 139) / )", + "600": "rgb(var(--theme-color-slate-600, 71 85 105) / )", + "700": "rgb(var(--theme-color-slate-700, 51 65 85) / )", + "800": "rgb(var(--theme-color-slate-800, 30 41 59) / )", + "900": "rgb(var(--theme-color-slate-900, 15 23 42) / )", + "950": "rgb(var(--theme-color-slate-950, 2 6 23) / )" + }, + "canvas": "rgb(var(--ui-canvas) / )", + "surface": "rgb(var(--ui-surface) / )", + "raised": "rgb(var(--ui-raised) / )", + "line": "rgb(var(--ui-line) / )", + "line-strong": "rgb(var(--ui-line-strong) / )", + "ink": "rgb(var(--ui-ink) / )", + "muted": "rgb(var(--ui-muted) / )" + }, + "fontFamily": { + "sans": [ + "var(--ui-font)" + ], + "mono": [ + "ui-monospace", + "SFMono-Regular", + "Menlo", + "Monaco", + "Consolas", + "monospace" + ] + }, + "boxShadow": { + "sm": "var(--theme-shadow-sm, 0 1px 2px 0 rgb(0 0 0 / 0.05))", + "DEFAULT": "var(--theme-shadow-default, 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1))", + "md": "var(--theme-shadow-md, 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1))", + "lg": "var(--theme-shadow-lg, 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1))", + "xl": "var(--theme-shadow-xl, 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1))", + "2xl": "var(--theme-shadow-2xl, 0 25px 50px -12px rgb(0 0 0 / 0.25))", + "inner": "var(--theme-shadow-inner, inset 0 2px 4px 0 rgb(0 0 0 / 0.05))", + "none": "var(--theme-shadow-none, none)", + "glass": "var(--theme-shadow-glass, 0 8px 32px rgba(0, 0, 0, 0.08))", + "glass-sm": "var(--theme-shadow-glass-sm, 0 4px 16px rgba(0, 0, 0, 0.06))", + "glow": "var(--theme-shadow-glow, 0 0 20px rgba(20, 184, 166, 0.25))", + "glow-lg": "var(--theme-shadow-glow-lg, 0 0 40px rgba(20, 184, 166, 0.35))", + "card": "var(--theme-shadow-card, 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.06))", + "card-hover": "var(--theme-shadow-card-hover, 0 10px 40px rgba(0, 0, 0, 0.08))", + "inner-glow": "var(--theme-shadow-inner-glow, inset 0 1px 0 rgba(255, 255, 255, 0.1))", + "outline": "var(--theme-shadow-outline, 0 0 0 1px rgb(var(--ui-line)))" + }, + "backgroundImage": { + "none": "none", + "gradient-to-t": "var(--theme-background-gradient-to-t, linear-gradient(to top, var(--tw-gradient-stops)))", + "gradient-to-tr": "var(--theme-background-gradient-to-tr, linear-gradient(to top right, var(--tw-gradient-stops)))", + "gradient-to-r": "var(--theme-background-gradient-to-r, linear-gradient(to right, var(--tw-gradient-stops)))", + "gradient-to-br": "var(--theme-background-gradient-to-br, linear-gradient(to bottom right, var(--tw-gradient-stops)))", + "gradient-to-b": "var(--theme-background-gradient-to-b, linear-gradient(to bottom, var(--tw-gradient-stops)))", + "gradient-to-bl": "var(--theme-background-gradient-to-bl, linear-gradient(to bottom left, var(--tw-gradient-stops)))", + "gradient-to-l": "var(--theme-background-gradient-to-l, linear-gradient(to left, var(--tw-gradient-stops)))", + "gradient-to-tl": "var(--theme-background-gradient-to-tl, linear-gradient(to top left, var(--tw-gradient-stops)))", + "gradient-radial": "var(--theme-background-gradient-radial, radial-gradient(var(--tw-gradient-stops)))", + "gradient-primary": "var(--theme-background-gradient-primary, linear-gradient(135deg, #14b8a6 0%, #0d9488 100%))", + "gradient-dark": "var(--theme-background-gradient-dark, linear-gradient(135deg, #1e293b 0%, #0f172a 100%))", + "gradient-glass": "var(--theme-background-gradient-glass, linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%))", + "mesh-gradient": "var(--theme-background-mesh-gradient, radial-gradient(at 40% 20%, rgba(20, 184, 166, 0.12) 0px, transparent 50%), radial-gradient(at 80% 0%, rgba(6, 182, 212, 0.08) 0px, transparent 50%), radial-gradient(at 0% 50%, rgba(20, 184, 166, 0.08) 0px, transparent 50%))" + }, + "animation": { + "fade-in": "var(--theme-animation-fade-in, fadeIn 0.3s ease-out)", + "slide-up": "var(--theme-animation-slide-up, slideUp 0.3s ease-out)", + "slide-down": "var(--theme-animation-slide-down, slideDown 0.3s ease-out)", + "slide-in-right": "var(--theme-animation-slide-in-right, slideInRight 0.3s ease-out)", + "scale-in": "var(--theme-animation-scale-in, scaleIn 0.2s ease-out)", + "pulse-slow": "var(--theme-animation-pulse-slow, pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite)", + "shimmer": "var(--theme-animation-shimmer, shimmer 2s linear infinite)", + "glow": "var(--theme-animation-glow, glow 2s ease-in-out infinite alternate)" + }, + "keyframes": { + "fadeIn": { + "0%": { + "opacity": "0" + }, + "100%": { + "opacity": "1" + } + }, + "slideUp": { + "0%": { + "opacity": "0", + "transform": "translateY(10px)" + }, + "100%": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "slideDown": { + "0%": { + "opacity": "0", + "transform": "translateY(-10px)" + }, + "100%": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "slideInRight": { + "0%": { + "opacity": "0", + "transform": "translateX(20px)" + }, + "100%": { + "opacity": "1", + "transform": "translateX(0)" + } + }, + "scaleIn": { + "0%": { + "opacity": "0", + "transform": "scale(0.95)" + }, + "100%": { + "opacity": "1", + "transform": "scale(1)" + } + }, + "shimmer": { + "0%": { + "backgroundPosition": "-200% 0" + }, + "100%": { + "backgroundPosition": "200% 0" + } + }, + "glow": { + "0%": { + "boxShadow": "0 0 20px rgba(20, 184, 166, 0.25)" + }, + "100%": { + "boxShadow": "0 0 30px rgba(20, 184, 166, 0.4)" + } + } + }, + "backdropBlur": { + "xs": "2px" + }, + "borderRadius": { + "none": "0px", + "sm": "var(--theme-radius-sm, 0.125rem)", + "DEFAULT": "var(--theme-radius-default, 0.25rem)", + "md": "var(--theme-radius-md, 0.375rem)", + "lg": "var(--theme-radius-lg, 0.5rem)", + "xl": "var(--theme-radius-xl, 0.75rem)", + "2xl": "var(--theme-radius-2xl, 1rem)", + "3xl": "var(--theme-radius-3xl, 1.5rem)", + "full": "9999px", + "4xl": "var(--theme-radius-4xl, 2rem)" + } + } + }, + "plugins": [] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 4bbaab036b33..cbc2c25d4f36 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -17,8 +17,6 @@ "noFallthroughCasesInSwitch": true, "paths": { "@/*": ["./src/*"], - "@sub2api/plugin-ui": ["../backend/pkg/extensionapi/ui/index.ts"], - "@sub2api/plugin-ui/*": ["../backend/pkg/extensionapi/ui/*"], "vue-draggable-plus": ["./node_modules/vue-draggable-plus"], "vue": ["./node_modules/vue"], "vue-i18n": ["./node_modules/vue-i18n"] diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 100cd9c50ee2..7118e0581c38 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -98,7 +98,6 @@ export default defineConfig(({ mode }) => { dedupe: ['vue', 'vue-i18n'], alias: { '@': resolve(__dirname, 'src'), - '@sub2api/plugin-ui': resolve(__dirname, '../backend/pkg/extensionapi/ui'), 'vue': resolve(__dirname, 'node_modules/vue/dist/vue.runtime.esm-bundler.js'), // 使用 vue-i18n 运行时版本,避免 CSP unsafe-eval 问题 'vue-i18n': 'vue-i18n/dist/vue-i18n.runtime.esm-bundler.js' diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index acc6a26a7c83..3e00c000ee30 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -11,7 +11,6 @@ export default defineConfig({ dedupe: ['vue', 'vue-i18n', '@vue/test-utils', 'vitest', 'vue-draggable-plus'], alias: { '@': resolve(__dirname, 'src'), - '@sub2api/plugin-ui': resolve(__dirname, '../backend/pkg/extensionapi/ui'), 'vue': resolve(__dirname, 'node_modules/vue/dist/vue.runtime.esm-bundler.js'), 'vue-i18n': 'vue-i18n/dist/vue-i18n.runtime.esm-bundler.js' } @@ -20,7 +19,7 @@ export default defineConfig({ globals: true, environment: 'jsdom', setupFiles: ['./src/__tests__/setup.ts'], - include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}', '../plugins/*/ui/src/**/*.{test,spec}.{js,ts,jsx,tsx}'], + include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'], exclude: ['node_modules', 'dist'], coverage: { provider: 'v8', diff --git a/plugins/account-tools/cmd/plugin/main.go b/plugins/account-tools/cmd/plugin/main.go deleted file mode 100644 index 418923eacd1c..000000000000 --- a/plugins/account-tools/cmd/plugin/main.go +++ /dev/null @@ -1,12 +0,0 @@ -package main - -import ( - policy "github.com/HTExplicit/sub2api-plugins/accounttools/policy" - extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" -) - -var version = "0.2.11" - -func main() { - extensionv1.Serve(extensionv1.Definition{ID: "codexrip.account-tools", Version: version, Capabilities: []string{extensionv1.CapabilityAdmin, extensionv1.CapabilityUI}}, policy.New()) -} diff --git a/plugins/account-tools/go.mod b/plugins/account-tools/go.mod deleted file mode 100644 index abcf2c6d107d..000000000000 --- a/plugins/account-tools/go.mod +++ /dev/null @@ -1,24 +0,0 @@ -module github.com/HTExplicit/sub2api-plugins/accounttools - -go 1.27.0 - -require github.com/Wei-Shaw/sub2api v0.0.0 - -require ( - github.com/fatih/color v1.18.0 // indirect - github.com/golang/protobuf v1.5.4 // indirect - github.com/hashicorp/go-hclog v1.6.3 // indirect - github.com/hashicorp/go-plugin v1.8.0 // indirect - github.com/hashicorp/yamux v0.1.2 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/oklog/run v1.1.0 // indirect - golang.org/x/net v0.58.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.41.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/grpc v1.83.2 // indirect - google.golang.org/protobuf v1.36.11 // indirect -) - -replace github.com/Wei-Shaw/sub2api => ../../backend diff --git a/plugins/account-tools/go.sum b/plugins/account-tools/go.sum deleted file mode 100644 index 76372f7247c2..000000000000 --- a/plugins/account-tools/go.sum +++ /dev/null @@ -1,83 +0,0 @@ -github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= -github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= -github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs= -github.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= -github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= -github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= -github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= -github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= -github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= -github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= -go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= -golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= -gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= -google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/account-tools/manifest.source.json b/plugins/account-tools/manifest.source.json deleted file mode 100644 index 4d551244a258..000000000000 --- a/plugins/account-tools/manifest.source.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "schema_version": 1, - "id": "codexrip.account-tools", - "name": "账号工具", - "version": "0.2.13", - "description": "账号分类操作策略与连接测试选项", - "author": "HTExplicit", - "requires": { "sub2api": ">=0.2.7-codexrip.2 <0.2.9", "tested_sub2api_versions": [], "plugin_protocol": 1, "transport_api": 1, "ui_bridge": 1, "extension_api": 1 }, - "capabilities": [ - { "id": "extensions.admin.v1", "platform": "*", "account_type": "*" }, - { "id": "extensions.ui.v1", "platform": "*", "account_type": "*" } - ], - "operations": { "extensions.admin.v1": ["taxonomy.name", "taxonomy.order", "taxonomy.bulk", "taxonomy.assignment", "taxonomy.delete", "test.reasoning", "test.prompt", "test.batch", "import.plan"] }, - "resources": [ - { "name": "import.preview", "capability": "extensions.admin.v1", "permission": "admin" }, - { "name": "import.submit", "capability": "extensions.admin.v1", "permission": "admin" }, - { "name": "taxonomy.folders.list", "capability": "extensions.admin.v1", "permission": "admin" }, - { "name": "taxonomy.folders.create", "capability": "extensions.admin.v1", "permission": "admin" }, - { "name": "taxonomy.folders.order", "capability": "extensions.admin.v1", "permission": "admin" }, - { "name": "taxonomy.folders.update", "capability": "extensions.admin.v1", "permission": "admin" }, - { "name": "taxonomy.folders.delete", "capability": "extensions.admin.v1", "permission": "admin" }, - { "name": "taxonomy.tags.list", "capability": "extensions.admin.v1", "permission": "admin" }, - { "name": "taxonomy.tags.create", "capability": "extensions.admin.v1", "permission": "admin" }, - { "name": "taxonomy.tags.order", "capability": "extensions.admin.v1", "permission": "admin" }, - { "name": "taxonomy.tags.update", "capability": "extensions.admin.v1", "permission": "admin" }, - { "name": "taxonomy.tags.delete", "capability": "extensions.admin.v1", "permission": "admin" }, - { "name": "taxonomy.account.update", "capability": "extensions.admin.v1", "permission": "admin" }, - { "name": "taxonomy.bulk.update", "capability": "extensions.admin.v1", "permission": "admin" }, - { "name": "tests.models", "capability": "extensions.admin.v1", "permission": "admin" }, - { "name": "tests.submit", "capability": "extensions.admin.v1", "permission": "admin" } - ], - "contributions": [ - { "id": "account-import", "slot": "surface", "capability": "extensions.admin.v1", "entrypoint": "ui/compiled/index.html", "events": ["close"], "permission": "admin", "label": { "zh": "导入账号", "en": "Import accounts" } }, - { - "id": "test-prompt", "slot": "account.test.prompt", "capability": "extensions.admin.v1", "permission": "admin", - "label": { "zh": "文本测试提示词", "en": "Text test prompt" }, - "fields": [{ "key": "prompt", "kind": "textarea", "rows": 3, "max_length": 8192, "placeholder": "hi", - "label": { "zh": "文本测试提示词", "en": "Text test prompt" }, - "hint": { "zh": "留空使用 hi;仅当前站点和管理员浏览器记忆。", "en": "Blank uses hi. Remembered only for this site and administrator in this browser." }, - "reset_label": { "zh": "恢复默认", "en": "Restore default" }, - "limit_message": { "zh": "提示词超过8192字符,请缩短后重试。", "en": "Prompt exceeds 8192 characters. Shorten it to continue." } - }] - }, - { - "id": "test-reasoning", "slot": "account.test", "capability": "extensions.admin.v1", "permission": "admin", - "label": { "zh": "推理强度", "en": "Reasoning effort" }, - "fields": [{ "key": "reasoning_effort", "kind": "select", "label": { "zh": "推理强度", "en": "Reasoning effort" }, "options_source": "reasoning_efforts", "default_label": { "zh": "默认", "en": "Default" }, "default_source": "default_reasoning_effort" }] - }, - { "id": "account-taxonomy", "slot": "surface", "capability": "extensions.admin.v1", "permission": "admin", "label": { "zh": "账号分类", "en": "Account classification" } }, - { "id": "account-taxonomy-navigation", "slot": "surface", "capability": "extensions.admin.v1", "entrypoint": "ui/compiled/index.html", "events": ["select", "manage", "retry"], "permission": "admin", "label": { "zh": "账号分类", "en": "Account classification" } }, - { "id": "account-taxonomy-manager", "slot": "surface", "capability": "extensions.admin.v1", "entrypoint": "ui/compiled/index.html", "events": ["close", "changed"], "permission": "admin", "label": { "zh": "管理分类", "en": "Manage classification" } }, - { "id": "account-taxonomy-bulk", "slot": "surface", "capability": "extensions.admin.v1", "entrypoint": "ui/compiled/index.html", "events": ["close", "stale"], "permission": "admin", "label": { "zh": "批量分类", "en": "Bulk classification" } }, - { "id": "account-taxonomy-edit", "slot": "surface", "capability": "extensions.admin.v1", "entrypoint": "ui/compiled/index.html", "events": ["changed"], "permission": "admin", "label": { "zh": "账号分类", "en": "Account classification" } }, - { "id": "account-batch-test", "slot": "surface", "capability": "extensions.admin.v1", "entrypoint": "ui/compiled/index.html", "events": ["close"], "permission": "admin", "label": { "zh": "批量测试", "en": "Batch testing" } }, - { "id": "account-tools-settings", "slot": "admin.settings", "capability": "extensions.admin.v1", "action": "tools.describe", "entrypoint": "ui/index.html", "permission": "admin", "label": { "zh": "账号工具", "en": "Account tools" } } - ], - "runtimes": {}, - "ui": { "entrypoint": "ui/index.html" }, - "files": {} -} diff --git a/plugins/account-tools/scripts/check-ui.py b/plugins/account-tools/scripts/check-ui.py deleted file mode 100644 index c3af2b05beab..000000000000 --- a/plugins/account-tools/scripts/check-ui.py +++ /dev/null @@ -1,192 +0,0 @@ -"""Offline checks of independent account tools, including inline frame sizing.""" -import argparse -import json -import mimetypes -import threading -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path -from urllib.parse import urlsplit -from playwright.sync_api import expect, sync_playwright - -ROOT = Path(__file__).resolve().parents[3] -COMPILED = ROOT / "plugins/account-tools/ui/compiled" -HARNESS = r'''''' - - -class Handler(BaseHTTPRequestHandler): - def log_message(self, *_): - pass - - def do_GET(self): - target = urlsplit(self.path).path - if target == "/": - data, mime = HARNESS.encode(), "text/html; charset=utf-8" - else: - assets = {"/ui/" + file.relative_to(COMPILED).as_posix(): file for file in COMPILED.rglob("*") if file.is_file()} - if target not in assets: - self.send_error(404) - return - data, mime = assets[target].read_bytes(), mimetypes.guess_type(target)[0] or "application/octet-stream" - self.send_response(200) - self.send_header("Content-Type", mime) - self.send_header("Cache-Control", "no-store") - if target.startswith("/ui/"): - self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Cross-Origin-Resource-Policy", "cross-origin") - self.send_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:; connect-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'") - self.end_headers() - self.wfile.write(data) - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--output", type=Path, required=True) - parser.add_argument("--mode") - parser.add_argument("--theme", default="dark", choices=["dark", "light"]) - parser.add_argument("--width", type=int) - parser.add_argument("--skip", action="append", default=[]) - args = parser.parse_args() - args.output.mkdir(parents=True, exist_ok=True) - server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) - threading.Thread(target=server.serve_forever, daemon=True).start() - results = [] - try: - with sync_playwright() as playwright: - browser = playwright.chromium.launch() - try: - modes = ['account-taxonomy-navigation', 'account-taxonomy-manager', 'account-taxonomy-bulk', 'account-batch-test', 'account-taxonomy-edit', 'account-import'] - for width in ([args.width] if args.width else (1200, 390)): - for mode in modes: - if args.mode and args.mode != mode: - continue - if f'{mode}:{width}' in args.skip: - continue - page = browser.new_page(viewport={"width": width, "height": 920}) - errors, external = [], [] - workers = [] - page.on('worker', lambda worker: workers.append(worker.url)) - page.on('pageerror', lambda error: errors.append(str(error))) - def route_request(route): - if urlsplit(route.request.url).hostname != '127.0.0.1': - external.append(route.request.url) - route.abort() - else: - route.continue_() - page.route('**/*', route_request) - page.goto(f'http://127.0.0.1:{server.server_port}/?mode={mode}&theme={args.theme}') - frame = page.frame_locator('iframe') - if mode == 'account-import': - expect(frame.locator('#account-import-job-form')).to_be_visible() - else: - expect(frame.locator('#app')).not_to_be_empty() - labels = frame.get_by_role('button').all_text_contents() - assert labels - if mode == 'account-taxonomy-navigation': - if width < 1024: - frame.locator('[aria-haspopup="listbox"]').click() - choice = frame.get_by_role('option').filter(has_text='生产账号') - else: - choice = frame.locator('[data-test="desktop-taxonomy-option"]').filter(has_text='生产账号') - expect(choice).to_be_visible() - box = choice.bounding_box() - assert box['y'] + box['height'] <= page.locator('iframe').bounding_box()['height'] + 2 - choice.click() - page.wait_for_function("events.some(item=>item.name==='select'&&item.payload==='3')") - elif mode == 'account-import': - imported = {'type': 'sub2api-data', 'version': 2, 'exported_at': '2030-01-01T00:00:00Z', 'accounts': [{'name': 'Fixture', 'platform': 'openai', 'type': 'oauth', 'credentials': {'access_token': 'synthetic-token'}}], 'proxies': []} - frame.locator('input[type="file"]').set_input_files({'name': 'fixture.json', 'mimeType': 'application/json', 'buffer': json.dumps(imported).encode()}) - expect(frame.locator('[data-test="preview-import"]')).to_be_enabled() - frame.locator('[data-test="preview-import"]').click() - expect(frame.locator('[data-test="import-preview"]')).to_be_visible() - frame.locator('[data-test="submit-import-job"]').click() - page.wait_for_function('jobs.includes(77)') - assert page.evaluate("writes[0].operation==='import.submit' && writes[0].input.body.data.accounts[0].credentials.access_token==='synthetic-token'") - assert workers, 'Import parsing must execute in the packaged worker' - elif mode == 'account-taxonomy-manager': - frame.locator('form input').fill('新分类') - frame.locator('form button[type="submit"]').click() - page.wait_for_function("events.some(item=>item.name==='changed')") - elif mode == 'account-taxonomy-bulk': - frame.locator('[data-test="bulk-taxonomy-folder-set"]').check() - frame.locator('[data-test="bulk-taxonomy-folder-select"]').select_option('3') - frame.locator('[data-test="bulk-taxonomy-submit"]').click() - page.wait_for_function('jobs.includes(77)') - assert page.evaluate('Boolean(writes[0].input.operation_key)') - elif mode == 'account-batch-test': - expect(frame.locator('[data-account-id]')).to_have_count(2) - expect(frame.locator('[data-account-id="1"]')).to_contain_text('Model A') - assert page.evaluate("modelReads.length===1 && modelReads[0].query.view==='account-test-plan-v1' && JSON.stringify(modelReads[0].body.account_ids)==='[1,2]'") - frame.locator('textarea').fill('离线测试提示词') - frame.locator('[data-account-id="1"] select').select_option('high') - frame.locator('button[form="batch-test-accounts"]').click() - page.wait_for_function('jobs.includes(77)') - assert page.evaluate("JSON.stringify(writes[0].input.body.items.map(item=>[item.account_id,item.model_id]))==='[[1,\"model-a\"],[2,\"model-a\"]]'") - assert page.evaluate('writes[0].input.body.items[0].reasoning_effort') == 'high' - assert page.evaluate('preference') == '离线测试提示词' - else: - frame.locator('select').select_option('3') - frame.get_by_role('button').first.click() - page.wait_for_function("events.some(item=>item.name==='changed')") - assert frame.locator('body').evaluate('node=>node.scrollWidth<=innerWidth') - assert not errors, errors - assert not external, external - if mode == 'account-taxonomy-manager' and width == 1200: - direct = [] - page.on('request', lambda request: direct.append(request.url) if '/blocked-native-submit' in request.url else None) - frame.locator('body').evaluate("node=>{const form=document.createElement('form');form.action='/blocked-native-submit';document.body.append(form);form.submit();form.remove()}") - expect(frame.locator('form input')).to_be_visible() - assert not direct, 'CSP must block native network form submission' - page.screenshot(path=str(args.output / f'{mode}-{width}.png'), full_page=True) - results.append({"mode": mode, "width": width, "theme": args.theme, "page_errors": errors, "external_requests": 0}) - page.close() - finally: - browser.close() - finally: - server.shutdown() - server.server_close() - output = {"offline_only": True, "cases": results} - (args.output / 'account-tools-ui.json').write_text(json.dumps(output, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') - print(json.dumps(output, ensure_ascii=False)) - - -if __name__ == '__main__': - main() diff --git a/plugins/account-tools/ui/assets/app.js b/plugins/account-tools/ui/assets/app.js deleted file mode 100644 index daa5c30b9076..000000000000 --- a/plugins/account-tools/ui/assets/app.js +++ /dev/null @@ -1,12 +0,0 @@ -/* global Sub2APIPluginBridge */ -(async () => { - const bridge = new Sub2APIPluginBridge() - window.addEventListener('pagehide', () => bridge.dispose(), { once: true }) - const context = await bridge.context() - document.documentElement.dataset.theme = context.theme || 'light' - const en = String(context.locale).startsWith('en') - await bridge.invoke('tools.describe') - document.getElementById('title').textContent = en ? 'Account tools' : '账号工具' - document.getElementById('description').textContent = en ? 'Manage folders and tags from Accounts. Connection tests offer reasoning levels supported by each model; batch tests retain each selection when retried.' : '在账号页管理文件夹与标签。连接测试按每个模型实际支持的档位选择推理强度,批量任务重试保留各账号的原选择。' - bridge.resize(document.documentElement.scrollHeight) -})().catch(error => { document.getElementById('description').textContent = error.message || 'Account tools unavailable' }) diff --git a/plugins/account-tools/ui/assets/styles.css b/plugins/account-tools/ui/assets/styles.css deleted file mode 100644 index 73433c67c7c6..000000000000 --- a/plugins/account-tools/ui/assets/styles.css +++ /dev/null @@ -1,3 +0,0 @@ -:root{color-scheme:light;--bg:#fff;--fg:#1f2937;--muted:#6b7280} -:root[data-theme=dark]{color-scheme:dark;--bg:#111827;--fg:#e5e7eb;--muted:#9ca3af} -body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.6 system-ui,sans-serif}main{padding:20px;max-width:920px;margin:auto}h2{font-size:18px;margin:0 0 16px}p{color:var(--muted)} diff --git a/plugins/account-tools/ui/index.html b/plugins/account-tools/ui/index.html deleted file mode 100644 index c394449eb647..000000000000 --- a/plugins/account-tools/ui/index.html +++ /dev/null @@ -1,3 +0,0 @@ - -账号工具 -

diff --git a/plugins/account-tools/ui/src/AccountBulkTaxonomyModal.vue b/plugins/account-tools/ui/src/AccountBulkTaxonomyModal.vue deleted file mode 100644 index 27e20612a704..000000000000 --- a/plugins/account-tools/ui/src/AccountBulkTaxonomyModal.vue +++ /dev/null @@ -1,149 +0,0 @@ - - - diff --git a/plugins/account-tools/ui/src/AccountFolderBar.vue b/plugins/account-tools/ui/src/AccountFolderBar.vue deleted file mode 100644 index 6fc26ef670de..000000000000 --- a/plugins/account-tools/ui/src/AccountFolderBar.vue +++ /dev/null @@ -1,178 +0,0 @@ - - - diff --git a/plugins/account-tools/ui/src/AccountTaxonomyManager.vue b/plugins/account-tools/ui/src/AccountTaxonomyManager.vue deleted file mode 100644 index 4b87434026bf..000000000000 --- a/plugins/account-tools/ui/src/AccountTaxonomyManager.vue +++ /dev/null @@ -1,267 +0,0 @@ - - - diff --git a/plugins/account-tools/ui/src/AccountTestModelSelect.vue b/plugins/account-tools/ui/src/AccountTestModelSelect.vue deleted file mode 100644 index 628b6511f183..000000000000 --- a/plugins/account-tools/ui/src/AccountTestModelSelect.vue +++ /dev/null @@ -1,13 +0,0 @@ - - diff --git a/plugins/account-tools/ui/src/AccountTestReasoningSelect.vue b/plugins/account-tools/ui/src/AccountTestReasoningSelect.vue deleted file mode 100644 index d72b5388f5a0..000000000000 --- a/plugins/account-tools/ui/src/AccountTestReasoningSelect.vue +++ /dev/null @@ -1,22 +0,0 @@ - - diff --git a/plugins/account-tools/ui/src/AccountTextTestPrompt.vue b/plugins/account-tools/ui/src/AccountTextTestPrompt.vue deleted file mode 100644 index 0e3a5a1b76e1..000000000000 --- a/plugins/account-tools/ui/src/AccountTextTestPrompt.vue +++ /dev/null @@ -1,21 +0,0 @@ - - -
- -
-
- - - - - - - - - - - -
{{ t('admin.systemPrompts.history.version') }}{{ t('admin.systemPrompts.history.status') }}{{ t('admin.systemPrompts.history.created') }}{{ t('admin.systemPrompts.history.actions') }}
{{ version.id === runtimeVersionId ? t('admin.systemPrompts.history.active') : t('admin.systemPrompts.history.candidate') }}{{ formatDate(version.created_at) }} - - -
{{ t('admin.systemPrompts.history.empty') }}
-
-
- - -
{{ t('admin.systemPrompts.templates.select') }}
-
- -
- - - - -
-
-
-
-
-
- - -
-
-
-
-
-
-
-
- - -
-
{{ t('admin.systemPrompts.source.repository') }}: {{ versionDetails.source_repository }}
{{ t('admin.systemPrompts.source.version') }}: {{ versionDetails.source_version }}
{{ t('admin.systemPrompts.source.commit') }}: {{ versionDetails.source_commit }}
{{ t('admin.systemPrompts.source.artifact') }}: {{ versionDetails.source_artifact }}
{{ versionDetails.source_artifact_sha256 }}
{{ versionDetails.source_license_sha256 }}
-
{{ t('admin.systemPrompts.source.inline') }}
-
-
- - -
-
-
-
-
-
- - - - - - diff --git a/plugins/prompt-skills/ui/src/api.ts b/plugins/prompt-skills/ui/src/api.ts deleted file mode 100644 index 3c7c7bfea630..000000000000 --- a/plugins/prompt-skills/ui/src/api.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { resource } from '@sub2api/plugin-ui' - -export type SystemPromptCompositionMode = 'inline' | 'codex_skill_hybrid' -export type SystemPromptClientMode = 'codex' | 'openai_compatible' -export type RemoteSkillSourceID = 'moxinggang' - -export interface SystemPromptRuntime { - enabled: boolean - expose_server_prompt: boolean - compact_enabled: boolean - template_id: number - version_id: number - template_version: number - revision: number - sha256: string - byte_length: number - degraded: boolean - composition_mode: SystemPromptCompositionMode - bundle_id: string - bundle_manifest_sha256: string - registry_revision?: number - registry_raw_tree_sha256?: string - registry_effective_tree_sha256?: string - registry_prompt_raw_sha256?: string - registry_prompt_effective_sha256?: string - registry_upstream_source_id?: RemoteSkillSourceID - registry_upstream_root?: string - registry_public_root?: string - bundle_available: boolean - bundle_degraded: boolean - degraded_reason?: string - updated_at: string -} - -export interface SystemPromptTemplate { - id: number - slug: string - name: string - description: string - is_seed: boolean - managed_source?: string - created_by?: number - updated_by?: number - created_at: string - updated_at: string -} - -export interface SystemPromptVersion { - id: number - template_id: number - version: number - body: string - sha256: string - byte_length: number - note: string - composition_mode: SystemPromptCompositionMode - bundle_id: string - bundle_manifest_sha256: string - created_by?: number - published_at?: string - published_by?: number - created_at: string - is_active: boolean - source_repository?: string - source_commit?: string - source_version?: string - source_artifact?: string - source_artifact_sha256?: string - source_license_sha256?: string -} - -export interface RemoteSkillBundleVersion { - id: number - upstream_source_id: RemoteSkillSourceID - upstream_root: string - public_root: string - raw_tree_sha256: string - effective_tree_sha256: string - prompt_version_id: number - file_count: number - raw_total_bytes: number - effective_total_bytes: number - added_files: number - modified_files: number - deleted_files: number - script_changes: number - binary_changes: number - fetched_at: string - created_by?: number - published_at?: string - published_by?: number - created_at: string -} - -export interface RemoteSkillPromptVersion { - id: number - raw_sha256: string - effective_sha256: string - diff: string - created_by?: number - created_at: string -} - -export interface RemoteSkillPromptVersionDetail extends RemoteSkillPromptVersion { - raw_body: string - effective_body: string -} - -export interface RemoteSkillFileChange { - path: string - change: 'added' | 'modified' | 'deleted' - kind: string - raw_sha256?: string - effective_sha256?: string - previous_effective_sha256?: string -} - -export interface RemoteSkillBundleVersionDetail extends RemoteSkillBundleVersion { - prompt: RemoteSkillPromptVersionDetail - file_changes: RemoteSkillFileChange[] - verified: boolean -} - -export interface RemoteSkillRegistrySnapshot { - revision: number - active?: RemoteSkillBundleVersion - active_prompt?: RemoteSkillPromptVersion - degraded: boolean - degraded_reason?: string - updated_at: string -} - -export interface RemoteSkillRegistryResponse { - runtime: RemoteSkillRegistrySnapshot - versions: RemoteSkillBundleVersion[] - source: { - upstream_source_id: RemoteSkillSourceID - upstream_root: string - public_root: string - } -} - -export interface RemoteSkillSyncJob { - id: number - status: 'queued' | 'running' | 'succeeded' | 'failed' - progress_stage: string - candidate_bundle_version_id?: number - prompt_capture_provided: boolean - error_code?: string - created_at: string - started_at?: string - completed_at?: string -} - -export type ManagedSourceSyncStatus = 'up_to_date' | 'no_prompt_change' | 'candidate_created' - -export interface ManagedSourceSyncVersion { - id: number - template_id: number - version: number - sha256: string - byte_length: number - source_repository?: string - source_commit?: string - source_version?: string - source_artifact?: string - source_artifact_sha256?: string - source_license_sha256?: string -} - -export interface ManagedSourceSyncResponse { - status: ManagedSourceSyncStatus - version?: ManagedSourceSyncVersion -} - -export interface SystemPromptListResponse { - templates: SystemPromptTemplate[] - runtime: SystemPromptRuntime -} - -export interface SystemPromptDetailResponse { - template: SystemPromptTemplate - versions: SystemPromptVersion[] - runtime: SystemPromptRuntime -} - -export interface CreateSystemPromptRequest { - slug: string - name: string - description: string - body: string - note: string - composition_mode?: SystemPromptCompositionMode - bundle_id?: string - bundle_manifest_sha256?: string - expected_revision: number -} - -export interface PreviewUpstreamResponse { - body: unknown - client_mode: SystemPromptClientMode - base_server_instructions: string - final_server_instructions: string - application: { - applied: boolean - carrier: string - client_instructions: string - server_instructions: string - revision: number - sha256: string - base_sha256?: string - effective_sha256?: string - effective_byte_length?: number - bundle_id?: string - bundle_manifest_sha256?: string - bundle_revision?: number - bundle_raw_tree_sha256?: string - bundle_effective_tree_sha256?: string - bundle_prompt_raw_sha256?: string - bundle_prompt_effective_sha256?: string - bundle_upstream_source_id?: string - bundle_upstream_root?: string - bundle_public_root?: string - degraded?: boolean - degraded_reason?: string - } -} - - -export const list = () => resource('prompts.list') -export const get = (id: number) => resource('prompts.read', { params: { id } }) -export const listVersions = (id: number) => resource('prompts.versions', { params: { id } }) -export const getSkillRegistry = () => resource('skills.registry') -export const getSkillVersion = (id: number) => resource('skills.version', { params: { bundle_version_id: id } }) -export const getSkillSync = (id: number) => resource('skills.sync.read', { params: { sync_id: id } }) - -export function startSkillSync(expectedRevision: number, promptCapture?: File) { - const form: Array<[string, string | Blob]> = [['expected_revision', String(expectedRevision)]] - if (promptCapture) form.push(['prompt_capture', promptCapture]) - return resource('skills.sync.start', { form }) -} - -export function publishSkillVersion(id: number, expectedRevision: number, rollback = false) { - return resource(rollback ? 'skills.rollback' : 'skills.publish', { - params: { bundle_version_id: id }, body: { expected_revision: expectedRevision } - }) -} - -export const create = (body: CreateSystemPromptRequest) => resource('prompts.create', { body }) -export function updateMetadata(id: number, body: { name?: string; description?: string; expected_revision: number }) { - return resource('prompts.update', { params: { id }, body }) -} - -export function saveDraft(id: number, body: { - body: string; note: string; composition_mode: SystemPromptCompositionMode; bundle_id: string - bundle_manifest_sha256: string; expected_latest_version: number; expected_revision: number -}) { - return resource('prompts.draft', { params: { id }, body }) -} - -export function syncManagedSource(id: number, body: { expected_latest_version: number; expected_revision: number }) { - return resource('prompts.source.sync', { params: { id }, body }) -} - -export function publish(id: number, versionId: number, expectedRevision: number, rollback = false) { - return resource(rollback ? 'prompts.rollback' : 'prompts.publish', { - params: { id, version_id: versionId }, body: { expected_revision: expectedRevision } - }) -} - -export function updateRuntime(body: { expected_revision: number; enabled: boolean; expose_server_prompt: boolean; compact_enabled: boolean }) { - return resource('prompts.runtime.update', { body }) -} - -export function duplicate(id: number, body: { slug: string; name: string; expected_revision: number }) { - return resource('prompts.duplicate', { params: { id }, body }) -} - -export function remove(id: number, expectedRevision: number) { - return resource<{ deleted: boolean }>('prompts.delete', { params: { id }, query: { expected_revision: expectedRevision } }) -} - -export function previewMerge(body: { - template_id?: number; version_id?: number; client_instructions: string; server_instructions?: string - composition_mode?: SystemPromptCompositionMode; bundle_id?: string; bundle_manifest_sha256?: string - body?: unknown; client_mode: SystemPromptClientMode -}) { - return resource<{ instructions: string; client_mode: SystemPromptClientMode; base_server_instructions: string; final_server_instructions: string; application: PreviewUpstreamResponse['application'] }>('prompts.preview.merge', { body }) -} - -export function previewUpstream(body: { - template_id: number; version_id: number; server_instructions?: string; composition_mode?: SystemPromptCompositionMode - bundle_id?: string; bundle_manifest_sha256?: string; protocol: 'responses' | 'chat'; compact: boolean - body: unknown; client_mode: SystemPromptClientMode -}) { - return resource('prompts.preview.upstream', { body }) -} - -export default { list, get, listVersions, getSkillRegistry, getSkillVersion, startSkillSync, getSkillSync, - publishSkillVersion, create, updateMetadata, saveDraft, syncManagedSource, publish, updateRuntime, - duplicate, remove, previewMerge, previewUpstream } diff --git a/plugins/prompt-skills/ui/src/index.html b/plugins/prompt-skills/ui/src/index.html deleted file mode 100644 index 1c108c4b3e56..000000000000 --- a/plugins/prompt-skills/ui/src/index.html +++ /dev/null @@ -1,2 +0,0 @@ - -提示词与技能管理
diff --git a/plugins/prompt-skills/ui/src/main.ts b/plugins/prompt-skills/ui/src/main.ts deleted file mode 100644 index bcf902935dde..000000000000 --- a/plugins/prompt-skills/ui/src/main.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { mountPlugin } from '@sub2api/plugin-ui' -import '@sub2api/plugin-ui/base.css' -import App from './App.vue' -import zh from './locales/zh' -import en from './locales/en' - -void mountPlugin(App, { - zh: { admin: zh, common: { confirm: '确认', cancel: '取消', close: '关闭', loading: '加载中', error: '操作失败' } }, - en: { admin: en, common: { confirm: 'Confirm', cancel: 'Cancel', close: 'Close', loading: 'Loading', error: 'Operation failed' } } -}).catch(error => { document.getElementById('app')!.textContent = error instanceof Error ? error.message : 'Plugin unavailable' }) diff --git a/tools/check-account-layout-stability.py b/tools/check-account-layout-stability.py deleted file mode 100644 index 5220dd30cb39..000000000000 --- a/tools/check-account-layout-stability.py +++ /dev/null @@ -1,438 +0,0 @@ -"""Offline Chromium regression for the real accounts page and two plugin frames. - -Starts only this task's Vite process; all API responses and auth are synthetic. -No backend, production cookies, model calls or external requests are used. -""" -from __future__ import annotations - -import argparse -import hashlib -import json -import mimetypes -import os -import shutil -import socket -import subprocess -import sys -import time -import traceback -from pathlib import Path -from urllib.parse import parse_qs, unquote, urlsplit - -import psutil -from playwright.sync_api import expect, sync_playwright - -ROOT = Path(__file__).resolve().parents[1] -PACKAGE = "a" * 64 -THEME_A, THEME_B = "b" * 64, "c" * 64 -USER = {"id": 1, "username": "offline-layout", "email": "layout@example.invalid", "role": "admin", "status": "active", "balance": 0, "concurrency": 10} -FOLDERS = [{"id": n, "name": f"分类 {n:02d} · Layout group", "sort_order": n, "account_count": 8, "created_at": "2026-09-23T00:00:00Z", "updated_at": "2026-09-23T00:00:00Z"} for n in range(1, 9)] -TAGS = [{"id": n, "name": f"标签 {n} / tag {n}", "sort_order": n, "account_count": 10, "created_at": "2026-09-23T00:00:00Z", "updated_at": "2026-09-23T00:00:00Z"} for n in range(1, 7)] -GROUP = {"id": 23, "name": "Offline model group", "platform": "openai", "status": "active", "rate_multiplier": 1} -ROWS = [{"id": n, "name": f"Offline account {n:03d}", "platform": "openai", "type": "apikey", "status": "active", "schedulable": True, "concurrency": 10, "priority": 0, "rate_multiplier": 1, "credentials": {}, "extra": {}, "group_ids": [23], "groups": [GROUP], "tags": TAGS[:2], "management_folder": FOLDERS[0], "created_at": "2026-09-23T00:00:00Z", "updated_at": "2026-09-23T00:00:00Z", "notes": "Offline fixture. " * 30} for n in range(201, 301)] - -PROBE = r"""(() => { - const ids = new WeakMap(); let next = 0; - const identify = node => { if (!node) return null; if (!ids.has(node)) ids.set(node, ++next); return ids.get(node); }; - const rect = node => { if (!node) return null; const r=node.getBoundingClientRect(); return {x:r.x,y:r.y,width:r.width,height:r.height,bottom:r.bottom}; }; - const scroll = node => node ? {top:node.scrollTop,left:node.scrollLeft,height:node.scrollHeight,client:node.clientHeight,node:identify(node)} : null; - window.__layoutProbe = {boot:Math.random().toString(36).slice(2),context:0,resizes:[],shifts:[],fontLoads:[],headMutations:0,measure() { - const root=document.getElementById('app'); const drawer=document.querySelector('[data-test="account-details-drawer"]'); - return {boot:this.boot,width:innerWidth,height:innerHeight,scrollY,root:rect(root),rootScroll:root?.scrollHeight, - docWidth:document.documentElement.scrollWidth,bodyHeight:document.body?.scrollHeight, - fontStatus:document.fonts.status,fontLoaded:document.fonts.check('14px MiSans'), - bodyFont:getComputedStyle(document.body).fontFamily, - links:[...document.querySelectorAll('link[data-plugin-theme]')].map(el=>({node:identify(el),href:el.getAttribute('href'),media:el.media})), - context:this.context,headMutations:this.headMutations, - menu:[...document.querySelectorAll('[role="listbox"], [role="menu"]')].map(rect), - sidebar:rect(document.querySelector('aside.sidebar')),sidebarNav:scroll(document.querySelector('.sidebar-nav')), - drawer:rect(drawer),drawerScroll:scroll(drawer?.querySelector('.overflow-y-auto')), - table:scroll(document.querySelector('[data-test="account-list-scroll"] .table-wrapper')), - frames:[...document.querySelectorAll('iframe')].map(el=>({src:el.getAttribute('src'),node:identify(el),...rect(el)}))}; - }}; - if (typeof FontFace !== 'undefined') { - const load=FontFace.prototype.load; - FontFace.prototype.load=function(...args){ - const sample={family:this.family,weight:this.weight,start:performance.now()};window.__layoutProbe.fontLoads.push(sample); - return load.apply(this,args).then(face=>{sample.end=performance.now();return face},error=>{sample.end=performance.now();sample.error=String(error);throw error}); - }; - } - addEventListener('message', event=>{ - const m=event.data; - if(m?.source==='sub2api-plugin-host' && m.type==='extension.context.updated') window.__layoutProbe.context++; - if(m?.source==='sub2api-plugin-ui' && m.type==='ui.resize') window.__layoutProbe.resizes.push({time:performance.now(),height:m.height,source:[...document.querySelectorAll('iframe')].find(el=>el.contentWindow===event.source)?.getAttribute('src')}); - }); - addEventListener('DOMContentLoaded',()=>{ - new MutationObserver(records=>window.__layoutProbe.headMutations+=records.filter(r=>[...r.addedNodes,...r.removedNodes].some(n=>n.nodeType===1 && n.matches?.('link[data-plugin-theme]'))).length).observe(document.head,{childList:true}); - try { new PerformanceObserver(list=>{for(const e of list.getEntries()) if(!e.hadRecentInput) window.__layoutProbe.shifts.push({time:e.startTime,value:e.value});}).observe({type:'layout-shift',buffered:true}); } catch {} - }); -})();""" - - -class Fixture: - def __init__(self, port: int): - self.port = port - self.calls: list[dict] = [] - self.external: list[str] = [] - self.unhandled: list[str] = [] - self.theme = THEME_A - self.fail_theme = False - self.theme_failures = 0 - self.started = time.monotonic() - manifest = json.loads((ROOT / "plugins/account-tools/manifest.source.json").read_text(encoding="utf-8")) - wanted = {"account-taxonomy-navigation", "account-taxonomy-edit"} - self.widgets = [dict(item, plugin_id=1, plugin_key="codexrip.account-tools", package_sha256=PACKAGE, available=True, account_scope={"version": 1, "bindings": [{"platform": "*", "account_type": "*", "rollout_percent": 100}]}) for item in manifest["contributions"] if item["id"] in wanted] - - def theme_item(self): - return {"plugin_id": 2, "plugin_key": "codexrip.admin-observability", "package_sha256": self.theme, "id": "flat-theme", "slot": "theme", "permission": "public", "available": True, "label": {"zh": "平面主题", "en": "Flat theme"}, "stylesheet_url": f"/api/v1/settings/plugins/2/theme/{self.theme}/ui/assets/theme.css"} - - def route(self, route): - request = route.request - url = urlsplit(request.url) - if url.hostname != "127.0.0.1" or url.port != self.port: - self.external.append(request.url) - route.abort() - return - path = unquote(url.path) - headers = {"Cache-Control": "no-store", "Access-Control-Allow-Origin": "*", "Cross-Origin-Resource-Policy": "cross-origin"} - if path == "/setup/status": - route.fulfill(json={"needs_setup": False, "code": 0, "data": {"needs_setup": False}}, headers=headers) - return - if path.startswith("/api/v1/settings/plugins/2/theme/"): - if path.endswith("theme.css") and self.fail_theme and self.theme in path: - self.theme_failures += 1 - route.fulfill(status=503, headers=headers, content_type="text/css", body="/* offline simulated theme failure */") - return - relative = path.split("/ui/assets/", 1)[-1] - target = (ROOT / "plugins/admin-observability/ui/assets" / relative).resolve() - assert target.is_relative_to((ROOT / "plugins/admin-observability/ui/assets").resolve()) - route.fulfill(path=str(target), content_type=mimetypes.guess_type(str(target))[0] or "application/octet-stream", headers=headers) - return - if path.startswith("/fixture/account-tools/"): - relative = path.removeprefix("/fixture/account-tools/") - target = (ROOT / "plugins/account-tools/ui/compiled" / relative).resolve() - assert target.is_relative_to((ROOT / "plugins/account-tools/ui/compiled").resolve()) - headers["Content-Security-Policy"] = "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; connect-src 'none'; worker-src blob:; base-uri 'none'; form-action 'none'; frame-ancestors 'self'" - route.fulfill(path=str(target), content_type=mimetypes.guess_type(str(target))[0] or "application/octet-stream", headers=headers) - return - if not path.startswith("/api/v1/"): - route.continue_() - return - path = path.removeprefix("/api/v1") - self.calls.append({"path": path, "method": request.method, "at": round(time.monotonic() - self.started, 3)}) - query = parse_qs(url.query) - if path == "/auth/me": - data = USER - elif path == "/admin/plugins/contributions": - data = self.widgets + [self.theme_item()] - elif path == "/settings/plugins": - data = [self.theme_item()] - elif path == "/admin/plugins/1/ui-session": - contribution = (request.post_data_json or {}).get("contribution_id") - assert contribution in {item["id"] for item in self.widgets}, contribution - token = "synthetic-" + contribution - data = {"plugin_key": "codexrip.account-tools", "package_sha256": PACKAGE, "permission": "admin", "url": f"/fixture/account-tools/index.html?contribution={contribution}#bridge_token={token}", "bridge_token": token, "ui_bridge_version": 1, "expires_at": "2099-01-01T00:00:00Z", "request_binding": "synthetic-binding"} - elif path == "/admin/accounts": - ids = set(query.get("account_ids", [""])[0].split(",")) - rows = [row for row in ROWS if str(row["id"]) in ids] if ids != {""} else ROWS - data = {"items": rows, "total": len(rows), "pages": 1, "page": 1, "page_size": 100} - elif path == "/admin/accounts/facets": - data = {"total": len(ROWS), "uncategorized_count": 12, "platforms": [], "types": [], "statuses": [], "plans": [], "proxies": [], "tags": TAGS, "folders": FOLDERS} - elif path == "/admin/accounts/folders": - data = FOLDERS - elif path == "/admin/accounts/tags": - data = TAGS - elif path == "/admin/groups/all": - data = [GROUP] - elif path.startswith("/admin/accounts/") and path.rsplit("/", 1)[-1].isdigit(): - data = next(row for row in ROWS if str(row["id"]) == path.rsplit("/", 1)[-1]) - elif path == "/admin/accounts/today-stats/batch": - data = {"stats": {}} - elif path == "/admin/accounts/usage/batch": - data = {"items": [], "usage": {}, "results": {}} - elif path.endswith("/all"): - data = [] - elif path.endswith("/announcements") or path == "/subscriptions/active": - data = [] - elif "jobs" in path or path == "/keys": - data = {"items": [], "total": 0, "page": 1, "pages": 0} - elif path == "/settings/public" or path == "/admin/settings": - data = {"site_name": "Offline layout check", "registration_enabled": False, "custom_menu_items": [], "run_mode": "standard"} - elif path.endswith("/models/context-capacities"): - data = {"rows": [], "editable": True} - elif path.endswith("/models"): - data = [] - elif path.endswith("/api-key-visibility"): - data = {"enabled": False, "configured": False} - elif path in {"/admin/compliance", "/admin/payment/config", "/admin/settings/web-search-emulation", "/admin/system/check-updates"}: - data = {"enabled": False, "update_available": False} - elif "upstream-billing" in path or "scheduler" in path or path.endswith("/version") or path.endswith("/usage"): - data = {} - else: - self.unhandled.append(path) - data = {} - route.fulfill(json={"code": 0, "message": "", "data": data}, headers=headers) - - -def own_process_cleanup(process): - if process is None: - return [] - try: - parent = psutil.Process(process.pid) - except psutil.NoSuchProcess: - return [] - owned = parent.children(recursive=True) + [parent] - for child in reversed(owned): - try: - child.terminate() - except psutil.NoSuchProcess: - pass - _, alive = psutil.wait_procs(owned, timeout=4) - for child in alive: - try: - child.kill() - except psutil.NoSuchProcess: - pass - return [child.pid for child in owned] - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--output", type=Path, required=True) - parser.add_argument("--port", type=int, default=4827) - parser.add_argument("--skip-registry", action="store_true", help="Reuse registry-refresh-passed.json when only fixing a later failed phase") - parser.add_argument("--resume-responsive", action="store_true", help="Reuse theme-menu-passed.json and run only the remaining breakpoint return") - args = parser.parse_args() - args.output.mkdir(parents=True, exist_ok=True) - sys.stdout.reconfigure(encoding="utf-8") - with socket.socket() as check: - check.bind(("127.0.0.1", args.port)) - fixture = Fixture(args.port) - result = {"offline_only": True, "backend_started": False, "model_requests": 0, "passed": False, "scenarios": [], "samples": [], "frame_events": [], "page_errors": [], "console_errors": []} - process = None - log = (args.output / "vite.log").open("w", encoding="utf-8") - try: - environment = dict(os.environ, VITE_DEV_PROXY_TARGET="http://127.0.0.1:9", VITE_DEV_PORT=str(args.port)) - process = subprocess.Popen([shutil.which("node") or "node", str(ROOT / "frontend/node_modules/vite/bin/vite.js"), "--host", "127.0.0.1", "--port", str(args.port), "--strictPort"], cwd=ROOT / "frontend", env=environment, stdout=log, stderr=subprocess.STDOUT, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0) - result["vite_pid"] = process.pid - deadline = time.monotonic() + 40 - while time.monotonic() < deadline: - if process.poll() is not None: - raise RuntimeError("Vite exited; inspect vite.log") - try: - with socket.create_connection(("127.0.0.1", args.port), timeout=0.3): - break - except OSError: - time.sleep(0.1) - else: - raise RuntimeError("Vite startup timeout") - with sync_playwright() as playwright: - browser = playwright.chromium.launch(headless=True) - try: - # A fresh context has no service worker registrations; all HTTP - # is intercepted. Playwright's SW-block init hook itself throws - # in opaque sandbox frames, so use a registration-free context. - context = browser.new_context(viewport={"width": 1440, "height": 1000}) - context.route("**/*", fixture.route) - def route_websocket(route): - address = urlsplit(route.url) - if address.hostname == "127.0.0.1" and address.port == args.port: - route.connect_to_server() - else: - fixture.external.append(route.url) - route.close() - context.route_web_socket("**/*", route_websocket) - context.add_init_script(PROBE) - context.add_init_script("if(window===top){localStorage.setItem('auth_token','synthetic-layout-token');localStorage.setItem('auth_user',JSON.stringify("+json.dumps(USER)+"));localStorage.setItem('sub2api_locale','zh');localStorage.setItem('account-auto-refresh',JSON.stringify({enabled:true,interval_seconds:15}));window.__APP_CONFIG__={site_name:'Offline layout check',registration_enabled:false,custom_menu_items:[]};}") - page = context.new_page() - page.on("framedetached", lambda frame: result["frame_events"].append({"event": "detached", "url": frame.url, "at": round(time.monotonic()-fixture.started, 3)})) - page.on("pageerror", lambda error: result["page_errors"].append(str(error))) - page.on("console", lambda message: result["console_errors"].append(message.text) if message.type == "error" else None) - page.goto(f"http://127.0.0.1:{args.port}/admin/accounts?group_id=23&page=1&page_size=100", wait_until="networkidle", timeout=45000) - if page.locator(".driver-popover").count(): - page.keyboard.press("Escape") - nav_locator = page.locator('iframe[src*="contribution=account-taxonomy-navigation"]') - expect(nav_locator).to_be_visible(timeout=35000) - nav = nav_locator.element_handle().content_frame() - expect(nav.locator("#app")).not_to_be_empty(timeout=15000) - table = page.locator('[data-test="account-list-scroll"] .table-wrapper') - expect(table).to_be_visible() - table.evaluate("el=>el.scrollTop=el.scrollHeight*.45") - page.wait_for_timeout(200) - visible = [row for row in page.locator("tbody tr").all() if row.bounding_box() and table.bounding_box()["y"]+40 < row.bounding_box()["y"] < table.bounding_box()["y"]+table.bounding_box()["height"]-80] - assert visible, "No account row inside table viewport" - visible[0].locator("td").nth(1).click() - drawer = page.locator('[data-test="account-details-drawer"]') - expect(drawer).to_be_visible() - edit_locator = drawer.locator('iframe[src*="contribution=account-taxonomy-edit"]') - expect(edit_locator).to_be_visible(timeout=15000) - edit = edit_locator.element_handle().content_frame() - expect(edit.locator("select")).to_be_visible(timeout=15000) - opened_account = drawer.locator("h2").inner_text() - edit.locator("select").select_option("2") - expect(edit.get_by_role("button").first).to_be_enabled() - drawer.locator(".overflow-y-auto").first.evaluate("el=>el.scrollTop=60") - page.locator(".sidebar-nav").evaluate("el=>el.scrollTop=120") - for frame in [page.main_frame, nav, edit]: - frame.evaluate("async()=>{await document.fonts.ready}") - - def snapshot(label): - sample = {"label": label, "at": round(time.monotonic()-fixture.started, 3), "host": page.evaluate("()=>window.__layoutProbe.measure()"), "navigation": nav.evaluate("()=>window.__layoutProbe.measure()"), "editor": edit.evaluate("()=>window.__layoutProbe.measure()")} - result["samples"].append(sample) - return sample - - def settle(label, milliseconds=900): - samples = [] - for _ in range(max(4, milliseconds//150)): - page.wait_for_timeout(150) - samples.append(snapshot(label)) - for area in ["navigation", "editor"]: - tail = [sample[area]["height"] for sample in samples[-4:]] - assert max(tail)-min(tail) <= 1, (label, area, "unstable height", tail) - latest = samples[-1][area] - assert latest["rootScroll"] <= latest["height"]+2, (label, area, "content clipped", latest) - assert latest["docWidth"] <= latest["width"]+1, (label, area, "horizontal overflow", latest) - return samples[-1] - - baseline = settle("baseline") - page.screenshot(path=str(args.output / "baseline.png")) - print("Offline layout: baseline ready", flush=True) - base_calls = len([call for call in fixture.calls if call["path"] == "/admin/plugins/contributions"]) - until = time.monotonic()+(0 if args.skip_registry else 32) - while time.monotonic() < until: - page.wait_for_timeout(250) - snapshot("registry-15s-refresh") - refreshed = settle("after-two-refreshes") - refresh_calls = len([call for call in fixture.calls if call["path"] == "/admin/plugins/contributions"])-base_calls - if args.skip_registry: - evidence_path = args.output / "registry-refresh-passed.json" - prior = json.loads(evidence_path.read_text(encoding="utf-8")) - assert any(item.get("name") == "two-normal-registry-refreshes" and item.get("passed") for item in prior["scenarios"]) - result["reused_registry_evidence"] = str(evidence_path) - else: - assert refresh_calls >= 2, ("normal 15s registry timers did not fire twice", refresh_calls) - for area in ["navigation", "editor"]: - assert refreshed[area]["boot"] == baseline[area]["boot"], (area, "iframe reloaded") - assert refreshed[area]["links"] == baseline[area]["links"], (area, "same theme replaced") - assert refreshed[area]["height"] == baseline[area]["height"], (area, "height drift") - if not args.skip_registry: - assert refreshed[area]["context"] >= baseline[area]["context"]+2, (area, "context not refreshed") - for name in ["table", "drawerScroll", "sidebarNav"]: - assert refreshed["host"][name]["node"] == baseline["host"][name]["node"] - assert abs(refreshed["host"][name]["top"]-baseline["host"][name]["top"]) <= 1, (name, "scroll moved") - assert refreshed["host"]["sidebar"]["height"] == baseline["host"]["sidebar"]["height"] - assert refreshed["host"]["sidebarNav"]["height"] == baseline["host"]["sidebarNav"]["height"] - assert refreshed["host"]["links"] == baseline["host"]["links"] - assert edit.locator("select").input_value() == "2", "draft reset by context refresh" - if not args.skip_registry: - result["scenarios"].append({"name": "two-normal-registry-refreshes", "passed": True, "refresh_calls": refresh_calls, "seconds": 32}) - - if not args.resume_responsive: - fixture.theme, fixture.fail_theme = THEME_B, True - page.evaluate("()=>window.dispatchEvent(new Event('sub2api:plugins-changed'))") - failed_theme = settle("theme-network-failure", 2200) - assert fixture.theme_failures >= 1 - for area in ["host", "navigation", "editor"]: - assert failed_theme[area]["links"] == refreshed[area]["links"], (area, "last-good theme removed") - fixture.fail_theme = False - page.evaluate("()=>window.dispatchEvent(new Event('sub2api:plugins-changed'))") - for frame in [page.main_frame, nav, edit]: - frame.wait_for_function("digest=>[...document.querySelectorAll('link[data-plugin-theme]')].some(link=>link.href.includes(digest)&&link.media==='')", arg=THEME_B, timeout=15000) - restored = settle("theme-network-restored", 2200) - for area in ["host", "navigation", "editor"]: - assert len(restored[area]["links"]) == 1 and THEME_B in restored[area]["links"][0]["href"], (area, "theme not replaced after recovery") - assert restored[area]["fontStatus"] == "loaded" and restored[area]["fontLoaded"] - for name in ["table", "drawerScroll", "sidebarNav"]: - assert abs(restored["host"][name]["top"]-baseline["host"][name]["top"]) <= 1, (name, "theme update moved scroll") - result["scenarios"].append({"name": "theme-failure-and-recovery", "passed": True, "simulated_failures": fixture.theme_failures}) - page.screenshot(path=str(args.output / "theme-restored.png")) - - else: - evidence_path = args.output / "theme-menu-passed.json" - prior = json.loads(evidence_path.read_text(encoding="utf-8")) - for scenario in ["theme-failure-and-recovery", "menu-natural-expansion-and-shrink"]: - assert any(item.get("name") == scenario and item.get("passed") for item in prior["scenarios"]) - result["reused_theme_menu_evidence"] = str(evidence_path) - - page.set_viewport_size({"width": 1000, "height": 1000}) - narrow = settle("below-1024", 1400) - if not args.resume_responsive: - # Close only the existing drawer to interact with its underlying menu; - # reopen it after checking the menu, retaining the same account fixture. - drawer.get_by_role("button", name="关闭", exact=True).click() - expect(drawer).not_to_be_visible() - trigger = nav.locator('[aria-haspopup="listbox"]') - expect(trigger).to_be_visible() - trigger.click() - expect(nav.get_by_role("listbox")).to_be_visible() - menu_samples = [] - for _ in range(8): - page.wait_for_timeout(150) - menu_samples.append(nav.evaluate("()=>window.__layoutProbe.measure()")) - opened = menu_samples[-1] - assert opened["height"] > narrow["navigation"]["height"] - assert opened["menu"][0]["bottom"]+opened["scrollY"] <= opened["height"]+2, "menu clipped by iframe" - page.screenshot(path=str(args.output / "mobile-menu.png")) - trigger.click() - expect(nav.get_by_role("listbox")).not_to_be_visible() - page.wait_for_timeout(500) - closed = nav.evaluate("()=>window.__layoutProbe.measure()") - assert abs(closed["height"]-narrow["navigation"]["height"]) <= 1, ("menu did not shrink", closed["height"], narrow["navigation"]["height"]) - result["scenarios"].append({"name": "menu-natural-expansion-and-shrink", "passed": True, "closed_height": closed["height"], "open_height": opened["height"]}) - page.set_viewport_size({"width": 1440, "height": 1000}) - page.wait_for_timeout(900) - final_nav = nav.evaluate("()=>window.__layoutProbe.measure()") - assert abs(final_nav["height"]-baseline["navigation"]["height"]) <= 1 - expect(page.locator("aside.sidebar")).to_be_visible() - if not drawer.is_visible(): - # The responsive table leaves its fixed scrolling viewport; - # its old off-screen row need not exist in the virtual DOM. - account_row = page.locator("tbody tr").filter(has_text="Offline account").first - account_row.locator("td").nth(1).click() - expect(drawer).to_be_visible() - edit_locator = drawer.locator('iframe[src*="contribution=account-taxonomy-edit"]') - expect(edit_locator).to_be_visible() - edit = edit_locator.element_handle().content_frame() - expect(edit.locator("select")).to_be_visible() - final = settle("desktop-return", 1200) - assert final["editor"]["height"] == baseline["editor"]["height"] - assert final["host"]["sidebar"]["height"] == baseline["host"]["sidebar"]["height"] - result["scenarios"].append({"name": "cross-lg-breakpoint-and-return", "passed": True, "widths": [1440,1000,1440]}) - result["responsive_table_scroll"] = {"before": baseline["host"]["table"], "narrow": narrow["host"]["table"], "returned": final["host"]["table"], "note": "Below lg the table uses document flow instead of a fixed scrolling viewport; refresh/theme scroll retention is verified separately."} - page.screenshot(path=str(args.output / "final.png")) - assert not result["page_errors"], result["page_errors"] - assert not fixture.external, fixture.external - assert not context.service_workers, "fixture must not install a service worker" - result["passed"] = True - result["browser_version"] = browser.version - result["resize_messages"] = page.evaluate("()=>window.__layoutProbe.resizes") - result["layout_shifts"] = page.evaluate("()=>window.__layoutProbe.shifts") - except Exception: - if "page" in locals(): - page.screenshot(path=str(args.output / "failure.png"), full_page=True) - (args.output / "failure-dom.txt").write_text(page.locator("body").inner_text(), encoding="utf-8") - raise - finally: - if "page" in locals(): - result["font_loads"] = [] - for frame in page.frames: - try: - result["font_loads"].append({"url": frame.url, "loads": frame.evaluate("()=>window.__layoutProbe?.fontLoads || []")}) - except Exception: - pass - browser.close() - except Exception as error: - result["failure"] = str(error) - result["traceback"] = traceback.format_exc() - finally: - result["closed_owned_pids"] = own_process_cleanup(process) - log.close() - result["api_calls"], result["external_requests"], result["unhandled_api_paths"] = fixture.calls, fixture.external, sorted(set(fixture.unhandled)) - result["source_sha256"] = {str(path.relative_to(ROOT)): hashlib.sha256(path.read_bytes()).hexdigest() for path in [ROOT / "backend/pkg/extensionapi/ui/stylesheets.ts", ROOT / "backend/pkg/extensionapi/ui/presentation.ts", ROOT / "backend/pkg/extensionapi/ui/sizing.ts", ROOT / "backend/pkg/extensionapi/ui/client.ts", ROOT / "frontend/src/components/plugins/theme.ts", ROOT / "frontend/src/components/plugins/PluginFrame.vue"]} - (args.output / "result.json").write_text(json.dumps(result, ensure_ascii=False, indent=2)+"\n", encoding="utf-8") - print(json.dumps({"passed": result["passed"], "failure": (result.get("failure") or "")[:350], "scenarios": result["scenarios"], "page_errors": result["page_errors"], "external_requests": len(fixture.external), "unhandled_api_paths": result["unhandled_api_paths"], "output": str(args.output)}, ensure_ascii=False)) - return 0 if result["passed"] else 1 - - -if __name__ == "__main__": - raise SystemExit(main())