diff --git a/.github/workflows/core-fast.yml b/.github/workflows/core-fast.yml index 8b28eeb76..6c89ec503 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/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 74b8ecd72..90bf641ee 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/Makefile b/Makefile index f68e47754..0c8cfa444 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/... + 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/... 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/cli/plugin-kit-ai/cmd/agentplugins/compose.go b/cli/plugin-kit-ai/cmd/agentplugins/compose.go index f8afb9c1d..f21ed8432 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/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 694b6a8a4..724388e8e 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 335b1bfa7..31af4ae16 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 7c187b308..062199f39 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/adapters/nativeconfig/kernel.go b/install/integrationctl/agentplugins/adapters/nativeconfig/kernel.go index 3aaaec99f..770bbccbc 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 84bff6c75..ef83a1945 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/all/registry_test.go b/install/integrationctl/agentplugins/clients/all/registry_test.go index db54fe88c..8040f226a 100644 --- a/install/integrationctl/agentplugins/clients/all/registry_test.go +++ b/install/integrationctl/agentplugins/clients/all/registry_test.go @@ -90,5 +90,25 @@ 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 + }, + }, + { + 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 d8219c800..28a7e3a19 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 18535448a..dba685622 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/clients/cline/activate.go b/install/integrationctl/agentplugins/clients/cline/activate.go index 04ddee3d5..7f004ba8c 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/identity.go b/install/integrationctl/agentplugins/clients/cline/identity.go index 3151d512e..1a1738d15 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/cline/lifecycle.go b/install/integrationctl/agentplugins/clients/cline/lifecycle.go index 1eb161d83..62c8187e1 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) @@ -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") } @@ -69,7 +72,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 51275f5c2..9087baa06 100644 --- a/install/integrationctl/agentplugins/clients/cline/native.go +++ b/install/integrationctl/agentplugins/clients/cline/native.go @@ -59,8 +59,10 @@ 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 { + if err := kernel.RequireFileIO(); err != nil { + return err + } for _, object := range ClineObjects(objects) { if err := validateClineObject(configRoot, object); err != nil { return err @@ -143,7 +145,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/contracttest/identity.go b/install/integrationctl/agentplugins/clients/contracttest/identity.go index 808ccd4a5..592cf822f 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 73544ef6b..6e64a825a 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/gemini/activate.go b/install/integrationctl/agentplugins/clients/gemini/activate.go index fefd93623..c8317133b 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 8566e3df5..66084fa39 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 e9352294d..fe8c86534 100644 --- a/install/integrationctl/agentplugins/clients/gemini/native.go +++ b/install/integrationctl/agentplugins/clients/gemini/native.go @@ -38,7 +38,10 @@ 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 { + if err := kernel.RequireFileIO(); err != nil { + return err + } for _, object := range GeminiObjects(objects) { if err := validateGeminiObject(configRoot, object); err != nil { return err @@ -49,7 +52,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 +74,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 +115,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 +130,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 +144,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 +165,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 +174,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,24 +213,24 @@ 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 } 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 } 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 } 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 +246,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/gemini/native_apply.go b/install/integrationctl/agentplugins/clients/gemini/native_apply.go index f89994a1d..0596463c5 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") } @@ -88,7 +91,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 +99,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 +113,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 +121,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 81c9cf1b8..33a5e503f 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/kiro/activate.go b/install/integrationctl/agentplugins/clients/kiro/activate.go index ba202ac4e..a5ba1a424 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/clients/opencode/activate.go b/install/integrationctl/agentplugins/clients/opencode/activate.go index 958f6d173..a2a8c43b4 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, 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 dd82781d3..ef8d8de90 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,89 @@ 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 + } + 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/opencode/native_apply.go b/install/integrationctl/agentplugins/clients/opencode/native_apply.go index 106565bb0..ecc5a7989 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 } @@ -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 08bb2b9fb..0ad0a2d5b 100644 --- a/install/integrationctl/agentplugins/clients/opencode/native_mcp.go +++ b/install/integrationctl/agentplugins/clients/opencode/native_mcp.go @@ -13,7 +13,10 @@ 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, allowMissing bool) error { + if err := kernel.RequireFileIO(); err != nil { + return err + } projection := OpenCodeProjection{} if len(OpenCodeObjects(objects)) > 0 && activePath != "" { var err error @@ -26,37 +29,43 @@ 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, allowMissing); 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, 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) + 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) error { +func verifyOpenCodeMCP(projection OpenCodeProjection, object domain.NativeObjectOwnership, kernel nativeconfig.Kernel, allowMissing bool) 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) } + if !present && allowMissing { + return nil + } if !present || !exactlyOwned { return fmt.Errorf("verify managed OpenCode MCP server %q: %w", object.LogicalName, nativeconfig.ErrNotOwned) } @@ -75,11 +84,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 cf8c63165..2dcd2a1ce 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 abec4e87f..1b20afdf2 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 d2702b3d6..93b78a6d7 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 @@ -34,7 +37,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 +52,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 bb19aa2bf..668666e0f 100644 --- a/install/integrationctl/agentplugins/clients/windsurf/native.go +++ b/install/integrationctl/agentplugins/clients/windsurf/native.go @@ -32,7 +32,10 @@ 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 { + if err := kernel.RequireFileIO(); err != nil { + return err + } objectMap, err := windsurfObjectMap(configRoot, objects) if err != nil { return err @@ -48,7 +51,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,46 +82,50 @@ 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 + return clients.RegistryIndeterminate, nil } configPath, err := windsurfConfigPath(plan.NativeRegistryRoot) if err != nil { return clients.RegistryIndeterminate, err } - if managed != nil { - if err := VerifyWindsurfNativeObjects(plan.NativeRegistryRoot, plan.ActivePath, managed.NativeObjects, false); err != nil { - return clients.RegistryIndeterminate, err - } - if len(WindsurfObjects(managed.NativeObjects)) > 0 { - return clients.RegistryExpected, nil - } - } - return inspectWindsurfPlannedServers(configPath, plan) -} - -func inspectWindsurfPlannedServers(configPath string, plan domain.DeliveryPlan) (clients.RegistryFinding, error) { - kernel := nativeconfig.New() + 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) { 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 { diff --git a/install/integrationctl/agentplugins/domain/traits.go b/install/integrationctl/agentplugins/domain/traits.go index 2cee0d9ac..bf5372d89 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 5cdc9410d..bd16be9ac 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/activator.go b/install/integrationctl/agentplugins/providers/activator.go index 29ad62894..f13a400b1 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()} } @@ -37,7 +44,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 @@ -79,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) } @@ -92,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 86d680b07..cf76978e3 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") @@ -361,7 +390,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/gemini_native_test.go b/install/integrationctl/agentplugins/providers/gemini_native_test.go index 3d2410d48..1d2482419 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") diff --git a/install/integrationctl/agentplugins/providers/native_identity.go b/install/integrationctl/agentplugins/providers/native_identity.go index 67f53c1a5..68b3368e7 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.Kernel{} } func (observer NativeIdentityObserver) ObserveNativeIdentity(ctx context.Context, client domain.DetectedClient, plan domain.DeliveryPlan, managed *domain.ClientBinding) (domain.NativeIdentityObservation, error) { @@ -81,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 1736027dc..355e30278 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"}`) @@ -109,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) @@ -127,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 { @@ -443,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{ diff --git a/install/integrationctl/agentplugins/providers/opencode_logical_keys_test.go b/install/integrationctl/agentplugins/providers/opencode_logical_keys_test.go index 6e4940d28..55a1f174a 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(), 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); 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); 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 10a1b8397..41414f754 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" @@ -75,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); err != nil { + if err := opencode.VerifyOpenCodeNativeObjects(configRoot, active, firstObjects, nativeconfig.New(), false); err != nil { t.Fatal(err) } body := readOpenCodeTestFile(t, jsonc) @@ -268,7 +269,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(), 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") { @@ -313,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); 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 { @@ -347,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); 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 { @@ -366,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); 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 { @@ -399,6 +400,30 @@ 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) + } +} + func openCodeActivationFixture(t *testing.T, skillText string) (string, string, []domain.NativeObjectOwnership, domain.ActivationRequest) { t.Helper() root := t.TempDir() @@ -416,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/install/integrationctl/agentplugins/providers/stager.go b/install/integrationctl/agentplugins/providers/stager.go index 960874113..25d859a8e 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_support_test.go b/install/integrationctl/agentplugins/providers/stager_support_test.go index 7a37b91f3..472a8dbd7 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/providers/stager_test.go b/install/integrationctl/agentplugins/providers/stager_test.go index c4d99f4fd..20410c23b 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") diff --git a/install/integrationctl/agentplugins/providerstest/providerstest.go b/install/integrationctl/agentplugins/providerstest/providerstest.go index 21b08fa25..f07f9346b 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 } diff --git a/install/integrationctl/agentplugins/usecase/manual_remote_lifecycle_test.go b/install/integrationctl/agentplugins/usecase/manual_remote_lifecycle_test.go index a8b8a49a8..e5f967610 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 2744946f7..6f4bbb4b5 100644 --- a/install/integrationctl/agentplugins/usecase/windsurf_lifecycle_test.go +++ b/install/integrationctl/agentplugins/usecase/windsurf_lifecycle_test.go @@ -82,6 +82,66 @@ func TestWindsurfLifecycleAddUpdateRepairRemoveInIsolatedHome(t *testing.T) { } } +func TestWindsurfLifecycleRepairsWipedNativeMCP(t *testing.T) { + t.Parallel() + 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") + if err := os.MkdirAll(configRoot, 0o700); err != nil { + t.Fatal(err) + } + 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") + 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(foreign), 0o600); err != nil { + t.Fatal(err) + } + 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) + + 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) + } + 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) { t.Parallel() service, store, _ := serviceFixture(t) diff --git a/repotests/agentplugins_release_contract_test.go b/repotests/agentplugins_release_contract_test.go index 635e23c8c..2417273ee 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",