Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions install/integrationctl/agentplugins/clients/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ type PreparedRegistryInspector interface {
type SelectionLayout struct {
File string
Nested bool
// ServersMemberOptional marks a selection document that declares its
// "mcpServers" member only when servers were selected: a projected plugin
// manifest. For those, a verified document without the member is a proven
// empty selection. Dedicated selection documents keep failing closed on a
// missing member, because unrecognizable shape is never evidence.
ServersMemberOptional bool
}

// DefaultSelectionLayout is the managed MCP selection document every client
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ func (*Adapter) RefinePlan(_ context.Context, in clients.PlanInput, plan *domain
return nil
}
func (*Adapter) ManagedMCPSelection() clients.SelectionLayout {
return clients.SelectionLayout{File: filepath.Join(".kimi-plugin", "plugin.json"), Nested: true}
// The projected manifest is the Kimi MCP selection document and declares
// "mcpServers" only when servers were selected.
return clients.SelectionLayout{File: filepath.Join(".kimi-plugin", "plugin.json"), Nested: true, ServersMemberOptional: true}
}

const reloadAction = "Run /reload or /new in Kimi Code to load the installed plugin; runtime MCP connections and authentication have not been verified."
16 changes: 16 additions & 0 deletions install/integrationctl/agentplugins/clients/kimi/kimi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,3 +320,19 @@ func TestActivationReportsNativeEffectCompletion(t *testing.T) {
t.Fatalf("possible write failure lost uncertainty: %+v %v", out, err)
}
}

func TestProjectActiveNativeRevalidatesManifestIdentityWithoutWriting(t *testing.T) {
r := fixture(t)
root := r.Delivery.ActivePath
objects, err := New().ProjectActiveNative(context.Background(), root, domain.PackageEnvelope{}, r.Plan, "")
if err != nil {
t.Fatalf("read-only native projection: %v", err)
}
if len(objects) != 0 {
t.Fatalf("kimi owns nothing outside the managed package directory: %+v", objects)
}
write(t, filepath.Join(root, ".kimi-plugin", "plugin.json"), `{"name":"other"}`)
if _, err := New().ProjectActiveNative(context.Background(), root, domain.PackageEnvelope{}, r.Plan, ""); err == nil || !strings.Contains(err.Error(), "identity mismatch") {
t.Fatalf("manifest identity drift must fail the read-only projection: %v", err)
}
}
16 changes: 16 additions & 0 deletions install/integrationctl/agentplugins/clients/kimi/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,22 @@ import (
"github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain"
)

var _ clients.ActiveNativeProjector = (*Adapter)(nil)

// ProjectActiveNative revalidates the committed Kimi projection without
// 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) {
if err := ctx.Err(); err != nil {
return nil, err
}
if err := verifyManifest(root, plan.DeclaredName); err != nil {
return nil, err
}
return nil, nil
}

func (*Adapter) Project(ctx context.Context, in clients.ProjectionInput) ([]domain.NativeObjectOwnership, error) {
if err := ctx.Err(); err != nil {
return nil, err
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,42 +26,64 @@ func (stager Stager) ManagedMCPNames(ctx context.Context, client domain.ClientID
if reader, ok := clients.As[clients.SelectionReader](stager.Registry, client); ok {
layout = reader.ManagedMCPSelection()
}
names, err := readSelectionNames(root, layout)
if err != nil {
return nil, err
}
if err := stager.Verify(ctx, root, digest); err != nil {
return nil, err
}
sort.Strings(names)
return names, nil
}

func readSelectionNames(root string, layout clients.SelectionLayout) ([]string, error) {
candidate := filepath.Join(root, layout.File)
info, statErr := os.Lstat(candidate)
if statErr != nil && !os.IsNotExist(statErr) {
return nil, statErr
}
if statErr == nil && !info.Mode().IsRegular() {
if statErr != nil {
if !os.IsNotExist(statErr) {
return nil, statErr
}
} else if !info.Mode().IsRegular() {
return nil, fmt.Errorf("managed MCP selection is not a regular file")
} else if body, err := os.ReadFile(candidate); err != nil {
if !os.IsNotExist(err) {
return nil, err
}
} else {
return selectionServerNames(body, layout)
}
body, err := os.ReadFile(candidate)
names := []string{}
if err != nil && !os.IsNotExist(err) {
return nil, err
return []string{}, nil
}

func selectionServerNames(body []byte, layout clients.SelectionLayout) ([]string, error) {
var document map[string]json.RawMessage
if err := json.Unmarshal(body, &document); err != nil {
return nil, fmt.Errorf("managed MCP selection is invalid: %w", err)
}
if err == nil {
var document map[string]json.RawMessage
if err := json.Unmarshal(body, &document); err != nil || document == nil {
return nil, fmt.Errorf("managed MCP selection is invalid: %v", err)
}
servers := document
if layout.Nested {
raw, ok := document["mcpServers"]
if !ok {
if document == nil {
return nil, fmt.Errorf("managed MCP selection is invalid: expected an object")
}
servers := document
if layout.Nested {
raw, ok := document["mcpServers"]
if !ok {
if !layout.ServersMemberOptional {
return nil, fmt.Errorf("managed MCP selection lacks mcpServers")
}
servers = nil
if err := json.Unmarshal(raw, &servers); err != nil || servers == nil {
return nil, fmt.Errorf("managed MCP server selection is invalid: %v", err)
}
return []string{}, nil
}
servers = nil
if err := json.Unmarshal(raw, &servers); err != nil {
return nil, fmt.Errorf("managed MCP server selection is invalid: %w", err)
}
for name := range servers {
names = append(names, name)
if servers == nil {
return nil, fmt.Errorf("managed MCP server selection is invalid: expected an object")
}
}
if err := stager.Verify(ctx, root, digest); err != nil {
return nil, err
names := []string{}
for name := range servers {
names = append(names, name)
}
sort.Strings(names)
return names, nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
)

func TestManagedMCPSelectionRequiresExactArtifact(t *testing.T) {
for _, client := range []domain.ClientID{domain.ClientCursor, domain.ClientClaude, domain.ClientCodex, domain.ClientChatGPT} {
for _, client := range []domain.ClientID{domain.ClientCursor, domain.ClientClaude, domain.ClientCodex, domain.ClientChatGPT, domain.ClientKimi} {
t.Run(string(client), func(t *testing.T) {
root := t.TempDir()
filename := "mcp.json"
Expand All @@ -24,6 +24,13 @@ func TestManagedMCPSelectionRequiresExactArtifact(t *testing.T) {
if client == domain.ClientClaude {
body = `{"z":{},"a":{}}`
}
if client == domain.ClientKimi {
filename = filepath.Join(".kimi-plugin", "plugin.json")
body = `{"name":"demo","mcpServers":{"z":{},"a":{}}}`
}
if err := os.MkdirAll(filepath.Dir(filepath.Join(root, filename)), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, filename), []byte(body), 0600); err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -61,3 +68,42 @@ func TestVerifiedAbsentMCPSelectionIsKnownEmpty(t *testing.T) {
t.Fatalf("names=%v err=%v", names, err)
}
}

func TestManifestSelectionWithoutServersMemberIsProvenEmpty(t *testing.T) {
// The projected Kimi manifest declares "mcpServers" only when servers were
// selected, so a verified skills-only manifest records an empty selection.
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, ".kimi-plugin"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, ".kimi-plugin", "plugin.json"),
[]byte(`{"name":"demo","skills":"./skills/"}`), 0600); err != nil {
t.Fatal(err)
}
stager := testStager(Stager{})
var mismatch *ports.VerificationError
if err := stager.Verify(context.Background(), root, "observe"); !errors.As(err, &mismatch) {
t.Fatal(err)
}
names, err := stager.ManagedMCPNames(context.Background(), domain.ClientKimi, root, mismatch.ActualDigest)
if err != nil || names == nil || len(names) != 0 {
t.Fatalf("names=%v err=%v", names, err)
}
}

func TestDedicatedSelectionStillFailsClosedWithoutServersMember(t *testing.T) {
// A dedicated selection file without its member is unrecognizable shape,
// never a proven empty selection.
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "mcp.json"), []byte(`{"servers":{}}`), 0600); err != nil {
t.Fatal(err)
}
stager := testStager(Stager{})
var mismatch *ports.VerificationError
if err := stager.Verify(context.Background(), root, "observe"); !errors.As(err, &mismatch) {
t.Fatal(err)
}
if _, err := stager.ManagedMCPNames(context.Background(), domain.ClientCursor, root, mismatch.ActualDigest); err == nil {
t.Fatal("unrecognized nested selection shape trusted")
}
}
13 changes: 11 additions & 2 deletions install/integrationctl/agentplugins/usecase/group_activate.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ func (session *groupSession) activateOneGroupTarget(plannedIndex int, target pla
if lifecycleChanged {
session.result.Mutated = true
}
if !nativeComplete {
if !nativeComplete && activationErr == nil {
// An incomplete projection that re-activated successfully changed the
// target. A failure before or during activation claims no mutation; the
// recorded lifecycle change, if any, is the evidence.
target.noChange = false
}
session.assignActivationOutcome(target, outcome, activationErr, persistErr, lifecycleChanged)
Expand Down Expand Up @@ -87,7 +90,13 @@ func (session *groupSession) activateGroupDelivery(target plannedGroupTarget) (d
var err error
delivery, nativeComplete, err = session.service.activeNativeDelivery(session.ctx, target.input, target.plan, session.state.Installations[session.installationIndex], *target.managed)
if err != nil {
return domain.ActivationOutcome{}, false, err
// A projection failure happens before the adapter can run. Keep the
// recorded lifecycle and acknowledged objects instead of persisting
// an empty outcome, so the failure reports the projection error
// rather than a state refusal.
outcome := lifecycleOutcome(*target.managed)
outcome.NativeEffect = domain.NativeEffectUnchanged
return outcome, false, err
}
}
previous := []domain.NativeObjectOwnership(nil)
Expand Down
91 changes: 91 additions & 0 deletions install/integrationctl/agentplugins/usecase/group_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import (
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"

"github.com/777genius/plugin-kit-ai/install/integrationctl/adapters/dirswap"
"github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain"
"github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/ports"
"github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/transaction"
)

Expand Down Expand Up @@ -1381,3 +1383,92 @@ func (observer *countingNativeObserver) ObservePreparedIdentity(_ context.Contex
}
return domain.NativeIdentityObservation{State: domain.NativeIdentityManaged, Digest: managedDigest(*managed)}, nil
}

// hidesReadOnlyProjection wraps a stager so it can no longer derive the
// installed native projection read-only, modeling the capability gap that a
// projection failure exercises before any adapter runs.
type hidesReadOnlyProjection struct{ ports.PackageStager }

func kimiGroupInput(t *testing.T) AddInput {
t.Helper()
kimi := domain.DetectedClient{ClientID: domain.ClientKimi, Status: domain.DetectionDetected, ConfigRoot: filepath.Join(t.TempDir(), ".kimi-code")}
// A real Kimi package carries at least one native component; an empty
// selection would resolve to manual activation and never touch the registry.
input := kiroPowerInput(t, kimi, "1.0.0", "sha256:kimi-tree", "sha256:kimi-manifest")
input.Confirmed = true
return input
}

// A no-change Kimi update must rebuild and confirm the desired native
// projection read-only from the committed package instead of re-activating.
func TestGroupedKimiNoChangeUpdateRunsReadOnlyNativeProjection(t *testing.T) {
t.Parallel()
service, store, _ := serviceFixture(t)
input := kimiGroupInput(t)
if _, err := service.AddGroup(context.Background(), GroupInput{Targets: []AddInput{input}, Confirmed: true, OperationGroupID: "kimi-unchanged-add"}); err != nil {
t.Fatal(err)
}
before, err := store.Load()
if err != nil {
t.Fatal(err)
}
result, err := service.UpdateGroup(context.Background(), GroupInput{
Targets: []AddInput{input}, CompatibilityChecks: []AddInput{input}, Confirmed: true, OperationGroupID: "kimi-unchanged-update",
})
if err != nil {
t.Fatalf("kimi no-change update: %v", err)
}
if result.Mutated || len(result.Receipts) != 0 || result.Phase != GroupPhaseCompleted {
t.Fatalf("kimi no-change update = %+v", result)
}
if len(result.Targets) != 1 || !result.Targets[0].NoChange || result.Targets[0].GroupPhase != GroupTargetExternalCompleted {
t.Fatalf("kimi no-change target = %+v", result.Targets)
}
after, err := store.Load()
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(before, after) {
t.Fatalf("kimi no-change update rewrote state: %+v", after)
}
}

// A projection failure happens before any activation effect. The group flow
// must report the projection error and keep the recorded lifecycle instead of
// persisting an empty outcome that the state store then refuses.
func TestGroupedProjectionFailurePreservesRecordedLifecycle(t *testing.T) {
t.Parallel()
service, store, _ := serviceFixture(t)
input := kimiGroupInput(t)
if _, err := service.AddGroup(context.Background(), GroupInput{Targets: []AddInput{input}, Confirmed: true, OperationGroupID: "kimi-projection-add"}); err != nil {
t.Fatal(err)
}
before, err := store.Load()
if err != nil {
t.Fatal(err)
}
service.Stager = hidesReadOnlyProjection{PackageStager: service.Stager}
result, err := service.UpdateGroup(context.Background(), GroupInput{
Targets: []AddInput{input}, CompatibilityChecks: []AddInput{input}, Confirmed: true, OperationGroupID: "kimi-projection-update",
})
if err == nil || !strings.Contains(err.Error(), "stager cannot derive the installed native projection") {
t.Fatalf("projection failure must surface the projection error: %+v %v", result, err)
}
if strings.Contains(err.Error(), "refuse invalid state") {
t.Fatalf("projection failure was masked by a state refusal: %v", err)
}
if len(result.Targets) != 1 || result.Targets[0].Failure == nil || result.Targets[0].Failure.Stage == "persist" {
t.Fatalf("projection failure stage = %+v", result.Targets)
}
// Nothing ran, so the failed no-change attempt must not claim a mutation.
if result.Mutated || result.Targets[0].Mutated || !result.Targets[0].NoChange {
t.Fatalf("failed projection claimed a mutation: %+v", result.Targets[0])
}
after, err := store.Load()
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(before, after) {
t.Fatalf("projection failure rewrote the recorded lifecycle: %+v", after)
}
}
Loading