From 0067231772ef65238e23d2e02002f3c4dd18eccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D0=B8=D1=8F?= Date: Fri, 23 Oct 2026 23:53:50 +0300 Subject: [PATCH] feat(kimi): install project skills and MCP with exact scoped ownership --- cli/cmd/agentplugins/project_kimi_e2e_test.go | 118 +++++++++ .../agentplugins/project_kimi_fixture_test.go | 44 ++++ .../project_kimi_imperative_test.go | 44 ++++ .../project_kimi_transfer_test.go | 35 +++ .../plans/project-install-e2e/client-plans.md | 2 +- .../kimi-code-review-r1.md | 77 ++++++ .../kimi-code-review-r2.md | 37 +++ .../kimi-implementation-evidence.md | 198 +++++++++++++++ .../adapters/nativeconfig/kimi_project.go | 29 +++ .../nativeconfig/kimi_project_test.go | 66 +++++ .../adapters/nativeconfig/project.go | 3 + .../adapters/nativeconfig/types.go | 3 +- .../agentplugins/clients/kimi/adapter.go | 3 + .../agentplugins/clients/kimi/identity.go | 28 ++- .../agentplugins/clients/kimi/lifecycle.go | 16 +- .../agentplugins/clients/kimi/project.go | 11 +- .../clients/kimi/project_active.go | 49 ++++ .../clients/kimi/project_components.go | 90 +++++++ .../clients/kimi/project_lifecycle.go | 118 +++++++++ .../agentplugins/clients/kimi/project_mcp.go | 167 +++++++++++++ .../agentplugins/clients/kimi/scope.go | 235 ++++++++++++++++++ .../agentplugins/domain/clients.go | 4 +- .../installer/project_kimi_fixture_test.go | 96 +++++++ .../installer/project_kimi_lifecycle_test.go | 136 ++++++++++ .../golden/compat_invalid_components.json | 3 +- .../golden/compat_remote_mcp_and_app.json | 3 +- .../testdata/golden/compat_skills_only.json | 3 +- .../testdata/golden/compat_stdio_mcp.json | 3 +- .../providers/project_kimi_admission_test.go | 95 +++++++ .../providers/project_kimi_boundaries_test.go | 218 ++++++++++++++++ .../providers/project_kimi_fixture_test.go | 130 ++++++++++ .../providers/project_kimi_lifecycle_test.go | 102 ++++++++ .../providers/project_kimi_stdio_test.go | 54 ++++ 33 files changed, 2208 insertions(+), 12 deletions(-) create mode 100644 cli/cmd/agentplugins/project_kimi_e2e_test.go create mode 100644 cli/cmd/agentplugins/project_kimi_fixture_test.go create mode 100644 cli/cmd/agentplugins/project_kimi_imperative_test.go create mode 100644 cli/cmd/agentplugins/project_kimi_transfer_test.go create mode 100644 docs/plans/project-install-e2e/kimi-code-review-r1.md create mode 100644 docs/plans/project-install-e2e/kimi-code-review-r2.md create mode 100644 docs/plans/project-install-e2e/kimi-implementation-evidence.md create mode 100644 install/integrationctl/agentplugins/adapters/nativeconfig/kimi_project.go create mode 100644 install/integrationctl/agentplugins/adapters/nativeconfig/kimi_project_test.go create mode 100644 install/integrationctl/agentplugins/clients/kimi/project_active.go create mode 100644 install/integrationctl/agentplugins/clients/kimi/project_components.go create mode 100644 install/integrationctl/agentplugins/clients/kimi/project_lifecycle.go create mode 100644 install/integrationctl/agentplugins/clients/kimi/project_mcp.go create mode 100644 install/integrationctl/agentplugins/clients/kimi/scope.go create mode 100644 install/integrationctl/agentplugins/installer/project_kimi_fixture_test.go create mode 100644 install/integrationctl/agentplugins/installer/project_kimi_lifecycle_test.go create mode 100644 install/integrationctl/agentplugins/providers/project_kimi_admission_test.go create mode 100644 install/integrationctl/agentplugins/providers/project_kimi_boundaries_test.go create mode 100644 install/integrationctl/agentplugins/providers/project_kimi_fixture_test.go create mode 100644 install/integrationctl/agentplugins/providers/project_kimi_lifecycle_test.go create mode 100644 install/integrationctl/agentplugins/providers/project_kimi_stdio_test.go diff --git a/cli/cmd/agentplugins/project_kimi_e2e_test.go b/cli/cmd/agentplugins/project_kimi_e2e_test.go new file mode 100644 index 000000000..67839c4ea --- /dev/null +++ b/cli/cmd/agentplugins/project_kimi_e2e_test.go @@ -0,0 +1,118 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +// Public saved CLI, real TLS Git and the production source/store/planner, +// installer/native transactions. Detection/security are inert host boundaries. +// The trusted test executable is copied as launcher bytes but never executed; +// this test certifies no Kimi, MCP, model, trust or native discovery behavior. +func TestProjectKimiComposedSavedLifecycle(t *testing.T) { + transport := newProjectJourneyTransport(t, "git") + journey := newProjectJourney(t, transport) + user := filepath.Join(journey.home, ".kimi") + journey.app = configureProjectKimiJourney(journey.app, user, filepath.Join(journey.home, "kimi")) + foreign := `{"mcpServers":{"foreign":{"transport":"http","url":"https://foreign.test/mcp","enabled":false}},"policy":"keep"}` + for _, project := range []string{user, journey.a, journey.b} { + native := filepath.Join(project, ".kimi-code") + if project == user { + native = user + } else { + if err := os.Mkdir(filepath.Join(project, ".git"), 0700); err != nil { + t.Fatal(err) + } + } + projectJourneyWrite(t, filepath.Join(native, "mcp.json"), foreign) + projectJourneyWrite(t, filepath.Join(native, "skills", "foreign", "SKILL.md"), "foreign Kimi skill\n") + } + projectJourneyWrite(t, filepath.Join(journey.a, ".mcp.json"), foreign) + userBefore := securityFixtureTree(t, user) + for _, project := range []string{journey.a, journey.b} { + journey.run(t, project, "add", transport.endpoint, "--alias", "docs", "--target", "kimi") + projectKimiOwned(t, journey, project, projectJourneySkill) + } + projectJourneyWrite(t, filepath.Join(journey.b, "B-retained-marker"), "other project\n") + bBefore := securityFixtureTree(t, journey.b) + statePath := filepath.Join(journey.namespace(journey.a), "state-v2.json") + stateBefore := projectJourneyRead(t, statePath) + journey.run(t, journey.a, "deps", "sync", "--locked", "--yes") + if stateBefore != projectJourneyRead(t, statePath) { + t.Fatal("unchanged Kimi reconciliation rewrote ownership") + } + installation, binding := projectKimiOwned(t, journey, journey.a, projectJourneySkill) + projectJourneyDataMarker(t, journey, journey.a, installation) + projectJourneyWrite(t, filepath.Join(binding.TargetLocator, "skills", "docs", "SKILL.md"), "TEST immutable drift\n") + if err := os.RemoveAll(filepath.Join(journey.a, ".kimi-code", "skills", "docs")); err != nil { + t.Fatal(err) + } + projectJourneyWrite(t, filepath.Join(transport.repo, "skills", "docs", "SKILL.md"), projectJourneyAdvancedSkill) + projectJourneyGit(t, transport.repo, "add", ".") + projectJourneyGit(t, transport.repo, "commit", "-m", "advance disposable Kimi source") + projectJourneyGit(t, transport.repo, "push", "--quiet", transport.remote, "main") + journey.run(t, journey.a, "repair", installation.InstallationID, "--target", "kimi") + projectKimiOwned(t, journey, journey.a, projectJourneySkill) + projectKimiRecover(t, journey) + projectKimiUpdateRemove(t, journey, installation, binding) + if !reflect.DeepEqual(userBefore, securityFixtureTree(t, user)) || !reflect.DeepEqual(bBefore, securityFixtureTree(t, journey.b)) { + t.Fatal("Kimi A lifecycle mutated the user profile or B") + } +} + +func projectKimiUpdateRemove(t *testing.T, journey projectJourney, installation domain.Installation, binding domain.ClientBinding) { + t.Helper() + journey.run(t, journey.a, "update", "docs", "--target", "kimi") + updated, current := projectKimiOwned(t, journey, journey.a, projectJourneyAdvancedSkill) + if updated.Source.TreeDigest == installation.Source.TreeDigest || current.ClientBindingID != binding.ClientBindingID || current.TargetLocator != binding.TargetLocator || updated.Source.SourceBindingID != installation.Source.SourceBindingID { + t.Fatal("Kimi update lost exact source/binding authority or did not advance") + } + status := journey.run(t, journey.a, "deps", "status") + for _, expected := range []string{`"kimi"`, `"project"`, `"activation_prepared"`} { + if !strings.Contains(status, expected) { + t.Fatalf("Kimi status omitted %s: %s", expected, status) + } + } + journey.run(t, journey.a, "remove", "docs", "--target", "kimi") + projectJourneyAbsent(t, binding.TargetLocator, filepath.Join(journey.a, ".kimi-code", "skills", "docs")) + config := projectJourneyRead(t, filepath.Join(journey.a, ".kimi-code", "mcp.json")) + if strings.Contains(config, `"docs"`) || !strings.Contains(config, "foreign.test") || !strings.Contains(config, `"enabled":false`) { + t.Fatalf("Kimi exact removal changed foreign settings: %s", config) + } + if projectJourneyRead(t, filepath.Join(journey.a, ".kimi-code", "skills", "foreign", "SKILL.md")) != "foreign Kimi skill\n" { + t.Fatal("Kimi removal changed an unrelated project skill") + } + for key, receipt := range installation.DataReceipts { + if updated.DataReceipts[key].Locator != receipt.Locator || projectJourneyRead(t, filepath.Join(receipt.Locator, "keep.txt")) != "retained environment data\n" { + t.Fatal("Kimi update/remove changed retained data authority") + } + } + if len(journey.state(t, journey.a).EnvironmentReceipts) != 0 { + t.Fatal("Kimi remove retained saved environment ownership") + } + after := securityFixtureTree(t, journey.a) + _, _ = journey.execute(journey.a, "remove", "docs", "--target", "kimi") + if !reflect.DeepEqual(after, securityFixtureTree(t, journey.a)) { + t.Fatal("repeat absent Kimi removal changed bytes") + } +} + +func projectKimiRecover(t *testing.T, journey projectJourney) { + t.Helper() + github := securityFixtureTree(t, filepath.Join(journey.a, ".kimi-code")) + config := projectJourneyRead(t, filepath.Join(journey.a, ".kimi-code", "mcp.json")) + staging, active := projectJourneyInterruptDirectoryJournal(t, journey) + for range 2 { + journey.run(t, journey.a, "deps", "recover", "--yes") + } + journey.inspectNoRecovery(t, journey.a) + projectJourneyAbsent(t, active) + if !reflect.DeepEqual(github, securityFixtureTree(t, filepath.Join(journey.a, ".kimi-code"))) || config != projectJourneyRead(t, filepath.Join(journey.a, ".kimi-code", "mcp.json")) || projectJourneyRead(t, filepath.Join(staging, "unpublished.txt")) != "caller-owned unpublished bytes\n" { + t.Fatal("Kimi bounded pending-journal recovery changed native/caller bytes") + } +} diff --git a/cli/cmd/agentplugins/project_kimi_fixture_test.go b/cli/cmd/agentplugins/project_kimi_fixture_test.go new file mode 100644 index 000000000..26c962b95 --- /dev/null +++ b/cli/cmd/agentplugins/project_kimi_fixture_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + "path/filepath" + "testing" + + "github.com/777genius/plugin-kit-ai/cli/internal/agentpluginscli" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +type projectKimiDetector struct{ user, executable string } + +func (d projectKimiDetector) Detect(context.Context) ([]domain.DetectedClient, error) { + return []domain.DetectedClient{{ClientID: domain.ClientKimi, ConfigRoot: d.user, + Version: "2.3.1", ExecutablePath: d.executable, Status: domain.DetectionDetected}}, nil +} + +func configureProjectKimiJourney(app agentpluginscli.App, user, executable string) agentpluginscli.App { + app.Detector = projectKimiDetector{user: user, executable: executable} + compose := app.ForScope + app.ForScope = func(scope domain.InstallScope, root string) (agentpluginscli.App, error) { + selected, err := compose(scope, root) + if err != nil { + return selected, err + } + return configureProjectKimiJourney(selected, user, executable), nil + } + return app +} + +func projectKimiOwned(t *testing.T, journey projectJourney, project, expected string) (domain.Installation, domain.ClientBinding) { + t.Helper() + installation, binding := journey.owned(t, project) + if binding.ClientID != "kimi" || binding.Scope != "project" || binding.NativeProfileRoot != filepath.Join(project, ".kimi-code") || binding.Activation != domain.ActivationPrepared || binding.Verification != domain.VerificationInstalled { + t.Fatalf("Kimi binding lost project authority or conservative native status: %+v", binding) + } + for _, path := range []string{filepath.Join(project, ".kimi-code", "skills", "docs", "SKILL.md"), filepath.Join(binding.TargetLocator, "skills", "docs", "SKILL.md")} { + if projectJourneyRead(t, path) != expected { + t.Fatalf("Kimi managed/native skill differs at %s", path) + } + } + return installation, binding +} diff --git a/cli/cmd/agentplugins/project_kimi_imperative_test.go b/cli/cmd/agentplugins/project_kimi_imperative_test.go new file mode 100644 index 000000000..4bb50a0d0 --- /dev/null +++ b/cli/cmd/agentplugins/project_kimi_imperative_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +func TestProjectKimiImperativeLocalLifecyclePreservesUserAndB(t *testing.T) { + transport := newProjectJourneyTransport(t, "git") + journey := newProjectJourney(t, transport) + user := filepath.Join(journey.home, ".kimi-code") + journey.app = configureProjectKimiJourney(journey.app, user, filepath.Join(journey.home, "kimi")) + for _, root := range []string{journey.a, journey.b} { + if err := os.Mkdir(filepath.Join(root, ".git"), 0700); err != nil { + t.Fatal(err) + } + } + projectJourneyWrite(t, filepath.Join(user, "mcp.json"), `{"mcpServers":{"foreign":{"url":"https://foreign.test","enabled":false}}}`) + projectJourneyWrite(t, filepath.Join(journey.b, ".kimi-code", "skills", "foreign", "SKILL.md"), "other project\n") + beforeUser, beforeB := securityFixtureTree(t, user), securityFixtureTree(t, journey.b) + journey.run(t, journey.a, "add", transport.repo, "--target", "kimi", "--no-save") + state := journey.state(t, journey.a) + if len(state.Installations) != 1 || len(state.EnvironmentReceipts) != 0 { + t.Fatalf("imperative route changed saved ownership: %+v", state) + } + installation := state.Installations[0] + for _, binding := range installation.Clients { + if binding.ClientID != "kimi" || binding.Scope != "project" || binding.Activation != domain.ActivationPrepared || binding.Verification != domain.VerificationInstalled { + t.Fatalf("imperative Kimi overclaimed or lost scope: %+v", binding) + } + if projectJourneyRead(t, filepath.Join(journey.a, ".kimi-code", "skills", "docs", "SKILL.md")) != projectJourneySkill { + t.Fatal("imperative skill projection missing") + } + } + journey.run(t, journey.a, "remove", installation.InstallationID, "--target", "kimi") + projectJourneyAbsent(t, filepath.Join(journey.a, ".kimi-code", "skills", "docs")) + if !reflect.DeepEqual(beforeUser, securityFixtureTree(t, user)) || !reflect.DeepEqual(beforeB, securityFixtureTree(t, journey.b)) { + t.Fatal("imperative project lifecycle changed user or B") + } +} diff --git a/cli/cmd/agentplugins/project_kimi_transfer_test.go b/cli/cmd/agentplugins/project_kimi_transfer_test.go new file mode 100644 index 000000000..0ee89ddf5 --- /dev/null +++ b/cli/cmd/agentplugins/project_kimi_transfer_test.go @@ -0,0 +1,35 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// Cold-cache portable import uses the production transport/store/scope path; +// native Kimi and the inert trusted launcher are never executed. +func TestProjectKimiColdSavedImportPreservesPinnedAgreement(t *testing.T) { + transport := newProjectJourneyTransport(t, "archive") + original := newProjectJourney(t, transport) + original.app = configureProjectKimiJourney(original.app, filepath.Join(original.home, ".kimi-code"), filepath.Join(original.home, "kimi")) + if err := os.Mkdir(filepath.Join(original.a, ".git"), 0700); err != nil { + t.Fatal(err) + } + original.run(t, original.a, "add", transport.endpoint, "--alias", "docs", "--target", "kimi") + installation, _ := projectKimiOwned(t, original, original.a, projectJourneySkill) + pair := original.assertPortablePair(t, original.a) + exchange := t.TempDir() + original.run(t, original.a, "deps", "export", exchange, "--yes") + cold := newProjectJourney(t, transport) + cold.app = configureProjectKimiJourney(cold.app, filepath.Join(cold.home, ".kimi-code"), filepath.Join(cold.home, "kimi")) + if err := os.Mkdir(filepath.Join(cold.a, ".git"), 0700); err != nil { + t.Fatal(err) + } + projectJourneyAbsent(t, cold.cache, filepath.Join(cold.a, ".uap")) + cold.run(t, cold.a, "deps", "import", exchange, "--install", "--yes") + restored, _ := projectKimiOwned(t, cold, cold.a, projectJourneySkill) + if restored.Source.SourceBindingID != installation.Source.SourceBindingID || restored.Source.TreeDigest != installation.Source.TreeDigest || pair != cold.assertPortablePair(t, cold.a) { + t.Fatal("Kimi cold restore changed exact source/portable agreement") + } + cold.run(t, cold.a, "remove", "docs", "--target", "kimi") +} diff --git a/docs/plans/project-install-e2e/client-plans.md b/docs/plans/project-install-e2e/client-plans.md index 8e84f4d02..aee2a3981 100644 --- a/docs/plans/project-install-e2e/client-plans.md +++ b/docs/plans/project-install-e2e/client-plans.md @@ -620,6 +620,6 @@ is replaced by explicit unsupported. R2 fixes independently verified GO in | qoder | GO | Verified | GO | Root assignment required | | cline | GO | Verified | GO | Root assignment required | | windsurf | GO | Verified | GO | Root assignment required | -| kimi | GO (current docs) | Verified | GO | Root assignment required | +| kimi | GO (current docs) | Verified | GO | Authored project components; final implementation R1/R2 pending; native project discovery pending | | chatgpt | GO | Verified | GO | Explicit project refusal implemented; independent implementation review pending | | vscode | GO | Verified | GO | Root assignment required | diff --git a/docs/plans/project-install-e2e/kimi-code-review-r1.md b/docs/plans/project-install-e2e/kimi-code-review-r1.md new file mode 100644 index 000000000..599684246 --- /dev/null +++ b/docs/plans/project-install-e2e/kimi-code-review-r1.md @@ -0,0 +1,77 @@ +# Kimi Code project adapter — independent code R1 + +Reviewed frozen clean `7111008f61adc7a7859d6ce44dbee848943db74c` in +`work/uap-project-kimi`, including adapter checkpoint `456cb039` and its public +composition follow-up. **GO.** Reviewer did not author the implementation. +The original client plan has two GOs, pre-code pinned evidence is committed, +and the exact shared host-version prerequisite retains its separate two GOs. +No Kimi/native client, launcher, model, trust/auth or MCP process was executed +by this review. + +## Source and authority + +Independently read the captured official exact-tag 2.3.1 configLoader, +config-schema and skillRoots source, with recorded blob IDs/provenance. These +confirm strict JSON loading, user then worktree-root compatibility then selected +cwd branded MCP precedence, explicit stdio/http/sse transport, and nearest .git +skill-root selection. The adapter follows that current component contract; +the preserved user plugin registry is not reused as a project installer. +Static source/help evidence remains separate from actual project discovery. + +New install/update/repair requires the selected absolute clean kimi/kimi.exe +and exact host-observed 2.3.1. No adapter/SDK probe or global environment change +is added. UserConfigRoot remains the original selected root. Scope, native root, +exact physical-artifact leaf and `.uap/managed/clients/kimi/` must +agree. The generic entry gate plus both project hooks qualify only project +scope; user SSE/declarative support is preserved. A selected skill root cannot +silently move into another Git ancestor or follow an aliased marker. + +Both composed and direct activation reject a rebound/empty delivered path; +pre-staging preflight alone allows an absent Delivery. Receipt/layout checks +refuse wrong scope, package, root, unknown/duplicate/path-escaped objects and +stale foreign entries. Active projection recomputes and compares the complete +stored native entry against the canonical selected package/data/cwd/launcher +without writing immutable bytes. Wrong selected data requires refresh rather +than adopting old locators. Native mutation uses the already reviewed shared +component transaction and exact kernel receipts, preserving foreign disabled +members, unknown policy, skills and other environments. + +Positive activation attestation is rejected before generic fallback. Configured +files remain prepared with installation verification, never attested/loaded. +Native/user/compatibility precedence is warning context, not evidence of trusted +effective session loading. Exact recorded removal reconstructs package/native +authority without current version or Git admission and never invokes a user +registry/native cleanup command. Recovery and persistent data retain the +existing core ownership rules. Four compatibility goldens change only Kimi's +scope list, retaining every other field; old client tests/source remain present. + +## Independent verification + +- Project provider suite: **PASS 0.087s**, including scope/artifact/direct-path, + receipt/collision/stale ownership, data binding, nearest Git, component update/ + repair/removal and attestation boundaries. +- Public SDK project suite: **PASS 0.149s**; dedicated native codec tests: + **PASS 0.003s**. +- All three public composed CLI scenarios: **PASS 8.388s**, using genuine + loopback TLS Git/ZIP acquisition and production loader/store/planner/installer/ + transactions, with expressly inert detection/security and unexecuted launcher + bytes. They cover saved lifecycle, exact old-pin repair, pending directory + journal recovery, selected update/remove, cold portable restore, no-save + composition and user/B/data preservation. +- Complete preserved Kimi client suite: **PASS 0.013s**; `git diff --check` + passed. The author's exact changed/full-file lint evidence is separate. + +No blocking finding remains. Genuine project discovery, effective extra roots, +workspace trust, session policy, MCP initialization, model/auth and restart +remain pending; source/help and fixture E2E do not qualify those or a release. +Root owns publication and feature integration after the other independent GO. + +## Mechanical integration preservation closure + +GO remains valid at clean `83c85702acf1697a59d724c26159e341bc8ba942`. Independently compared every path in the authored `3e072c99..7111008f` delta against the refreshed head: all 33 paths are byte-identical. The merge brings the reviewed root base and evidence without changing the Kimi implementation or its assertions. No new compilation or native process was needed for this byte-preservation check. + +## Final authority/admission correction closure + +**GO at frozen `95b5f34cc9fb7796e79e0a76e3b853d8a8ce5386`.** Independently inspected the narrow delta after the byte-preserved 83c baseline. Project ValidateBindingProfile now requires literal ClientID kimi before profile/root checks, and a copied codex binding is refused with exact unchanged inventory. The admission fixture now supplies pathpolicy.Policy, first proves the qualified host reaches PlanReady, and requires the actual current-version/harness diagnostic for every negative case. This removes the prior possibility of a generic missing-Paths error satisfying those negatives. No remaining code blocker found. + +The prior independent full provider/SDK/codec/public CLI/legacy passes at711 remain recorded above. Latest independent two-case execution was attempted once and failed during setup writing /dev/shm/go-build.../_testmain.go with ENOSPC, before assertions; it is not a pass. Parent directed static final closure using the unchanged prior evidence plus separately attributed author's corrected provider PASS0.139s/focused0.016s and changed lint0, rather than repeating resource failures. Genuine native qualification remains pending and publication remains the parent's decision. diff --git a/docs/plans/project-install-e2e/kimi-code-review-r2.md b/docs/plans/project-install-e2e/kimi-code-review-r2.md new file mode 100644 index 000000000..1287aaabc --- /dev/null +++ b/docs/plans/project-install-e2e/kimi-code-review-r2.md @@ -0,0 +1,37 @@ +# Current Kimi Code project adapter — independent implementation review R2 + +Reviewed initial authored checkpoint `7111008f`, the byte-preserving static component-base merge `83c85702acf1697a59d724c26159e341bc8ba942`, and final corrective checkpoint **`95b5f34cc9fb7796e79e0a76e3b853d8a8ce5386`** in `work/uap-project-kimi`. Reviewer cursor_clients did not author or edit the adapter. **GO for the final source checkpoint**, with the explicit independent-execution resource limitation below. No native project/runtime, release or all-client qualification is conferred. + +## Findings and final closure + +1. The direct project `ValidateBindingProfile` boundary omitted the exact client discriminator. Higher-level inspection already rejected contradictory client IDs, but a direct caller could present an otherwise exact Codex-tagged binding. Final95b requires literal `binding.ClientID == "kimi"` only in the project branch. The added regression invokes that direct boundary, requires its client-specific refusal and compares unchanged whole inventories. Existing user behavior is retained. + +2. `TestProjectKimiRequiresCurrentObservedHarnessAndNearestGit` constructed a Planner without its required path policy, so its version-negative cases could pass on an unrelated earlier failure. Final95b supplies `pathpolicy.Policy{}`, first proves the exact observed current harness reaches `PlanReady`, and requires every missing/older/prerelease/wrong-primary case to report the current-harness admission reason. Whole-inventory no-effect assertions remain. This closes the false-evidence risk without changing production version policy. + +The corrective commit changes exactly the direct client gate, the two regression files and explanatory evidence. No additional production behavior was introduced in the correction. + +## Native contract and scope review + +Independently read retained official docs and source at exact Kimi Code2.3.1 commit `684bae9ea6a437f3bae98bba1dcb797a917d500b`, including `configLoader.ts`, `config-schema.ts` and `skillRoots.ts`, and checked recorded artifact provenance/help boundaries. Native MCP layers are user, nearest Git-root compatibility `.mcp.json`, then selected working-directory `.kimi-code/mcp.json`; the selected project layer wins same-name entries. Native project skills use the nearest upward `.git` root. The dedicated project codec correctly emits explicit stdio/http/sse transport fields. Legacy user plugin registration is user-only and remains separate. + +New installation/update/repair requires the selected primary clean absolute kimi/kimi.exe and exact normalized2.3.1 version. It does not borrow archived entrypoints, probe from adapters/SDK, manufacture a user profile or grant trust. Skill delivery refuses a missing/aliased/different nearest Git marker rather than silently writing an ancestor. Project MCP-only behavior remains scoped to the selected cwd. Lower-priority user/compatibility names produce an honest warning and retain original bytes. Effective extra skill roots, command/session selectors, trust and runtime loading remain unobserved. + +Project entry points bind exact scope/original user-context equality, native root, artifact leaf and `.uap/managed/clients/kimi/` path. Direct activation requires the delivered active package; only prestaging accepts an absent delivery locator. SelectedDelivery validation and exact owned object validation prevent package/receipt rebinding. Unknown native object kinds remain visible to refusal rather than being silently dropped; the generic managed package receipt remains core-owned. + +The stdio projection copies the trusted managed launcher, expands selected package/data/cwd/environment and binds original command arguments without executing them. Read-only active reuse re-renders the expected complete entry from the source envelope and selected data receipt, JSON-normalizes it and compares the actual immutable entry. Missing, relative or other-project data therefore requires a fresh projection with unchanged package/native/user/B inventories. + +## Lifecycle, ownership and preservation + +Skill/MCP writes use the reviewed shared component mutation and native configuration transaction kernel. Existing-member/disabled/foreign collisions, malformed documents, unsafe namespace paths, stale owned receipts, rebound artifacts and writer-lock failure are checked by meaningful composed tests. Exact recorded removal is independent of a current executable/version or still-present Git marker, consumes the recorded component authority and preserves foreign members and retained data. Fresh-session removal wording accurately distinguishes already-open native sessions. + +Project materialization returns prepared plus installation-verified; neither direct/composed activation nor generic activation attestation may upgrade this to loaded/active proof. No model, MCP process, native plugin registration, folder-trust write, autotrust switch or account operation is used. Existing Kimi user branches, plugin registry format and user SSE capability policy are retained. Only the source-qualified project codec/qualifier accepts SSE. Shared domain/codec/golden changes are additive to the reviewed d39 component baseline; no other client implementation is removed. New public tests all begin TestProject and match the existing cross-platform project-contract selector. + +## Evidence assessment and execution limitation + +Inspected public production CLI TLS Git lifecycle, exact installed-pin repair while source advances, fresh-state update/status, pending-journal recovery, A-only removal and user/B preservation, plus cold HTTPS ZIP export/import agreement. Inspected no-save production CLI and public SDK A/B stdio/data, restart, repair, version-independent removal and retained-data assertions. The injected host/security/launcher seams are explicitly inert; these tests compose real filesystem/state/acquisition/planning/staging/native mutation and never assert genuine native MCP initialization or tool/data execution. + +The author reports final authored public saved Git+cold archive PASS7.695s, imperative/no-save PASS0.705s, SDK PASS0.187s, providers PASS0.121s, existing user client/native codec/planner passes and both lint gates zero. At corrective95b the author independently reports focused2 PASS0.016s, complete project providers PASS0.139s and changed-line lint zero. These remain author-run results, not this reviewer's rerun. + +The reviewer independently attempted `go test -p 1 -count=1 -timeout=90s ./providers -run '^TestProjectKimi'` at95b with the parent-granted single Go slot, GOMAXPROCS2 and owned RAM TMPDIR/GOTMPDIR/cache. Compilation failed before test execution writing `$WORK/b125/_pkg_.a` with ENOSPC. No passing independent provider execution is claimed. The slot was explicitly released; no optional repeated builds or cache cleanup were performed. Independently compared all 6 authored public CLI/SDK Kimi lifecycle/fixture paths at7111008f and95b: every byte is unchanged from the prior independently observed R1 public passes (CLI8.388s, SDK0.149s). Final `git diff --check` passed. Source inspection and the exact closure regressions support this GO; hosted/normal-capacity checks must still provide execution evidence. + +Official genuine version/help observations remain bounded pre-code evidence. Native Kimi discovery, workspace trust, invocation, MCP initialization/data and fresh-session removal are pending, as are any broader Desktop/release or all-client claims. diff --git a/docs/plans/project-install-e2e/kimi-implementation-evidence.md b/docs/plans/project-install-e2e/kimi-implementation-evidence.md new file mode 100644 index 000000000..b3acf131a --- /dev/null +++ b/docs/plans/project-install-e2e/kimi-implementation-evidence.md @@ -0,0 +1,198 @@ +# Kimi Code project component implementation evidence + +Implementation owner codex_claude, 2026-10-23. The original current-Kimi client +plan and its two independent corrected GO reviews are retained. This checkpoint +records pre-code official artifact/help and exact-tag source evidence supplied +by root; it is not native project/runtime qualification. + +## Pinned artifact and genuine help + +- Product: current MoonshotAI Kimi Code **2.3.1**, not archived kimi-cli. +- Official release: https://github.com/MoonshotAI/kimi-code/releases/tag/%40moonshot-ai%2Fkimi-code%402.3.1 +- Exact official source tag commit: `684bae9ea6a437f3bae98bba1dcb797a917d500b`. +- Archive: https://github.com/MoonshotAI/kimi-code/releases/download/%40moonshot-ai/kimi-code%402.3.1/kimi-code-linux-x64.tar.gz +- Vendor GitHub asset and measured archive SHA256: + `0dbc7d771ce4d761acc150ade72325bcd8fc51973d481f7040295f4cf062fdbe`; + 65,922,255 compressed bytes, exactly one regular `kimi` member. +- Binary: 192,285,888 bytes, SHA256 + `781ec1c24e5e79a01aaaa31ad859e8818ae11ffe792e93645cdc0c068a264ba0`. +- Genuine bounded `--version`, `--help`, `doctor --help`, `acp --help` all exited0 + in root's fresh HOME/XDG/project, networkless `bwrap --unshare-all` with + read-only verified binary/system libraries and empty proc. Version stdout is + exactly `2.3.1`. No account, trust, model, MCP or plugin mutation occurred. +- Logs/provenance remain under `tools/native-probes/kimi/2.3.1` in the task + evidence area. The hash-verified extracted copy was reclaimed; do not download + it again merely to reproduce help. No standalone read-only MCP/skill listing + exists in the captured help, so the adapter stays prepared after file delivery. + +## Exact-tag documentation and implementation + +All following sources are at commit `684bae9ea6a437f3bae98bba1dcb797a917d500b` +in https://github.com/MoonshotAI/kimi-code : + +| Exact source path | Inspected contract | +| --- | --- | +| `docs/en/customization/mcp.md` | Working-directory `.kimi-code/mcp.json`; user `.kimi-code` or KIMI_CODE_HOME; project overrides user; workspace trust required; open-session removal differs from fresh-session absence | +| `docs/en/customization/skills.md` | `.kimi-code/skills` plus `.agents/skills`; nearest `.git` project root; frontmatter name/description; extra directories can affect effective selection | +| `docs/en/customization/plugins.md` | Plugin registry remains user-only; project components must not register a project plugin there | +| `packages/agent-core-v2/src/app/mcpConfig/configLoader.ts` | Merge user mcp.json, nearest Git worktree root `.mcp.json`, then selected cwd `.kimi-code/mcp.json`; project can be excluded when untrusted | +| `packages/agent-core-v2/src/mcpCore/config-schema.ts` | Explicit `transport:stdio/http/sse`; inference from command/url when absent; env/cwd or headers; enabled/deferred/tools/timeout policy | +| `packages/agent-core-v2/src/features/skill/catalog/skillRoots.ts` | Upward nearest `.git` existence controls project skills; branded `.kimi-code` and generic `.agents` roots; realpath normalization | +| `packages/agent-core-v2/src/features/skill/workspace/rootFileSkillSource.ts` | Root-file skill discovery and implicit context require conservative status | +| `packages/agent-core-v2/src/workspace/workspaceMcpConfig/workspaceMcpConfigService.ts` | Workspace trust gates loading and configuration changes | +| `packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts` | Trust is independent persisted workspace authority; installer must never grant it | + +Root's provenance records each retrieved blob ID and SHA256; the reviewer can +reproduce by exact commit rather than a mutable website. These are static source +observations, not a successful native list/connection. + +## Narrow implementation decisions + +Deliver exact project skills and MCP entries through reviewed +`shared.ProjectComponents` and the existing transaction/native-config kernel. +Native root is `/.kimi-code`; managed immutable package remains +`/.uap/managed/clients/kimi/`. Preserve detected original +user root, user plugin layout/registry/lifecycle, and KIMI_CODE_HOME. Do not +emit a project plugin manifest or invoke user registration. + +Only the current exact observed2.3.1 `kimi`/`kimi.exe` executable/version pair +qualifies new project install/update/repair. Use the separately reviewed shared +optional ClientVersion propagation once its frozen implementation has two GO; +no new probe belongs in Kimi's adapter or SDK Discover. Layout/reservation and +exact receipt removal/recovery remain independent of a running current binary. + +Use a narrow project-only native codec for explicit stdio/http/sse transport +keys, with no implicit user capability expansion. Stdio uses the existing +trusted managed launcher and validates immutable package/data/cwd/environment +against the selected receipts during active reuse. Preserve unsupported +components as unsupported; rendered command skills follow existing projection. + +A selected skill project must be its nearest `.git` ancestor (a regular gitfile +or actual directory, never a symlink). Reject a different ancestor rather than +silently installing skills where Kimi will not scan. Without any `.git`, fail +closed for the automatic skill lane until exact fallback semantics are further +qualified. No ancestor file is mutated. User/root compatibility MCP sources +are lower priority but remain visible as warnings/collision context; disabled +or foreign selected entries cannot be adopted or enabled. Effective extra skill +roots, aliases, session flags, trust, loaded MCP and authentication are unobserved. + +Configuration materialization returns prepared + installation-verified; it +never claims native loaded/runtime-active. Activation attestation is refused +before the generic composed-provider branch, including direct activation. +No KIMI_CODE_TRUST_WORKSPACE, --auto, --yolo, archived entrypoint or model prompt +is used to manufacture evidence. Exact removal only consumes recorded component +receipts and leaves user/B/foreign settings, retained data and package store. + +## Verification checkpoint + +Pending implementation: production provider boundary and public CLI/SDK +lifecycle tests for selected A versus B/user, exact roots/artifacts/receipts, +wrong data/rebound contexts, foreign/disabled collisions, nearest-Git mismatch, +current/missing/wrong version evidence, prepared/attestation refusal, rollback, +locked no-op/update/exact repair/recovery/removal and cold portable restore. +Two independent final implementation reviews are required before publication. +Genuine native project discovery/trust/session/restart/MCP/data evidence remains +pending; passing fixture composition does not change that boundary. + +## First implementation checkpoint (not review/publication-ready) + +The reviewed shared host contract64c7ee6d was incorporated exactly as3e072c99; +its two independent code GO reports are preserved alongside this evidence. +Project adapter now has scoped current-version/nearest-Git admission, dedicated +stdio/http/sse codec, exact package/artifact/native-root and original-user-root +fences, selected immutable stdio data/launcher validation, project-only SSE +qualification, component receipts and the reviewed native transaction helper. +User plugin behavior is unchanged. Project state stays prepared/installed and +attestation cannot override unobserved trust/discovery. + +Bounded checks before this checkpoint: full existing Kimi client suite +PASS0.014s; full nativeconfig suitePASS3.331s; new composed project provider +suitePASS0.069s, then with current-harness/nearest-Git and version-independent +removal casesPASS0.101s. Public saved TLS Git lifecycle reached the first real +installation but could not copy the trusted launcher because task temporary +storage returned ENOSPC; this is a resource failure, not a passed public E2E. +Root is coordinating headroom before rerun. Source files were then mechanically +split by responsibility without broad recompilation. The final public SDK +stdio lifecycle, public CLI successful run, lint and two final code reviews +remain pending; this commit is a recoverable implementation checkpoint only. + +## Final authored implementation verification checkpoint + +After root restored only task-owned reproducible cache headroom, the real public +saved TLS Git lifecycle passed: A/B installation, locked no-op, exact installed- +pin repair after immutable/native drift while the source advanced, fresh-state +update/status, bounded pending-journal recovery, exact removal/repeat refusal, +retained data and unchanged user/B. A separate cold HTTPS ZIP export/import +restored the exact agreement and source identity into a fresh content cache. +The existing no-save local route also passed through production CLI composition +with no environment receipt, selected current host metadata, prepared/installed +project binding and exact removal preserving user/B. No fake native executable +was run; detector/security/trusted-launcher bytes are explicit inert test seams. + +Public SDK tests passed A/B distinct selected stdio/data receipts, exact +launcher/args/environment/data cwd projection, retained data markers, locked +no-op, caller-supplied exact-pin repair after source advance, fresh Engine +restart/update/inspection and exact removal without current version/executable. +The launcher is copied trusted inert bytes and never executed in this fixture; +this does not establish a genuine MCP initialize/tool/data workload. + +Additional provider coverage passes current/missing/older/prerelease/wrong +harness refusal, nearest-Git mismatch with unchanged inventories, version/Git- +independent owned removal, malformed/disabled/foreign/symlink/stale native +ownership, scope/artifact/delivery mismatch, immutable selected-data refusal, +project-only SSE with preserved user SSE policy, and same-name lower-priority +user/nearest-Git compatibility entries preserved byte-for-byte. Their presence +is exposed as a warning; configured trust/effective loading stays unobserved. + +Final bounded results: + +| Check | Result | +| --- | --- | +| Public saved Git + cold archive import | PASS7.695s | +| Public imperative local/no-save | PASS0.705s | +| Public SDK project stdio/data lifecycle | PASS0.187s | +| Project composed provider boundaries/lifecycle | PASS0.121s | +| Existing Kimi user adapter suite | PASS0.014s | +| Full nativeconfig suite including Kimi codec | PASS3.331s | +| Compatibility planner/goldens | PASS0.013s | +| Exact changed-code lint2.13.2 | Zero issues | +| Whole changed-file size/complexity/architecture lint2.13.2 | Zero issues | + +An overly broad initial test expression also selected an existing Codex/Claude +user sibling-compatibility test and hit the known duplicate inherited proxy +environment rejection; that same exact existing test passed0.291s after removing +only ambient upper/lowercase proxy variables for its child environment. It is +not recorded as a Kimi failure or hidden as native qualification. A new test +fixture initially omitted the required planner path policy; it was corrected +without changing production admission. + +The final implementation is frozen for two independent code reviews. Genuine +Kimi project discovery, folder trust, skill invocation, MCP initialization/data +and fresh-session removal remain pending. Neither these tests nor the genuine +help-only artifact justify active/attested status or all-client/release claims. + +## Independent R2 corrections + +After the byte-preserving component-base merge at `83c85702`, independent R2 +identified two narrow omissions. The direct project profile validator now +requires the literal Kimi client ID before accepting an otherwise exact native +binding. A copied Codex-tagged binding refuses with unchanged inventories; the +higher-level inspector already rejected this mismatch. + +The dedicated harness-version admission test also lacked the required planner +path policy. It now supplies that policy, positively proves the selected current +2.3.1 harness reaches `PlanReady`, and requires each missing/old/prerelease/wrong +executable case to report the current-harness admission reason. This prevents +an unrelated earlier planner failure from satisfying the negative assertions. +Both focused regressions passed0.016s, and the complete project Kimi provider +selection passed0.139s. Production version, scope, trust and prepared-status +contracts are unchanged. + +Both independent final implementation reviews are GO at corrective source +`95b5f34cc9fb7796e79e0a76e3b853d8a8ce5386`, after the component-base merge and +both narrow R2 corrections. Their full reports preserve earlier independent +composed passes and separately identify final independent retries that failed +before assertions from actual ENOSPC. Those retries are not reported as passes; +the actual corrected author provider and lint results above remain separately +attributed. Parent requested no redundant compilation during the shared resource +shortage. No native Kimi project or runtime qualification is added by approval. diff --git a/install/integrationctl/agentplugins/adapters/nativeconfig/kimi_project.go b/install/integrationctl/agentplugins/adapters/nativeconfig/kimi_project.go new file mode 100644 index 000000000..3cfa0778e --- /dev/null +++ b/install/integrationctl/agentplugins/adapters/nativeconfig/kimi_project.go @@ -0,0 +1,29 @@ +package nativeconfig + +import ( + "fmt" + "strings" +) + +// Current Kimi Code project MCP uses transport:stdio/http/sse. Keep this +// project-only dialect separate from the preserved user plugin manifest. +func projectKimiServer(server Server, placeholders Placeholders) (map[string]any, error) { + transport := "stdio" + if strings.EqualFold(strings.TrimSpace(server.Type), "remote") { + switch server.RemoteTransport { + case "streamable-http": + transport = "http" + case "sse": + transport = "sse" + default: + return nil, fmt.Errorf("kimi project remote MCP requires streamable-http or sse transport") + } + server.RemoteTransport = "" + } + entry, err := projectBaseServer(CodecMCPServers, server, placeholders) + if err != nil { + return nil, err + } + entry["transport"] = transport + return entry, nil +} diff --git a/install/integrationctl/agentplugins/adapters/nativeconfig/kimi_project_test.go b/install/integrationctl/agentplugins/adapters/nativeconfig/kimi_project_test.go new file mode 100644 index 000000000..1984c9f8a --- /dev/null +++ b/install/integrationctl/agentplugins/adapters/nativeconfig/kimi_project_test.go @@ -0,0 +1,66 @@ +package nativeconfig + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestProjectKimiCodecPreservesExplicitTransportsAndForeignMembers(t *testing.T) { + path := filepath.Join(t.TempDir(), ".mcp.json") + const foreign = `{"mcpServers":{"foreign":{"transport":"http","url":"https://foreign.test/mcp","enabled":false}},"foreignPreference":"keep"}` + mustWrite(t, path, foreign) + kernel := New() + request := Request{Paths: Paths{JSON: path}, Codec: CodecKimi, Action: ActionAdd, Name: "managed", + Server: Server{Type: "remote", RemoteTransport: "streamable-http", URL: "https://managed.test/mcp", Headers: map[string]string{"X-Fixture": "inert"}}} + receipt, err := kernel.Apply(request) + if err != nil { + t.Fatal(err) + } + for _, transport := range []struct { + native string + server Server + }{{"http", request.Server}, {"sse", Server{Type: "remote", RemoteTransport: "sse", URL: "https://managed.test/events"}}, {"stdio", Server{Type: "stdio", Command: "TEST-never-executed", Args: []string{"inert"}}}} { + request.Action, request.Owned, request.Server = ActionUpdate, &receipt, transport.server + receipt, err = kernel.Apply(request) + if err != nil { + t.Fatal(err) + } + present, owned, err := kernel.Inspect(request.Paths, request.Codec, request.Name, &receipt) + body, readErr := os.ReadFile(path) + var observed struct { + Servers map[string]struct{ Transport string } `json:"mcpServers"` + } + decodeErr := json.Unmarshal(body, &observed) + if err != nil || readErr != nil || decodeErr != nil || !present || !owned || observed.Servers["managed"].Transport != transport.native || !strings.Contains(string(body), "foreign.test") || !strings.Contains(string(body), "foreignPreference") { + t.Fatalf("Kimi native transport %s = %s, %v, %v", transport.native, body, err, readErr) + } + } + request.Action, request.Owned = ActionRemove, &receipt + if _, err := kernel.Apply(request); err != nil { + t.Fatal(err) + } + if present, _, err := kernel.Inspect(request.Paths, request.Codec, request.Name, nil); err != nil || present { + t.Fatalf("Kimi exact removal = %t, %v", present, err) + } + request.Action, request.Name, request.Owned = ActionAdd, "foreign", nil + if _, err := kernel.Apply(request); !errors.Is(err, ErrCollision) { + t.Fatalf("disabled foreign Kimi server was adopted: %v", err) + } +} + +func TestProjectKimiCodecRejectsUnknownRemoteTransport(t *testing.T) { + path := filepath.Join(t.TempDir(), "mcp.json") + for _, server := range []Server{ + {Type: "remote", URL: "https://managed.test/mcp"}, + {Type: "remote", URL: "https://managed.test/mcp", RemoteTransport: "unknown"}, + {Type: "stdio", Command: "TEST-never-executed", RemoteTransport: "sse"}, + } { + if _, err := DesiredReceipt(path, CodecKimi, "managed", server, Placeholders{}); err == nil { + t.Fatalf("unqualified native transport admitted: %+v", server) + } + } +} diff --git a/install/integrationctl/agentplugins/adapters/nativeconfig/project.go b/install/integrationctl/agentplugins/adapters/nativeconfig/project.go index cff48d060..76d5b0fe2 100644 --- a/install/integrationctl/agentplugins/adapters/nativeconfig/project.go +++ b/install/integrationctl/agentplugins/adapters/nativeconfig/project.go @@ -52,6 +52,9 @@ func projectServer(codec Codec, server Server, placeholders Placeholders) (map[s if server.OpenCodeV2 != nil { return nil, fmt.Errorf("%s does not accept OpenCode V2 options", codec) } + if codec == CodecKimi { + return projectKimiServer(server, placeholders) + } if codec == CodecCopilot { return projectCopilotServer(server, placeholders) } diff --git a/install/integrationctl/agentplugins/adapters/nativeconfig/types.go b/install/integrationctl/agentplugins/adapters/nativeconfig/types.go index b89397f5f..f7f58adac 100644 --- a/install/integrationctl/agentplugins/adapters/nativeconfig/types.go +++ b/install/integrationctl/agentplugins/adapters/nativeconfig/types.go @@ -18,6 +18,7 @@ const ( // Clients with different native transport keys require their own codec. CodecMCPServers Codec = "mcpServers" CodecCopilot Codec = "copilot-mcpServers" + CodecKimi Codec = "kimi-project-mcpServers" CodecGemini Codec = "gemini-mcpServers" CodecCodex Codec = "codex-mcp-servers" CodecOpenCode Codec = "opencode-mcp" @@ -220,7 +221,7 @@ func validateRequest(req Request) error { } func supportedCodec(codec Codec) bool { - return codec == CodecMCPServers || codec == CodecCopilot || codec == CodecGemini || codec == CodecCodex || codec == CodecOpenCode || codec == CodecOpenCodeV2 || codec == CodecWindsurf || codec == CodecCline + return codec == CodecMCPServers || codec == CodecCopilot || codec == CodecKimi || codec == CodecGemini || codec == CodecCodex || codec == CodecOpenCode || codec == CodecOpenCodeV2 || codec == CodecWindsurf || codec == CodecCline } func codecCollectionKey(codec Codec) string { diff --git a/install/integrationctl/agentplugins/clients/kimi/adapter.go b/install/integrationctl/agentplugins/clients/kimi/adapter.go index 4944fb49f..41bb15641 100644 --- a/install/integrationctl/agentplugins/clients/kimi/adapter.go +++ b/install/integrationctl/agentplugins/clients/kimi/adapter.go @@ -48,6 +48,9 @@ func (*Adapter) TargetRoot(client domain.DetectedClient, _ domain.PackageMode, _ return client.ConfigRoot, filepath.Join(client.ConfigRoot, "plugins", "managed"), nil } func (*Adapter) RefinePlan(_ context.Context, in clients.PlanInput, plan *domain.DeliveryPlan) error { + if in.TargetContext.Scope == domain.ScopeProject { + return refineKimiProject(in, plan) + } shared.PromoteNativeReady(plan, in.Client.ConfigRoot, shared.OnlyNativeComponents(plan.Components)) plan.UserActions = shared.AppendUnique(plan.UserActions, reloadAction) return nil diff --git a/install/integrationctl/agentplugins/clients/kimi/identity.go b/install/integrationctl/agentplugins/clients/kimi/identity.go index c020e2032..87dce2747 100644 --- a/install/integrationctl/agentplugins/clients/kimi/identity.go +++ b/install/integrationctl/agentplugins/clients/kimi/identity.go @@ -2,6 +2,7 @@ package kimi import ( "context" + "fmt" "os" "path/filepath" "strings" @@ -13,10 +14,28 @@ import ( ) func (*Adapter) UsesNativeRegistryExecutable() bool { return false } -func (*Adapter) InspectNativeRegistry(ctx context.Context, _ clients.Env, _ domain.DetectedClient, p domain.DeliveryPlan, owned *domain.ClientBinding) (clients.RegistryFinding, error) { +func (*Adapter) InspectNativeRegistry(ctx context.Context, env clients.Env, client domain.DetectedClient, p domain.DeliveryPlan, owned *domain.ClientBinding) (clients.RegistryFinding, error) { if err := ctx.Err(); err != nil { return clients.RegistryIndeterminate, err } + if p.Scope == domain.ScopeProject || p.TargetContext.Scope == domain.ScopeProject { + root, err := kimiPlanRoot(client, p) + if err != nil { + return clients.RegistryIndeterminate, err + } + if owned != nil { + if owned.ClientID != "kimi" || owned.Scope != string(domain.ScopeProject) || owned.PhysicalArtifact != p.PhysicalArtifactID || owned.TargetLocator != p.ActivePath { + return clients.RegistryIndeterminate, fmt.Errorf("kimi project binding scope/artifact/path differs") + } + if err := New().ValidateBindingProfile(root, *owned); err != nil { + return clients.RegistryIndeterminate, err + } + selected := *owned + selected.NativeObjects = kimiNativeObjects(owned.NativeObjects) + owned = &selected + } + return kimiComponents(root).Inspect(p.Components, owned, env.NativeConfig) + } if err := validateIdentity(p.NativeRegistryRoot, p.DeclaredName, p.ActivePath); err != nil { return clients.RegistryIndeterminate, err } @@ -38,6 +57,13 @@ func (*Adapter) InspectNativeRegistry(ctx context.Context, _ clients.Env, _ doma return clients.RegistryCollision, nil } func (*Adapter) InspectPreparedRegistry(p domain.DeliveryPlan, name string, owned bool) (clients.RegistryFinding, error) { + if p.Scope == domain.ScopeProject || p.TargetContext.Scope == domain.ScopeProject { + _, err := kimiPlanRoot(domain.DetectedClient{ConfigRoot: p.TargetContext.UserConfigRoot}, p) + if err != nil { + return clients.RegistryIndeterminate, err + } + return clients.RegistryClear, nil + } if err := pathpolicy.RequireContainedChild(p.NativeRegistryRoot, p.TargetRoot); err != nil { return clients.RegistryIndeterminate, err } diff --git a/install/integrationctl/agentplugins/clients/kimi/lifecycle.go b/install/integrationctl/agentplugins/clients/kimi/lifecycle.go index 9c9e288c1..2a1b4dc3b 100644 --- a/install/integrationctl/agentplugins/clients/kimi/lifecycle.go +++ b/install/integrationctl/agentplugins/clients/kimi/lifecycle.go @@ -14,12 +14,18 @@ import ( ) func (*Adapter) AutomaticallyActivates(_ clients.Env, r domain.ActivationRequest) bool { + if r.Plan.Scope == domain.ScopeProject { + return r.Plan.InstallIntent == domain.InstallIntentAutomatic && shared.OnlyNativeComponents(r.Plan.Components) + } return r.Plan.InstallIntent == domain.InstallIntentAutomatic && shared.HasNativeConfigRoot(r) } func (*Adapter) VerifierAvailable(_ domain.DetectedClient, p domain.DeliveryPlan, _ string) bool { return p.NativeRegistryRoot != "" } -func (*Adapter) PreflightActivation(_ clients.Env, r domain.ActivationRequest) error { +func (*Adapter) PreflightActivation(env clients.Env, r domain.ActivationRequest) error { + if r.Plan.Scope == domain.ScopeProject || r.Plan.TargetContext.Scope == domain.ScopeProject { + return preflightKimiProject(env, r) + } // Generic preflight runs before staging, so Delivery and DeclaredName are // still empty there. Validate those fields only once they are supplied. if r.Client.ClientID != domain.ClientKimi || r.Plan.ClientID != domain.ClientKimi || @@ -45,6 +51,9 @@ func (*Adapter) PreflightActivation(_ clients.Env, r domain.ActivationRequest) e return err } func (a *Adapter) Activate(ctx context.Context, env clients.Env, r domain.ActivationRequest) (domain.ActivationOutcome, error) { + if r.Plan.Scope == domain.ScopeProject || r.Plan.TargetContext.Scope == domain.ScopeProject { + return activateKimiProject(ctx, env, r) + } out := shared.StartedActivation(r) // Native-config lifecycle persists an attempt before dispatch. Report its // effect explicitly so verified success can clear that marker; failures @@ -94,7 +103,10 @@ func (a *Adapter) Activate(ctx context.Context, env clients.Env, r domain.Activa out.UserActions = []string{reloadAction} return out, nil } -func (*Adapter) Deactivate(ctx context.Context, _ clients.Env, r domain.DeactivationRequest) (domain.DeactivationOutcome, error) { +func (*Adapter) Deactivate(ctx context.Context, env clients.Env, r domain.DeactivationRequest) (domain.DeactivationOutcome, error) { + if r.TargetContext.Scope == domain.ScopeProject { + return deactivateKimiProject(ctx, env, r) + } out := shared.StartedDeactivation() if err := ctx.Err(); err != nil { return out, err diff --git a/install/integrationctl/agentplugins/clients/kimi/project.go b/install/integrationctl/agentplugins/clients/kimi/project.go index 4f107dba2..cac12edc2 100644 --- a/install/integrationctl/agentplugins/clients/kimi/project.go +++ b/install/integrationctl/agentplugins/clients/kimi/project.go @@ -19,10 +19,16 @@ var _ clients.ActiveNativeProjector = (*Adapter)(nil) // writing. Everything Kimi owns — the projected manifest included — lives // inside the managed package directory the stager already digest-verified, so // after the manifest identity check there is no external ownership to rebuild. -func (*Adapter) ProjectActiveNative(ctx context.Context, root string, _ domain.PackageEnvelope, plan domain.DeliveryPlan, _ string) ([]domain.NativeObjectOwnership, error) { +func (*Adapter) ProjectActiveNative(ctx context.Context, root string, envelope domain.PackageEnvelope, plan domain.DeliveryPlan, data string) ([]domain.NativeObjectOwnership, error) { if err := ctx.Err(); err != nil { return nil, err } + if plan.Scope == domain.ScopeProject || plan.TargetContext.Scope == domain.ScopeProject { + if err := validateKimiActive(root, envelope, plan, data); err != nil { + return nil, err + } + return kimiProjectObjects(root, envelope, plan) + } if err := verifyManifest(root, plan.DeclaredName); err != nil { return nil, err } @@ -30,6 +36,9 @@ func (*Adapter) ProjectActiveNative(ctx context.Context, root string, _ domain.P } func (*Adapter) Project(ctx context.Context, in clients.ProjectionInput) ([]domain.NativeObjectOwnership, error) { + if in.Plan.Scope == domain.ScopeProject || in.Plan.TargetContext.Scope == domain.ScopeProject { + return projectKimiComponents(ctx, in) + } if err := ctx.Err(); err != nil { return nil, err } diff --git a/install/integrationctl/agentplugins/clients/kimi/project_active.go b/install/integrationctl/agentplugins/clients/kimi/project_active.go new file mode 100644 index 000000000..cdf77efe1 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/kimi/project_active.go @@ -0,0 +1,49 @@ +package kimi + +import ( + "encoding/json" + "fmt" + "path/filepath" + "reflect" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/shared" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +func validateKimiActive(root string, envelope domain.PackageEnvelope, plan domain.DeliveryPlan, data string) error { + if _, err := kimiPlanRoot(domain.DetectedClient{ConfigRoot: plan.TargetContext.UserConfigRoot}, plan); err != nil { + return err + } + if root != plan.ActivePath { + return fmt.Errorf("kimi active package differs from selected plan") + } + if err := plan.SelectedDelivery.ValidatePlan(plan, envelope.TreeDigest); err != nil { + return err + } + for _, name := range shared.SupportedMCPNames(plan) { + if envelope.MCP.Servers[name].Type == "stdio" && (!filepath.IsAbs(data) || filepath.Clean(data) != data) { + return fmt.Errorf("%w: kimi selected stdio data root must be absolute and clean", domain.ErrProjectionRefreshRequired) + } + expected, err := renderKimiProjectServer(envelope.MCP.Servers[name], plan.ActivePath, data, root) + if err != nil { + return err + } + actual, err := readKimiProjectEntry(root, name) + if err != nil { + return err + } + // JSON normalization equates []string projected in memory with decoded []any. + body, err := json.Marshal(expected) + if err != nil { + return err + } + var normalized map[string]any + if err := json.Unmarshal(body, &normalized); err != nil { + return err + } + if !reflect.DeepEqual(actual, normalized) { + return fmt.Errorf("%w: kimi immutable package/data/launcher projection differs", domain.ErrProjectionRefreshRequired) + } + } + return nil +} diff --git a/install/integrationctl/agentplugins/clients/kimi/project_components.go b/install/integrationctl/agentplugins/clients/kimi/project_components.go new file mode 100644 index 000000000..9eb025e65 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/kimi/project_components.go @@ -0,0 +1,90 @@ +package kimi + +import ( + "fmt" + "path/filepath" + "sort" + + "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/shared" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +const ( + kimiProjectSkillKind = "kimi_project_skill_directory" + kimiProjectMCPKind = "kimi_project_mcp_server" +) + +func kimiComponents(root string) shared.ProjectComponents { + return shared.ProjectComponents{ProjectRoot: filepath.Dir(root), SkillsRoot: filepath.Join(root, "skills"), ConfigPaths: nativeconfig.Paths{JSON: filepath.Join(root, "mcp.json")}, Codec: nativeconfig.CodecKimi, SkillKind: kimiProjectSkillKind, MCPKind: kimiProjectMCPKind} +} + +func kimiProjectObjects(root string, envelope domain.PackageEnvelope, plan domain.DeliveryPlan) ([]domain.NativeObjectOwnership, error) { + native, err := kimiPlanRoot(domain.DetectedClient{ConfigRoot: plan.TargetContext.UserConfigRoot}, plan) + if err != nil { + return nil, err + } + if err := pathpolicy.RequireContainedChild(plan.TargetContext.ProjectRoot, root); err != nil { + return nil, err + } + layout := kimiComponents(native) + objects := []domain.NativeObjectOwnership{} + for _, component := range plan.Components { + if component.Support == domain.SupportUnsupported { + continue + } + var object domain.NativeObjectOwnership + switch component.Kind { + case domain.ComponentSkill: + skill, ok := envelope.Skills[component.Name] + if !ok { + return nil, fmt.Errorf("kimi planned skill is missing") + } + relative := filepath.ToSlash(filepath.Dir(filepath.FromSlash(skill.RelativePath))) + if skill.RelativePath == "" { + relative = "skills/" + component.Name + } + object, err = layout.SkillObject(component.Name, relative, root) + case domain.ComponentMCPServer: + var server nativeconfig.Server + server, err = kimiProjectServer(root, component.Name) + if err == nil { + object, err = layout.MCPObject(component.Name, server, layout.ConfigPaths.JSON) + } + default: + return nil, fmt.Errorf("unsupported Kimi project component %q", component.Kind) + } + if err != nil { + return nil, err + } + objects = append(objects, object) + } + sort.Slice(objects, func(i, j int) bool { return objects[i].ObjectID < objects[j].ObjectID }) + return objects, layout.ValidateObjects(objects) +} + +func kimiDesiredServers(root string, objects []domain.NativeObjectOwnership) (map[string]nativeconfig.Server, error) { + servers := map[string]nativeconfig.Server{} + for _, object := range objects { + if object.Kind != kimiProjectMCPKind { + continue + } + server, err := kimiProjectServer(root, object.LogicalName) + if err != nil { + return nil, err + } + servers[object.LogicalName] = server + } + return servers, nil +} + +func kimiNativeObjects(objects []domain.NativeObjectOwnership) []domain.NativeObjectOwnership { + selected := []domain.NativeObjectOwnership{} + for _, object := range objects { + if object.Kind != "managed_package_directory" { + selected = append(selected, object) + } + } + return selected +} diff --git a/install/integrationctl/agentplugins/clients/kimi/project_lifecycle.go b/install/integrationctl/agentplugins/clients/kimi/project_lifecycle.go new file mode 100644 index 000000000..47bb168a5 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/kimi/project_lifecycle.go @@ -0,0 +1,118 @@ +package kimi + +import ( + "context" + "fmt" + "path/filepath" + + "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" +) + +const KimiProjectAction = "Kimi Code project components are configured. Review folder trust and the selected working directory, start a fresh session and inspect skills/MCP. Effective extra skill roots, disabled/session selection, native discovery, MCP initialization and authentication remain unobserved; existing sessions retain removed MCP tools until restarted." + +func preflightKimiProject(env clients.Env, r domain.ActivationRequest) error { + root, err := kimiPlanRoot(r.Client, r.Plan) + if err != nil { + return err + } + if r.Client.ClientID != domain.ClientKimi || (r.Delivery.ClientID != "" && r.Delivery.ClientID != domain.ClientKimi) { + return fmt.Errorf("kimi activation client mismatch") + } + if r.Delivery.ActivePath != "" && r.Delivery.ActivePath != r.Plan.ActivePath { + return fmt.Errorf("kimi delivery differs from selected artifact") + } + if r.ActivationComplete { + return fmt.Errorf("kimi project trust and native discovery cannot be established by activation attestation") + } + if err := currentKimi(r.Client); err != nil { + return err + } + if shared.ComponentKindPresent(r.Plan.Components, domain.ComponentSkill) { + if err := kimiSkillRoot(r.Plan.TargetContext.ProjectRoot); err != nil { + return err + } + } + if !shared.OnlyNativeComponents(r.Plan.Components) { + return fmt.Errorf("kimi project delivery requires supported skills and MCP only") + } + layout := kimiComponents(root) + if err := layout.ValidateObjects(kimiNativeObjects(r.PreviousNativeObjects)); err != nil { + return err + } + if err := layout.ValidateObjects(kimiNativeObjects(r.Delivery.NativeObjects)); err != nil { + return err + } + if err := env.NativeConfig.RequireFileIO(); err != nil { + return err + } + if shared.ComponentKindPresent(r.Plan.Components, domain.ComponentMCPServer) { + _, err := kimiConfigNames(filepath.Join(root, "mcp.json")) + return err + } + return nil +} + +func activateKimiProject(ctx context.Context, env clients.Env, r domain.ActivationRequest) (domain.ActivationOutcome, error) { + if err := ctx.Err(); err != nil { + return domain.ActivationOutcome{}, err + } + if err := shared.ActivationIdentityMismatch(r); err != nil { + return domain.ActivationOutcome{}, err + } + if r.Delivery.ActivePath != r.Plan.ActivePath { + return domain.ActivationOutcome{}, fmt.Errorf("kimi delivery differs from selected artifact") + } + if err := preflightKimiProject(env, r); err != nil { + return domain.ActivationOutcome{}, err + } + layout := kimiComponents(r.Plan.NativeRegistryRoot) + out, err := shared.CompleteNativeConfigActivation(ctx, r, shared.NativeConfigActivation{ + Automatic: r.Plan.InstallIntent == domain.InstallIntentAutomatic, + UnavailableAction: "select supported Kimi project components", RepairAction: "repair exact Kimi project entries", RetryAction: "retry Kimi project component installation", CompletedAction: KimiProjectAction, + Verify: func() error { + return layout.Verify(kimiNativeObjects(r.Delivery.NativeObjects), false, env.NativeConfig) + }, + Activate: func(ctx context.Context, request domain.ActivationRequest) error { + servers, err := kimiDesiredServers(request.Delivery.ActivePath, kimiNativeObjects(request.Delivery.NativeObjects)) + if err != nil { + return &shared.NativeEffectError{Effect: domain.NativeEffectUnchanged, Err: err} + } + return (shared.ProjectComponentMutation{Layout: layout, ActivePath: request.Delivery.ActivePath, Previous: kimiNativeObjects(request.PreviousNativeObjects), Desired: kimiNativeObjects(request.Delivery.NativeObjects), Servers: servers, Kernel: env.NativeConfig}).Apply(ctx) + }, + }) + if err == nil && out.Verification == domain.VerificationInstalled { + out.Activation, out.ActivationAttested = domain.ActivationPrepared, false + } + return out, err +} + +func deactivateKimiProject(ctx context.Context, env clients.Env, r domain.DeactivationRequest) (domain.DeactivationOutcome, error) { + out := shared.StartedDeactivation() + if err := ctx.Err(); err != nil { + return out, err + } + plan := domain.DeliveryPlan{Scope: domain.ScopeProject, TargetContext: r.TargetContext, ClientID: domain.ClientKimi, PhysicalArtifactID: r.PhysicalArtifactID, ActivePath: r.ManagedArtifactPath, NativeRegistryRoot: r.NativeRegistryRoot} + root, err := kimiPlanRoot(r.Client, plan) + if err != nil { + return out, err + } + if r.Client.ClientID != domain.ClientKimi { + return out, fmt.Errorf("kimi deactivation client mismatch") + } + layout := kimiComponents(root) + if err := layout.ValidateObjects(kimiNativeObjects(r.NativeObjects)); err != nil { + return out, err + } + if !r.Confirmed { + out.UserActions = []string{"agentplugins will remove only exact owned Kimi project skill directories and MCP members"} + return out, nil + } + if err := (shared.ProjectComponentMutation{Layout: layout, Previous: kimiNativeObjects(r.NativeObjects), Kernel: env.NativeConfig}).Apply(ctx); err != nil && !shared.CommittedNativeDeactivationCleanup(&out, err) { + return out, err + } + out.ExternalRemovalComplete = true + out.UserActions = []string{"Start a fresh Kimi session to observe removal; already-open sessions can retain removed MCP tools"} + return out, nil +} diff --git a/install/integrationctl/agentplugins/clients/kimi/project_mcp.go b/install/integrationctl/agentplugins/clients/kimi/project_mcp.go new file mode 100644 index 000000000..c64fe30b6 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/kimi/project_mcp.go @@ -0,0 +1,167 @@ +package kimi + +import ( + "context" + "encoding/json" + "fmt" + "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/clients/shared" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/managedstdio" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/pathcontract" +) + +func projectKimiComponents(ctx context.Context, in clients.ProjectionInput) ([]domain.NativeObjectOwnership, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if _, err := kimiPlanRoot(domain.DetectedClient{ConfigRoot: in.Plan.TargetContext.UserConfigRoot}, in.Plan); err != nil { + return nil, err + } + if err := pathpolicy.RequireContainedChild(in.Plan.TargetContext.ProjectRoot, in.StagingPath); err != nil { + return nil, err + } + if err := shared.DeliverManagedStdio(in.DeliverLauncher(), in.StagingPath, in.Envelope, in.Plan); err != nil { + return nil, err + } + entries := map[string]any{} + for _, name := range shared.SupportedMCPNames(in.Plan) { + entry, err := renderKimiProjectServer(in.Envelope.MCP.Servers[name], in.Plan.ActivePath, in.PluginDataPath, in.StagingPath) + if err != nil { + return nil, err + } + entries[name] = entry + } + if len(entries) > 0 { + if err := shared.WriteJSON(filepath.Join(in.StagingPath, "mcp.json"), map[string]any{"mcpServers": entries}); err != nil { + return nil, err + } + } + return kimiProjectObjects(in.StagingPath, in.Envelope, in.Plan) +} + +func renderKimiProjectServer(source domain.MCPServer, active, data, staging string) (map[string]any, error) { + entry := shared.CloneObject(source.Decoded) + delete(entry, "type") + switch source.Type { + case "stdio": + if !filepath.IsAbs(data) || filepath.Clean(data) != data { + return nil, fmt.Errorf("kimi selected stdio data path must be absolute and clean") + } + if err := shared.ApplyStdioDataContract(entry, active, data, staging); err != nil { + return nil, err + } + if err := bindKimiStdio(entry, source, active, data); err != nil { + return nil, err + } + entry["transport"] = "stdio" + case "streamable-http": + entry["transport"] = "http" + case "sse": + entry["transport"] = "sse" + default: + return nil, fmt.Errorf("unsupported Kimi project MCP transport %q", source.Type) + } + return entry, nil +} + +func bindKimiStdio(entry map[string]any, source domain.MCPServer, active, data string) error { + rawCWD, _ := source.Decoded["cwd"].(string) + cwd, err := pathcontract.ExpandCWD(rawCWD, active, data) + if err != nil { + return err + } + absolute, ok := entry["cwd"].(string) + if !ok { + return fmt.Errorf("kimi projected stdio cwd is missing") + } + args := []string{} + if raw, present := entry["args"]; present { + values, ok := raw.([]any) + if !ok { + return fmt.Errorf("kimi stdio arguments must be strings") + } + for _, value := range values { + text, ok := value.(string) + if !ok { + return fmt.Errorf("kimi stdio argument must be string") + } + args = append(args, text) + } + } + command, _ := source.Decoded["command"].(string) + if strings.HasPrefix(command, "${package.root}/") { + command = "./" + strings.TrimPrefix(command, "${package.root}/") + } + entry["command"] = filepath.Join(active, filepath.FromSlash(managedstdio.RelativeDirectory), managedstdio.ExecutableName) + entry["args"] = managedstdio.Arguments(active, data, absolute, cwd.Anchor, command, args) + delete(entry, "cwd") + return nil +} + +func readKimiFile(path string) ([]byte, error) { + file, err := nativeconfig.New().ReadExactFile(path) + if err != nil { + return nil, err + } + if len(file.Body) > 1<<20 { + return nil, fmt.Errorf("kimi MCP file exceeds inspection limit") + } + return file.Body, nil +} + +func readKimiProjectEntry(root, name string) (map[string]any, error) { + path := filepath.Join(root, "mcp.json") + if err := pathpolicy.RequireContainedChild(root, path); err != nil { + return nil, err + } + body, err := readKimiFile(path) + if err != nil { + return nil, err + } + document, err := shared.DecodeStrictJSONObject(body) + if err != nil { + return nil, err + } + entries, ok := document["mcpServers"].(map[string]any) + if !ok { + return nil, fmt.Errorf("kimi projected MCP collection is missing") + } + entry, ok := entries[name].(map[string]any) + if !ok { + return nil, fmt.Errorf("kimi selected MCP entry is missing") + } + return entry, nil +} + +func kimiProjectServer(root, name string) (nativeconfig.Server, error) { + entry, err := readKimiProjectEntry(root, name) + if err != nil { + return nativeconfig.Server{}, err + } + body, err := json.Marshal(entry) + if err != nil { + return nativeconfig.Server{}, err + } + var server nativeconfig.Server + if err := json.Unmarshal(body, &server); err != nil { + return server, err + } + switch entry["transport"] { + case "stdio": + server.Type = "stdio" + case "http": + server.Type, server.RemoteTransport = "remote", "streamable-http" + case "sse": + server.Type, server.RemoteTransport = "remote", "sse" + default: + return server, fmt.Errorf("kimi native transport is unsupported") + } + server.StdioValuesResolved, server.CWDResolved = true, true + return server, nil +} diff --git a/install/integrationctl/agentplugins/clients/kimi/scope.go b/install/integrationctl/agentplugins/clients/kimi/scope.go new file mode 100644 index 000000000..f051ef4e7 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/kimi/scope.go @@ -0,0 +1,235 @@ +package kimi + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/adapters/pathpolicy" + "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" +) + +var ( + _ clients.ProjectTargetLayout = (*Adapter)(nil) + _ clients.NativeRegistryLayout = (*Adapter)(nil) + _ clients.PlanQualifier = (*Adapter)(nil) + _ clients.PlanPrecondition = (*Adapter)(nil) + _ clients.ProfileBindingValidator = (*Adapter)(nil) +) + +func (*Adapter) NativeRegistry(in clients.PlanInput) (string, string) { + if in.TargetContext.Scope == domain.ScopeProject { + return filepath.Join(in.TargetContext.ProjectRoot, ".kimi-code"), "" + } + return in.Client.ConfigRoot, in.Client.ExecutablePath +} + +func (*Adapter) ProjectTargetRoot(client domain.DetectedClient, mode domain.PackageMode, managedRoot string, target domain.TargetContext) (string, string, error) { + if _, err := kimiProjectRoot(client, target, ""); err != nil { + return "", "", err + } + if err := pathpolicy.RequireContainedChild(target.ProjectRoot, managedRoot); err != nil { + return "", "", err + } + return shared.ManagedTargetRoot(client, mode, managedRoot) +} + +func (*Adapter) CheckPlanPrecondition(in clients.PlanInput, plan *domain.DeliveryPlan) error { + if in.TargetContext.Scope != domain.ScopeProject { + return nil + } + if _, err := kimiProjectRoot(in.Client, in.TargetContext, plan.NativeRegistryRoot); err != nil { + return err + } + if err := currentKimi(in.Client); err != nil { + return err + } + if len(in.Envelope.Skills) > 0 || len(in.Envelope.Commands) > 0 { + return kimiSkillRoot(in.TargetContext.ProjectRoot) + } + return nil +} + +func currentKimi(client domain.DetectedClient) error { + name := filepath.Base(client.ExecutablePath) + if (name != "kimi" && name != "kimi.exe") || !filepath.IsAbs(client.ExecutablePath) || filepath.Clean(client.ExecutablePath) != client.ExecutablePath || client.Version != "2.3.1" { + return fmt.Errorf("kimi project components require the explicitly selected current Kimi Code 2.3.1 executable/version; archived or unknown harnesses are unqualified") + } + return nil +} + +func kimiProjectRoot(client domain.DetectedClient, target domain.TargetContext, planned string) (string, error) { + validated, err := domain.NewTargetContext(target.Scope, target.ProjectRoot, target.UserConfigRoot) + if err != nil { + return "", err + } + if validated.Scope != domain.ScopeProject { + return "", fmt.Errorf("kimi components require project scope") + } + if validated.UserConfigRoot != client.ConfigRoot { + return "", fmt.Errorf("kimi original user profile differs from selected target") + } + root := filepath.Join(validated.ProjectRoot, ".kimi-code") + if planned != "" && planned != root { + return "", fmt.Errorf("kimi native registry differs from selected project") + } + return root, pathpolicy.RequireContainedChild(validated.ProjectRoot, root) +} + +func kimiPlanRoot(client domain.DetectedClient, plan domain.DeliveryPlan) (string, error) { + if plan.Scope != domain.ScopeProject || plan.TargetContext.Scope != domain.ScopeProject || plan.ClientID != domain.ClientKimi { + return "", fmt.Errorf("kimi project plan scope/client mismatch") + } + root, err := kimiProjectRoot(client, plan.TargetContext, plan.NativeRegistryRoot) + if err != nil { + return "", err + } + if err := pathpolicy.ValidateLeafID(plan.PhysicalArtifactID); err != nil { + return "", err + } + expected := filepath.Join(plan.TargetContext.ProjectRoot, ".uap", "managed", "clients", "kimi", plan.PhysicalArtifactID) + if plan.ActivePath != expected { + return "", fmt.Errorf("kimi managed package differs from selected project/artifact") + } + return root, pathpolicy.RequireContainedChild(plan.TargetContext.ProjectRoot, expected) +} + +// Native skill discovery chooses the nearest existing .git marker. Never move +// selected skills into a different ancestor, or qualify an unknown fallback. +func kimiSkillRoot(project string) error { + root, err := kimiNearestGitRoot(project) + if err != nil { + return err + } + if root == "" { + return fmt.Errorf("kimi project skill discovery requires an observed .git marker at the selected project root") + } + if root != project { + return fmt.Errorf("kimi skills resolve at nearest Git ancestor %s, which differs from selected project", root) + } + return nil +} + +func kimiNearestGitRoot(project string) (string, error) { + for directory := project; ; directory = filepath.Dir(directory) { + marker := filepath.Join(directory, ".git") + info, err := os.Lstat(marker) + if err == nil { + if info.Mode()&os.ModeSymlink != 0 || (!info.IsDir() && !info.Mode().IsRegular()) { + return "", fmt.Errorf("kimi nearest Git marker must be a real directory or regular gitfile") + } + return directory, nil + } + if !os.IsNotExist(err) { + return "", err + } + if filepath.Dir(directory) == directory { + return "", nil + } + } +} + +func refineKimiProject(in clients.PlanInput, plan *domain.DeliveryPlan) error { + root, err := kimiPlanRoot(in.Client, *plan) + if err != nil { + return err + } + plan.NativeRegistryRoot, plan.NativeRegistryExecutable = root, "" + if plan.Status == domain.PlanUnsupported || !shared.OnlyNativeComponents(plan.Components) { + return nil + } + plan.Status, plan.Activation, plan.Verification = domain.PlanReady, domain.ActivationPrepared, domain.VerificationPackageValid + plan.UserActions = []string{KimiProjectAction} + compatibilityRoot, err := kimiNearestGitRoot(in.TargetContext.ProjectRoot) + if err != nil { + return err + } + if compatibilityRoot == "" { + compatibilityRoot = in.TargetContext.ProjectRoot + } + for _, path := range []string{filepath.Join(in.Client.ConfigRoot, "mcp.json"), filepath.Join(compatibilityRoot, ".mcp.json")} { + if in.Client.ConfigRoot == "" && !filepath.IsAbs(path) { + continue + } + names, err := kimiConfigNames(path) + if err != nil { + return err + } + for _, name := range shared.SupportedMCPNames(*plan) { + if names[name] { + plan.Warnings = shared.AppendUnique(plan.Warnings, "Kimi selected project MCP overrides a same-name lower-priority user or compatibility entry; effective trust/session loading remains unobserved") + } + } + } + return nil +} + +func (*Adapter) ValidateBindingProfile(root string, binding domain.ClientBinding) error { + if binding.Scope != string(domain.ScopeProject) { + return nil + } + if binding.ClientID != "kimi" { + return fmt.Errorf("kimi recorded project client differs") + } + if binding.NativeProfileRoot != root || filepath.Base(root) != ".kimi-code" { + return fmt.Errorf("kimi recorded project registry differs") + } + if err := pathpolicy.ValidateLeafID(binding.PhysicalArtifact); err != nil { + return err + } + expected := filepath.Join(filepath.Dir(root), ".uap", "managed", "clients", "kimi", binding.PhysicalArtifact) + if binding.TargetLocator != expected { + return fmt.Errorf("kimi recorded package differs from project/artifact") + } + if err := pathpolicy.RequireContainedChild(filepath.Dir(root), expected); err != nil { + return err + } + return kimiComponents(root).ValidateObjects(kimiNativeObjects(binding.NativeObjects)) +} + +func kimiConfigNames(path string) (map[string]bool, error) { + file, err := readKimiFile(path) + if err != nil { + return nil, err + } + if strings.TrimSpace(string(file)) == "" { + return map[string]bool{}, nil + } + doc, err := shared.DecodeStrictJSONObject(file) + if err != nil { + return nil, err + } + names := map[string]bool{} + raw, present := doc["mcpServers"] + if !present { + return names, nil + } + entries, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("kimi MCP collection must be an object") + } + for name := range entries { + names[name] = true + } + return names, nil +} + +// Current pinned Kimi supports SSE in the project file. Its preserved user +// plugin capability remains unsupported; qualify only this observed lane. +func (*Adapter) QualifyPlan(in clients.PlanInput, plan *domain.DeliveryPlan) error { + if in.TargetContext.Scope != domain.ScopeProject { + return nil + } + if err := currentKimi(in.Client); err != nil { + return err + } + for i, component := range plan.Components { + if component.Kind == domain.ComponentMCPServer && in.Envelope.MCP.Servers[component.Name].Type == "sse" { + plan.Components[i].Support, plan.Components[i].Reason = domain.SupportNative, "" + } + } + return nil +} diff --git a/install/integrationctl/agentplugins/domain/clients.go b/install/integrationctl/agentplugins/domain/clients.go index 0f1fa054b..0e7dc7e48 100644 --- a/install/integrationctl/agentplugins/domain/clients.go +++ b/install/integrationctl/agentplugins/domain/clients.go @@ -154,10 +154,10 @@ var clientDefinitions = []ClientDefinition{ InstallIntents: []InstallIntent{InstallIntentAutomatic}, LifecycleKind: LifecycleCLIRegistry, }), - clientDefinition(ClientKimi, "Kimi Code", "kimi", "managed", "native", false, PackageNative, SupportNative, SupportNative, SupportUnsupported, SupportUnsupported, SupportUnsupported, ClientTraits{ + withProjectScope(clientDefinition(ClientKimi, "Kimi Code", "kimi", "managed", "native", false, PackageNative, SupportNative, SupportNative, SupportUnsupported, SupportUnsupported, SupportUnsupported, ClientTraits{ InstallIntents: []InstallIntent{InstallIntentAutomatic}, LifecycleKind: LifecycleNativeConfig, - }), + }), LifecycleNativeConfig), clientDefinition(ClientQoder, "Qoder", "qoder", "managed", "native", false, PackageNative, SupportNative, SupportNative, SupportUnsupported, SupportUnsupported, SupportUnsupported, ClientTraits{ InstallIntents: []InstallIntent{InstallIntentAutomatic}, LifecycleKind: LifecycleCLIRegistry, diff --git a/install/integrationctl/agentplugins/installer/project_kimi_fixture_test.go b/install/integrationctl/agentplugins/installer/project_kimi_fixture_test.go new file mode 100644 index 000000000..5416be65c --- /dev/null +++ b/install/integrationctl/agentplugins/installer/project_kimi_fixture_test.go @@ -0,0 +1,96 @@ +package installer_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/statev2" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/kimi" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/installer" +) + +type kimiSDKFixture struct { + project, user, pkg, data string + engine *installer.Engine + request installer.Request +} + +func kimiSDKWrite(t *testing.T, path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatal(err) + } +} + +func kimiSDKRead(t *testing.T, path string) string { + t.Helper() + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(body) +} + +func newKimiSDKFixture(t *testing.T, project, user, pkg, helper string) *kimiSDKFixture { + t.Helper() + if err := os.MkdirAll(project, 0700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(project, ".git"), 0700); err != nil { + t.Fatal(err) + } + registry, err := clients.NewRegistry(kimi.New()) + if err != nil { + t.Fatal(err) + } + engine, err := installer.New(installer.Config{ProjectRoot: project, StateRoot: filepath.Join(project, ".uap"), Registry: registry, HelperExecutable: helper, TrustedLocalPackages: true}) + if err != nil { + t.Fatal(err) + } + return &kimiSDKFixture{project: project, user: user, pkg: pkg, engine: engine, request: installer.Request{Scope: domain.ScopeProject, ProjectRoot: project, Operation: installer.OpInstall, PackageRoot: pkg, ClientID: "kimi", ClientConfigRoot: user, ClientExecutable: filepath.Join(filepath.Dir(project), "kimi"), ClientVersion: "2.3.1", + InstallationID: "00000000-0000-4000-8000-000000002331", OperationID: "kimi-project-sdk", RequiredComponents: []string{"skills", "mcp"}}} +} + +func (f *kimiSDKFixture) apply(t *testing.T, op installer.Operation, label string) installer.Result { + t.Helper() + f.request.Operation, f.request.OperationID = op, label + handle, err := f.engine.Prepare(t.Context(), f.request) + if err != nil { + t.Fatalf("prepare Kimi SDK %s: %v", label, err) + } + defer func() { + if err := handle.Close(); err != nil { + t.Error(err) + } + }() + result, err := f.engine.Apply(t.Context(), handle, installer.Decision{Confirmed: true}) + if err != nil || result.Outcome != installer.OutcomeCompleted && result.Outcome != installer.OutcomeUnchanged { + t.Fatalf("Kimi SDK %s=%+v,%v", label, result, err) + } + if result.Binding.DataRoot != "" { + if f.data != "" && f.data != result.Binding.DataRoot { + t.Fatal("SDK changed selected persistent data authority") + } + f.data = result.Binding.DataRoot + } + return result +} + +func (f *kimiSDKFixture) binding(t *testing.T) domain.ClientBinding { + t.Helper() + state, err := (statev2.Store{Path: filepath.Join(f.project, ".uap", "state-v2.json"), ProjectRoot: f.project}).Load() + if err != nil || len(state.Installations) != 1 { + t.Fatalf("Kimi SDK state=%+v,%v", state, err) + } + for _, binding := range state.Installations[0].Clients { + return binding + } + t.Fatal("Kimi SDK binding absent") + return domain.ClientBinding{} +} diff --git a/install/integrationctl/agentplugins/installer/project_kimi_lifecycle_test.go b/install/integrationctl/agentplugins/installer/project_kimi_lifecycle_test.go new file mode 100644 index 000000000..e2f4a6d20 --- /dev/null +++ b/install/integrationctl/agentplugins/installer/project_kimi_lifecycle_test.go @@ -0,0 +1,136 @@ +package installer_test + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/installer" +) + +// Public SDK composition with real filesystem/state and inert stdio helper +// bytes. No runner, native client, launcher or MCP process is executed. +func TestProjectKimiPublicSDKStdioDataRepairRestartAndRemove(t *testing.T) { + base := t.TempDir() + user, pkg, helper := filepath.Join(base, "user", ".kimi-code"), filepath.Join(base, "package"), filepath.Join(base, "inert-helper") + kimiSDKWrite(t, helper, "inert helper bytes; never executed") + if err := os.Chmod(helper, 0700); err != nil { + t.Fatal(err) + } + const original = "---\nname: docs\ndescription: Inert Kimi SDK fixture\n---\nOriginal pinned bytes.\n" + kimiSDKWrite(t, filepath.Join(pkg, "plugin.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"sdk-kimi","version":"1.0.0"}`) + kimiSDKWrite(t, filepath.Join(pkg, "skills", "docs", "SKILL.md"), original) + kimiSDKWrite(t, filepath.Join(pkg, "mcp.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"docs":{"type":"stdio","command":"node","args":["${PLUGIN_ROOT}/server.js","${PLUGIN_DATA}"],"cwd":"${PLUGIN_DATA}"}}}`) + kimiSDKWrite(t, filepath.Join(pkg, "server.js"), "// Inert fixture; never executed.\n") + const foreign = `{"mcpServers":{"foreign":{"url":"https://foreign.test","enabled":false}},"policy":"keep"}` + kimiSDKWrite(t, filepath.Join(user, "mcp.json"), foreign) + a, b := newKimiSDKFixture(t, filepath.Join(base, "A"), user, pkg, helper), newKimiSDKFixture(t, filepath.Join(base, "B"), user, pkg, helper) + for _, f := range []*kimiSDKFixture{a, b} { + kimiSDKWrite(t, filepath.Join(f.project, ".kimi-code", "mcp.json"), foreign) + f.apply(t, installer.OpInstall, "install") + binding := f.binding(t) + if binding.Activation != domain.ActivationPrepared || binding.Verification != domain.VerificationInstalled || f.data == "" { + t.Fatalf("Kimi SDK overclaimed/omitted data: %+v", binding) + } + kimiSDKWrite(t, filepath.Join(f.data, "keep.txt"), f.project) + kimiSDKCheckStdio(t, f) + } + if a.data == b.data { + t.Fatal("project stdio data receipts alias") + } + beforeB := kimiSDKRead(t, filepath.Join(b.project, ".uap", "state-v2.json")) + bBinding := b.binding(t) + kimiSDKRepairUpdateRemove(t, a, pkg, helper, original) + if beforeB != kimiSDKRead(t, filepath.Join(b.project, ".uap", "state-v2.json")) || !reflect.DeepEqual(bBinding, b.binding(t)) { + t.Fatal("Kimi SDK changed project B ownership") + } + kimiSDKCheckStdio(t, b) + if kimiSDKRead(t, filepath.Join(user, "mcp.json")) != foreign { + t.Fatal("Kimi SDK changed global native configuration") + } + for _, f := range []*kimiSDKFixture{a, b} { + if kimiSDKRead(t, filepath.Join(f.data, "keep.txt")) != f.project { + t.Fatal("SDK removed scoped persistent data") + } + } +} + +func kimiSDKCheckStdio(t *testing.T, f *kimiSDKFixture) { + t.Helper() + binding := f.binding(t) + servers, err := kimiSDKServers(filepath.Join(f.project, ".kimi-code", "mcp.json")) + if err != nil { + t.Fatal(err) + } + entry := servers["docs"].(map[string]any) + env := entry["env"].(map[string]any) + args := entry["args"].([]any) + if env["PLUGIN_ROOT"] != binding.TargetLocator || env["PLUGIN_DATA"] != f.data || len(args) < 7 || args[1] != binding.TargetLocator || args[2] != f.data || args[3] != f.data || args[4] != "data" || entry["cwd"] != nil || entry["transport"] != "stdio" { + t.Fatalf("SDK stdio binding = %+v", entry) + } + if kimiSDKRead(t, entry["command"].(string)) != "inert helper bytes; never executed" { + t.Fatal("SDK launcher did not come from injected trusted helper") + } +} + +func kimiSDKRepairUpdateRemove(t *testing.T, f *kimiSDKFixture, pkg, helper, original string) { + t.Helper() + binding := f.binding(t) + nativeSkill := filepath.Join(f.project, ".kimi-code", "skills", "docs", "SKILL.md") + if got := f.apply(t, installer.OpUpdate, "unchanged"); got.Mutated { + t.Fatalf("unchanged=%+v", got) + } + pinned := t.TempDir() + for _, name := range []string{"plugin.json", "mcp.json", "server.js"} { + kimiSDKWrite(t, filepath.Join(pinned, name), kimiSDKRead(t, filepath.Join(pkg, name))) + } + kimiSDKWrite(t, filepath.Join(pinned, "skills", "docs", "SKILL.md"), original) + kimiSDKWrite(t, filepath.Join(pkg, "skills", "docs", "SKILL.md"), "---\nname: docs\ndescription: Advanced Kimi SDK fixture\n---\nUpdated bytes.\n") + if err := os.RemoveAll(filepath.Dir(nativeSkill)); err != nil { + t.Fatal(err) + } + f.request.PackageRoot, f.request.SourceRoot = pinned, pkg + f.apply(t, installer.OpRepair, "repair-installed-pin") + if kimiSDKRead(t, nativeSkill) != original { + t.Fatal("SDK repair followed moving source") + } + fresh := newKimiSDKFixture(t, f.project, f.user, pkg, helper) + fresh.data = f.data + *f = *fresh + f.apply(t, installer.OpUpdate, "update-after-restart") + if kimiSDKRead(t, nativeSkill) == original { + t.Fatal("SDK update failed to advance selected project") + } + kimiSDKCheckStdio(t, f) + view, err := f.engine.Inspect(t.Context()) + if err != nil || view.Recovery.Required { + t.Fatalf("Kimi SDK status=%+v,%v", view, err) + } + f.request.ClientVersion, f.request.ClientExecutable = "", "" + f.apply(t, installer.OpRemove, "remove") + if _, err := os.Stat(binding.TargetLocator); !os.IsNotExist(err) { + t.Fatalf("SDK managed artifact survived: %v", err) + } + if _, err := os.Stat(nativeSkill); !os.IsNotExist(err) { + t.Fatalf("SDK native skill survived: %v", err) + } + servers, err := kimiSDKServers(filepath.Join(f.project, ".kimi-code", "mcp.json")) + if err != nil || servers["docs"] != nil || servers["foreign"] == nil { + t.Fatalf("SDK remove ownership=%+v,%v", servers, err) + } +} + +func kimiSDKServers(path string) (map[string]any, error) { + body, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var document struct { + Servers map[string]any `json:"mcpServers"` + } + err = json.Unmarshal(body, &document) + return document.Servers, err +} diff --git a/install/integrationctl/agentplugins/planner/testdata/golden/compat_invalid_components.json b/install/integrationctl/agentplugins/planner/testdata/golden/compat_invalid_components.json index 6412776b4..8155c968a 100644 --- a/install/integrationctl/agentplugins/planner/testdata/golden/compat_invalid_components.json +++ b/install/integrationctl/agentplugins/planner/testdata/golden/compat_invalid_components.json @@ -345,7 +345,8 @@ }, "PackageMode": "native", "Scopes": [ - "user" + "user", + "project" ], "SkillSupport": "native" }, diff --git a/install/integrationctl/agentplugins/planner/testdata/golden/compat_remote_mcp_and_app.json b/install/integrationctl/agentplugins/planner/testdata/golden/compat_remote_mcp_and_app.json index b276273a0..e473a7b8d 100644 --- a/install/integrationctl/agentplugins/planner/testdata/golden/compat_remote_mcp_and_app.json +++ b/install/integrationctl/agentplugins/planner/testdata/golden/compat_remote_mcp_and_app.json @@ -521,7 +521,8 @@ }, "PackageMode": "native", "Scopes": [ - "user" + "user", + "project" ], "SkillSupport": "native" }, diff --git a/install/integrationctl/agentplugins/planner/testdata/golden/compat_skills_only.json b/install/integrationctl/agentplugins/planner/testdata/golden/compat_skills_only.json index 777369011..eb83623a5 100644 --- a/install/integrationctl/agentplugins/planner/testdata/golden/compat_skills_only.json +++ b/install/integrationctl/agentplugins/planner/testdata/golden/compat_skills_only.json @@ -313,7 +313,8 @@ }, "PackageMode": "native", "Scopes": [ - "user" + "user", + "project" ], "SkillSupport": "native" }, diff --git a/install/integrationctl/agentplugins/planner/testdata/golden/compat_stdio_mcp.json b/install/integrationctl/agentplugins/planner/testdata/golden/compat_stdio_mcp.json index 1fef4910e..6765212b4 100644 --- a/install/integrationctl/agentplugins/planner/testdata/golden/compat_stdio_mcp.json +++ b/install/integrationctl/agentplugins/planner/testdata/golden/compat_stdio_mcp.json @@ -373,7 +373,8 @@ }, "PackageMode": "native", "Scopes": [ - "user" + "user", + "project" ], "SkillSupport": "native" }, diff --git a/install/integrationctl/agentplugins/providers/project_kimi_admission_test.go b/install/integrationctl/agentplugins/providers/project_kimi_admission_test.go new file mode 100644 index 000000000..e96fd1ed4 --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_kimi_admission_test.go @@ -0,0 +1,95 @@ +package providers_test + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "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/domain" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/planner" +) + +func TestProjectKimiRequiresCurrentObservedHarnessAndNearestGit(t *testing.T) { + f := newKimiProjectFixture(t, false, false) + p := planner.Planner{Registry: f.registry, Paths: pathpolicy.Policy{}, ProjectRoot: f.project, ManagedRoot: filepath.Join(f.project, ".uap", "managed")} + qualified, err := p.Plan(context.Background(), domain.PlanRequest{Envelope: f.envelope, Client: f.request.Client, Scope: domain.ScopeProject, ProjectRoot: f.project, PhysicalArtifactID: f.request.Plan.PhysicalArtifactID}) + if err != nil || qualified.Status != domain.PlanReady { + t.Fatalf("current observed Kimi harness did not qualify: %+v, %v", qualified, err) + } + for _, change := range []func(*domain.DetectedClient){ + func(c *domain.DetectedClient) { c.Version = "" }, func(c *domain.DetectedClient) { c.Version = "2.3.0" }, func(c *domain.DetectedClient) { c.Version = "2.3.1-alpha" }, func(c *domain.DetectedClient) { c.ExecutablePath = filepath.Join(f.base, "kimi-cli") }, + } { + client := f.request.Client + change(&client) + before := projectGeminiInventory(t, f.base) + if _, err := p.Plan(context.Background(), domain.PlanRequest{Envelope: f.envelope, Client: client, Scope: domain.ScopeProject, ProjectRoot: f.project, PhysicalArtifactID: f.request.Plan.PhysicalArtifactID}); err == nil || !strings.Contains(err.Error(), "require the explicitly selected current Kimi Code 2.3.1 executable/version") { + t.Fatalf("unobserved current Kimi harness refusal = %v", err) + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, f.base)) { + t.Fatal("admission refusal created effects") + } + } + if err := os.Remove(filepath.Join(f.project, ".git")); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(f.base, ".git"), 0700); err != nil { + t.Fatal(err) + } + before := projectGeminiInventory(t, f.base) + if _, err := f.activator(nativeconfig.New()).Activate(context.Background(), f.request); err == nil || !strings.Contains(err.Error(), "differs from selected project") { + t.Fatalf("nearest Git ancestor mismatch = %v", err) + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, f.base)) { + t.Fatal("nearest Git refusal changed files") + } +} + +func TestProjectKimiExactRemovalDoesNotNeedCurrentVersionOrGit(t *testing.T) { + f := newKimiProjectFixture(t, false, false) + a := f.activator(nativeconfig.New()) + out, err := a.Activate(context.Background(), f.request) + kimiPrepared(t, out, err) + r := f.remove(out.NativeObjects) + r.Client.Version, r.Client.ExecutablePath = "", "" + if err := os.Remove(filepath.Join(f.project, ".git")); err != nil { + t.Fatal(err) + } + removed, err := a.Deactivate(context.Background(), r) + if err != nil || !removed.ExternalRemovalComplete { + t.Fatalf("exact receipt removal depended on native harness/discovery: %+v %v", removed, err) + } +} + +func TestProjectKimiSSEIsScopedAndLowerPrioritySourcesStayForeign(t *testing.T) { + f := newKimiProjectFixture(t, false, true) + userConfig := filepath.Join(f.user, "mcp.json") + const lower = `{"mcpServers":{"docs":{"transport":"http","url":"https://lower.test/mcp","enabled":false}}}` + projectGeminiWrite(t, userConfig, lower) + projectGeminiWrite(t, filepath.Join(f.project, ".mcp.json"), lower) + if err := os.RemoveAll(filepath.Join(f.source, "skills")); err != nil { + t.Fatal(err) + } + projectGeminiWrite(t, filepath.Join(f.source, "mcp.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"docs":{"type":"sse","url":"https://managed.test/events"}}}`) + f.stage(t, "sse-only") + if !strings.Contains(strings.Join(f.request.Plan.Warnings, " "), "overrides a same-name lower-priority") { + t.Fatal("project-over-user/compatibility policy was not exposed") + } + out, err := f.activator(nativeconfig.New()).Activate(context.Background(), f.request) + kimiPrepared(t, out, err) + body, err := os.ReadFile(f.config) + if err != nil || !strings.Contains(string(body), `"transport":"sse"`) { + t.Fatalf("project-only SSE transport = %s,%v", body, err) + } + projectGeminiAssert(t, userConfig, lower) + projectGeminiAssert(t, filepath.Join(f.project, ".mcp.json"), lower) + userPlanner := planner.Planner{Registry: f.registry, Paths: pathpolicy.Policy{}, ManagedRoot: filepath.Join(f.base, "user-managed")} + plan, err := userPlanner.Plan(context.Background(), domain.PlanRequest{Envelope: f.envelope, Client: f.request.Client, Scope: domain.ScopeUser, PhysicalArtifactID: "sse-user-0123456789ab"}) + if err != nil || len(plan.Components) != 1 || plan.Components[0].Support != domain.SupportUnsupported { + t.Fatalf("project SSE widened preserved user policy: %+v,%v", plan, err) + } +} diff --git a/install/integrationctl/agentplugins/providers/project_kimi_boundaries_test.go b/install/integrationctl/agentplugins/providers/project_kimi_boundaries_test.go new file mode 100644 index 000000000..7b5b96267 --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_kimi_boundaries_test.go @@ -0,0 +1,218 @@ +package providers_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "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/kimi" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/providers" +) + +func TestProjectKimiRefusesUnsafeConfigWithoutEffects(t *testing.T) { + for name, mutate := range map[string]func(*testing.T, *kimiProjectFixture) nativeconfig.Kernel{ + "malformed JSON": func(t *testing.T, f *kimiProjectFixture) nativeconfig.Kernel { + projectGeminiWrite(t, f.config, `{"mcpServers":{},}`) + return nativeconfig.New() + }, + "disabled foreign same-name": func(t *testing.T, f *kimiProjectFixture) nativeconfig.Kernel { + projectGeminiWrite(t, f.config, `{"mcpServers":{"docs":{"transport":"http","url":"https://foreign.test/mcp","enabled":false}}}`) + return nativeconfig.New() + }, + "foreign skill": func(t *testing.T, f *kimiProjectFixture) nativeconfig.Kernel { + projectGeminiWrite(t, filepath.Join(f.project, ".kimi-code", "skills", "docs", "SKILL.md"), "foreign claimed name\n") + return nativeconfig.New() + }, + "native writer unavailable": func(*testing.T, *kimiProjectFixture) nativeconfig.Kernel { + return nativeconfig.NewWithLockAcquirer(func(nativeconfig.Paths, nativeconfig.Codec) (func() error, error) { + return nil, errors.New("TEST native writer unavailable") + }) + }, + } { + t.Run(name, func(t *testing.T) { + f := newKimiProjectFixture(t, false, false) + kernel := mutate(t, f) + before := projectGeminiInventory(t, f.base) + if _, err := f.activator(kernel).Activate(context.Background(), f.request); err == nil { + t.Fatal("unsafe Kimi project activation succeeded") + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, f.base)) { + t.Fatal("refused Kimi activation left filesystem effects") + } + }) + } +} + +func TestProjectKimiPreparedCollisionAndExactBindingAuthority(t *testing.T) { + f := newKimiProjectFixture(t, false, false) + adapter := kimi.New() + kernel := nativeconfig.New() + observer := providers.NativeIdentityObserver{Registry: f.registry, NativeConfig: &kernel} + plan := f.request.Plan + plan.DeclaredName = "demo" + before := projectGeminiInventory(t, f.base) + finding, err := observer.ObservePreparedIdentity(context.Background(), f.request.Client, plan, nil) + if err != nil || finding.NativeDiscoveryAttempted { + t.Fatalf("read-only prepared inspection = %+v, %v", finding, err) + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, f.base)) { + t.Fatal("prepared inspection created state") + } + out, err := f.activator(nativeconfig.New()).Activate(context.Background(), f.request) + kimiPrepared(t, out, err) + plan.PreviousNativeObjects = out.NativeObjects + binding := domain.ClientBinding{ClientID: "kimi", Scope: "project", PhysicalArtifact: plan.PhysicalArtifactID, TargetLocator: plan.ActivePath, + NativeProfileRoot: filepath.Join(f.project, ".kimi-code"), NativeObjects: out.NativeObjects} + if err := adapter.ValidateBindingProfile(filepath.Join(f.project, ".kimi-code"), binding); err != nil { + t.Fatal(err) + } + copied := binding + copied.ClientID = "codex" + before = projectGeminiInventory(t, f.base) + if err := adapter.ValidateBindingProfile(binding.NativeProfileRoot, copied); err == nil || !strings.Contains(err.Error(), "recorded project client differs") { + t.Fatalf("direct project profile validator accepted another client: %v", err) + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, f.base)) { + t.Fatal("direct copied-client refusal changed bytes") + } + finding, err = observer.ObservePreparedIdentity(context.Background(), f.request.Client, plan, nil) + if err != nil || finding.State != domain.NativeIdentityUnmanaged { + t.Fatalf("unowned native components were adopted: %+v, %v", finding, err) + } + for _, change := range []func(*domain.ClientBinding){ + func(b *domain.ClientBinding) { b.Scope = "user" }, + func(b *domain.ClientBinding) { b.ClientID = "codex" }, + func(b *domain.ClientBinding) { b.PhysicalArtifact = "other" }, + func(b *domain.ClientBinding) { b.TargetLocator = filepath.Join(f.other, "artifact") }, + func(b *domain.ClientBinding) { b.NativeProfileRoot = f.other }, + } { + wrong := binding + change(&wrong) + if _, err := adapter.InspectNativeRegistry(context.Background(), clientsKimiEnv(), f.request.Client, plan, &wrong); err == nil { + t.Fatalf("mismatched binding accepted: %+v", wrong) + } + } +} + +func TestProjectKimiScopeAndAliasCannotReachUserOrOtherProject(t *testing.T) { + f := newKimiProjectFixture(t, false, false) + for _, change := range []func(*domain.ActivationRequest){ + func(r *domain.ActivationRequest) { r.Plan.Scope = domain.ScopeUser }, + func(r *domain.ActivationRequest) { r.Plan.NativeRegistryRoot = f.user }, + func(r *domain.ActivationRequest) { r.Plan.TargetContext.UserConfigRoot = f.other }, + } { + request := f.request + change(&request) + before := projectGeminiInventory(t, f.base) + if _, err := f.activator(nativeconfig.New()).Activate(context.Background(), request); err == nil { + t.Fatal("mismatched Kimi project context succeeded") + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, f.base)) { + t.Fatal("scope refusal changed bytes") + } + } + if err := os.Rename(filepath.Join(f.project, ".kimi-code"), filepath.Join(f.project, "github-preserved")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(f.other, ".kimi-code"), filepath.Join(f.project, ".kimi-code")); err != nil { + t.Skipf("test symlink unavailable: %v", err) + } + other := projectGeminiInventory(t, f.other) + if _, err := f.activator(nativeconfig.New()).Activate(context.Background(), f.request); err == nil { + t.Fatal("Kimi project followed an alias into B") + } + if !reflect.DeepEqual(other, projectGeminiInventory(t, f.other)) { + t.Fatal("alias refusal changed B") + } +} + +func TestProjectKimiStaleOwnedNativeMemberCannotBeRemovedOrOverwritten(t *testing.T) { + f := newKimiProjectFixture(t, false, false) + a := f.activator(nativeconfig.New()) + out, err := a.Activate(context.Background(), f.request) + kimiPrepared(t, out, err) + projectGeminiWrite(t, f.config, `{"mcpServers":{"docs":{"transport":"http","url":"https://external.test/changed","enabled":false},"foreign":{"transport":"http","url":"https://foreign.test/mcp"}}}`) + before := projectGeminiInventory(t, f.base) + f.request.PreviousNativeObjects = out.NativeObjects + if _, err := a.Activate(context.Background(), f.request); err == nil { + t.Fatal("Kimi overwrote a stale owned MCP member") + } + if _, err := a.Deactivate(context.Background(), f.remove(out.NativeObjects)); err == nil { + t.Fatal("Kimi removed a stale owned MCP member") + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, f.base)) { + t.Fatal("stale receipt refusal changed native or package bytes") + } +} + +func TestProjectKimiRejectsReboundPhysicalArtifactBeforeEffects(t *testing.T) { + for _, mismatch := range []string{"in-project-package", "physical-artifact"} { + t.Run(mismatch, func(t *testing.T) { + f := newKimiProjectFixture(t, false, false) + request := f.request + if mismatch == "in-project-package" { + request.Plan.ActivePath = filepath.Join(f.project, "other-package") + request.Delivery.ActivePath = request.Plan.ActivePath + projectGeminiWrite(t, filepath.Join(request.Plan.ActivePath, "keep.txt"), "other project child\n") + } else { + request.Plan.PhysicalArtifactID = "another-artifact" + } + before := projectGeminiInventory(t, f.base) + adapter := kimi.New() + operations := map[string]func() error{ + "composed activation": func() error { + _, err := f.activator(nativeconfig.New()).Activate(context.Background(), request) + return err + }, + "direct activation": func() error { + _, err := adapter.Activate(context.Background(), clientsKimiEnv(), request) + return err + }, + "direct projection": func() error { + _, err := adapter.Project(context.Background(), clients.ProjectionInput{StagingPath: request.Delivery.ActivePath, Plan: request.Plan, Envelope: f.envelope}) + return err + }, + "direct active reuse": func() error { + _, err := adapter.ProjectActiveNative(context.Background(), request.Plan.ActivePath, f.envelope, request.Plan, "") + return err + }, + } + for name, operation := range operations { + if err := operation(); err == nil || !strings.Contains(err.Error(), "managed package differs") { + t.Fatalf("%s accepted rebound %s: %v", name, mismatch, err) + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, f.base)) { + t.Fatalf("%s changed native/package/user/B bytes", name) + } + } + }) + } +} + +func TestProjectKimiDirectActivationRequiresExactDeliveredArtifact(t *testing.T) { + f := newKimiProjectFixture(t, false, false) + other := filepath.Join(f.project, "other-delivered-package") + projectGeminiWrite(t, filepath.Join(other, "keep.txt"), "foreign in-project package\n") + before := projectGeminiInventory(t, f.base) + adapter := kimi.New() + for _, path := range []string{other, ""} { + request := f.request + request.Delivery.ActivePath = path + if err := adapter.PreflightActivation(clientsKimiEnv(), request); (path == "") != (err == nil) { + t.Fatalf("pre-staging empty delivery/project mismatch admission %q = %v", path, err) + } + if _, err := adapter.Activate(context.Background(), clientsKimiEnv(), request); err == nil || !strings.Contains(err.Error(), "delivery differs") { + t.Fatalf("direct activation accepted delivery-only mismatch %q: %v", path, err) + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, f.base)) { + t.Fatal("delivery-only mismatch changed project/native/user/B bytes") + } + } +} diff --git a/install/integrationctl/agentplugins/providers/project_kimi_fixture_test.go b/install/integrationctl/agentplugins/providers/project_kimi_fixture_test.go new file mode 100644 index 000000000..468a8bc10 --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_kimi_fixture_test.go @@ -0,0 +1,130 @@ +package providers_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/adapters/pathpolicy" + "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/specregistry" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/kimi" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/managedstdio" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/planner" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/providers" +) + +const kimiForeignConfig = `{"mcpServers":{"foreign":{"transport":"http","url":"https://foreign.test/mcp","enabled":false}},"preference":"keep"}` +const kimiSkill = "---\nname: docs\ndescription: Inert Kimi project fixture\n---\nKIMI-PROJECT-ONE\n" + +type kimiProjectFixture struct { + base, project, user, other, source, data string + config string + request domain.ActivationRequest + envelope domain.PackageEnvelope + registry *clients.Registry + stager providers.Stager +} + +func newKimiProjectFixture(t *testing.T, stdio, rootConfig bool) *kimiProjectFixture { + t.Helper() + f := &kimiProjectFixture{base: projectGeminiTemp(t)} + f.project, f.user, f.other, f.source = filepath.Join(f.base, "A"), filepath.Join(f.base, "user"), filepath.Join(f.base, "B"), filepath.Join(f.base, "source") + for _, root := range []string{f.project, f.user, f.other} { + native := filepath.Join(root, ".kimi-code") + if root == f.user { + native = f.user + } + projectGeminiWrite(t, filepath.Join(native, "mcp.json"), kimiForeignConfig) + projectGeminiWrite(t, filepath.Join(native, "skills", "foreign", "SKILL.md"), "foreign skill\n") + if root != f.user { + if err := os.Mkdir(filepath.Join(root, ".git"), 0700); err != nil { + t.Fatal(err) + } + } + } + f.config = filepath.Join(f.project, ".kimi-code", "mcp.json") + if rootConfig { + projectGeminiWrite(t, filepath.Join(f.project, ".mcp.json"), kimiForeignConfig) + } + var err error + f.registry, err = clients.NewRegistry(kimi.New()) + if err != nil { + t.Fatal(err) + } + f.request.Client = domain.DetectedClient{ClientID: domain.ClientKimi, ConfigRoot: f.user, ExecutablePath: filepath.Join(f.base, "kimi"), Version: "2.3.1", Status: domain.DetectionDetected} + f.stager = providers.Stager{Registry: f.registry, Paths: pathpolicy.Policy{}} + f.data = filepath.Join(f.project, ".uap", "data", "kimi-exact-receipt") + projectGeminiWrite(t, filepath.Join(f.data, "keep.txt"), "persistent data\n") + projectGeminiWrite(t, filepath.Join(f.source, "plugin.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"demo","version":"1.0.0"}`) + projectGeminiWrite(t, filepath.Join(f.source, "skills", "docs", "SKILL.md"), kimiSkill) + mcp := `{"type":"streamable-http","url":"https://managed.test/one"}` + if stdio { + mcp = `{"type":"stdio","command":"node","args":["${PLUGIN_ROOT}/server.js","${PLUGIN_DATA}"],"cwd":"${PLUGIN_ROOT}","env":{"MARKER":"${PLUGIN_DATA}/marker"}}` + projectGeminiWrite(t, filepath.Join(f.source, "server.js"), "// Inert; never executed.\n") + launcher := filepath.Join(f.base, "TEST-inert-launcher") + if err := os.WriteFile(launcher, []byte("TEST trusted launcher bytes; never executed"), 0700); err != nil { + t.Fatal(err) + } + f.stager.LauncherSource, err = managedstdio.NewSource(launcher, "fixture") + if err != nil { + t.Fatal(err) + } + } + projectGeminiWrite(t, filepath.Join(f.source, "mcp.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"docs":`+mcp+`}}`) + f.stage(t, "initial") + return f +} + +func (f *kimiProjectFixture) stage(t *testing.T, operation string) { + t.Helper() + schemas, err := specregistry.New() + if err != nil { + t.Fatal(err) + } + f.envelope, err = (loader.Loader{Registry: schemas}).Load(context.Background(), domain.LoadInput{SnapshotRoot: f.source}) + if err != nil { + t.Fatal(err) + } + planning := planner.Planner{ProjectRoot: f.project, ManagedRoot: filepath.Join(f.project, ".uap", "managed"), Registry: f.registry, Paths: pathpolicy.Policy{}} + plan, err := planning.Plan(context.Background(), domain.PlanRequest{Envelope: f.envelope, Client: f.request.Client, Scope: domain.ScopeProject, ProjectRoot: f.project, PhysicalArtifactID: "demo-0123456789ab"}) + if err != nil || plan.Status != domain.PlanReady || plan.NativeRegistryRoot != filepath.Join(f.project, ".kimi-code") || plan.Activation != domain.ActivationPrepared { + t.Fatalf("Kimi repository plan = %+v, %v", plan, err) + } + delivery, err := f.stager.StageWithPluginData(context.Background(), f.envelope, plan, operation, domain.CompatibilityHints{}, f.data) + if err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(plan.ActivePath); err == nil { + if err := os.Rename(plan.ActivePath, plan.ActivePath+"-"+operation+"-old"); err != nil { + t.Fatal(err) + } + } else if !os.IsNotExist(err) { + t.Fatal(err) + } + if err := os.Rename(delivery.StagingPath, plan.ActivePath); err != nil { + t.Fatal(err) + } + delivery.StagingPath = "" + f.request.Plan, f.request.Delivery, f.request.DeclaredName = plan, delivery, "demo" +} + +func (f *kimiProjectFixture) activator(kernel nativeconfig.Kernel) providers.Activator { + return providers.Activator{Registry: f.registry, NativeConfig: &kernel} +} + +func (f *kimiProjectFixture) remove(objects []domain.NativeObjectOwnership) domain.DeactivationRequest { + return domain.DeactivationRequest{Client: f.request.Client, TargetContext: f.request.Plan.TargetContext, + NativeRegistryRoot: filepath.Join(f.project, ".kimi-code"), PhysicalArtifactID: f.request.Plan.PhysicalArtifactID, ManagedArtifactPath: f.request.Plan.ActivePath, Confirmed: true, NativeObjects: objects} +} + +func kimiPrepared(t *testing.T, outcome domain.ActivationOutcome, err error) { + t.Helper() + if err != nil || outcome.Activation != domain.ActivationPrepared || outcome.Verification != domain.VerificationInstalled || outcome.ActivationAttested { + t.Fatalf("Kimi configured state = %+v, %v", outcome, err) + } +} diff --git a/install/integrationctl/agentplugins/providers/project_kimi_lifecycle_test.go b/install/integrationctl/agentplugins/providers/project_kimi_lifecycle_test.go new file mode 100644 index 000000000..1a38f9d6e --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_kimi_lifecycle_test.go @@ -0,0 +1,102 @@ +package providers_test + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +// Real loader/planner/stager/native kernel; no Kimi or server process runs. +func TestProjectKimiComposedLifecycleSelectsEffectiveConfig(t *testing.T) { + for _, rootConfig := range []bool{false, true} { + t.Run(map[bool]string{false: "github-fallback", true: "root-precedence"}[rootConfig], func(t *testing.T) { + f := newKimiProjectFixture(t, false, rootConfig) + user, other := projectGeminiInventory(t, f.user), projectGeminiInventory(t, f.other) + activator := f.activator(nativeconfig.New()) + outcome, err := activator.Activate(context.Background(), f.request) + kimiPrepared(t, outcome, err) + projectGeminiAssert(t, filepath.Join(f.project, ".kimi-code", "skills", "docs", "SKILL.md"), kimiSkill) + config, err := os.ReadFile(f.config) + if err != nil || !strings.Contains(string(config), "managed.test/one") || !strings.Contains(string(config), `"enabled":false`) || !strings.Contains(string(config), `"transport":"http"`) { + t.Fatalf("Kimi config lost transport or foreign settings: %s, %v", config, err) + } + if rootConfig { + projectGeminiAssert(t, filepath.Join(f.project, ".mcp.json"), kimiForeignConfig) + } + f.request.PreviousNativeObjects = outcome.NativeObjects + for _, verify := range []bool{false, true} { + f.request.VerifyOnly = verify + outcome, err = activator.Activate(context.Background(), f.request) + kimiPrepared(t, outcome, err) + } + for range 2 { + removed, err := activator.Deactivate(context.Background(), f.remove(outcome.NativeObjects)) + if err != nil || !removed.ExternalRemovalComplete { + t.Fatalf("exact repeated Kimi remove = %+v, %v", removed, err) + } + } + if _, err := os.Stat(filepath.Join(f.project, ".kimi-code", "skills", "docs")); !os.IsNotExist(err) { + t.Fatalf("Kimi managed skill survived: %v", err) + } + remaining, err := os.ReadFile(f.config) + if err != nil || strings.Contains(string(remaining), "managed.test") || !strings.Contains(string(remaining), "foreign.test") { + t.Fatalf("Kimi removal changed foreign MCP data: %s, %v", remaining, err) + } + projectGeminiAssert(t, filepath.Join(f.data, "keep.txt"), "persistent data\n") + if !reflect.DeepEqual(user, projectGeminiInventory(t, f.user)) || !reflect.DeepEqual(other, projectGeminiInventory(t, f.other)) { + t.Fatal("Kimi project lifecycle mutated user or B") + } + }) + } +} + +func TestProjectKimiUpdatesRepairsAndDropsMCP(t *testing.T) { + f := newKimiProjectFixture(t, false, false) + a := f.activator(nativeconfig.New()) + out, err := a.Activate(context.Background(), f.request) + kimiPrepared(t, out, err) + f.request.PreviousNativeObjects = out.NativeObjects + projectGeminiWrite(t, filepath.Join(f.source, "skills", "docs", "SKILL.md"), strings.Replace(kimiSkill, "ONE", "TWO", 1)) + projectGeminiWrite(t, filepath.Join(f.source, "mcp.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"docs":{"type":"sse","url":"https://managed.test/two"}}}`) + f.stage(t, "update") + out, err = a.Activate(context.Background(), f.request) + kimiPrepared(t, out, err) + skill := filepath.Join(f.project, ".kimi-code", "skills", "docs") + if err := os.RemoveAll(skill); err != nil { + t.Fatal(err) + } + f.request.PreviousNativeObjects = out.NativeObjects + out, err = a.Activate(context.Background(), f.request) + kimiPrepared(t, out, err) + projectGeminiAssert(t, filepath.Join(skill, "SKILL.md"), strings.Replace(kimiSkill, "ONE", "TWO", 1)) + projectGeminiWrite(t, filepath.Join(f.source, "mcp.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{}}`) + f.request.PreviousNativeObjects = out.NativeObjects + f.stage(t, "drop-mcp") + out, err = a.Activate(context.Background(), f.request) + kimiPrepared(t, out, err) + body, err := os.ReadFile(f.config) + if err != nil || strings.Contains(string(body), "managed.test") || !strings.Contains(string(body), "foreign.test") { + t.Fatalf("dropped MCP/foreign config = %s, %v", body, err) + } +} + +func TestProjectKimiRejectsAttestationBeforeAutomaticFallback(t *testing.T) { + f := newKimiProjectFixture(t, false, false) + before := projectGeminiInventory(t, f.base) + f.request.ActivationComplete = true + for _, support := range []domain.SupportLevel{domain.SupportNative, domain.SupportPrepared} { + f.request.Plan.Components[0].Support = support + if _, err := f.activator(nativeconfig.New()).Activate(context.Background(), f.request); err == nil || !strings.Contains(err.Error(), "cannot be established by activation attestation") { + t.Fatalf("Kimi project attestation bypass: %v", err) + } + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, f.base)) { + t.Fatal("Kimi attestation created effects") + } +} diff --git a/install/integrationctl/agentplugins/providers/project_kimi_stdio_test.go b/install/integrationctl/agentplugins/providers/project_kimi_stdio_test.go new file mode 100644 index 000000000..d8060d674 --- /dev/null +++ b/install/integrationctl/agentplugins/providers/project_kimi_stdio_test.go @@ -0,0 +1,54 @@ +package providers_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "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" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/managedstdio" +) + +func clientsKimiEnv() clients.Env { return clients.Env{NativeConfig: nativeconfig.New()} } + +func TestProjectKimiStdioBindsImmutablePackageAndSelectedData(t *testing.T) { + if !managedstdio.Supported() { + t.Skip("managed stdio platform unsupported") + } + f := newKimiProjectFixture(t, true, true) + out, err := f.activator(nativeconfig.New()).Activate(context.Background(), f.request) + kimiPrepared(t, out, err) + body, err := os.ReadFile(f.config) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`"transport":"stdio"`, `"PLUGIN_DATA"`, "stdio-v1"} { + if !strings.Contains(string(body), want) { + t.Fatalf("Kimi launcher contract omitted %s: %s", want, body) + } + } + active := f.request.Plan.ActivePath + projectGeminiWrite(t, filepath.Join(f.other, "data", "marker"), "other data\n") + before := projectGeminiInventory(t, f.base) + objects, err := f.stager.ProjectActiveNative(context.Background(), f.envelope, f.request.Plan, f.request.Delivery.ArtifactDigest, f.data) + if err != nil || len(objects) != 2 { + t.Fatalf("correct receipt projection = %+v, %v", objects, err) + } + for _, selected := range []string{"", "relative", filepath.Join(f.other, "data")} { + if _, err := f.stager.ProjectActiveNative(context.Background(), f.envelope, f.request.Plan, f.request.Delivery.ArtifactDigest, selected); !errors.Is(err, domain.ErrProjectionRefreshRequired) { + t.Fatalf("wrong selected stdio data %q = %v", selected, err) + } + } + if !reflect.DeepEqual(before, projectGeminiInventory(t, f.base)) { + t.Fatal("active receipt inspection rewrote immutable package, native config, user or B") + } + if _, err := os.Stat(filepath.Join(active, filepath.FromSlash(managedstdio.RelativeDirectory), managedstdio.ExecutableName)); err != nil { + t.Fatal(err) + } +}