From 858be4fb19f4a1f80e41df3d29084e876185d819 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 19 Sep 2026 09:49:22 +0300 Subject: [PATCH 01/11] docs(agentplugins): record both composition roots and the merge-base lint gate ADR 0007 and ARCHITECTURE claimed cmd/agentplugins was the only production clients/all importer; authoring's plugin-kit-ai binary is a second root. Coverage now cds into the nested module, and the plan matches the lint.yml that #319 actually shipped. --- .github/workflows/coverage.yml | 6 ++++- docs/ARCHITECTURE.md | 11 +++++---- ...07-client-adapter-contract-and-registry.md | 7 +++--- .../installer-core-clean-architecture-plan.md | 24 +++++++++++++------ .../agentplugins/clients/cline/native.go | 2 +- .../agentplugins/clients/gemini/native.go | 6 ++--- .../agentplugins/clients/windsurf/native.go | 2 +- 7 files changed, 37 insertions(+), 21 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 74b8ecd7..90bf641e 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -35,7 +35,11 @@ jobs: # incompatible with coverage instrumentation itself. go test -count=1 -timeout=20m -skip '^TestReleaseCompletionProcessStderr$' -covermode=atomic -coverprofile=coverage/cli.out ./cli/plugin-kit-ai/... go test -count=1 -timeout=20m -skip '^TestExecutableVersionProbeIsSanitizedAndIsolated$' -covermode=atomic -coverprofile=coverage/integrationctl.out ./install/integrationctl/... - go test -count=1 -timeout=20m -skip '^TestExecutableVersionProbeIsSanitizedAndIsolated$' -covermode=atomic -coverprofile=coverage/agentplugins.out ./install/integrationctl/agentplugins/... + # Nested module: do not use a parent-relative ./agentplugins/... glob. + # That pattern needs go.work; GOWORK=off fails loud instead of covering + # the child. cd keeps this job aligned with CodeQL / release / govulncheck. + agentplugins_cover="${PWD}/coverage/agentplugins.out" + (cd install/integrationctl/agentplugins && go test -count=1 -timeout=20m -skip '^TestExecutableVersionProbeIsSanitizedAndIsolated$' -covermode=atomic -coverprofile="$agentplugins_cover" ./...) go test -count=1 -timeout=20m -covermode=atomic -coverprofile=coverage/plugininstall.out ./install/plugininstall/... go test -count=1 -timeout=20m -covermode=atomic -coverprofile=coverage/sdk.out ./sdk/... diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 694b6a8a..724388e8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -44,7 +44,7 @@ point inward. | Client adapters | `agentplugins/clients/` | the client contract, `clients/shared`, `domain`, `ports` | yes, `clients-no-upward`, `clients-no-concrete-clients` | | Adapters | `agentplugins/{adapters,providers,planner}` | the layers above, never `clients/all` | partly, `libraries-take-an-injected-registry` | | CLI | `agentpluginscli` | the public facades of the layers above, never providers or pathpolicy | yes, `cli-no-core-internals` | -| Composition root | `cmd/agentplugins` | everything, and nothing imports it | yes: it is the only production importer of `clients/all` | +| Composition root | `cmd/agentplugins`, `cmd/plugin-kit-ai` | everything, and nothing imports them | yes: they are the production importers of `clients/all` | The "Enforced today" column is deliberate: the middle column is the target, and the left-hand rules are checked by `depguard` in `.golangci.yml`. Domain, ports, @@ -71,10 +71,11 @@ adapter into any binary that imports a generic package and would put the registr outside the composition root's control. `libraries-take-an-injected-registry` holds the other side of that line: `providers`, `planner` and `adapters/clientdetect` may not import `clients/all` outside their tests, so the -assembled registry reaches them only as an argument. `cmd/agentplugins` is the -one place that builds it. The CLI receives `Planner`, `Targets` and `Registry` -from that root; it does not construct `planner.Planner{}`. Detection is -request-scoped: `domain.PlanRequest.Detected` is the map `Planner.Plan` reads. +assembled registry reaches them only as an argument. `cmd/agentplugins` and +`cmd/plugin-kit-ai` are the production places that build it. The installer CLI +receives `Planner`, `Targets` and `Registry` from that root; it does not +construct `planner.Planner{}`. Detection is request-scoped: +`domain.PlanRequest.Detected` is the map `Planner.Plan` reads. `cli-no-core-internals` keeps `agentpluginscli` off `providers` and `pathpolicy`. The CLI may still import the thin public planner facade diff --git a/docs/adr/0007-client-adapter-contract-and-registry.md b/docs/adr/0007-client-adapter-contract-and-registry.md index 335b1bfa..31af4ae1 100644 --- a/docs/adr/0007-client-adapter-contract-and-registry.md +++ b/docs/adr/0007-client-adapter-contract-and-registry.md @@ -22,9 +22,10 @@ compensation, not a substitute for a module boundary. ## Decision Client-specific behavior lives in `install/integrationctl/agentplugins/clients/`. -The composition root (`cli/plugin-kit-ai/cmd/agentplugins`) is the only -production package that imports `clients/all`. Generic packages receive a -`clients.Registry` and fail closed when it is nil. +The production composition roots that import `clients/all` are +`cli/plugin-kit-ai/cmd/agentplugins` (installer CLI) and +`cli/plugin-kit-ai/cmd/plugin-kit-ai` (authoring CLI). Generic packages receive +a `clients.Registry` and fail closed when it is nil. Invariants: diff --git a/docs/plans/installer-core-clean-architecture-plan.md b/docs/plans/installer-core-clean-architecture-plan.md index 7c187b30..062199f3 100644 --- a/docs/plans/installer-core-clean-architecture-plan.md +++ b/docs/plans/installer-core-clean-architecture-plan.md @@ -555,7 +555,7 @@ issues: Один режим на всё не работает: `new-from-*` фильтрует по изменённым строкам, а `funlen`/`gocyclo`/`file-length-limit` репортят на строке объявления функции/файла — добавление 40 строк в середину старой 200-строчной функции такой фильтр не поймает. Поэтому: -- **Прогон A** — корректность и стиль, только изменённые строки: все линтеры, кроме size/arch. CI: `only-new-issues: true` (на PR — патч через GitHub API; на push — `new-from-rev=`). Локально: `golangci-lint run --new-from-merge-base=$(BASE)`; в checkout нужен `fetch-depth: 0`. +- **Прогон A** — корректность и стиль, только изменённые строки: все линтеры, кроме size/arch. CI и локально: `golangci-lint run --new-from-merge-base=$(LINT_BASE)`. `LINT_BASE` на PR — `origin/` (для stacked part-PR это architecture, для `#288` — `main`). `golangci-lint-action` `only-new-issues` здесь нельзя: reusable `workflow_call` не несёт `github.event.pull_request`, а GitHub Files API отдаёт 406 на diff > 300 файлов и молча падает в полный прогон. В checkout нужен `fetch-depth: 0` и явный `git fetch` базовой ветки. - **Прогон B** — size/arch-гейт, полные файлы, всегда: `golangci-lint run --enable-only=revive,funlen,gocyclo,gocognit,dupl,depguard`. Легаси-нарушения размера покрыты baseline-исключениями (сгенерированы этим же прогоном в Part 0a); всё остальное строго: новый файл > 500 строк кода, новая функция > 60/40, рост сложности в файле вне baseline, любой запрещённый импорт — красный. `depguard` в full-file режиме — граница держится всегда, а не только на изменённых строках. - **Baseline shrink-only**: `scripts/check-lint-baseline.sh ` извлекает блок `BEGIN/END LEGACY SIZE BASELINE` из `.golangci.yml` в HEAD и из `git show :.golangci.yml`, `comm -13` → любые добавленные `- path:` = ошибка. Файл из скоупа выходит из baseline в той части, которая его режет (критерий приёмки части). В Part 11 блок содержит только файлы вне скоупа (§11). @@ -565,21 +565,30 @@ issues: name: Lint on: workflow_call: + inputs: + lint-base: { type: string, default: origin/main } jobs: lint: runs-on: ubuntu-latest strategy: fail-fast: false matrix: - module: [".", "cli/plugin-kit-ai", "install/integrationctl"] + include: + - { name: root, module: "" } + - { name: cli/plugin-kit-ai, module: cli/plugin-kit-ai } + - { name: install/integrationctl, module: install/integrationctl } + - { name: install/integrationctl/agentplugins, module: install/integrationctl/agentplugins } steps: - uses: actions/checkout@ # v7.0.1, пин по SHA как остальные actions в репо with: { persist-credentials: false, fetch-depth: 0 } + - name: Fetch lint base + env: { LINT_BASE: ${{ inputs.lint-base }} } + run: git fetch --no-tags origin "${LINT_BASE#origin/}:refs/remotes/origin/${LINT_BASE#origin/}" - uses: actions/setup-go@ # v7.0.0 with: { go-version: "1.25.13", cache: false } - name: Lint changed lines (correctness, style) uses: golangci/golangci-lint-action@ # v9.3.0 - with: { version: v2.13.2, working-directory: ${{ matrix.module }}, only-new-issues: true } + with: { version: v2.13.2, working-directory: ${{ matrix.module }}, only-new-issues: false, args: --new-from-merge-base=${{ inputs.lint-base }} } - name: Size and architecture gate (full files) uses: golangci/golangci-lint-action@ # v9.3.0 with: @@ -588,13 +597,14 @@ jobs: only-new-issues: false args: --enable-only=revive,funlen,gocyclo,gocognit,dupl,depguard - name: Baseline is shrink-only - if: matrix.module == '.' - run: bash scripts/check-lint-baseline.sh "origin/${{ github.base_ref || 'main' }}" + if: matrix.name == 'root' + env: { LINT_BASE: ${{ inputs.lint-base }} } + run: bash scripts/check-lint-baseline.sh "$LINT_BASE" ``` Матрица покрывает модули с кодом ядра (включая nested `install/integrationctl/agentplugins`); `install/plugininstall` и `sdk` из `go.work` линтом не покрываются (не меняются планом), но покрываются `go vet` в `core-fast` (§10). -`ci.yml`: `jobs.lint: uses: ./.github/workflows/lint.yml` параллельно с `test`. Почему отдельный job, а не шаг в `test`: `test` — критический путь 9-27 мин; lint в трёх параллельных матричных job ожидаемо 3-5 мин и не удлиняет путь; при этом он в том же workflow «Required», т.е. входит в required-гейт. +`ci.yml` / `core-fast.yml` передают `lint-base: origin/${{ github.base_ref || 'main' }}` — caller видит `github.base_ref`, called workflow на `workflow_call` его не имеет. Почему отдельный job, а не шаг в `test`: `test` — критический путь 9-27 мин; lint в четырёх параллельных матричных job ожидаемо 3-5 мин и не удлиняет путь; при этом он в том же workflow «Required», т.е. входит в required-гейт. Версии: golangci-lint v2.13.2 (релиз 2026-08-27) и golangci-lint-action v9.3.0 (2026-06-29) — последние стабильные по GitHub Releases на дату плана (VERIFIED). Локально нужен апгрейд с v1.64.8 (`brew upgrade golangci-lint`): v1 не читает `version: "2"` и не содержит правило `file-length-limit`. @@ -979,7 +989,7 @@ Wall 5-7 мин и для `main`. Полезно, но меняет общий C - Код конкурента AgentBridge (оценки взяты из аудита пользователя). - Длительность CLI-тестов и root `./...` в CI по отдельности (замерено только локально: `ap/...` = 62.6 с, CLI-пакет = 56 с). - Точный состав `gocritic disabled-checks`/`gosec excludes` и порог `dupl` — по первому прогону в Part 0a. -- Поведение `golangci-lint-action only-new-issues` для `push`-событий (`new-from-rev=before`) — по документации action; проверить на первом push в базовую ветку. +- Поведение `--new-from-merge-base` на `push` в `main` (`lint-base` = `origin/main` = HEAD): прогон A вырождается в no-op, size/arch всё равно полный. Для architecture-push сравнение с `origin/main` — нужный гейт для `#288`. - Поведение `depguard` при `relative-path-mode: gitroot` для glob-паттернов `files:` — проверить на негативных тестах Part 0a (§6.7, п.3). - Аргументы `revive file-length-limit` прочитаны в исходниках `mgechev/revive` (`rule/file_length_limit.go`, `rule/utils.go`, `internal/config/config.go`) на ветке `master`, а не на теге, вшитом в golangci-lint v2.13.2 — теоретически они могли отличаться в момент вендоринга. Негативный тест §6.7 п.3 закрывает это эмпирически на первом прогоне. - Точное число мест конструирования `Service{}`/`Planner{}`: подсчёты разными способами дают 23/32 (grep по строкам на HEAD) против 23/39 (подсчёт критика). Планируем по верхней границе ≈62; точное число выяснится при первой компиляции после введения fail-fast. diff --git a/install/integrationctl/agentplugins/clients/cline/native.go b/install/integrationctl/agentplugins/clients/cline/native.go index 51275f5c..88581307 100644 --- a/install/integrationctl/agentplugins/clients/cline/native.go +++ b/install/integrationctl/agentplugins/clients/cline/native.go @@ -143,7 +143,7 @@ func validateClineObject(configRoot string, object domain.NativeObjectOwnership) } case ClineMCPObjectKind: if !filepath.IsAbs(object.Path) || !shared.SameCleanPath(object.Path, ClineMCPSettingsPath(configRoot)) { - return fmt.Errorf("cline MCP ownership path changed") + return fmt.Errorf("the Cline MCP ownership path changed") } default: return fmt.Errorf("unsupported Cline native object kind %q", object.Kind) diff --git a/install/integrationctl/agentplugins/clients/gemini/native.go b/install/integrationctl/agentplugins/clients/gemini/native.go index e9352294..238a1532 100644 --- a/install/integrationctl/agentplugins/clients/gemini/native.go +++ b/install/integrationctl/agentplugins/clients/gemini/native.go @@ -216,7 +216,7 @@ func requireGeminiObjectAbsent(root string, object domain.NativeObjectOwnership) } if object.Kind == GeminiSkillObjectKind { if _, err := os.Lstat(object.Path); err == nil { - return fmt.Errorf("gemini skill %q already exists without agentplugins ownership", object.LogicalName) + return fmt.Errorf("the Gemini skill %q already exists without agentplugins ownership", object.LogicalName) } else if !os.IsNotExist(err) { return err } @@ -227,7 +227,7 @@ func requireGeminiObjectAbsent(root string, object domain.NativeObjectOwnership) return err } if present { - return fmt.Errorf("gemini MCP server %q already exists without agentplugins ownership", object.LogicalName) + return fmt.Errorf("the Gemini MCP server %q already exists without agentplugins ownership", object.LogicalName) } return nil } @@ -243,7 +243,7 @@ func validateGeminiObject(root string, object domain.NativeObjectOwnership) erro return fmt.Errorf("unsupported Gemini native object kind %q", object.Kind) } if !shared.SameCleanPath(expected, object.Path) { - return fmt.Errorf("gemini native object %q has an untrusted path", object.LogicalName) + return fmt.Errorf("the Gemini native object %q has an untrusted path", object.LogicalName) } return pathpolicy.RequireContainedChild(root, object.Path) } diff --git a/install/integrationctl/agentplugins/clients/windsurf/native.go b/install/integrationctl/agentplugins/clients/windsurf/native.go index bb19aa2b..32df0263 100644 --- a/install/integrationctl/agentplugins/clients/windsurf/native.go +++ b/install/integrationctl/agentplugins/clients/windsurf/native.go @@ -119,7 +119,7 @@ func inspectWindsurfPlannedServers(configPath string, plan domain.DeliveryPlan) func windsurfConfigPath(configRoot string) (string, error) { root := filepath.Clean(strings.TrimSpace(configRoot)) if root == "." || !filepath.IsAbs(root) { - return "", fmt.Errorf("windsurf channel config root must be absolute") + return "", fmt.Errorf("the Windsurf channel config root must be absolute") } path := filepath.Join(root, "mcp_config.json") if err := pathpolicy.RequireContainedChild(root, path); err != nil { From 61f8c5335b0d29e30f75aecb76e4093cc1d815b3 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 19 Sep 2026 09:57:23 +0300 Subject: [PATCH 02/11] fix(agentplugins): thread one nativeconfig kernel through activate, verify, and identity inspect Activate wrote through env.NativeConfig while Verify and registry inspect constructed nativeconfig.New(), so an injected kernel never observed its own writes. --- cli/plugin-kit-ai/cmd/agentplugins/compose.go | 10 ++++--- .../agentplugins/clients/cline/activate.go | 2 +- .../agentplugins/clients/cline/lifecycle.go | 4 +-- .../agentplugins/clients/cline/native.go | 3 +-- .../agentplugins/clients/gemini/activate.go | 2 +- .../agentplugins/clients/gemini/identity.go | 4 +-- .../agentplugins/clients/gemini/native.go | 26 +++++++++---------- .../clients/gemini/native_apply.go | 8 +++--- .../agentplugins/clients/gemini/native_mcp.go | 12 ++++----- .../agentplugins/clients/opencode/activate.go | 2 +- .../clients/opencode/native_apply.go | 2 +- .../clients/opencode/native_mcp.go | 15 +++++------ .../agentplugins/clients/windsurf/activate.go | 2 +- .../agentplugins/clients/windsurf/identity.go | 4 +-- .../clients/windsurf/lifecycle.go | 4 +-- .../agentplugins/clients/windsurf/native.go | 12 ++++----- .../providers/cline_native_test.go | 2 +- .../agentplugins/providers/native_identity.go | 11 +++++++- .../providers/opencode_logical_keys_test.go | 6 ++--- .../providers/opencode_native_test.go | 10 +++---- .../providers/stager_support_test.go | 9 +++++++ .../providerstest/providerstest.go | 9 +++++++ 22 files changed, 92 insertions(+), 67 deletions(-) diff --git a/cli/plugin-kit-ai/cmd/agentplugins/compose.go b/cli/plugin-kit-ai/cmd/agentplugins/compose.go index f8afb9c1..f21ed843 100644 --- a/cli/plugin-kit-ai/cmd/agentplugins/compose.go +++ b/cli/plugin-kit-ai/cmd/agentplugins/compose.go @@ -16,6 +16,7 @@ import ( "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/directoryv1" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/discoveryv1" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/loader" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/processlock" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/securityscan" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/securityv1" @@ -68,9 +69,10 @@ func newAgentpluginsCLIApp(home, dataRoot string, directoryClient *directoryv1.C mutationLock := processlock.Lock{Path: filepath.Join(dataRoot, "mutation.lock")} clientRegistry := clientregistry.Default() paths := pathpolicy.Policy{} + nativeKernel := nativeconfig.New() stager := newManagedStager(clientRegistry, paths) planner := clientplanner.Planner{ManagedRoot: filepath.Join(dataRoot, "managed"), Paths: paths, Registry: clientRegistry} - lifecycle := newAgentpluginsLifecycle(dataRoot, v2Store, paths, clientRegistry, stager, runner, planner, directoryManager, mutationLock) + lifecycle := newAgentpluginsLifecycle(dataRoot, v2Store, paths, clientRegistry, stager, runner, planner, directoryManager, mutationLock, nativeKernel) return assembleAgentpluginsApp(home, dataRoot, v2Store, mutationLock, lifecycle, directoryClient, discoveryClient, securityClient, packageLoader, clientRegistry, planner) } @@ -84,12 +86,12 @@ func newManagedStager(clientRegistry *clients.Registry, paths pathpolicy.Policy) return stager } -func newAgentpluginsLifecycle(dataRoot string, v2Store statev2.Store, paths pathpolicy.Policy, clientRegistry *clients.Registry, stager providers.Stager, runner processadapter.OS, planner clientplanner.Planner, directoryManager dirswap.Manager, mutationLock processlock.Lock) usecase.Service { +func newAgentpluginsLifecycle(dataRoot string, v2Store statev2.Store, paths pathpolicy.Policy, clientRegistry *clients.Registry, stager providers.Stager, runner processadapter.OS, planner clientplanner.Planner, directoryManager dirswap.Manager, mutationLock processlock.Lock, nativeKernel nativeconfig.Kernel) usecase.Service { return usecase.Service{ StateStore: v2Store, Paths: paths, Planner: planner, Targets: planner, Stager: stager, - Activator: providers.Activator{Runner: runner, Registry: clientRegistry}, + Activator: providers.Activator{Runner: runner, Registry: clientRegistry, NativeConfig: &nativeKernel}, Lock: mutationLock, Kernel: transaction.Kernel{StateStore: v2Store, Directory: directoryManager}, - NativeObserver: providers.NativeIdentityObserver{Stager: stager, Runner: runner, Registry: clientRegistry}, + NativeObserver: providers.NativeIdentityObserver{Stager: stager, Runner: runner, Registry: clientRegistry, NativeConfig: &nativeKernel}, PluginData: providers.PluginDataManager{Base: filepath.Join(dataRoot, "plugin-data")}, } } diff --git a/install/integrationctl/agentplugins/clients/cline/activate.go b/install/integrationctl/agentplugins/clients/cline/activate.go index 04ddee3d..7f004ba8 100644 --- a/install/integrationctl/agentplugins/clients/cline/activate.go +++ b/install/integrationctl/agentplugins/clients/cline/activate.go @@ -35,7 +35,7 @@ func (*Adapter) Activate(ctx context.Context, env clients.Env, request domain.Ac RetryAction: "retry the managed Cline native installation", CompletedAction: "reload the Cline MCP view in VS Code, or start a new Cline CLI process", Verify: func() error { - return VerifyClineNativeObjects(request.Client.ConfigRoot, request.Delivery.NativeObjects, false) + return VerifyClineNativeObjects(request.Client.ConfigRoot, request.Delivery.NativeObjects, false, env.NativeConfig) }, Activate: func(ctx context.Context, request domain.ActivationRequest) error { return ActivateClineNativeWithKernel(ctx, request, env.NativeConfig) diff --git a/install/integrationctl/agentplugins/clients/cline/lifecycle.go b/install/integrationctl/agentplugins/clients/cline/lifecycle.go index 1eb161d8..7f5be82c 100644 --- a/install/integrationctl/agentplugins/clients/cline/lifecycle.go +++ b/install/integrationctl/agentplugins/clients/cline/lifecycle.go @@ -39,7 +39,7 @@ func ApplyClineNativeMutationWithKernelRenameAndCapacity(configRoot, activePath if err := txn.backupAndInstall(); err != nil { return err } - if err := VerifyClineNativeObjects(prepared.configRoot, clineSkillObjects(prepared.desired), false); err != nil { + if err := VerifyClineNativeObjects(prepared.configRoot, clineSkillObjects(prepared.desired), false, prepared.kernel); err != nil { return err } return mutateClineMCPWithKernelAndCapacity(prepared.configRoot, prepared.activePath, prepared.previousByID, prepared.desiredByID, prepared.kernel, prepared.idsCapacity) @@ -69,7 +69,7 @@ func prepareClineNativeApply(configRoot, activePath string, previous, desired [] return nil, fmt.Errorf("the Cline config root is unavailable") } previous, desired = ClineObjects(previous), ClineObjects(desired) - if err := VerifyClineNativeObjects(configRoot, previous, true); err != nil { + if err := VerifyClineNativeObjects(configRoot, previous, true, kernel); err != nil { return nil, err } previousByID, desiredByID := shared.ObjectMap(previous), shared.ObjectMap(desired) diff --git a/install/integrationctl/agentplugins/clients/cline/native.go b/install/integrationctl/agentplugins/clients/cline/native.go index 88581307..79171d85 100644 --- a/install/integrationctl/agentplugins/clients/cline/native.go +++ b/install/integrationctl/agentplugins/clients/cline/native.go @@ -59,8 +59,7 @@ func applyClineNativeMutationWithKernelAndRename(configRoot, activePath string, return ApplyClineNativeMutationWithKernelRenameAndCapacity(configRoot, activePath, previous, desired, kernel, rename, shared.CheckedCombinedCapacity) } -func VerifyClineNativeObjects(configRoot string, objects []domain.NativeObjectOwnership, allowMissing bool) error { - kernel := nativeconfig.New() +func VerifyClineNativeObjects(configRoot string, objects []domain.NativeObjectOwnership, allowMissing bool, kernel nativeconfig.Kernel) error { for _, object := range ClineObjects(objects) { if err := validateClineObject(configRoot, object); err != nil { return err diff --git a/install/integrationctl/agentplugins/clients/gemini/activate.go b/install/integrationctl/agentplugins/clients/gemini/activate.go index fefd9362..c8317133 100644 --- a/install/integrationctl/agentplugins/clients/gemini/activate.go +++ b/install/integrationctl/agentplugins/clients/gemini/activate.go @@ -34,7 +34,7 @@ func (*Adapter) Activate(ctx context.Context, env clients.Env, request domain.Ac RetryAction: "retry the managed Gemini CLI native installation", CompletedAction: "in a running Gemini CLI session use `/mcp reload` and `/skills reload`, or restart Gemini CLI", Verify: func() error { - return VerifyGeminiNativeObjects(request.Client.ConfigRoot, request.Delivery.NativeObjects, false) + return VerifyGeminiNativeObjects(request.Client.ConfigRoot, request.Delivery.NativeObjects, false, env.NativeConfig) }, Activate: func(ctx context.Context, request domain.ActivationRequest) error { return ActivateGeminiNativeWithKernel(ctx, request, env.NativeConfig) diff --git a/install/integrationctl/agentplugins/clients/gemini/identity.go b/install/integrationctl/agentplugins/clients/gemini/identity.go index 8566e3df..66084fa3 100644 --- a/install/integrationctl/agentplugins/clients/gemini/identity.go +++ b/install/integrationctl/agentplugins/clients/gemini/identity.go @@ -11,9 +11,9 @@ var _ clients.RegistryInspector = (*Adapter)(nil) func (*Adapter) UsesNativeRegistryExecutable() bool { return false } -func (*Adapter) InspectNativeRegistry(ctx context.Context, _ clients.Env, _ domain.DetectedClient, plan domain.DeliveryPlan, managed *domain.ClientBinding) (clients.RegistryFinding, error) { +func (*Adapter) InspectNativeRegistry(ctx context.Context, env clients.Env, _ domain.DetectedClient, plan domain.DeliveryPlan, managed *domain.ClientBinding) (clients.RegistryFinding, error) { if err := ctx.Err(); err != nil { return clients.RegistryIndeterminate, err } - return InspectGeminiRegistry(plan, managed) + return InspectGeminiRegistry(plan, managed, env.NativeConfig) } diff --git a/install/integrationctl/agentplugins/clients/gemini/native.go b/install/integrationctl/agentplugins/clients/gemini/native.go index 238a1532..f1570b80 100644 --- a/install/integrationctl/agentplugins/clients/gemini/native.go +++ b/install/integrationctl/agentplugins/clients/gemini/native.go @@ -38,7 +38,7 @@ func DeactivateGeminiNativeWithKernel(ctx context.Context, request domain.Deacti return applyGeminiNativeMutationWithKernel(request.Client.ConfigRoot, "", request.NativeObjects, nil, kernel) } -func VerifyGeminiNativeObjects(configRoot string, objects []domain.NativeObjectOwnership, allowMissing bool) error { +func VerifyGeminiNativeObjects(configRoot string, objects []domain.NativeObjectOwnership, allowMissing bool, kernel nativeconfig.Kernel) error { for _, object := range GeminiObjects(objects) { if err := validateGeminiObject(configRoot, object); err != nil { return err @@ -49,7 +49,7 @@ func VerifyGeminiNativeObjects(configRoot string, objects []domain.NativeObjectO return err } case GeminiMCPObjectKind: - if err := verifyGeminiMCP(configRoot, object, allowMissing); err != nil { + if err := verifyGeminiMCP(configRoot, object, allowMissing, kernel); err != nil { return err } } @@ -71,8 +71,8 @@ func verifyGeminiSkill(object domain.NativeObjectOwnership, allowMissing bool) e return nil } -func verifyGeminiMCP(configRoot string, object domain.NativeObjectOwnership, allowMissing bool) error { - present, owned, err := nativeconfig.New().Inspect(GeminiConfigPaths(configRoot), nativeconfig.CodecGemini, object.LogicalName, GeminiReceipt(object)) +func verifyGeminiMCP(configRoot string, object domain.NativeObjectOwnership, allowMissing bool, kernel nativeconfig.Kernel) error { + present, owned, err := kernel.Inspect(GeminiConfigPaths(configRoot), nativeconfig.CodecGemini, object.LogicalName, GeminiReceipt(object)) if err != nil { return err } @@ -112,13 +112,13 @@ func applyGeminiNativeMutationWithKernelAndRename(configRoot, activePath string, return ApplyGeminiNativeMutationWithKernelRenameAndCapacity(configRoot, activePath, previous, desired, kernel, rename, shared.CheckedCombinedCapacity) } -func InspectGeminiRegistry(plan domain.DeliveryPlan, managed *domain.ClientBinding) (clients.RegistryFinding, error) { +func InspectGeminiRegistry(plan domain.DeliveryPlan, managed *domain.ClientBinding, kernel nativeconfig.Kernel) (clients.RegistryFinding, error) { root := strings.TrimSpace(plan.NativeRegistryRoot) if root == "" { return clients.RegistryIndeterminate, nil } if managed != nil { - if err := VerifyGeminiNativeObjects(root, managed.NativeObjects, true); err != nil { + if err := VerifyGeminiNativeObjects(root, managed.NativeObjects, true, kernel); err != nil { return clients.RegistryIndeterminate, err } } @@ -127,7 +127,7 @@ func InspectGeminiRegistry(plan domain.DeliveryPlan, managed *domain.ClientBindi if component.Support == domain.SupportUnsupported { continue } - exists, owned, err := inspectGeminiComponent(root, managed, component) + exists, owned, err := inspectGeminiComponent(root, managed, component, kernel) if err != nil { return clients.RegistryIndeterminate, err } @@ -141,12 +141,12 @@ func InspectGeminiRegistry(plan domain.DeliveryPlan, managed *domain.ClientBindi return finding, nil } -func inspectGeminiComponent(root string, managed *domain.ClientBinding, component domain.ComponentDecision) (bool, bool, error) { +func inspectGeminiComponent(root string, managed *domain.ClientBinding, component domain.ComponentDecision, kernel nativeconfig.Kernel) (bool, bool, error) { switch component.Kind { case domain.ComponentSkill: return inspectGeminiSkillComponent(root, managed, component.Name) case domain.ComponentMCPServer: - return inspectGeminiMCPComponent(root, managed, component.Name) + return inspectGeminiMCPComponent(root, managed, component.Name, kernel) default: return false, false, nil } @@ -162,7 +162,7 @@ func inspectGeminiSkillComponent(root string, managed *domain.ClientBinding, nam return err == nil, owned, nil } -func inspectGeminiMCPComponent(root string, managed *domain.ClientBinding, name string) (bool, bool, error) { +func inspectGeminiMCPComponent(root string, managed *domain.ClientBinding, name string, kernel nativeconfig.Kernel) (bool, bool, error) { var receipt *nativeconfig.Receipt if managed != nil { for _, object := range GeminiObjects(managed.NativeObjects) { @@ -171,7 +171,7 @@ func inspectGeminiMCPComponent(root string, managed *domain.ClientBinding, name } } } - return nativeconfig.New().Inspect(GeminiConfigPaths(root), nativeconfig.CodecGemini, name, receipt) + return kernel.Inspect(GeminiConfigPaths(root), nativeconfig.CodecGemini, name, receipt) } func GeminiReceipt(object domain.NativeObjectOwnership) *nativeconfig.Receipt { @@ -210,7 +210,7 @@ func managedGeminiObjectExists(objects []domain.NativeObjectOwnership, kind, nam return false } -func requireGeminiObjectAbsent(root string, object domain.NativeObjectOwnership) error { +func requireGeminiObjectAbsent(root string, object domain.NativeObjectOwnership, kernel nativeconfig.Kernel) error { if err := validateGeminiObject(root, object); err != nil { return err } @@ -222,7 +222,7 @@ func requireGeminiObjectAbsent(root string, object domain.NativeObjectOwnership) } return nil } - present, _, err := nativeconfig.New().Inspect(GeminiConfigPaths(root), nativeconfig.CodecGemini, object.LogicalName, nil) + present, _, err := kernel.Inspect(GeminiConfigPaths(root), nativeconfig.CodecGemini, object.LogicalName, nil) if err != nil { return err } diff --git a/install/integrationctl/agentplugins/clients/gemini/native_apply.go b/install/integrationctl/agentplugins/clients/gemini/native_apply.go index f89994a1..821a574e 100644 --- a/install/integrationctl/agentplugins/clients/gemini/native_apply.go +++ b/install/integrationctl/agentplugins/clients/gemini/native_apply.go @@ -88,7 +88,7 @@ func prepareGeminiNativeApply(configRoot, activePath string, previous, desired [ return nil, fmt.Errorf("the Gemini config root is unavailable") } previous, desired = GeminiObjects(previous), GeminiObjects(desired) - if err := VerifyGeminiNativeObjects(configRoot, previous, true); err != nil { + if err := VerifyGeminiNativeObjects(configRoot, previous, true, kernel); err != nil { return nil, err } previousByID, desiredByID := shared.ObjectMap(previous), shared.ObjectMap(desired) @@ -96,7 +96,7 @@ func prepareGeminiNativeApply(configRoot, activePath string, previous, desired [ if capacityErr != nil { return nil, fmt.Errorf("prepare managed Gemini object set: %w", capacityErr) } - if err := validateGeminiDesiredIdentity(configRoot, previousByID, desiredByID); err != nil { + if err := validateGeminiDesiredIdentity(configRoot, previousByID, desiredByID, kernel); err != nil { return nil, err } descriptor, err := loadGeminiDescriptor(activePath, desired) @@ -110,7 +110,7 @@ func prepareGeminiNativeApply(configRoot, activePath string, previous, desired [ }, nil } -func validateGeminiDesiredIdentity(configRoot string, previousByID, desiredByID map[string]domain.NativeObjectOwnership) error { +func validateGeminiDesiredIdentity(configRoot string, previousByID, desiredByID map[string]domain.NativeObjectOwnership, kernel nativeconfig.Kernel) error { for id, object := range desiredByID { if prior, replacing := previousByID[id]; replacing { if prior.Kind != object.Kind || prior.LogicalName != object.LogicalName || !shared.SameCleanPath(prior.Path, object.Path) { @@ -118,7 +118,7 @@ func validateGeminiDesiredIdentity(configRoot string, previousByID, desiredByID } continue } - if err := requireGeminiObjectAbsent(configRoot, object); err != nil { + if err := requireGeminiObjectAbsent(configRoot, object, kernel); err != nil { return err } } diff --git a/install/integrationctl/agentplugins/clients/gemini/native_mcp.go b/install/integrationctl/agentplugins/clients/gemini/native_mcp.go index 81c9cf1b..33a5e503 100644 --- a/install/integrationctl/agentplugins/clients/gemini/native_mcp.go +++ b/install/integrationctl/agentplugins/clients/gemini/native_mcp.go @@ -44,7 +44,7 @@ func geminiMCPRequest(prepared *geminiNativeApply, id string) (nativeconfig.Requ return geminiMCPUpsertRequest(prepared, prior, next, hadPrior) } if hadPrior { - return geminiMCPRemoveRequest(prepared.configRoot, prior) + return geminiMCPRemoveRequest(prepared.kernel, prepared.configRoot, prior) } return nativeconfig.Request{}, false, nil } @@ -58,7 +58,7 @@ func geminiMCPUpsertRequest(prepared *geminiNativeApply, prior, next domain.Nati if err != nil { return nativeconfig.Request{}, false, err } - present, owned, err := inspectOwnedGeminiMCP(prepared.configRoot, prior, hadPrior) + present, owned, err := inspectOwnedGeminiMCP(prepared.kernel, prepared.configRoot, prior, hadPrior) if err != nil { return nativeconfig.Request{}, false, err } @@ -78,15 +78,15 @@ func geminiMCPUpsertRequest(prepared *geminiNativeApply, prior, next domain.Nati }, true, nil } -func inspectOwnedGeminiMCP(configRoot string, prior domain.NativeObjectOwnership, hadPrior bool) (bool, bool, error) { +func inspectOwnedGeminiMCP(kernel nativeconfig.Kernel, configRoot string, prior domain.NativeObjectOwnership, hadPrior bool) (bool, bool, error) { if !hadPrior { return false, false, nil } - return nativeconfig.New().Inspect(GeminiConfigPaths(configRoot), nativeconfig.CodecGemini, prior.LogicalName, GeminiReceipt(prior)) + return kernel.Inspect(GeminiConfigPaths(configRoot), nativeconfig.CodecGemini, prior.LogicalName, GeminiReceipt(prior)) } -func geminiMCPRemoveRequest(configRoot string, prior domain.NativeObjectOwnership) (nativeconfig.Request, bool, error) { - present, owned, err := nativeconfig.New().Inspect(GeminiConfigPaths(configRoot), nativeconfig.CodecGemini, prior.LogicalName, GeminiReceipt(prior)) +func geminiMCPRemoveRequest(kernel nativeconfig.Kernel, configRoot string, prior domain.NativeObjectOwnership) (nativeconfig.Request, bool, error) { + present, owned, err := kernel.Inspect(GeminiConfigPaths(configRoot), nativeconfig.CodecGemini, prior.LogicalName, GeminiReceipt(prior)) if err != nil { return nativeconfig.Request{}, false, err } diff --git a/install/integrationctl/agentplugins/clients/opencode/activate.go b/install/integrationctl/agentplugins/clients/opencode/activate.go index 958f6d17..6ff03ab5 100644 --- a/install/integrationctl/agentplugins/clients/opencode/activate.go +++ b/install/integrationctl/agentplugins/clients/opencode/activate.go @@ -34,7 +34,7 @@ func (*Adapter) Activate(ctx context.Context, env clients.Env, request domain.Ac RetryAction: "retry the managed OpenCode native installation", CompletedAction: "restart OpenCode to load the installed plugin", Verify: func() error { - return VerifyOpenCodeNativeObjects(request.Client.ConfigRoot, request.Delivery.ActivePath, request.Delivery.NativeObjects) + return VerifyOpenCodeNativeObjects(request.Client.ConfigRoot, request.Delivery.ActivePath, request.Delivery.NativeObjects, env.NativeConfig) }, Activate: func(ctx context.Context, request domain.ActivationRequest) error { return ActivateOpenCodeNativeWithKernel(ctx, request, env.NativeConfig) diff --git a/install/integrationctl/agentplugins/clients/opencode/native_apply.go b/install/integrationctl/agentplugins/clients/opencode/native_apply.go index 106565bb..f6b7f701 100644 --- a/install/integrationctl/agentplugins/clients/opencode/native_apply.go +++ b/install/integrationctl/agentplugins/clients/opencode/native_apply.go @@ -49,7 +49,7 @@ func applyOpenCodeNativeWithKernelAndOps(configRoot, activePath string, previous if err != nil { return err } - requests, err := openCodeMCPRequests(prepared.projection, prepared.previous, prepared.desired) + requests, err := openCodeMCPRequests(prepared.kernel, prepared.projection, prepared.previous, prepared.desired) if err != nil { return err } diff --git a/install/integrationctl/agentplugins/clients/opencode/native_mcp.go b/install/integrationctl/agentplugins/clients/opencode/native_mcp.go index 08bb2b9f..cf994755 100644 --- a/install/integrationctl/agentplugins/clients/opencode/native_mcp.go +++ b/install/integrationctl/agentplugins/clients/opencode/native_mcp.go @@ -13,7 +13,7 @@ import ( "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" ) -func VerifyOpenCodeNativeObjects(configRoot, activePath string, objects []domain.NativeObjectOwnership) error { +func VerifyOpenCodeNativeObjects(configRoot, activePath string, objects []domain.NativeObjectOwnership, kernel nativeconfig.Kernel) error { projection := OpenCodeProjection{} if len(OpenCodeObjects(objects)) > 0 && activePath != "" { var err error @@ -26,21 +26,21 @@ func VerifyOpenCodeNativeObjects(configRoot, activePath string, objects []domain } } for _, object := range OpenCodeObjects(objects) { - if err := verifyOpenCodeObject(configRoot, projection, object); err != nil { + if err := verifyOpenCodeObject(configRoot, projection, object, kernel); err != nil { return err } } return nil } -func verifyOpenCodeObject(configRoot string, projection OpenCodeProjection, object domain.NativeObjectOwnership) error { +func verifyOpenCodeObject(configRoot string, projection OpenCodeProjection, object domain.NativeObjectOwnership, kernel nativeconfig.Kernel) error { if err := validateOpenCodeObject(configRoot, projection, object); err != nil { return err } if object.Kind == openCodeSkillKind { return verifyOpenCodeSkill(object) } - return verifyOpenCodeMCP(projection, object) + return verifyOpenCodeMCP(projection, object, kernel) } func verifyOpenCodeSkill(object domain.NativeObjectOwnership) error { @@ -51,9 +51,9 @@ func verifyOpenCodeSkill(object domain.NativeObjectOwnership) error { return nil } -func verifyOpenCodeMCP(projection OpenCodeProjection, object domain.NativeObjectOwnership) error { +func verifyOpenCodeMCP(projection OpenCodeProjection, object domain.NativeObjectOwnership, kernel nativeconfig.Kernel) error { receipt := receiptFromOpenCodeObject(object) - present, exactlyOwned, err := nativeconfig.New().Inspect(nativeconfig.Paths{JSON: projection.ConfigJSON, JSONC: projection.ConfigJSONC}, nativeconfig.CodecOpenCode, object.LogicalName, &receipt) + present, exactlyOwned, err := kernel.Inspect(nativeconfig.Paths{JSON: projection.ConfigJSON, JSONC: projection.ConfigJSONC}, nativeconfig.CodecOpenCode, object.LogicalName, &receipt) if err != nil { return fmt.Errorf("verify managed OpenCode MCP server %q: %w", object.LogicalName, err) } @@ -75,11 +75,10 @@ func validateOpenCodeProjection(configRoot, activePath string, projection OpenCo return nil } -func openCodeMCPRequests(projection OpenCodeProjection, previous, desired []domain.NativeObjectOwnership) ([]nativeconfig.Request, error) { +func openCodeMCPRequests(kernel nativeconfig.Kernel, projection OpenCodeProjection, previous, desired []domain.NativeObjectOwnership) ([]nativeconfig.Request, error) { previousByID, desiredByID := shared.ObjectMap(previous), shared.ObjectMap(desired) paths := openCodeMCPPaths(projection, previous) placeholders := nativeconfig.Placeholders{PackageRoot: projection.PackageRoot, DataRoot: projection.DataRoot} - kernel := nativeconfig.New() previousRequests, err := openCodeMCPPreviousRequests(kernel, paths, placeholders, projection, previousByID, desiredByID) if err != nil { return nil, err diff --git a/install/integrationctl/agentplugins/clients/windsurf/activate.go b/install/integrationctl/agentplugins/clients/windsurf/activate.go index cf8c6316..2dcd2a1c 100644 --- a/install/integrationctl/agentplugins/clients/windsurf/activate.go +++ b/install/integrationctl/agentplugins/clients/windsurf/activate.go @@ -47,7 +47,7 @@ func (*Adapter) Activate(ctx context.Context, env clients.Env, request domain.Ac return outcome, nil } if request.VerifyOnly { - if err := VerifyWindsurfNativeObjects(request.Client.ConfigRoot, request.Delivery.ActivePath, request.Delivery.NativeObjects, false); err != nil { + if err := VerifyWindsurfNativeObjects(request.Client.ConfigRoot, request.Delivery.ActivePath, request.Delivery.NativeObjects, false, env.NativeConfig); err != nil { return shared.FailedActivation(outcome, "repair the managed Windsurf MCP configuration", err) } outcome.Activation = domain.ActivationActive diff --git a/install/integrationctl/agentplugins/clients/windsurf/identity.go b/install/integrationctl/agentplugins/clients/windsurf/identity.go index abec4e87..1b20afdf 100644 --- a/install/integrationctl/agentplugins/clients/windsurf/identity.go +++ b/install/integrationctl/agentplugins/clients/windsurf/identity.go @@ -11,9 +11,9 @@ var _ clients.RegistryInspector = (*Adapter)(nil) func (*Adapter) UsesNativeRegistryExecutable() bool { return false } -func (*Adapter) InspectNativeRegistry(ctx context.Context, _ clients.Env, _ domain.DetectedClient, plan domain.DeliveryPlan, managed *domain.ClientBinding) (clients.RegistryFinding, error) { +func (*Adapter) InspectNativeRegistry(ctx context.Context, env clients.Env, _ domain.DetectedClient, plan domain.DeliveryPlan, managed *domain.ClientBinding) (clients.RegistryFinding, error) { if err := ctx.Err(); err != nil { return clients.RegistryIndeterminate, err } - return InspectWindsurfRegistry(plan, managed) + return InspectWindsurfRegistry(plan, managed, env.NativeConfig) } diff --git a/install/integrationctl/agentplugins/clients/windsurf/lifecycle.go b/install/integrationctl/agentplugins/clients/windsurf/lifecycle.go index d2702b3d..ad9cc6ea 100644 --- a/install/integrationctl/agentplugins/clients/windsurf/lifecycle.go +++ b/install/integrationctl/agentplugins/clients/windsurf/lifecycle.go @@ -34,7 +34,7 @@ func applyWindsurfNativeMutationWithKernel(configRoot, activePath string, previo return err } if len(mutations) == 0 { - return VerifyWindsurfNativeObjects(configRoot, activePath, desired, false) + return VerifyWindsurfNativeObjects(configRoot, activePath, desired, false, kernel) } requests := make([]nativeconfig.Request, len(mutations)) for index := range mutations { @@ -49,7 +49,7 @@ func applyWindsurfNativeMutationWithKernel(configRoot, activePath string, previo return fmt.Errorf("the Windsurf MCP entry %q ownership digest changed during apply", mutations[index].name) } } - return VerifyWindsurfNativeObjects(configRoot, activePath, desired, false) + return VerifyWindsurfNativeObjects(configRoot, activePath, desired, false, kernel) } func loadWindsurfNativeState(configRoot, activePath string, previous, desired []domain.NativeObjectOwnership) (windsurfNativeState, error) { diff --git a/install/integrationctl/agentplugins/clients/windsurf/native.go b/install/integrationctl/agentplugins/clients/windsurf/native.go index 32df0263..dcacd884 100644 --- a/install/integrationctl/agentplugins/clients/windsurf/native.go +++ b/install/integrationctl/agentplugins/clients/windsurf/native.go @@ -32,7 +32,7 @@ func ApplyWindsurfNativeMutation(configRoot, activePath string, previous, desire return applyWindsurfNativeMutationWithKernel(configRoot, activePath, previous, desired, nativeconfig.New()) } -func VerifyWindsurfNativeObjects(configRoot, activePath string, objects []domain.NativeObjectOwnership, allowMissing bool) error { +func VerifyWindsurfNativeObjects(configRoot, activePath string, objects []domain.NativeObjectOwnership, allowMissing bool, kernel nativeconfig.Kernel) error { objectMap, err := windsurfObjectMap(configRoot, objects) if err != nil { return err @@ -48,7 +48,6 @@ func VerifyWindsurfNativeObjects(configRoot, activePath string, objects []domain if err != nil { return err } - kernel := nativeconfig.New() for name, object := range objectMap { if err := verifyWindsurfNativeObject(kernel, configPath, servers, name, object, allowMissing); err != nil { return err @@ -80,7 +79,7 @@ func verifyWindsurfNativeObject(kernel nativeconfig.Kernel, configPath string, s return nil } -func InspectWindsurfRegistry(plan domain.DeliveryPlan, managed *domain.ClientBinding) (clients.RegistryFinding, error) { +func InspectWindsurfRegistry(plan domain.DeliveryPlan, managed *domain.ClientBinding, kernel nativeconfig.Kernel) (clients.RegistryFinding, error) { if strings.TrimSpace(plan.NativeRegistryRoot) == "" { return clients.RegistryClear, nil } @@ -89,18 +88,17 @@ func InspectWindsurfRegistry(plan domain.DeliveryPlan, managed *domain.ClientBin return clients.RegistryIndeterminate, err } if managed != nil { - if err := VerifyWindsurfNativeObjects(plan.NativeRegistryRoot, plan.ActivePath, managed.NativeObjects, false); err != nil { + if err := VerifyWindsurfNativeObjects(plan.NativeRegistryRoot, plan.ActivePath, managed.NativeObjects, false, kernel); err != nil { return clients.RegistryIndeterminate, err } if len(WindsurfObjects(managed.NativeObjects)) > 0 { return clients.RegistryExpected, nil } } - return inspectWindsurfPlannedServers(configPath, plan) + return inspectWindsurfPlannedServers(configPath, plan, kernel) } -func inspectWindsurfPlannedServers(configPath string, plan domain.DeliveryPlan) (clients.RegistryFinding, error) { - kernel := nativeconfig.New() +func inspectWindsurfPlannedServers(configPath string, plan domain.DeliveryPlan, kernel nativeconfig.Kernel) (clients.RegistryFinding, error) { for _, component := range plan.Components { if component.Kind != domain.ComponentMCPServer || component.Support == domain.SupportUnsupported { continue diff --git a/install/integrationctl/agentplugins/providers/cline_native_test.go b/install/integrationctl/agentplugins/providers/cline_native_test.go index 86d680b0..eced51b9 100644 --- a/install/integrationctl/agentplugins/providers/cline_native_test.go +++ b/install/integrationctl/agentplugins/providers/cline_native_test.go @@ -361,7 +361,7 @@ func TestClineTamperedReceiptFailsClosed(t *testing.T) { t.Fatal(err) } desired[0].ManagedDigest = "sha256:00" - if err := cline.VerifyClineNativeObjects(configRoot, desired, false); !errors.Is(err, nativeconfig.ErrNotOwned) && !strings.Contains(err.Error(), "changed outside") { + if err := cline.VerifyClineNativeObjects(configRoot, desired, false, nativeconfig.New()); !errors.Is(err, nativeconfig.ErrNotOwned) && !strings.Contains(err.Error(), "changed outside") { t.Fatalf("tamper was not rejected: %v", err) } } diff --git a/install/integrationctl/agentplugins/providers/native_identity.go b/install/integrationctl/agentplugins/providers/native_identity.go index 67f53c1a..89641c11 100644 --- a/install/integrationctl/agentplugins/providers/native_identity.go +++ b/install/integrationctl/agentplugins/providers/native_identity.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/shared" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" @@ -30,6 +31,7 @@ type NativeIdentityObserver struct { Stager packageVerifier Runner ports.CommandRunner DiscoveryTimeout time.Duration + NativeConfig *nativeconfig.Kernel // Registry supplies the client adapters that inspect native identity. It is // injected by the composition root and never defaulted to "every client". Registry *clients.Registry @@ -56,7 +58,14 @@ func (observer NativeIdentityObserver) requireRegistry() error { } func (observer NativeIdentityObserver) env() clients.Env { - return clients.Env{Runner: observer.Runner} + return clients.Env{Runner: observer.Runner, NativeConfig: observer.nativeConfigKernel()} +} + +func (observer NativeIdentityObserver) nativeConfigKernel() nativeconfig.Kernel { + if observer.NativeConfig != nil { + return *observer.NativeConfig + } + return nativeconfig.New() } func (observer NativeIdentityObserver) ObserveNativeIdentity(ctx context.Context, client domain.DetectedClient, plan domain.DeliveryPlan, managed *domain.ClientBinding) (domain.NativeIdentityObservation, error) { diff --git a/install/integrationctl/agentplugins/providers/opencode_logical_keys_test.go b/install/integrationctl/agentplugins/providers/opencode_logical_keys_test.go index 6e4940d2..12006866 100644 --- a/install/integrationctl/agentplugins/providers/opencode_logical_keys_test.go +++ b/install/integrationctl/agentplugins/providers/opencode_logical_keys_test.go @@ -79,14 +79,14 @@ func TestOpenCodeLogicalKeysLifecycle(t *testing.T) { if err := opencode.ApplyOpenCodeNative(configRoot, active, nil, first); err != nil { t.Fatal(err) } - if err := opencode.VerifyOpenCodeNativeObjects(configRoot, active, first); err != nil { + if err := opencode.VerifyOpenCodeNativeObjects(configRoot, active, first, nativeconfig.New()); err != nil { t.Fatal(err) } second := build("v2") if err := opencode.ApplyOpenCodeNative(configRoot, active, first, second); err != nil { t.Fatal(err) } - if err := opencode.VerifyOpenCodeNativeObjects(configRoot, active, second); err != nil { + if err := opencode.VerifyOpenCodeNativeObjects(configRoot, active, second, nativeconfig.New()); err != nil { t.Fatal(err) } // Exact repair recreates absent entries while preserving the foreign entry. @@ -94,7 +94,7 @@ func TestOpenCodeLogicalKeysLifecycle(t *testing.T) { if err := opencode.ApplyOpenCodeNative(configRoot, active, second, second); err != nil { t.Fatal(err) } - if err := opencode.VerifyOpenCodeNativeObjects(configRoot, active, second); err != nil { + if err := opencode.VerifyOpenCodeNativeObjects(configRoot, active, second, nativeconfig.New()); err != nil { t.Fatal(err) } if err := opencode.ApplyOpenCodeNative(configRoot, "", second, nil); err != nil { diff --git a/install/integrationctl/agentplugins/providers/opencode_native_test.go b/install/integrationctl/agentplugins/providers/opencode_native_test.go index 10a1b839..66bfcc1d 100644 --- a/install/integrationctl/agentplugins/providers/opencode_native_test.go +++ b/install/integrationctl/agentplugins/providers/opencode_native_test.go @@ -75,7 +75,7 @@ func TestOpenCodeNativeAddUpdateVerifyRemovePreservesJSONCAndForeignConfig(t *te if err := opencode.ApplyOpenCodeNative(configRoot, active, nil, firstObjects); err != nil { t.Fatal(err) } - if err := opencode.VerifyOpenCodeNativeObjects(configRoot, active, firstObjects); err != nil { + if err := opencode.VerifyOpenCodeNativeObjects(configRoot, active, firstObjects, nativeconfig.New()); err != nil { t.Fatal(err) } body := readOpenCodeTestFile(t, jsonc) @@ -268,7 +268,7 @@ func TestOpenCodeCommittedCleanupFailureKeepsReceiptsAndLaterLifecycleConsistent if cleanupCalls != 1 { t.Fatalf("committed cleanup calls = %d, want 1", cleanupCalls) } - if err := opencode.VerifyOpenCodeNativeObjects(configRoot, activeV2, second); err != nil { + if err := opencode.VerifyOpenCodeNativeObjects(configRoot, activeV2, second, nativeconfig.New()); err != nil { t.Fatalf("committed OpenCode receipts do not describe external state: %v", err) } if got := readOpenCodeTestFile(t, filepath.Join(configRoot, "skills", "docs", "SKILL.md")); !strings.Contains(got, "new") { @@ -313,7 +313,7 @@ func TestOpenCodeActivatorTreatsCommittedUnlockFailureAsSuccessfulLifecycle(t *t configRoot, active, objects, request := openCodeActivationFixture(t, "add") outcome, err := testActivator(Activator{NativeConfig: &committedKernel}).Activate(context.Background(), request) assertOpenCodeCommittedActivation(t, outcome, err) - if err := opencode.VerifyOpenCodeNativeObjects(configRoot, active, objects); err != nil { + if err := opencode.VerifyOpenCodeNativeObjects(configRoot, active, objects, nativeconfig.New()); err != nil { t.Fatalf("committed add state: %v", err) } if err := opencode.ApplyOpenCodeNative(configRoot, "", objects, nil); err != nil { @@ -347,7 +347,7 @@ func TestOpenCodeActivatorTreatsCommittedUnlockFailureAsSuccessfulLifecycle(t *t } outcome, err := testActivator(Activator{NativeConfig: &committedKernel}).Activate(context.Background(), request) assertOpenCodeCommittedActivation(t, outcome, err) - if err := opencode.VerifyOpenCodeNativeObjects(configRoot, activeV2, second); err != nil { + if err := opencode.VerifyOpenCodeNativeObjects(configRoot, activeV2, second, nativeconfig.New()); err != nil { t.Fatalf("committed update state: %v", err) } if err := opencode.ApplyOpenCodeNative(configRoot, activeV2, second, second); err != nil { @@ -366,7 +366,7 @@ func TestOpenCodeActivatorTreatsCommittedUnlockFailureAsSuccessfulLifecycle(t *t request.Replacing = true outcome, err := testActivator(Activator{NativeConfig: &committedKernel}).Activate(context.Background(), request) assertOpenCodeCommittedActivation(t, outcome, err) - if err := opencode.VerifyOpenCodeNativeObjects(configRoot, active, objects); err != nil { + if err := opencode.VerifyOpenCodeNativeObjects(configRoot, active, objects, nativeconfig.New()); err != nil { t.Fatalf("committed repair state: %v", err) } if err := opencode.ApplyOpenCodeNative(configRoot, "", objects, nil); err != nil { diff --git a/install/integrationctl/agentplugins/providers/stager_support_test.go b/install/integrationctl/agentplugins/providers/stager_support_test.go index 7a37b91f..472a8dbd 100644 --- a/install/integrationctl/agentplugins/providers/stager_support_test.go +++ b/install/integrationctl/agentplugins/providers/stager_support_test.go @@ -2,6 +2,7 @@ package providers import ( "github.com/777genius/plugin-kit-ai/install/integrationctl/adapters/pathpolicy" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/all" ) @@ -22,6 +23,10 @@ func testActivator(base Activator) Activator { if base.Registry == nil { base.Registry = all.Default() } + if base.NativeConfig == nil { + kernel := nativeconfig.New() + base.NativeConfig = &kernel + } return base } @@ -30,5 +35,9 @@ func testObserver(base NativeIdentityObserver) NativeIdentityObserver { if base.Registry == nil { base.Registry = all.Default() } + if base.NativeConfig == nil { + kernel := nativeconfig.New() + base.NativeConfig = &kernel + } return base } diff --git a/install/integrationctl/agentplugins/providerstest/providerstest.go b/install/integrationctl/agentplugins/providerstest/providerstest.go index 21b08fa2..f07f9346 100644 --- a/install/integrationctl/agentplugins/providerstest/providerstest.go +++ b/install/integrationctl/agentplugins/providerstest/providerstest.go @@ -5,6 +5,7 @@ package providerstest import ( "github.com/777genius/plugin-kit-ai/install/integrationctl/adapters/pathpolicy" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/all" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/providers" ) @@ -28,6 +29,10 @@ func NewActivator(base providers.Activator) providers.Activator { if base.Registry == nil { base.Registry = all.Default() } + if base.NativeConfig == nil { + kernel := nativeconfig.New() + base.NativeConfig = &kernel + } return base } @@ -36,5 +41,9 @@ func NewObserver(base providers.NativeIdentityObserver) providers.NativeIdentity if base.Registry == nil { base.Registry = all.Default() } + if base.NativeConfig == nil { + kernel := nativeconfig.New() + base.NativeConfig = &kernel + } return base } From 0d4b0317fa4f1ee9c0f2506cdd0c60c438925ae2 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 19 Sep 2026 10:01:29 +0300 Subject: [PATCH 03/11] fix(agentplugins): fail closed on a missing nativeconfig kernel An omitted NativeConfig used to construct a second OS kernel, so production and tests could not tell a wired kernel from a default. Zero Kernel now fails Inspect/Apply; Kiro re-runs duplex preflight before native writes; Makefile/core-fast cd into the nested module under GOWORK=off. --- .github/workflows/core-fast.yml | 4 ++-- Makefile | 5 +++-- .../agentplugins/clients/all/registry_test.go | 10 ++++++++++ .../agentplugins/clients/kiro/activate.go | 3 +++ .../integrationctl/agentplugins/providers/activator.go | 5 ++++- .../agentplugins/providers/native_identity.go | 2 +- .../agentplugins/providers/opencode_native_test.go | 9 +++++++++ 7 files changed, 32 insertions(+), 6 deletions(-) diff --git a/.github/workflows/core-fast.yml b/.github/workflows/core-fast.yml index 8b28eeb7..6c89ec50 100644 --- a/.github/workflows/core-fast.yml +++ b/.github/workflows/core-fast.yml @@ -63,9 +63,9 @@ jobs: - name: Report core coverage continue-on-error: true run: | - go list ./install/integrationctl/agentplugins/... \ + (cd install/integrationctl/agentplugins && go list ./... \ | grep -v '/adapters/clientdetect$' \ - | xargs go test -count=1 -cover > core-cover.log 2>&1 || true + | xargs go test -count=1 -cover) > core-cover.log 2>&1 || true (cd cli/plugin-kit-ai && go test -count=1 -cover ./internal/agentpluginscli/... ./cmd/agentplugins/...) >> core-cover.log 2>&1 || true cat core-cover.log { diff --git a/Makefile b/Makefile index f68e4775..c54b709b 100644 --- a/Makefile +++ b/Makefile @@ -47,7 +47,8 @@ lint-baseline-check: test-core: # adapters/pathpolicy is outside the agentplugins tree but holds the only # ports.PathPolicy implementation, so the fast gate has to run it too. - $(CORE_TEST_GIT_ENV) go test -count=1 -timeout=$(CORE_TEST_TIMEOUT) ./install/integrationctl/agentplugins/... ./install/integrationctl/adapters/pathpolicy/... + $(CORE_TEST_GIT_ENV) go test -count=1 -timeout=$(CORE_TEST_TIMEOUT) ./install/integrationctl/adapters/pathpolicy/... + cd install/integrationctl/agentplugins && $(CORE_TEST_GIT_ENV) go test -count=1 -timeout=$(CORE_TEST_TIMEOUT) ./... cd cli/plugin-kit-ai && $(CORE_TEST_GIT_ENV) go test -count=1 -timeout=$(CORE_TEST_TIMEOUT) ./internal/agentpluginscli/... ./cmd/agentplugins/... test: @@ -57,7 +58,7 @@ test-required: go test -count=1 -timeout=$(REQUIRED_TEST_TIMEOUT) ./... go test -count=1 -timeout=$(REQUIRED_TEST_TIMEOUT) ./cli/plugin-kit-ai/... go test -count=1 -timeout=$(REQUIRED_TEST_TIMEOUT) ./install/integrationctl/... - go test -count=1 -timeout=$(REQUIRED_TEST_TIMEOUT) ./install/integrationctl/agentplugins/... + cd install/integrationctl/agentplugins && go test -count=1 -timeout=$(REQUIRED_TEST_TIMEOUT) ./... go test -count=1 -timeout=$(REQUIRED_TEST_TIMEOUT) ./install/plugininstall/... go test -count=1 -timeout=$(REQUIRED_TEST_TIMEOUT) ./sdk/... cd npm/agentplugins && npm test && npm pack --dry-run --ignore-scripts diff --git a/install/integrationctl/agentplugins/clients/all/registry_test.go b/install/integrationctl/agentplugins/clients/all/registry_test.go index db54fe88..e35749c2 100644 --- a/install/integrationctl/agentplugins/clients/all/registry_test.go +++ b/install/integrationctl/agentplugins/clients/all/registry_test.go @@ -90,5 +90,15 @@ func traitParityRequirements() []contracttest.CapabilityRequirement { return ok }, }, + { + Name: "Claude Code CLI probe", + Holds: func(definition domain.ClientDefinition) bool { + return definition.ID == domain.ClientClaude + }, + Implements: func(adapter clients.Adapter) bool { + _, ok := adapter.(clients.ActivationPreflighter) + return ok + }, + }, } } diff --git a/install/integrationctl/agentplugins/clients/kiro/activate.go b/install/integrationctl/agentplugins/clients/kiro/activate.go index ba202ac4..a5ba1a42 100644 --- a/install/integrationctl/agentplugins/clients/kiro/activate.go +++ b/install/integrationctl/agentplugins/clients/kiro/activate.go @@ -127,6 +127,9 @@ func verifyKiroInstall(ctx context.Context, env clients.Env, request domain.Acti } func activateAutomatic(ctx context.Context, env clients.Env, request domain.ActivationRequest, outcome domain.ActivationOutcome) (domain.ActivationOutcome, error) { + if err := (*Adapter)(nil).PreflightActivation(env, request); err != nil { + return domain.ActivationOutcome{}, err + } if err := ActivateNative(ctx, request); err != nil { return shared.FailedActivation(outcome, "retry the managed Kiro native installation", err) } diff --git a/install/integrationctl/agentplugins/providers/activator.go b/install/integrationctl/agentplugins/providers/activator.go index 29ad6289..2d46c027 100644 --- a/install/integrationctl/agentplugins/providers/activator.go +++ b/install/integrationctl/agentplugins/providers/activator.go @@ -37,7 +37,10 @@ func (activator Activator) nativeConfigKernel() nativeconfig.Kernel { if activator.NativeConfig != nil { return *activator.NativeConfig } - return nativeconfig.New() + // A zero Kernel cannot Inspect or Apply. Do not hide-default to + // nativeconfig.New(): Registry and Paths are fail-closed, and an omitted + // kernel must not silently write through a second OS instance. + return nativeconfig.Kernel{} } // AutomaticallyActivates reports whether Activate will use a managed client diff --git a/install/integrationctl/agentplugins/providers/native_identity.go b/install/integrationctl/agentplugins/providers/native_identity.go index 89641c11..26ae1fdc 100644 --- a/install/integrationctl/agentplugins/providers/native_identity.go +++ b/install/integrationctl/agentplugins/providers/native_identity.go @@ -65,7 +65,7 @@ func (observer NativeIdentityObserver) nativeConfigKernel() nativeconfig.Kernel if observer.NativeConfig != nil { return *observer.NativeConfig } - return nativeconfig.New() + return nativeconfig.Kernel{} } func (observer NativeIdentityObserver) ObserveNativeIdentity(ctx context.Context, client domain.DetectedClient, plan domain.DeliveryPlan, managed *domain.ClientBinding) (domain.NativeIdentityObservation, error) { diff --git a/install/integrationctl/agentplugins/providers/opencode_native_test.go b/install/integrationctl/agentplugins/providers/opencode_native_test.go index 66bfcc1d..932c6f36 100644 --- a/install/integrationctl/agentplugins/providers/opencode_native_test.go +++ b/install/integrationctl/agentplugins/providers/opencode_native_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/all" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/opencode" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/shared" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" @@ -399,6 +400,14 @@ func TestOpenCodeActivatorTreatsCommittedUnlockFailureAsSuccessfulLifecycle(t *t }) } +func TestActivatorRejectsNativeConfigMutationWithoutKernel(t *testing.T) { + _, _, _, request := openCodeActivationFixture(t, "docs") + _, err := Activator{Registry: all.Default()}.Activate(context.Background(), request) + if err == nil || !strings.Contains(err.Error(), "native config file IO is required") { + t.Fatalf("missing NativeConfig was not fail-closed: %v", err) + } +} + func openCodeActivationFixture(t *testing.T, skillText string) (string, string, []domain.NativeObjectOwnership, domain.ActivationRequest) { t.Helper() root := t.TempDir() From 9b202ba631cd987e6ee35633654329081a987c77 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 19 Sep 2026 10:05:01 +0300 Subject: [PATCH 04/11] fix(agentplugins): fail closed when a native client has no projector Staging a native or native-config client without As[Projector] used to succeed with empty native objects. Claude and ChatGPT Activate now run PreflightActivation themselves so a skipped dispatcher lookup cannot bypass the probe. --- .../agentplugins/clients/all/registry_test.go | 10 ++++++++++ .../agentplugins/clients/chatgpt/lifecycle.go | 5 ++++- .../agentplugins/clients/claude/lifecycle.go | 3 +++ .../integrationctl/agentplugins/domain/traits.go | 14 ++++++++++++++ .../agentplugins/domain/traits_test.go | 9 +++++++++ .../agentplugins/providers/stager.go | 3 +++ .../agentplugins/providers/stager_test.go | 13 +++++++++++++ 7 files changed, 56 insertions(+), 1 deletion(-) diff --git a/install/integrationctl/agentplugins/clients/all/registry_test.go b/install/integrationctl/agentplugins/clients/all/registry_test.go index e35749c2..8040f226 100644 --- a/install/integrationctl/agentplugins/clients/all/registry_test.go +++ b/install/integrationctl/agentplugins/clients/all/registry_test.go @@ -100,5 +100,15 @@ func traitParityRequirements() []contracttest.CapabilityRequirement { return ok }, }, + { + Name: "native projector", + Holds: func(definition domain.ClientDefinition) bool { + return domain.RequiresNativeProjector(definition.ID) + }, + Implements: func(adapter clients.Adapter) bool { + _, ok := adapter.(clients.Projector) + return ok + }, + }, } } diff --git a/install/integrationctl/agentplugins/clients/chatgpt/lifecycle.go b/install/integrationctl/agentplugins/clients/chatgpt/lifecycle.go index d8219c80..28a7e3a1 100644 --- a/install/integrationctl/agentplugins/clients/chatgpt/lifecycle.go +++ b/install/integrationctl/agentplugins/clients/chatgpt/lifecycle.go @@ -28,10 +28,13 @@ func (*Adapter) PreflightActivation(_ clients.Env, request domain.ActivationRequ // Activate either records a prepared personal mapping or leaves ChatGPT as a // remote manual install. There is no managed executable. -func (*Adapter) Activate(_ context.Context, _ clients.Env, request domain.ActivationRequest) (domain.ActivationOutcome, error) { +func (*Adapter) Activate(_ context.Context, env clients.Env, request domain.ActivationRequest) (domain.ActivationOutcome, error) { if err := shared.ActivationIdentityMismatch(request); err != nil { return domain.ActivationOutcome{}, err } + if err := (*Adapter)(nil).PreflightActivation(env, request); err != nil { + return domain.ActivationOutcome{}, err + } outcome := shared.StartedActivation(request) if request.Plan.InstallIntent == domain.InstallIntentPrepare { outcome.Activation = domain.ActivationPrepared diff --git a/install/integrationctl/agentplugins/clients/claude/lifecycle.go b/install/integrationctl/agentplugins/clients/claude/lifecycle.go index 18535448..dba68562 100644 --- a/install/integrationctl/agentplugins/clients/claude/lifecycle.go +++ b/install/integrationctl/agentplugins/clients/claude/lifecycle.go @@ -40,6 +40,9 @@ func (*Adapter) Activate(ctx context.Context, env clients.Env, request domain.Ac if err := shared.ActivationIdentityMismatch(request); err != nil { return domain.ActivationOutcome{}, err } + if err := (*Adapter)(nil).PreflightActivation(env, request); err != nil { + return domain.ActivationOutcome{}, err + } outcome := shared.StartedActivation(request) if !shared.HasClientCLI(env, request.BackendExecutable) { return shared.FailedActivation(outcome, "install Claude Code CLI and retry exact @skills-dir verification", fmt.Errorf("trusted Claude Code CLI is required")) diff --git a/install/integrationctl/agentplugins/domain/traits.go b/install/integrationctl/agentplugins/domain/traits.go index 2cee0d9a..bf5372d8 100644 --- a/install/integrationctl/agentplugins/domain/traits.go +++ b/install/integrationctl/agentplugins/domain/traits.go @@ -37,6 +37,20 @@ func ClientTraitsFor(id ClientID) ClientTraits { return definition.Traits } +// RequiresNativeProjector reports whether staging this client must produce +// native object ownership through a Projector. A missing projector is +// fail-closed: empty native objects would look like a successful stage. +func RequiresNativeProjector(id ClientID) bool { + definition, ok := ClientDefinitionFor(id) + if !ok { + return false + } + if definition.Capabilities.PackageMode == PackageNative || definition.Traits.LifecycleKind == LifecycleNativeConfig { + return true + } + return id == ClientVSCode +} + // Allows reports whether this client's table lists the intent. Validate still // accepts historical empty automatic intent even when the slice omits it; // callers that need that exception must go through Validate. diff --git a/install/integrationctl/agentplugins/domain/traits_test.go b/install/integrationctl/agentplugins/domain/traits_test.go index 5cdc9410..bd16be9a 100644 --- a/install/integrationctl/agentplugins/domain/traits_test.go +++ b/install/integrationctl/agentplugins/domain/traits_test.go @@ -89,3 +89,12 @@ func TestShouldReadOnlyVerifyFollowsTraitsNotClientIDs(t *testing.T) { t.Fatal("Kiro automatic verifies when the executable names the client") } } + +func TestRequiresNativeProjectorMatchesPackageAndLifecycle(t *testing.T) { + if !RequiresNativeProjector(ClientGemini) || !RequiresNativeProjector(ClientOpenCode) || !RequiresNativeProjector(ClientVSCode) || !RequiresNativeProjector(ClientKiro) { + t.Fatal("native and native-config clients require a projector") + } + if RequiresNativeProjector(ClientClaude) || RequiresNativeProjector(ClientCodex) || RequiresNativeProjector(ClientChatGPT) { + t.Fatal("projection and manual clients do not require a projector") + } +} diff --git a/install/integrationctl/agentplugins/providers/stager.go b/install/integrationctl/agentplugins/providers/stager.go index 96087411..25d859a8 100644 --- a/install/integrationctl/agentplugins/providers/stager.go +++ b/install/integrationctl/agentplugins/providers/stager.go @@ -287,6 +287,9 @@ func (stager Stager) project( ) ([]domain.NativeObjectOwnership, error) { projector, ok := clients.As[clients.Projector](stager.Registry, plan.ClientID) if !ok { + if domain.RequiresNativeProjector(plan.ClientID) { + return nil, fmt.Errorf("client %q requires a native projector", plan.ClientID) + } return nil, nil } return projector.Project(ctx, clients.ProjectionInput{ diff --git a/install/integrationctl/agentplugins/providers/stager_test.go b/install/integrationctl/agentplugins/providers/stager_test.go index c4d99f4f..20410c23 100644 --- a/install/integrationctl/agentplugins/providers/stager_test.go +++ b/install/integrationctl/agentplugins/providers/stager_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/claude" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/kiro" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/shared" @@ -565,6 +566,18 @@ func TestStagerVerifyRejectsMarkersExcludedFromPortableSnapshotDigest(t *testing } } +func TestStagerRejectsMissingProjectorForNativeConfigClient(t *testing.T) { + t.Parallel() + registry, err := clients.NewRegistry(&claude.Adapter{}) + if err != nil { + t.Fatal(err) + } + _, err = testStager(Stager{Registry: registry}).Stage(context.Background(), stagingEnvelope(t), stagingPlan(t, domain.ClientGemini, domain.PackageNative), "operation-gemini", domain.CompatibilityHints{}) + if err == nil || !strings.Contains(err.Error(), "requires a native projector") { + t.Fatalf("missing Gemini projector was not fail-closed: %v", err) + } +} + func stagingEnvelope(t *testing.T) domain.PackageEnvelope { t.Helper() root := filepath.Join(t.TempDir(), "snapshot") From c4be2aec6eaa8047e62089148674cd1373458d5a Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 19 Sep 2026 10:12:29 +0300 Subject: [PATCH 05/11] fix(agentplugins): refuse skill-only native writes without a kernel A zero nativeconfig kernel skipped ApplyBatch on Cline/OpenCode skill-only paths, so omitted NativeConfig still mutated the host tree. Require FileIO before those writes and keep GOWORK=off test-core inside nested modules. --- Makefile | 2 +- .../adapters/nativeconfig/kernel.go | 18 ++++++-- .../adapters/nativeconfig/kernel_test.go | 10 +++++ .../agentplugins/clients/cline/lifecycle.go | 3 ++ .../agentplugins/clients/cline/native.go | 3 ++ .../clients/opencode/native_apply.go | 3 ++ .../clients/opencode/native_mcp.go | 3 ++ .../agentplugins/providers/activator.go | 13 ++++++ .../providers/cline_native_test.go | 29 +++++++++++++ .../agentplugins/providers/native_identity.go | 3 ++ .../providers/native_identity_test.go | 10 +++++ .../providers/opencode_native_test.go | 42 +++++++++++++++++++ .../agentplugins_release_contract_test.go | 4 +- 13 files changed, 137 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index c54b709b..0c8cfa44 100644 --- a/Makefile +++ b/Makefile @@ -47,7 +47,7 @@ lint-baseline-check: test-core: # adapters/pathpolicy is outside the agentplugins tree but holds the only # ports.PathPolicy implementation, so the fast gate has to run it too. - $(CORE_TEST_GIT_ENV) go test -count=1 -timeout=$(CORE_TEST_TIMEOUT) ./install/integrationctl/adapters/pathpolicy/... + cd install/integrationctl && $(CORE_TEST_GIT_ENV) go test -count=1 -timeout=$(CORE_TEST_TIMEOUT) ./adapters/pathpolicy/... cd install/integrationctl/agentplugins && $(CORE_TEST_GIT_ENV) go test -count=1 -timeout=$(CORE_TEST_TIMEOUT) ./... cd cli/plugin-kit-ai && $(CORE_TEST_GIT_ENV) go test -count=1 -timeout=$(CORE_TEST_TIMEOUT) ./internal/agentpluginscli/... ./cmd/agentplugins/... diff --git a/install/integrationctl/agentplugins/adapters/nativeconfig/kernel.go b/install/integrationctl/agentplugins/adapters/nativeconfig/kernel.go index 3aaaec99..770bbccb 100644 --- a/install/integrationctl/agentplugins/adapters/nativeconfig/kernel.go +++ b/install/integrationctl/agentplugins/adapters/nativeconfig/kernel.go @@ -31,13 +31,23 @@ func (kernel Kernel) Apply(req Request) (Receipt, error) { return receipts[0], err } +// RequireFileIO reports that this kernel can Inspect and Apply. A zero Kernel +// has no FileIO and must not be used as a hide-default on skill-only native +// paths that never call ApplyBatch. +func (kernel Kernel) RequireFileIO() error { + if kernel.files == nil { + return fmt.Errorf("native config file IO is required") + } + return nil +} + // ApplyBatch validates and renders related MCP entry mutations into one atomic // replacement. The batch is all-or-none for cooperating agentplugins writers. // See conditionalFileIO for the unavoidable portable race with clients that do // not honor the same locks. func (kernel Kernel) ApplyBatch(requests []Request) (receipts []Receipt, err error) { - if kernel.files == nil { - return nil, fmt.Errorf("native config file IO is required") + if err := kernel.RequireFileIO(); err != nil { + return nil, err } if len(requests) == 0 { return nil, nil @@ -178,8 +188,8 @@ func (kernel Kernel) ApplyBatch(requests []Request) (receipts []Receipt, err err // Inspect performs a strict read-only ownership check for one native entry. func (kernel Kernel) Inspect(paths Paths, codec Codec, name string, owned *Receipt) (present bool, exactlyOwned bool, err error) { - if kernel.files == nil { - return false, false, fmt.Errorf("native config file IO is required") + if err := kernel.RequireFileIO(); err != nil { + return false, false, err } if strings.TrimSpace(name) == "" { return false, false, fmt.Errorf("MCP entry name is required") diff --git a/install/integrationctl/agentplugins/adapters/nativeconfig/kernel_test.go b/install/integrationctl/agentplugins/adapters/nativeconfig/kernel_test.go index 84bff6c7..ef83a194 100644 --- a/install/integrationctl/agentplugins/adapters/nativeconfig/kernel_test.go +++ b/install/integrationctl/agentplugins/adapters/nativeconfig/kernel_test.go @@ -12,6 +12,16 @@ import ( "time" ) +func TestRequireFileIORejectsZeroKernel(t *testing.T) { + t.Parallel() + if err := (Kernel{}).RequireFileIO(); err == nil || !strings.Contains(err.Error(), "native config file IO is required") { + t.Fatalf("zero Kernel was not fail-closed: %v", err) + } + if err := New().RequireFileIO(); err != nil { + t.Fatalf("production Kernel rejected FileIO: %v", err) + } +} + func TestMCPServersAddPreservesUnrelatedConfigAndResolvesExplicitPaths(t *testing.T) { root := t.TempDir() path := filepath.Join(root, "client.json") diff --git a/install/integrationctl/agentplugins/clients/cline/lifecycle.go b/install/integrationctl/agentplugins/clients/cline/lifecycle.go index 7f5be82c..62c8187e 100644 --- a/install/integrationctl/agentplugins/clients/cline/lifecycle.go +++ b/install/integrationctl/agentplugins/clients/cline/lifecycle.go @@ -58,6 +58,9 @@ type clineNativeApply struct { } func prepareClineNativeApply(configRoot, activePath string, previous, desired []domain.NativeObjectOwnership, kernel nativeconfig.Kernel, rename clineRenameFunc, capacity shared.CombinedCapacityFunc) (*clineNativeApply, error) { + if err := kernel.RequireFileIO(); err != nil { + return nil, err + } if rename == nil { return nil, fmt.Errorf("the Cline rename operation is unavailable") } diff --git a/install/integrationctl/agentplugins/clients/cline/native.go b/install/integrationctl/agentplugins/clients/cline/native.go index 79171d85..9087baa0 100644 --- a/install/integrationctl/agentplugins/clients/cline/native.go +++ b/install/integrationctl/agentplugins/clients/cline/native.go @@ -60,6 +60,9 @@ func applyClineNativeMutationWithKernelAndRename(configRoot, activePath string, } func VerifyClineNativeObjects(configRoot string, objects []domain.NativeObjectOwnership, allowMissing bool, kernel nativeconfig.Kernel) error { + if err := kernel.RequireFileIO(); err != nil { + return err + } for _, object := range ClineObjects(objects) { if err := validateClineObject(configRoot, object); err != nil { return err diff --git a/install/integrationctl/agentplugins/clients/opencode/native_apply.go b/install/integrationctl/agentplugins/clients/opencode/native_apply.go index f6b7f701..ecc5a798 100644 --- a/install/integrationctl/agentplugins/clients/opencode/native_apply.go +++ b/install/integrationctl/agentplugins/clients/opencode/native_apply.go @@ -78,6 +78,9 @@ type openCodeNativeApply struct { } func prepareOpenCodeNativeApply(configRoot, activePath string, previous, desired []domain.NativeObjectOwnership, kernel nativeconfig.Kernel, rename openCodeRenameFunc, removeAll func(string) error) (*openCodeNativeApply, error) { + if err := kernel.RequireFileIO(); err != nil { + return nil, err + } if rename == nil { return nil, fmt.Errorf("OpenCode rename operation is unavailable") } diff --git a/install/integrationctl/agentplugins/clients/opencode/native_mcp.go b/install/integrationctl/agentplugins/clients/opencode/native_mcp.go index cf994755..3edaa409 100644 --- a/install/integrationctl/agentplugins/clients/opencode/native_mcp.go +++ b/install/integrationctl/agentplugins/clients/opencode/native_mcp.go @@ -14,6 +14,9 @@ import ( ) func VerifyOpenCodeNativeObjects(configRoot, activePath string, objects []domain.NativeObjectOwnership, kernel nativeconfig.Kernel) error { + if err := kernel.RequireFileIO(); err != nil { + return err + } projection := OpenCodeProjection{} if len(OpenCodeObjects(objects)) > 0 && activePath != "" { var err error diff --git a/install/integrationctl/agentplugins/providers/activator.go b/install/integrationctl/agentplugins/providers/activator.go index 2d46c027..f13a400b 100644 --- a/install/integrationctl/agentplugins/providers/activator.go +++ b/install/integrationctl/agentplugins/providers/activator.go @@ -29,6 +29,13 @@ func (activator Activator) requireRegistry() error { return nil } +func requireNativeConfigKernel(id domain.ClientID, kernel nativeconfig.Kernel) error { + if domain.ClientTraitsFor(id).LifecycleKind != domain.LifecycleNativeConfig { + return nil + } + return kernel.RequireFileIO() +} + func (activator Activator) env() clients.Env { return clients.Env{Runner: activator.Runner, NativeConfig: activator.nativeConfigKernel()} } @@ -82,6 +89,9 @@ func (activator Activator) Deactivate(ctx context.Context, request domain.Deacti if err := activator.requireRegistry(); err != nil { return domain.DeactivationOutcome{}, err } + if err := requireNativeConfigKernel(request.Client.ClientID, activator.nativeConfigKernel()); err != nil { + return domain.DeactivationOutcome{}, err + } if lifecycle, ok := clients.As[clients.Lifecycle](activator.Registry, request.Client.ClientID); ok { return lifecycle.Deactivate(ctx, activator.env(), request) } @@ -95,6 +105,9 @@ func (activator Activator) Activate(ctx context.Context, request domain.Activati if err := activator.requireRegistry(); err != nil { return domain.ActivationOutcome{}, err } + if err := requireNativeConfigKernel(request.Client.ClientID, activator.nativeConfigKernel()); err != nil { + return domain.ActivationOutcome{}, err + } if err := shared.ActivationIdentityMismatch(request); err != nil { return domain.ActivationOutcome{}, err } diff --git a/install/integrationctl/agentplugins/providers/cline_native_test.go b/install/integrationctl/agentplugins/providers/cline_native_test.go index eced51b9..cf76978e 100644 --- a/install/integrationctl/agentplugins/providers/cline_native_test.go +++ b/install/integrationctl/agentplugins/providers/cline_native_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/all" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/cline" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/shared" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" @@ -347,6 +348,34 @@ func TestStagerBuildsClineNestedTransportAndOwnership(t *testing.T) { } } +func TestClineRejectsSkillOnlyMutationWithoutKernel(t *testing.T) { + root := t.TempDir() + configRoot := filepath.Join(root, ".cline") + active := filepath.Join(root, "managed", "demo") + writeTestFile(t, filepath.Join(active, "skills", "guide", "SKILL.md"), "---\nname: guide\ndescription: Guide\n---\n") + writeClineProjectionFixture(t, active, map[string]nativeconfig.Server{}) + desired := clineFixtureObjects(t, configRoot, active, "guide", "", nativeconfig.Server{}) + request := domain.ActivationRequest{ + Client: domain.DetectedClient{ClientID: domain.ClientCline, Status: domain.DetectionDetected, ConfigRoot: configRoot}, + Plan: domain.DeliveryPlan{ClientID: domain.ClientCline, ActivePath: active, Components: []domain.ComponentDecision{{Kind: domain.ComponentSkill, Name: "guide", Support: domain.SupportPrepared}}}, + Delivery: domain.StagedDelivery{ClientID: domain.ClientCline, OwnedBase: filepath.Dir(active), ActivePath: active, NativeObjects: desired}, + DeclaredName: "demo", + } + _, err := Activator{Registry: all.Default()}.Activate(context.Background(), request) + if err == nil || !strings.Contains(err.Error(), "native config file IO is required") { + t.Fatalf("missing NativeConfig was not fail-closed: %v", err) + } + if _, statErr := os.Lstat(filepath.Join(configRoot, "skills", "guide")); !os.IsNotExist(statErr) { + t.Fatalf("Cline skill tree mutated without kernel: %v", statErr) + } + + request.VerifyOnly = true + _, err = Activator{Registry: all.Default()}.Activate(context.Background(), request) + if err == nil || !strings.Contains(err.Error(), "native config file IO is required") { + t.Fatalf("verify-only missing NativeConfig was not fail-closed: %v", err) + } +} + func TestClineTamperedReceiptFailsClosed(t *testing.T) { root := t.TempDir() configRoot := filepath.Join(root, ".cline") diff --git a/install/integrationctl/agentplugins/providers/native_identity.go b/install/integrationctl/agentplugins/providers/native_identity.go index 26ae1fdc..68b3368e 100644 --- a/install/integrationctl/agentplugins/providers/native_identity.go +++ b/install/integrationctl/agentplugins/providers/native_identity.go @@ -90,6 +90,9 @@ func (observer NativeIdentityObserver) observeIdentity(ctx context.Context, clie if err := observer.requireRegistry(); err != nil { return domain.NativeIdentityObservation{State: domain.NativeIdentityIndeterminate}, err } + if err := requireNativeConfigKernel(client.ClientID, observer.nativeConfigKernel()); err != nil { + return domain.NativeIdentityObservation{State: domain.NativeIdentityIndeterminate}, err + } prepared, preparedErr := observer.inspectPreparedRegistry(client.ClientID, plan, name, managed != nil) if preparedErr != nil { prepared = registryIndeterminate diff --git a/install/integrationctl/agentplugins/providers/native_identity_test.go b/install/integrationctl/agentplugins/providers/native_identity_test.go index 1736027d..43b962af 100644 --- a/install/integrationctl/agentplugins/providers/native_identity_test.go +++ b/install/integrationctl/agentplugins/providers/native_identity_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/all" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/shared" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" legacyports "github.com/777genius/plugin-kit-ai/install/integrationctl/ports" @@ -47,6 +48,15 @@ func TestNativeIdentityFailsClosedWithoutRegistry(t *testing.T) { } } +func TestNativeIdentityFailsClosedWithoutKernelForNativeConfigClient(t *testing.T) { + t.Parallel() + plan := identityPlan(filepath.Join(t.TempDir(), "prepared")) + _, err := (NativeIdentityObserver{Registry: all.Default()}).ObserveNativeIdentity(context.Background(), domain.DetectedClient{ClientID: domain.ClientOpenCode}, plan, nil) + if err == nil || !strings.Contains(err.Error(), "native config file IO is required") { + t.Fatalf("missing NativeConfig was not fail-closed: %v", err) + } +} + func TestNativeIdentityCursorReadsEveryAuthoritativeLocalManifest(t *testing.T) { root := filepath.Join(t.TempDir(), ".cursor", "plugins", "local") writeIdentityFile(t, filepath.Join(root, "foreign-path", ".cursor-plugin", "plugin.json"), `{"name":"demo"}`) diff --git a/install/integrationctl/agentplugins/providers/opencode_native_test.go b/install/integrationctl/agentplugins/providers/opencode_native_test.go index 932c6f36..89e7e5aa 100644 --- a/install/integrationctl/agentplugins/providers/opencode_native_test.go +++ b/install/integrationctl/agentplugins/providers/opencode_native_test.go @@ -402,6 +402,22 @@ func TestOpenCodeActivatorTreatsCommittedUnlockFailureAsSuccessfulLifecycle(t *t func TestActivatorRejectsNativeConfigMutationWithoutKernel(t *testing.T) { _, _, _, request := openCodeActivationFixture(t, "docs") + assertActivatorRejectsMissingKernel(t, request) +} + +func TestActivatorRejectsSkillOnlyNativeConfigMutationWithoutKernel(t *testing.T) { + configRoot, _, _, request := openCodeSkillOnlyActivationFixture(t) + assertActivatorRejectsMissingKernel(t, request) + if _, err := os.Lstat(filepath.Join(configRoot, "skills", "docs")); !os.IsNotExist(err) { + t.Fatalf("OpenCode skill tree mutated without kernel: %v", err) + } + + request.VerifyOnly = true + assertActivatorRejectsMissingKernel(t, request) +} + +func assertActivatorRejectsMissingKernel(t *testing.T, request domain.ActivationRequest) { + t.Helper() _, err := Activator{Registry: all.Default()}.Activate(context.Background(), request) if err == nil || !strings.Contains(err.Error(), "native config file IO is required") { t.Fatalf("missing NativeConfig was not fail-closed: %v", err) @@ -425,6 +441,32 @@ func openCodeActivationFixture(t *testing.T, skillText string) (string, string, return configRoot, active, objects, request } +func openCodeSkillOnlyActivationFixture(t *testing.T) (string, string, []domain.NativeObjectOwnership, domain.ActivationRequest) { + t.Helper() + root := t.TempDir() + configRoot := filepath.Join(root, "xdg", "opencode") + active := filepath.Join(root, "managed", "demo") + writeOpenCodeTestFile(t, filepath.Join(active, "skills", "docs", "SKILL.md"), "# docs") + envelope := domain.PackageEnvelope{ + Skills: map[string]domain.Skill{"docs": {Name: "docs", RelativePath: "skills/docs/SKILL.md"}}, + } + plan := domain.DeliveryPlan{ClientID: domain.ClientOpenCode, NativeRegistryRoot: configRoot, ActivePath: active, Components: []domain.ComponentDecision{ + {Kind: domain.ComponentSkill, Name: "docs", Support: domain.SupportPrepared}, + }} + if err := opencode.ProjectOpenCodeNative(active, envelope, plan, filepath.Join(filepath.Dir(active), "data")); err != nil { + t.Fatal(err) + } + objects, err := opencode.BuildOpenCodeNativeObjects(active, envelope, plan) + if err != nil { + t.Fatal(err) + } + request := domain.ActivationRequest{ + Client: domain.DetectedClient{ClientID: domain.ClientOpenCode, Status: domain.DetectionDetected, ConfigRoot: configRoot}, + Plan: plan, Delivery: domain.StagedDelivery{ClientID: domain.ClientOpenCode, OwnedBase: filepath.Dir(active), ActivePath: active, NativeObjects: objects}, DeclaredName: "demo", + } + return configRoot, active, objects, request +} + func assertOpenCodeCommittedActivation(t *testing.T, outcome domain.ActivationOutcome, err error) { t.Helper() if err != nil { diff --git a/repotests/agentplugins_release_contract_test.go b/repotests/agentplugins_release_contract_test.go index 635e23c8..2417273e 100644 --- a/repotests/agentplugins_release_contract_test.go +++ b/repotests/agentplugins_release_contract_test.go @@ -29,7 +29,9 @@ func TestAgentpluginsReleaseContractsStayFailClosed(t *testing.T) { "go test -count=1 -timeout=$(REQUIRED_TEST_TIMEOUT) ./...", "go test -count=1 -timeout=$(REQUIRED_TEST_TIMEOUT) ./cli/plugin-kit-ai/...", "go test -count=1 -timeout=$(REQUIRED_TEST_TIMEOUT) ./install/integrationctl/...", - "go test -count=1 -timeout=$(REQUIRED_TEST_TIMEOUT) ./install/integrationctl/agentplugins/...", + "cd install/integrationctl/agentplugins && go test -count=1 -timeout=$(REQUIRED_TEST_TIMEOUT) ./...", + "cd install/integrationctl/agentplugins && $(CORE_TEST_GIT_ENV) go test -count=1 -timeout=$(CORE_TEST_TIMEOUT) ./...", + "cd install/integrationctl && $(CORE_TEST_GIT_ENV) go test -count=1 -timeout=$(CORE_TEST_TIMEOUT) ./adapters/pathpolicy/...", "go test -count=1 -timeout=$(REQUIRED_TEST_TIMEOUT) ./install/plugininstall/...", "go test -count=1 -timeout=$(REQUIRED_TEST_TIMEOUT) ./sdk/...", "cd npm/agentplugins && npm test && npm pack --dry-run --ignore-scripts", From d36163221350d0431e924e132961b12d239e113d Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 19 Sep 2026 10:15:00 +0300 Subject: [PATCH 06/11] fix(agentplugins): refuse Gemini skill writes without a kernel Gemini installs skills before ApplyBatch, so a zero kernel still mutated the host tree and only failed on the later MCP batch. Require FileIO on the Gemini and Windsurf native entry points before any skill or config IO. --- .../agentplugins/clients/gemini/native.go | 3 ++ .../clients/gemini/native_apply.go | 3 ++ .../clients/windsurf/lifecycle.go | 3 ++ .../agentplugins/clients/windsurf/native.go | 3 ++ .../providers/gemini_native_test.go | 36 +++++++++++++++++++ 5 files changed, 48 insertions(+) diff --git a/install/integrationctl/agentplugins/clients/gemini/native.go b/install/integrationctl/agentplugins/clients/gemini/native.go index f1570b80..fe8c8653 100644 --- a/install/integrationctl/agentplugins/clients/gemini/native.go +++ b/install/integrationctl/agentplugins/clients/gemini/native.go @@ -39,6 +39,9 @@ func DeactivateGeminiNativeWithKernel(ctx context.Context, request domain.Deacti } func VerifyGeminiNativeObjects(configRoot string, objects []domain.NativeObjectOwnership, allowMissing bool, kernel nativeconfig.Kernel) error { + if err := kernel.RequireFileIO(); err != nil { + return err + } for _, object := range GeminiObjects(objects) { if err := validateGeminiObject(configRoot, object); err != nil { return err diff --git a/install/integrationctl/agentplugins/clients/gemini/native_apply.go b/install/integrationctl/agentplugins/clients/gemini/native_apply.go index 821a574e..0596463c 100644 --- a/install/integrationctl/agentplugins/clients/gemini/native_apply.go +++ b/install/integrationctl/agentplugins/clients/gemini/native_apply.go @@ -77,6 +77,9 @@ func ApplyGeminiNativeMutationWithKernelRenameAndCapacity(configRoot, activePath } func prepareGeminiNativeApply(configRoot, activePath string, previous, desired []domain.NativeObjectOwnership, kernel nativeconfig.Kernel, rename geminiRenameFunc, capacity shared.CombinedCapacityFunc) (*geminiNativeApply, error) { + if err := kernel.RequireFileIO(); err != nil { + return nil, err + } if rename == nil { return nil, fmt.Errorf("the Gemini rename operation is unavailable") } diff --git a/install/integrationctl/agentplugins/clients/windsurf/lifecycle.go b/install/integrationctl/agentplugins/clients/windsurf/lifecycle.go index ad9cc6ea..93b78a6d 100644 --- a/install/integrationctl/agentplugins/clients/windsurf/lifecycle.go +++ b/install/integrationctl/agentplugins/clients/windsurf/lifecycle.go @@ -25,6 +25,9 @@ type windsurfNativeState struct { } func applyWindsurfNativeMutationWithKernel(configRoot, activePath string, previous, desired []domain.NativeObjectOwnership, kernel nativeconfig.Kernel) error { + if err := kernel.RequireFileIO(); err != nil { + return err + } state, err := loadWindsurfNativeState(configRoot, activePath, previous, desired) if err != nil { return err diff --git a/install/integrationctl/agentplugins/clients/windsurf/native.go b/install/integrationctl/agentplugins/clients/windsurf/native.go index dcacd884..c226b4f2 100644 --- a/install/integrationctl/agentplugins/clients/windsurf/native.go +++ b/install/integrationctl/agentplugins/clients/windsurf/native.go @@ -33,6 +33,9 @@ func ApplyWindsurfNativeMutation(configRoot, activePath string, previous, desire } func VerifyWindsurfNativeObjects(configRoot, activePath string, objects []domain.NativeObjectOwnership, allowMissing bool, kernel nativeconfig.Kernel) error { + if err := kernel.RequireFileIO(); err != nil { + return err + } objectMap, err := windsurfObjectMap(configRoot, objects) if err != nil { return err diff --git a/install/integrationctl/agentplugins/providers/gemini_native_test.go b/install/integrationctl/agentplugins/providers/gemini_native_test.go index 3d2410d4..1d248241 100644 --- a/install/integrationctl/agentplugins/providers/gemini_native_test.go +++ b/install/integrationctl/agentplugins/providers/gemini_native_test.go @@ -207,6 +207,42 @@ func TestGeminiTransportProjection(t *testing.T) { } } +func TestGeminiRejectsSkillMutationWithoutKernel(t *testing.T) { + configRoot := filepath.Join(t.TempDir(), ".gemini") + active, desired := geminiNativeFixture(t, configRoot, "v1", "https://docs.test/v1") + var skills []domain.NativeObjectOwnership + for _, object := range desired { + if object.Kind == gemini.GeminiSkillObjectKind { + skills = append(skills, object) + } + } + err := gemini.ApplyGeminiNativeMutationWithKernelRenameAndCapacity(configRoot, active, nil, skills, nativeconfig.Kernel{}, shared.RenameDirectoryExclusive, shared.CheckedCombinedCapacity) + if err == nil || !strings.Contains(err.Error(), "native config file IO is required") { + t.Fatalf("missing kernel was not fail-closed: %v", err) + } + if _, statErr := os.Lstat(filepath.Join(configRoot, "skills")); !os.IsNotExist(statErr) { + t.Fatalf("Gemini skill tree mutated without kernel: %v", statErr) + } +} + +func TestActivatorRejectsGeminiNativeMutationWithoutKernel(t *testing.T) { + configRoot := filepath.Join(t.TempDir(), ".gemini") + active, desired := geminiNativeFixture(t, configRoot, "v1", "https://docs.test/v1") + request := domain.ActivationRequest{ + Client: domain.DetectedClient{ClientID: domain.ClientGemini, Status: domain.DetectionDetected, ConfigRoot: configRoot}, + Plan: domain.DeliveryPlan{ClientID: domain.ClientGemini, ActivePath: active, Components: []domain.ComponentDecision{ + {Kind: domain.ComponentSkill, Name: "docs", Support: domain.SupportPrepared}, + {Kind: domain.ComponentMCPServer, Name: "docs", Support: domain.SupportPrepared}, + }}, + Delivery: domain.StagedDelivery{ClientID: domain.ClientGemini, OwnedBase: filepath.Dir(active), ActivePath: active, NativeObjects: desired}, + DeclaredName: "demo", + } + assertActivatorRejectsMissingKernel(t, request) + if _, err := os.Lstat(filepath.Join(configRoot, "skills")); !os.IsNotExist(err) { + t.Fatalf("Gemini skill tree mutated without kernel: %v", err) + } +} + func geminiNativeFixture(t *testing.T, configRoot, marker, url string) (string, []domain.NativeObjectOwnership) { t.Helper() active := filepath.Join(t.TempDir(), "active") From 9fc4e97d6a58f2da72ab3f617b2efecd015fe9b7 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 19 Sep 2026 10:26:11 +0300 Subject: [PATCH 07/11] fix(agentplugins): inspect Cline and OpenCode native identity before commit Always-Clear InspectNativeRegistry let colliding skills and MCP names pass the pre-commit gate. Mirror Gemini: empty registry root is indeterminate, and a present host name is a collision. --- .../agentplugins/clients/cline/identity.go | 89 ++++++++++++++++- .../clients/contracttest/identity.go | 30 ++++++ .../clients/contracttest/identity_test.go | 6 ++ .../agentplugins/clients/opencode/identity.go | 97 ++++++++++++++++++- .../agentplugins/clients/windsurf/native.go | 2 +- .../providers/native_identity_test.go | 66 +++++++++++-- 6 files changed, 273 insertions(+), 17 deletions(-) diff --git a/install/integrationctl/agentplugins/clients/cline/identity.go b/install/integrationctl/agentplugins/clients/cline/identity.go index 3151d512..1a1738d1 100644 --- a/install/integrationctl/agentplugins/clients/cline/identity.go +++ b/install/integrationctl/agentplugins/clients/cline/identity.go @@ -2,7 +2,12 @@ package cline import ( "context" + "os" + "path/filepath" + "strings" + "github.com/777genius/plugin-kit-ai/install/integrationctl/adapters/pathpolicy" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" ) @@ -11,11 +16,87 @@ var _ clients.RegistryInspector = (*Adapter)(nil) func (*Adapter) UsesNativeRegistryExecutable() bool { return false } -func (*Adapter) InspectNativeRegistry(ctx context.Context, _ clients.Env, _ domain.DetectedClient, _ domain.DeliveryPlan, _ *domain.ClientBinding) (clients.RegistryFinding, error) { +func (*Adapter) InspectNativeRegistry(ctx context.Context, env clients.Env, _ domain.DetectedClient, plan domain.DeliveryPlan, managed *domain.ClientBinding) (clients.RegistryFinding, error) { if err := ctx.Err(); err != nil { return clients.RegistryIndeterminate, err } - // Cline MCP identities are per-server and protected by exact receipts; - // its skill paths are checked before the all-or-none native config batch. - return clients.RegistryClear, nil + return InspectClineRegistry(plan, managed, env.NativeConfig) +} + +func InspectClineRegistry(plan domain.DeliveryPlan, managed *domain.ClientBinding, kernel nativeconfig.Kernel) (clients.RegistryFinding, error) { + root := strings.TrimSpace(plan.NativeRegistryRoot) + if root == "" { + return clients.RegistryIndeterminate, nil + } + if managed != nil { + if err := VerifyClineNativeObjects(root, managed.NativeObjects, true, kernel); err != nil { + return clients.RegistryIndeterminate, err + } + } + finding := clients.RegistryClear + for _, component := range plan.Components { + if component.Support == domain.SupportUnsupported { + continue + } + exists, owned, err := inspectClineComponent(root, managed, component, kernel) + if err != nil { + return clients.RegistryIndeterminate, err + } + if exists && !owned { + return clients.RegistryCollision, nil + } + if exists && owned { + finding = clients.RegistryExpected + } + } + return finding, nil +} + +func inspectClineComponent(root string, managed *domain.ClientBinding, component domain.ComponentDecision, kernel nativeconfig.Kernel) (bool, bool, error) { + switch component.Kind { + case domain.ComponentSkill: + return inspectClineSkillComponent(root, managed, component.Name) + case domain.ComponentMCPServer: + return inspectClineMCPComponent(root, managed, component.Name, kernel) + default: + return false, false, nil + } +} + +func inspectClineSkillComponent(root string, managed *domain.ClientBinding, name string) (bool, bool, error) { + if err := pathpolicy.ValidateLeafID(name); err != nil { + return false, false, err + } + path := filepath.Join(root, "skills", name) + if err := pathpolicy.RequireContainedChild(root, path); err != nil { + return false, false, err + } + _, err := os.Lstat(path) + if err != nil && !os.IsNotExist(err) { + return false, false, err + } + owned := managed != nil && managedClineObjectExists(managed.NativeObjects, ClineSkillObjectKind, name) + return err == nil, owned, nil +} + +func inspectClineMCPComponent(root string, managed *domain.ClientBinding, name string, kernel nativeconfig.Kernel) (bool, bool, error) { + var receipt *nativeconfig.Receipt + if managed != nil { + for _, object := range ClineObjects(managed.NativeObjects) { + if object.Kind == ClineMCPObjectKind && object.LogicalName == name { + owned := clineReceipt(object) + receipt = &owned + } + } + } + return kernel.Inspect(nativeconfig.Paths{JSON: ClineMCPSettingsPath(root)}, nativeconfig.CodecCline, name, receipt) +} + +func managedClineObjectExists(objects []domain.NativeObjectOwnership, kind, name string) bool { + for _, object := range ClineObjects(objects) { + if object.Kind == kind && object.LogicalName == name { + return true + } + } + return false } diff --git a/install/integrationctl/agentplugins/clients/contracttest/identity.go b/install/integrationctl/agentplugins/clients/contracttest/identity.go index 808ccd4a..592cf822 100644 --- a/install/integrationctl/agentplugins/clients/contracttest/identity.go +++ b/install/integrationctl/agentplugins/clients/contracttest/identity.go @@ -31,6 +31,7 @@ func registryInspectorViolations(t *testing.T, inspector clients.RegistryInspect violations = append(violations, registryInspectorNilRunnerViolations(inspector, id)...) violations = append(violations, registryInspectorCanceledContextViolations(inspector, id)...) violations = append(violations, registryInspectorUnmanagedFindingViolations(inspector, id)...) + violations = append(violations, registryInspectorNativeConfigEmptyRootViolations(inspector, id)...) return violations } @@ -90,6 +91,35 @@ func registryInspectorUnmanagedFindingViolations(inspector clients.RegistryInspe return nil } +func registryInspectorNativeConfigEmptyRootViolations(inspector clients.RegistryInspector, id domain.ClientID) []string { + if domain.ClientTraitsFor(id).LifecycleKind != domain.LifecycleNativeConfig { + return nil + } + plan := registryInspectorPlan(id) + plan.NativeRegistryRoot = "" + plan.Components = []domain.ComponentDecision{ + {Kind: domain.ComponentSkill, Name: "docs", Support: domain.SupportNative}, + {Kind: domain.ComponentMCPServer, Name: "docs", Support: domain.SupportNative}, + } + var panicked any + var finding clients.RegistryFinding + var err error + func() { + defer func() { panicked = recover() }() + finding, err = inspector.InspectNativeRegistry(context.Background(), clients.Env{}, registryInspectorClient(id), plan, nil) + }() + if panicked != nil { + return []string{fmt.Sprintf("InspectNativeRegistry panicked with an empty NativeRegistryRoot: %v", panicked)} + } + if err != nil { + return nil + } + if finding == clients.RegistryClear { + return []string{"LifecycleNativeConfig inspector returned RegistryClear with an empty NativeRegistryRoot"} + } + return nil +} + func registryInspectorClient(id domain.ClientID) domain.DetectedClient { return domain.DetectedClient{ClientID: id, ConfigRoot: "/agentplugins-contract/config"} } diff --git a/install/integrationctl/agentplugins/clients/contracttest/identity_test.go b/install/integrationctl/agentplugins/clients/contracttest/identity_test.go index 73544ef6..6e64a825 100644 --- a/install/integrationctl/agentplugins/clients/contracttest/identity_test.go +++ b/install/integrationctl/agentplugins/clients/contracttest/identity_test.go @@ -66,6 +66,12 @@ func TestRegistryInspectorViolationsRejectABrokenAdapter(t *testing.T) { return clients.RegistryExpected, nil }, }, + "native config empty root is clear": identityAdapter{ + exampleAdapter: exampleAdapter{id: domain.ClientCline}, + inspect: func(context.Context, clients.Env, *domain.ClientBinding) (clients.RegistryFinding, error) { + return clients.RegistryClear, nil + }, + }, } for name, inspector := range cases { if violations := registryInspectorViolations(t, inspector, domain.ClientCursor); len(violations) == 0 { diff --git a/install/integrationctl/agentplugins/clients/opencode/identity.go b/install/integrationctl/agentplugins/clients/opencode/identity.go index dd82781d..2bfa1715 100644 --- a/install/integrationctl/agentplugins/clients/opencode/identity.go +++ b/install/integrationctl/agentplugins/clients/opencode/identity.go @@ -2,7 +2,12 @@ package opencode import ( "context" + "os" + "path/filepath" + "strings" + "github.com/777genius/plugin-kit-ai/install/integrationctl/adapters/pathpolicy" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" ) @@ -11,12 +16,94 @@ var _ clients.RegistryInspector = (*Adapter)(nil) func (*Adapter) UsesNativeRegistryExecutable() bool { return false } -func (*Adapter) InspectNativeRegistry(ctx context.Context, _ clients.Env, _ domain.DetectedClient, _ domain.DeliveryPlan, _ *domain.ClientBinding) (clients.RegistryFinding, error) { +func (*Adapter) InspectNativeRegistry(ctx context.Context, env clients.Env, _ domain.DetectedClient, plan domain.DeliveryPlan, managed *domain.ClientBinding) (clients.RegistryFinding, error) { if err := ctx.Err(); err != nil { return clients.RegistryIndeterminate, err } - // OpenCode MCP entries are keyed by individual server names rather than - // the package identity. Exact entry collision and ownership checks happen - // transactionally in the native config provider after staging. - return clients.RegistryClear, nil + return InspectOpenCodeRegistry(plan, managed, env.NativeConfig) +} + +func InspectOpenCodeRegistry(plan domain.DeliveryPlan, managed *domain.ClientBinding, kernel nativeconfig.Kernel) (clients.RegistryFinding, error) { + root := strings.TrimSpace(plan.NativeRegistryRoot) + if root == "" { + return clients.RegistryIndeterminate, nil + } + if managed != nil { + if err := VerifyOpenCodeNativeObjects(root, plan.ActivePath, managed.NativeObjects, kernel); err != nil { + return clients.RegistryIndeterminate, err + } + } + finding := clients.RegistryClear + for _, component := range plan.Components { + if component.Support == domain.SupportUnsupported { + continue + } + exists, owned, err := inspectOpenCodeComponent(root, managed, component, kernel) + if err != nil { + return clients.RegistryIndeterminate, err + } + if exists && !owned { + return clients.RegistryCollision, nil + } + if exists && owned { + finding = clients.RegistryExpected + } + } + return finding, nil +} + +func inspectOpenCodeComponent(root string, managed *domain.ClientBinding, component domain.ComponentDecision, kernel nativeconfig.Kernel) (bool, bool, error) { + switch component.Kind { + case domain.ComponentSkill: + return inspectOpenCodeSkillComponent(root, managed, component.Name) + case domain.ComponentMCPServer: + return inspectOpenCodeMCPComponent(root, managed, component.Name, kernel) + default: + return false, false, nil + } +} + +func inspectOpenCodeSkillComponent(root string, managed *domain.ClientBinding, name string) (bool, bool, error) { + if err := pathpolicy.ValidateLeafID(name); err != nil { + return false, false, err + } + path := filepath.Join(root, "skills", name) + if err := pathpolicy.RequireContainedChild(root, path); err != nil { + return false, false, err + } + _, err := os.Lstat(path) + if err != nil && !os.IsNotExist(err) { + return false, false, err + } + owned := managed != nil && managedOpenCodeObjectExists(managed.NativeObjects, openCodeSkillKind, name) + return err == nil, owned, nil +} + +func inspectOpenCodeMCPComponent(root string, managed *domain.ClientBinding, name string, kernel nativeconfig.Kernel) (bool, bool, error) { + paths := nativeconfig.Paths{JSON: filepath.Join(root, "opencode.json"), JSONC: filepath.Join(root, "opencode.jsonc")} + if err := pathpolicy.RequireContainedChild(root, paths.JSON); err != nil { + return false, false, err + } + if err := pathpolicy.RequireContainedChild(root, paths.JSONC); err != nil { + return false, false, err + } + var receipt *nativeconfig.Receipt + if managed != nil { + for _, object := range OpenCodeObjects(managed.NativeObjects) { + if object.Kind == OpenCodeMCPObjectKind && object.LogicalName == name { + owned := receiptFromOpenCodeObject(object) + receipt = &owned + } + } + } + return kernel.Inspect(paths, nativeconfig.CodecOpenCode, name, receipt) +} + +func managedOpenCodeObjectExists(objects []domain.NativeObjectOwnership, kind, name string) bool { + for _, object := range OpenCodeObjects(objects) { + if object.Kind == kind && object.LogicalName == name { + return true + } + } + return false } diff --git a/install/integrationctl/agentplugins/clients/windsurf/native.go b/install/integrationctl/agentplugins/clients/windsurf/native.go index c226b4f2..0832bcad 100644 --- a/install/integrationctl/agentplugins/clients/windsurf/native.go +++ b/install/integrationctl/agentplugins/clients/windsurf/native.go @@ -84,7 +84,7 @@ func verifyWindsurfNativeObject(kernel nativeconfig.Kernel, configPath string, s func InspectWindsurfRegistry(plan domain.DeliveryPlan, managed *domain.ClientBinding, kernel nativeconfig.Kernel) (clients.RegistryFinding, error) { if strings.TrimSpace(plan.NativeRegistryRoot) == "" { - return clients.RegistryClear, nil + return clients.RegistryIndeterminate, nil } configPath, err := windsurfConfigPath(plan.NativeRegistryRoot) if err != nil { diff --git a/install/integrationctl/agentplugins/providers/native_identity_test.go b/install/integrationctl/agentplugins/providers/native_identity_test.go index 43b962af..355e3027 100644 --- a/install/integrationctl/agentplugins/providers/native_identity_test.go +++ b/install/integrationctl/agentplugins/providers/native_identity_test.go @@ -119,13 +119,11 @@ func TestNativeIdentityUnqualifiedPluginRootIgnoresForeignNonDirectoryEntries(t } // TestNativeIdentityOpenCodeIgnoresForeignNonDirectoryEntries confirms the -// same fix protects OpenCode too: OpenCode's own native registry check -// (inspectNativeRegistry) never scans a directory, but its prepared-identity -// check still goes through shared.InspectUnqualifiedPluginRoot exactly -// like Cursor's does (observeIdentity calls inspectPreparedRegistry -// unconditionally for every client before any client-specific override), so -// a foreign .DS_Store in OpenCode's managed clients root would have hit the -// identical bug if shared.InspectUnqualifiedPluginRoot had not already been fixed. +// same fix protects OpenCode too: native registry inspect looks at planned +// skills and MCP names, but the prepared-identity check still goes through +// shared.InspectUnqualifiedPluginRoot, so a foreign .DS_Store in OpenCode's +// managed clients root would have hit the identical bug if +// shared.InspectUnqualifiedPluginRoot had not already been fixed. func TestNativeIdentityOpenCodeIgnoresForeignNonDirectoryEntries(t *testing.T) { root := filepath.Join(t.TempDir(), "managed", "clients", "opencode") plan := identityPlan(root) @@ -137,6 +135,7 @@ func TestNativeIdentityOpenCodeIgnoresForeignNonDirectoryEntries(t *testing.T) { t.Fatal(err) } managed := &domain.ClientBinding{NativeObjects: []domain.NativeObjectOwnership{{Kind: "managed_package_directory", ManagedDigest: "sha256:owned"}}} + plan.NativeRegistryRoot = filepath.Join(t.TempDir(), "opencode") observer := testObserver(NativeIdentityObserver{Stager: acceptingPackageVerifier{}}) observation, err := observer.ObserveNativeIdentity(context.Background(), domain.DetectedClient{ClientID: domain.ClientOpenCode}, plan, managed) if err != nil || observation.State != domain.NativeIdentityManaged { @@ -453,6 +452,59 @@ func TestNativeIdentityKiroManualPowerAuthorizesOnlyLocalPreparation(t *testing. } } +func TestNativeIdentityClineReadsGlobalSkillAndMCPRegistry(t *testing.T) { + configRoot := filepath.Join(t.TempDir(), ".cline") + if err := os.MkdirAll(filepath.Join(configRoot, "skills", "docs"), 0o700); err != nil { + t.Fatal(err) + } + plan := identityPlan(filepath.Join(t.TempDir(), "prepared")) + plan.NativeRegistryRoot = configRoot + plan.Components = []domain.ComponentDecision{{Kind: domain.ComponentSkill, Name: "docs", Support: domain.SupportNative}} + observation, err := (testObserver(NativeIdentityObserver{})).ObserveNativeIdentity(context.Background(), domain.DetectedClient{ClientID: domain.ClientCline}, plan, nil) + if err != nil || observation.State != domain.NativeIdentityUnmanaged { + t.Fatalf("skill observation = %+v, err = %v", observation, err) + } + + writeIdentityFile(t, filepath.Join(configRoot, "data", "settings", "cline_mcp_settings.json"), `{"mcpServers":{"docs":{"transport":{"type":"stdio","command":"foreign"}}}}`) + plan.Components = []domain.ComponentDecision{{Kind: domain.ComponentMCPServer, Name: "docs", Support: domain.SupportNative}} + observation, err = (testObserver(NativeIdentityObserver{})).ObserveNativeIdentity(context.Background(), domain.DetectedClient{ClientID: domain.ClientCline}, plan, nil) + if err != nil || observation.State != domain.NativeIdentityUnmanaged { + t.Fatalf("MCP observation = %+v, err = %v", observation, err) + } +} + +func TestNativeIdentityOpenCodeReadsGlobalSkillAndMCPRegistry(t *testing.T) { + configRoot := filepath.Join(t.TempDir(), "opencode") + if err := os.MkdirAll(filepath.Join(configRoot, "skills", "docs"), 0o700); err != nil { + t.Fatal(err) + } + plan := identityPlan(filepath.Join(t.TempDir(), "prepared")) + plan.NativeRegistryRoot = configRoot + plan.Components = []domain.ComponentDecision{{Kind: domain.ComponentSkill, Name: "docs", Support: domain.SupportNative}} + observation, err := (testObserver(NativeIdentityObserver{})).ObserveNativeIdentity(context.Background(), domain.DetectedClient{ClientID: domain.ClientOpenCode}, plan, nil) + if err != nil || observation.State != domain.NativeIdentityUnmanaged { + t.Fatalf("skill observation = %+v, err = %v", observation, err) + } + + writeIdentityFile(t, filepath.Join(configRoot, "opencode.json"), `{"mcp":{"docs":{"type":"remote","url":"https://foreign.test"}}}`) + plan.Components = []domain.ComponentDecision{{Kind: domain.ComponentMCPServer, Name: "docs", Support: domain.SupportNative}} + observation, err = (testObserver(NativeIdentityObserver{})).ObserveNativeIdentity(context.Background(), domain.DetectedClient{ClientID: domain.ClientOpenCode}, plan, nil) + if err != nil || observation.State != domain.NativeIdentityUnmanaged { + t.Fatalf("MCP observation = %+v, err = %v", observation, err) + } +} + +func TestNativeIdentityNativeConfigEmptyRootIsIndeterminate(t *testing.T) { + plan := identityPlan(filepath.Join(t.TempDir(), "prepared")) + plan.Components = []domain.ComponentDecision{{Kind: domain.ComponentMCPServer, Name: "docs", Support: domain.SupportNative}} + for _, client := range []domain.ClientID{domain.ClientCline, domain.ClientOpenCode, domain.ClientGemini, domain.ClientWindsurf} { + observation, err := (testObserver(NativeIdentityObserver{})).ObserveNativeIdentity(context.Background(), domain.DetectedClient{ClientID: client}, plan, nil) + if err != nil || observation.State != domain.NativeIdentityIndeterminate { + t.Fatalf("%s empty-root observation = %+v, err = %v", client, observation, err) + } + } +} + func identityPlan(root string) domain.DeliveryPlan { artifact := "demo-0123456789ab" return domain.DeliveryPlan{ From 4220d68e73b59b85a8b4e137eb9fe083e9a1f3df Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 19 Sep 2026 10:31:42 +0300 Subject: [PATCH 08/11] fix(agentplugins): allow missing OpenCode objects during identity inspect Repair must observe identity after a host wiped managed MCP entries. Exact verify stays on Activate; identity inspect now matches Gemini's allow-missing check. --- .../agentplugins/clients/opencode/activate.go | 2 +- .../agentplugins/clients/opencode/identity.go | 2 +- .../clients/opencode/native_mcp.go | 20 ++++++++++++------- .../providers/opencode_logical_keys_test.go | 6 +++--- .../providers/opencode_native_test.go | 10 +++++----- 5 files changed, 23 insertions(+), 17 deletions(-) diff --git a/install/integrationctl/agentplugins/clients/opencode/activate.go b/install/integrationctl/agentplugins/clients/opencode/activate.go index 6ff03ab5..a2a8c43b 100644 --- a/install/integrationctl/agentplugins/clients/opencode/activate.go +++ b/install/integrationctl/agentplugins/clients/opencode/activate.go @@ -34,7 +34,7 @@ func (*Adapter) Activate(ctx context.Context, env clients.Env, request domain.Ac RetryAction: "retry the managed OpenCode native installation", CompletedAction: "restart OpenCode to load the installed plugin", Verify: func() error { - return VerifyOpenCodeNativeObjects(request.Client.ConfigRoot, request.Delivery.ActivePath, request.Delivery.NativeObjects, env.NativeConfig) + return VerifyOpenCodeNativeObjects(request.Client.ConfigRoot, request.Delivery.ActivePath, request.Delivery.NativeObjects, env.NativeConfig, false) }, Activate: func(ctx context.Context, request domain.ActivationRequest) error { return ActivateOpenCodeNativeWithKernel(ctx, request, env.NativeConfig) diff --git a/install/integrationctl/agentplugins/clients/opencode/identity.go b/install/integrationctl/agentplugins/clients/opencode/identity.go index 2bfa1715..de8614a4 100644 --- a/install/integrationctl/agentplugins/clients/opencode/identity.go +++ b/install/integrationctl/agentplugins/clients/opencode/identity.go @@ -29,7 +29,7 @@ func InspectOpenCodeRegistry(plan domain.DeliveryPlan, managed *domain.ClientBin return clients.RegistryIndeterminate, nil } if managed != nil { - if err := VerifyOpenCodeNativeObjects(root, plan.ActivePath, managed.NativeObjects, kernel); err != nil { + if err := VerifyOpenCodeNativeObjects(root, plan.ActivePath, managed.NativeObjects, kernel, true); err != nil { return clients.RegistryIndeterminate, err } } diff --git a/install/integrationctl/agentplugins/clients/opencode/native_mcp.go b/install/integrationctl/agentplugins/clients/opencode/native_mcp.go index 3edaa409..0ad0a2d5 100644 --- a/install/integrationctl/agentplugins/clients/opencode/native_mcp.go +++ b/install/integrationctl/agentplugins/clients/opencode/native_mcp.go @@ -13,7 +13,7 @@ import ( "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" ) -func VerifyOpenCodeNativeObjects(configRoot, activePath string, objects []domain.NativeObjectOwnership, kernel nativeconfig.Kernel) error { +func VerifyOpenCodeNativeObjects(configRoot, activePath string, objects []domain.NativeObjectOwnership, kernel nativeconfig.Kernel, allowMissing bool) error { if err := kernel.RequireFileIO(); err != nil { return err } @@ -29,37 +29,43 @@ func VerifyOpenCodeNativeObjects(configRoot, activePath string, objects []domain } } for _, object := range OpenCodeObjects(objects) { - if err := verifyOpenCodeObject(configRoot, projection, object, kernel); err != nil { + if err := verifyOpenCodeObject(configRoot, projection, object, kernel, allowMissing); err != nil { return err } } return nil } -func verifyOpenCodeObject(configRoot string, projection OpenCodeProjection, object domain.NativeObjectOwnership, kernel nativeconfig.Kernel) error { +func verifyOpenCodeObject(configRoot string, projection OpenCodeProjection, object domain.NativeObjectOwnership, kernel nativeconfig.Kernel, allowMissing bool) error { if err := validateOpenCodeObject(configRoot, projection, object); err != nil { return err } if object.Kind == openCodeSkillKind { - return verifyOpenCodeSkill(object) + return verifyOpenCodeSkill(object, allowMissing) } - return verifyOpenCodeMCP(projection, object, kernel) + return verifyOpenCodeMCP(projection, object, kernel, allowMissing) } -func verifyOpenCodeSkill(object domain.NativeObjectOwnership) error { +func verifyOpenCodeSkill(object domain.NativeObjectOwnership, allowMissing bool) error { digest, err := shared.DigestSkillDirectory(object.Path) + if os.IsNotExist(err) && allowMissing { + return nil + } if err != nil || digest != object.ManagedDigest { return fmt.Errorf("managed OpenCode skill %q is missing or changed", object.LogicalName) } return nil } -func verifyOpenCodeMCP(projection OpenCodeProjection, object domain.NativeObjectOwnership, kernel nativeconfig.Kernel) error { +func verifyOpenCodeMCP(projection OpenCodeProjection, object domain.NativeObjectOwnership, kernel nativeconfig.Kernel, allowMissing bool) error { receipt := receiptFromOpenCodeObject(object) present, exactlyOwned, err := kernel.Inspect(nativeconfig.Paths{JSON: projection.ConfigJSON, JSONC: projection.ConfigJSONC}, nativeconfig.CodecOpenCode, object.LogicalName, &receipt) if err != nil { return fmt.Errorf("verify managed OpenCode MCP server %q: %w", object.LogicalName, err) } + if !present && allowMissing { + return nil + } if !present || !exactlyOwned { return fmt.Errorf("verify managed OpenCode MCP server %q: %w", object.LogicalName, nativeconfig.ErrNotOwned) } diff --git a/install/integrationctl/agentplugins/providers/opencode_logical_keys_test.go b/install/integrationctl/agentplugins/providers/opencode_logical_keys_test.go index 12006866..55a1f174 100644 --- a/install/integrationctl/agentplugins/providers/opencode_logical_keys_test.go +++ b/install/integrationctl/agentplugins/providers/opencode_logical_keys_test.go @@ -79,14 +79,14 @@ func TestOpenCodeLogicalKeysLifecycle(t *testing.T) { if err := opencode.ApplyOpenCodeNative(configRoot, active, nil, first); err != nil { t.Fatal(err) } - if err := opencode.VerifyOpenCodeNativeObjects(configRoot, active, first, nativeconfig.New()); err != nil { + if err := opencode.VerifyOpenCodeNativeObjects(configRoot, active, first, nativeconfig.New(), false); err != nil { t.Fatal(err) } second := build("v2") if err := opencode.ApplyOpenCodeNative(configRoot, active, first, second); err != nil { t.Fatal(err) } - if err := opencode.VerifyOpenCodeNativeObjects(configRoot, active, second, nativeconfig.New()); err != nil { + if err := opencode.VerifyOpenCodeNativeObjects(configRoot, active, second, nativeconfig.New(), false); err != nil { t.Fatal(err) } // Exact repair recreates absent entries while preserving the foreign entry. @@ -94,7 +94,7 @@ func TestOpenCodeLogicalKeysLifecycle(t *testing.T) { if err := opencode.ApplyOpenCodeNative(configRoot, active, second, second); err != nil { t.Fatal(err) } - if err := opencode.VerifyOpenCodeNativeObjects(configRoot, active, second, nativeconfig.New()); err != nil { + if err := opencode.VerifyOpenCodeNativeObjects(configRoot, active, second, nativeconfig.New(), false); err != nil { t.Fatal(err) } if err := opencode.ApplyOpenCodeNative(configRoot, "", second, nil); err != nil { diff --git a/install/integrationctl/agentplugins/providers/opencode_native_test.go b/install/integrationctl/agentplugins/providers/opencode_native_test.go index 89e7e5aa..41414f75 100644 --- a/install/integrationctl/agentplugins/providers/opencode_native_test.go +++ b/install/integrationctl/agentplugins/providers/opencode_native_test.go @@ -76,7 +76,7 @@ func TestOpenCodeNativeAddUpdateVerifyRemovePreservesJSONCAndForeignConfig(t *te if err := opencode.ApplyOpenCodeNative(configRoot, active, nil, firstObjects); err != nil { t.Fatal(err) } - if err := opencode.VerifyOpenCodeNativeObjects(configRoot, active, firstObjects, nativeconfig.New()); err != nil { + if err := opencode.VerifyOpenCodeNativeObjects(configRoot, active, firstObjects, nativeconfig.New(), false); err != nil { t.Fatal(err) } body := readOpenCodeTestFile(t, jsonc) @@ -269,7 +269,7 @@ func TestOpenCodeCommittedCleanupFailureKeepsReceiptsAndLaterLifecycleConsistent if cleanupCalls != 1 { t.Fatalf("committed cleanup calls = %d, want 1", cleanupCalls) } - if err := opencode.VerifyOpenCodeNativeObjects(configRoot, activeV2, second, nativeconfig.New()); err != nil { + if err := opencode.VerifyOpenCodeNativeObjects(configRoot, activeV2, second, nativeconfig.New(), false); err != nil { t.Fatalf("committed OpenCode receipts do not describe external state: %v", err) } if got := readOpenCodeTestFile(t, filepath.Join(configRoot, "skills", "docs", "SKILL.md")); !strings.Contains(got, "new") { @@ -314,7 +314,7 @@ func TestOpenCodeActivatorTreatsCommittedUnlockFailureAsSuccessfulLifecycle(t *t configRoot, active, objects, request := openCodeActivationFixture(t, "add") outcome, err := testActivator(Activator{NativeConfig: &committedKernel}).Activate(context.Background(), request) assertOpenCodeCommittedActivation(t, outcome, err) - if err := opencode.VerifyOpenCodeNativeObjects(configRoot, active, objects, nativeconfig.New()); err != nil { + if err := opencode.VerifyOpenCodeNativeObjects(configRoot, active, objects, nativeconfig.New(), false); err != nil { t.Fatalf("committed add state: %v", err) } if err := opencode.ApplyOpenCodeNative(configRoot, "", objects, nil); err != nil { @@ -348,7 +348,7 @@ func TestOpenCodeActivatorTreatsCommittedUnlockFailureAsSuccessfulLifecycle(t *t } outcome, err := testActivator(Activator{NativeConfig: &committedKernel}).Activate(context.Background(), request) assertOpenCodeCommittedActivation(t, outcome, err) - if err := opencode.VerifyOpenCodeNativeObjects(configRoot, activeV2, second, nativeconfig.New()); err != nil { + if err := opencode.VerifyOpenCodeNativeObjects(configRoot, activeV2, second, nativeconfig.New(), false); err != nil { t.Fatalf("committed update state: %v", err) } if err := opencode.ApplyOpenCodeNative(configRoot, activeV2, second, second); err != nil { @@ -367,7 +367,7 @@ func TestOpenCodeActivatorTreatsCommittedUnlockFailureAsSuccessfulLifecycle(t *t request.Replacing = true outcome, err := testActivator(Activator{NativeConfig: &committedKernel}).Activate(context.Background(), request) assertOpenCodeCommittedActivation(t, outcome, err) - if err := opencode.VerifyOpenCodeNativeObjects(configRoot, active, objects, nativeconfig.New()); err != nil { + if err := opencode.VerifyOpenCodeNativeObjects(configRoot, active, objects, nativeconfig.New(), false); err != nil { t.Fatalf("committed repair state: %v", err) } if err := opencode.ApplyOpenCodeNative(configRoot, "", objects, nil); err != nil { From 1f662f01b526bf49276da7a19f7f6df902bbb65e Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 19 Sep 2026 10:33:09 +0300 Subject: [PATCH 09/11] fix(agentplugins): allow missing Windsurf MCP during identity inspect Wiping a managed Windsurf entry must still be repairable. Identity now uses allow-missing verify like Gemini; Activate still requires exact ownership. --- .../agentplugins/clients/windsurf/native.go | 2 +- .../usecase/windsurf_lifecycle_test.go | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/install/integrationctl/agentplugins/clients/windsurf/native.go b/install/integrationctl/agentplugins/clients/windsurf/native.go index 0832bcad..5d2fab9e 100644 --- a/install/integrationctl/agentplugins/clients/windsurf/native.go +++ b/install/integrationctl/agentplugins/clients/windsurf/native.go @@ -91,7 +91,7 @@ func InspectWindsurfRegistry(plan domain.DeliveryPlan, managed *domain.ClientBin return clients.RegistryIndeterminate, err } if managed != nil { - if err := VerifyWindsurfNativeObjects(plan.NativeRegistryRoot, plan.ActivePath, managed.NativeObjects, false, kernel); err != nil { + if err := VerifyWindsurfNativeObjects(plan.NativeRegistryRoot, plan.ActivePath, managed.NativeObjects, true, kernel); err != nil { return clients.RegistryIndeterminate, err } if len(WindsurfObjects(managed.NativeObjects)) > 0 { diff --git a/install/integrationctl/agentplugins/usecase/windsurf_lifecycle_test.go b/install/integrationctl/agentplugins/usecase/windsurf_lifecycle_test.go index 2744946f..5d1e3f4e 100644 --- a/install/integrationctl/agentplugins/usecase/windsurf_lifecycle_test.go +++ b/install/integrationctl/agentplugins/usecase/windsurf_lifecycle_test.go @@ -82,6 +82,35 @@ func TestWindsurfLifecycleAddUpdateRepairRemoveInIsolatedHome(t *testing.T) { } } +func TestWindsurfLifecycleRepairsWipedNativeMCP(t *testing.T) { + t.Parallel() + service, _, _ := serviceFixture(t) + service.NativeObserver = providerstest.NewObserver(providers.NativeIdentityObserver{Stager: service.Stager}) + configRoot := filepath.Join(t.TempDir(), "home", ".codeium", "windsurf") + configPath := filepath.Join(configRoot, "mcp_config.json") + if err := os.MkdirAll(configRoot, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, []byte(`{"mcpServers":{"foreign":{"url":"https://foreign.test"}}}`), 0o600); err != nil { + t.Fatal(err) + } + client := domain.DetectedClient{ClientID: domain.ClientWindsurf, Status: domain.DetectionDetected, ConfigRoot: configRoot} + add := windsurfUsecaseInput(t, client, "one") + add.Confirmed = true + if _, err := service.Add(context.Background(), add); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, []byte(`{"mcpServers":{"foreign":{"url":"https://foreign.test"}}}`), 0o600); err != nil { + t.Fatal(err) + } + add.OperationID = "windsurf-native-repair" + repaired, err := service.Repair(context.Background(), add) + if err != nil || !repaired.Mutated || repaired.Activation.Verification != domain.VerificationInstalled { + t.Fatalf("wiped Windsurf MCP was not repaired: %+v, %v", repaired, err) + } + assertUsecaseWindsurfConfig(t, configPath, "one", true) +} + func TestWindsurfLifecycleRejectsUnmanagedCollisionBeforePackageMutation(t *testing.T) { t.Parallel() service, store, _ := serviceFixture(t) From 79ba41dbd6ab6dfe9f212c2cab745311b1a86d54 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 19 Sep 2026 10:45:00 +0300 Subject: [PATCH 10/11] test(agentplugins): cover Windsurf RepairGroup after wiped native MCP Single-client Repair skips identity inspect. RepairGroup and Update are the production gates that must restore a missing owned Windsurf entry. --- .../usecase/windsurf_lifecycle_test.go | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/install/integrationctl/agentplugins/usecase/windsurf_lifecycle_test.go b/install/integrationctl/agentplugins/usecase/windsurf_lifecycle_test.go index 5d1e3f4e..2c36a2e6 100644 --- a/install/integrationctl/agentplugins/usecase/windsurf_lifecycle_test.go +++ b/install/integrationctl/agentplugins/usecase/windsurf_lifecycle_test.go @@ -91,24 +91,39 @@ func TestWindsurfLifecycleRepairsWipedNativeMCP(t *testing.T) { if err := os.MkdirAll(configRoot, 0o700); err != nil { t.Fatal(err) } - if err := os.WriteFile(configPath, []byte(`{"mcpServers":{"foreign":{"url":"https://foreign.test"}}}`), 0o600); err != nil { + foreign := `{"mcpServers":{"foreign":{"url":"https://foreign.test"}}}` + if err := os.WriteFile(configPath, []byte(foreign), 0o600); err != nil { t.Fatal(err) } client := domain.DetectedClient{ClientID: domain.ClientWindsurf, Status: domain.DetectionDetected, ConfigRoot: configRoot} add := windsurfUsecaseInput(t, client, "one") - add.Confirmed = true - if _, err := service.Add(context.Background(), add); err != nil { + added, err := service.AddGroup(context.Background(), GroupInput{Targets: []AddInput{add}, OperationGroupID: "windsurf-add", Confirmed: true}) + if err != nil { t.Fatal(err) } - if err := os.WriteFile(configPath, []byte(`{"mcpServers":{"foreign":{"url":"https://foreign.test"}}}`), 0o600); err != nil { + if err := os.WriteFile(configPath, []byte(foreign), 0o600); err != nil { t.Fatal(err) } - add.OperationID = "windsurf-native-repair" - repaired, err := service.Repair(context.Background(), add) - if err != nil || !repaired.Mutated || repaired.Activation.Verification != domain.VerificationInstalled { + add.InstallationID = added.InstallationID + // RepairGroup observes native identity before apply. Single-client Repair + // skips that gate, so it cannot catch an inspect that refuse-missing owned MCP. + repaired, err := service.RepairGroup(context.Background(), GroupInput{Targets: []AddInput{add}, OperationGroupID: "windsurf-native-repair", Confirmed: true, Repair: true}) + if err != nil || !repaired.Mutated || repaired.Targets[0].Activation.Verification != domain.VerificationInstalled { t.Fatalf("wiped Windsurf MCP was not repaired: %+v, %v", repaired, err) } assertUsecaseWindsurfConfig(t, configPath, "one", true) + + if err := os.WriteFile(configPath, []byte(foreign), 0o600); err != nil { + t.Fatal(err) + } + update := windsurfUsecaseInput(t, client, "two") + update.Confirmed = true + update.OperationID = "windsurf-native-update" + updated, err := service.Update(context.Background(), update) + if err != nil || !updated.Mutated || updated.Activation.Activation != domain.ActivationActive { + t.Fatalf("update after wiped Windsurf MCP = %+v, %v", updated, err) + } + assertUsecaseWindsurfConfig(t, configPath, "two", true) } func TestWindsurfLifecycleRejectsUnmanagedCollisionBeforePackageMutation(t *testing.T) { From 96884ad960a64723332f65651360094c14d0a469 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 19 Sep 2026 10:59:34 +0300 Subject: [PATCH 11/11] fix(agentplugins): inspect OpenCode and Windsurf host identity without the package RepairGroup refused a missing managed directory because inspect still verified the live ActivePath projection. Host-only inspect matches Cline and Gemini so a wiped package can be restaged. --- .../agentplugins/clients/opencode/identity.go | 5 --- .../agentplugins/clients/windsurf/native.go | 39 +++++++++++-------- .../usecase/manual_remote_lifecycle_test.go | 14 ++++++- .../usecase/windsurf_lifecycle_test.go | 18 ++++++++- 4 files changed, 52 insertions(+), 24 deletions(-) diff --git a/install/integrationctl/agentplugins/clients/opencode/identity.go b/install/integrationctl/agentplugins/clients/opencode/identity.go index de8614a4..ef8d8de9 100644 --- a/install/integrationctl/agentplugins/clients/opencode/identity.go +++ b/install/integrationctl/agentplugins/clients/opencode/identity.go @@ -28,11 +28,6 @@ func InspectOpenCodeRegistry(plan domain.DeliveryPlan, managed *domain.ClientBin if root == "" { return clients.RegistryIndeterminate, nil } - if managed != nil { - if err := VerifyOpenCodeNativeObjects(root, plan.ActivePath, managed.NativeObjects, kernel, true); err != nil { - return clients.RegistryIndeterminate, err - } - } finding := clients.RegistryClear for _, component := range plan.Components { if component.Support == domain.SupportUnsupported { diff --git a/install/integrationctl/agentplugins/clients/windsurf/native.go b/install/integrationctl/agentplugins/clients/windsurf/native.go index 5d2fab9e..668666e0 100644 --- a/install/integrationctl/agentplugins/clients/windsurf/native.go +++ b/install/integrationctl/agentplugins/clients/windsurf/native.go @@ -90,31 +90,36 @@ func InspectWindsurfRegistry(plan domain.DeliveryPlan, managed *domain.ClientBin if err != nil { return clients.RegistryIndeterminate, err } - if managed != nil { - if err := VerifyWindsurfNativeObjects(plan.NativeRegistryRoot, plan.ActivePath, managed.NativeObjects, true, kernel); err != nil { - return clients.RegistryIndeterminate, err - } - if len(WindsurfObjects(managed.NativeObjects)) > 0 { - return clients.RegistryExpected, nil - } - } - return inspectWindsurfPlannedServers(configPath, plan, kernel) -} - -func inspectWindsurfPlannedServers(configPath string, plan domain.DeliveryPlan, kernel nativeconfig.Kernel) (clients.RegistryFinding, error) { + finding := clients.RegistryClear for _, component := range plan.Components { if component.Kind != domain.ComponentMCPServer || component.Support == domain.SupportUnsupported { continue } - present, _, inspectErr := kernel.Inspect(nativeconfig.Paths{JSON: configPath}, nativeconfig.CodecWindsurf, component.Name, nil) - if present { - return clients.RegistryCollision, nil - } + present, owned, inspectErr := inspectWindsurfHostEntry(configPath, component.Name, managed, kernel) if inspectErr != nil { return clients.RegistryIndeterminate, inspectErr } + if present && !owned { + return clients.RegistryCollision, nil + } + if present && owned { + finding = clients.RegistryExpected + } + } + return finding, nil +} + +func inspectWindsurfHostEntry(configPath, name string, managed *domain.ClientBinding, kernel nativeconfig.Kernel) (bool, bool, error) { + var receipt *nativeconfig.Receipt + if managed != nil { + for _, object := range WindsurfObjects(managed.NativeObjects) { + if object.LogicalName == name { + owned := windsurfReceipt(object) + receipt = &owned + } + } } - return clients.RegistryClear, nil + return kernel.Inspect(nativeconfig.Paths{JSON: configPath}, nativeconfig.CodecWindsurf, name, receipt) } func windsurfConfigPath(configRoot string) (string, error) { diff --git a/install/integrationctl/agentplugins/usecase/manual_remote_lifecycle_test.go b/install/integrationctl/agentplugins/usecase/manual_remote_lifecycle_test.go index a8b8a49a..e5f96761 100644 --- a/install/integrationctl/agentplugins/usecase/manual_remote_lifecycle_test.go +++ b/install/integrationctl/agentplugins/usecase/manual_remote_lifecycle_test.go @@ -138,6 +138,18 @@ func TestOpenCodeSupportsAutomaticMCPAndSkillLifecycle(t *testing.T) { if _, err := os.Stat(filepath.Join(client.ConfigRoot, "skills", "docs", "SKILL.md")); err != nil { t.Fatalf("OpenCode skill was not installed: %v", err) } + state, err := store.Load() + if err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(onlyBinding(state.Installations[0]).TargetLocator); err != nil { + t.Fatal(err) + } + add.InstallationID = added.InstallationID + repairedPackage, err := service.RepairGroup(context.Background(), GroupInput{Targets: []AddInput{add}, OperationGroupID: "opencode-package-repair", Confirmed: true, Repair: true}) + if err != nil || repairedPackage.Targets[0].Activation.Verification != domain.VerificationInstalled { + t.Fatalf("missing OpenCode package was not repaired: %+v, %v", repairedPackage, err) + } update := openCodePluginInput(t, client, "2.0.0", "sha256:opencode-v2", "sha256:opencode-manifest-v2", "bun") if _, err := service.UpdateGroup(context.Background(), GroupInput{Targets: []AddInput{update}, CompatibilityChecks: []AddInput{update}, OperationGroupID: "opencode-update", Confirmed: true}); err != nil { @@ -148,7 +160,7 @@ func TestOpenCodeSupportsAutomaticMCPAndSkillLifecycle(t *testing.T) { if argv := server["command"].([]any); argv[0] != "bun" { t.Fatalf("OpenCode MCP update did not converge: %#v", server) } - state, err := store.Load() + state, err = store.Load() if err != nil { t.Fatal(err) } diff --git a/install/integrationctl/agentplugins/usecase/windsurf_lifecycle_test.go b/install/integrationctl/agentplugins/usecase/windsurf_lifecycle_test.go index 2c36a2e6..6f4bbb4b 100644 --- a/install/integrationctl/agentplugins/usecase/windsurf_lifecycle_test.go +++ b/install/integrationctl/agentplugins/usecase/windsurf_lifecycle_test.go @@ -84,7 +84,7 @@ func TestWindsurfLifecycleAddUpdateRepairRemoveInIsolatedHome(t *testing.T) { func TestWindsurfLifecycleRepairsWipedNativeMCP(t *testing.T) { t.Parallel() - service, _, _ := serviceFixture(t) + service, store, _ := serviceFixture(t) service.NativeObserver = providerstest.NewObserver(providers.NativeIdentityObserver{Stager: service.Stager}) configRoot := filepath.Join(t.TempDir(), "home", ".codeium", "windsurf") configPath := filepath.Join(configRoot, "mcp_config.json") @@ -113,6 +113,22 @@ func TestWindsurfLifecycleRepairsWipedNativeMCP(t *testing.T) { } assertUsecaseWindsurfConfig(t, configPath, "one", true) + state, err := store.Load() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, []byte(foreign), 0o600); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(onlyBinding(state.Installations[0]).TargetLocator); err != nil { + t.Fatal(err) + } + repairedPackage, err := service.RepairGroup(context.Background(), GroupInput{Targets: []AddInput{add}, OperationGroupID: "windsurf-package-repair", Confirmed: true, Repair: true}) + if err != nil || !repairedPackage.Mutated || repairedPackage.Targets[0].Activation.Verification != domain.VerificationInstalled { + t.Fatalf("missing Windsurf package was not repaired: %+v, %v", repairedPackage, err) + } + assertUsecaseWindsurfConfig(t, configPath, "one", true) + if err := os.WriteFile(configPath, []byte(foreign), 0o600); err != nil { t.Fatal(err) }