From 8f1ba2703fcb7dd787ad230a3bf00deea0dcf1d6 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 19 Oct 2026 13:08:28 +0300 Subject: [PATCH 01/13] feat(qoder): support remote streamable-http MCP with OpenAI auth hints Qoder's plugin contract is an OpenAI-shaped .mcp.json that already carries remote MCP and OAuth auth hints through the projection, but the client definition withheld remote transports (stdio-only). Lift the restriction and enable HonorsOpenAIMCPAuthHints so HTTP/OAuth MCP servers such as Context7 can be projected and authenticated on Qoder. Regenerate the planner goldens and the landing client capability matrix to record Qoder's streamable-http support. Validation: domain, planner, clients/qoder, and genclientcapabilities tests. --- .../agentplugins/domain/clients.go | 18 ++++++------------ .../agentplugins/domain/clients_test.go | 8 ++++++-- .../golden/compat_invalid_components.json | 2 +- .../golden/compat_remote_mcp_and_app.json | 8 +++----- .../testdata/golden/compat_skills_only.json | 2 +- .../testdata/golden/compat_stdio_mcp.json | 2 +- .../golden/plan_remote_mcp_and_app.json | 4 ++-- landing/data/clientCapabilities.generated.json | 2 +- 8 files changed, 21 insertions(+), 25 deletions(-) diff --git a/install/integrationctl/agentplugins/domain/clients.go b/install/integrationctl/agentplugins/domain/clients.go index 55236f647..d36d8549d 100644 --- a/install/integrationctl/agentplugins/domain/clients.go +++ b/install/integrationctl/agentplugins/domain/clients.go @@ -157,11 +157,12 @@ var clientDefinitions = []ClientDefinition{ InstallIntents: []InstallIntent{InstallIntentAutomatic}, LifecycleKind: LifecycleNativeConfig, }), - stdioOnlyMCP(clientDefinition(ClientQoder, "Qoder", "qoder", "managed", "native", false, PackageNative, SupportNative, SupportNative, SupportUnsupported, SupportUnsupported, SupportUnsupported, ClientTraits{ - InstallIntents: []InstallIntent{InstallIntentAutomatic}, - LifecycleKind: LifecycleCLIRegistry, - DiscoversNativeHooks: true, - })), + clientDefinition(ClientQoder, "Qoder", "qoder", "managed", "native", false, PackageNative, SupportNative, SupportNative, SupportUnsupported, SupportUnsupported, SupportUnsupported, ClientTraits{ + InstallIntents: []InstallIntent{InstallIntentAutomatic}, + LifecycleKind: LifecycleCLIRegistry, + DiscoversNativeHooks: true, + HonorsOpenAIMCPAuthHints: true, + }), } func withActivation(definition ClientDefinition, activation ActivationMode) ClientDefinition { @@ -179,13 +180,6 @@ func withoutCommands(definition ClientDefinition) ClientDefinition { return definition } -// stdioOnlyMCP withholds remote MCP transports from a client whose native -// plugin contract only carries local stdio servers. -func stdioOnlyMCP(definition ClientDefinition) ClientDefinition { - definition.Capabilities.MCPTransports["streamable-http"] = SupportUnsupported - return definition -} - func withoutHostPresence(definition ClientDefinition) ClientDefinition { definition.PlansWithoutHostPresence = true return definition diff --git a/install/integrationctl/agentplugins/domain/clients_test.go b/install/integrationctl/agentplugins/domain/clients_test.go index 004d242ee..27fcdd9cd 100644 --- a/install/integrationctl/agentplugins/domain/clients_test.go +++ b/install/integrationctl/agentplugins/domain/clients_test.go @@ -60,7 +60,8 @@ func TestClientRegistryHasStableOrderAndSharedCopilotBackend(t *testing.T) { } // TestQoderClientDefinition pins Qoder's single identity shared by Desktop and -// CLI: native plugin bundles, stdio-only MCP, and the single user install scope +// CLI: native plugin bundles, stdio and remote (streamable-http) MCP with +// OpenAI-shaped auth hints, and the single user install scope // the agentplugins lifecycle actually installs and verifies // (`qoder plugins install --scope user`). func TestQoderClientDefinition(t *testing.T) { @@ -75,7 +76,7 @@ func TestQoderClientDefinition(t *testing.T) { if capabilities.PackageMode != PackageNative || capabilities.SkillSupport != SupportNative { t.Fatalf("Qoder native skill capability = %+v", capabilities) } - if capabilities.MCPTransports["stdio"] != SupportNative || capabilities.MCPTransports["streamable-http"] != SupportUnsupported || capabilities.MCPTransports["sse"] != SupportUnsupported { + if capabilities.MCPTransports["stdio"] != SupportNative || capabilities.MCPTransports["streamable-http"] != SupportNative || capabilities.MCPTransports["sse"] != SupportUnsupported { t.Fatalf("Qoder MCP transports = %v", capabilities.MCPTransports) } if capabilities.AppSupport != SupportUnsupported || capabilities.ExtensionSupport != SupportUnsupported { @@ -87,6 +88,9 @@ func TestQoderClientDefinition(t *testing.T) { if definition.Traits.LifecycleKind != LifecycleCLIRegistry || !definition.Traits.Allows(InstallIntentAutomatic) { t.Fatalf("Qoder traits = %+v", definition.Traits) } + if !definition.Traits.HonorsOpenAIMCPAuthHints { + t.Fatal("Qoder must honor OpenAI MCP auth hints so remote OAuth servers can authenticate") + } } func TestClientRegistryReturnsDefensiveCapabilityCopies(t *testing.T) { 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 7bfddcd2f..de0253c2a 100644 --- a/install/integrationctl/agentplugins/planner/testdata/golden/compat_invalid_components.json +++ b/install/integrationctl/agentplugins/planner/testdata/golden/compat_invalid_components.json @@ -458,7 +458,7 @@ "MCPTransports": { "sse": "unsupported", "stdio": "native", - "streamable-http": "unsupported" + "streamable-http": "native" }, "PackageMode": "native", "Scopes": [ 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 018565032..8fbb5d9e9 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 @@ -700,7 +700,7 @@ "MCPTransports": { "sse": "unsupported", "stdio": "native", - "streamable-http": "unsupported" + "streamable-http": "native" }, "PackageMode": "native", "Scopes": [ @@ -732,11 +732,9 @@ { "Index": 1, "Kind": "mcp_server", - "Limitations": [ - "component_not_supported_by_client" - ], + "Limitations": null, "Source": "", - "Support": "unsupported" + "Support": "native" }, { "Index": 1, 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 88de7083b..09692c991 100644 --- a/install/integrationctl/agentplugins/planner/testdata/golden/compat_skills_only.json +++ b/install/integrationctl/agentplugins/planner/testdata/golden/compat_skills_only.json @@ -414,7 +414,7 @@ "MCPTransports": { "sse": "unsupported", "stdio": "native", - "streamable-http": "unsupported" + "streamable-http": "native" }, "PackageMode": "native", "Scopes": [ 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 1a0aea19d..6111451ec 100644 --- a/install/integrationctl/agentplugins/planner/testdata/golden/compat_stdio_mcp.json +++ b/install/integrationctl/agentplugins/planner/testdata/golden/compat_stdio_mcp.json @@ -495,7 +495,7 @@ "MCPTransports": { "sse": "unsupported", "stdio": "native", - "streamable-http": "unsupported" + "streamable-http": "native" }, "PackageMode": "native", "Scopes": [ diff --git a/install/integrationctl/agentplugins/planner/testdata/golden/plan_remote_mcp_and_app.json b/install/integrationctl/agentplugins/planner/testdata/golden/plan_remote_mcp_and_app.json index b40542763..3668c17da 100644 --- a/install/integrationctl/agentplugins/planner/testdata/golden/plan_remote_mcp_and_app.json +++ b/install/integrationctl/agentplugins/planner/testdata/golden/plan_remote_mcp_and_app.json @@ -800,10 +800,10 @@ { "Kind": "mcp_server", "Name": "remote", - "Reason": "component_not_supported_by_client", + "Reason": "", "Source": "", "SourceName": "", - "Support": "unsupported" + "Support": "native" }, { "Kind": "app", diff --git a/landing/data/clientCapabilities.generated.json b/landing/data/clientCapabilities.generated.json index d1748df05..8d0b69c74 100644 --- a/landing/data/clientCapabilities.generated.json +++ b/landing/data/clientCapabilities.generated.json @@ -202,7 +202,7 @@ "mcp_transports": { "sse": "unsupported", "stdio": "native", - "streamable-http": "unsupported" + "streamable-http": "native" }, "app_support": "unsupported", "extension_support": "unsupported", From 971023a4af620359b9d58a89589f0b949ae9098f Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 19 Oct 2026 13:26:17 +0300 Subject: [PATCH 02/13] feat(grok,kimi): add Grok Build and Kimi Code plugin package lanes Model both targets as scaffold + packaging lanes in the current PlatformProfile schema, register their platformexec adapters, and regenerate derived artifacts. Grok projects portable MCP as a wrapped .mcp.json sidecar; Kimi inlines MCP in the manifest. Both compile portable skills into the observed plugin bundle. --- cli/internal/platformexec/packaged_plugin.go | 472 ++++++++++++++++++ .../platformexec/packaged_plugin_test.go | 186 +++++++ cli/internal/platformexec/registry.go | 2 + cli/internal/scaffold/platforms_gen.go | 22 + .../scaffold/templates/grok.README.md.tmpl | 43 ++ .../scaffold/templates/kimi.README.md.tmpl | 37 ++ cli/internal/validate/rules_gen.go | 22 + docs/generated/target_support_matrix.md | 2 + .../descriptors/gen/completeness_gen_test.go | 2 +- sdk/internal/platforms/grok/doc.go | 6 + sdk/internal/platforms/kimi/doc.go | 7 + sdk/platformmeta/platformmeta_profile_grok.go | 76 +++ sdk/platformmeta/platformmeta_profile_kimi.go | 75 +++ .../platformmeta_profiles_tooling.go | 2 + sdk/platformmeta/platformmeta_test.go | 2 + 15 files changed, 955 insertions(+), 1 deletion(-) create mode 100644 cli/internal/platformexec/packaged_plugin.go create mode 100644 cli/internal/platformexec/packaged_plugin_test.go create mode 100644 cli/internal/scaffold/templates/grok.README.md.tmpl create mode 100644 cli/internal/scaffold/templates/kimi.README.md.tmpl create mode 100644 sdk/internal/platforms/grok/doc.go create mode 100644 sdk/internal/platforms/kimi/doc.go create mode 100644 sdk/platformmeta/platformmeta_profile_grok.go create mode 100644 sdk/platformmeta/platformmeta_profile_kimi.go diff --git a/cli/internal/platformexec/packaged_plugin.go b/cli/internal/platformexec/packaged_plugin.go new file mode 100644 index 000000000..13211e01d --- /dev/null +++ b/cli/internal/platformexec/packaged_plugin.go @@ -0,0 +1,472 @@ +package platformexec + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/777genius/plugin-kit-ai/cli/internal/pluginmodel" +) + +type packagedMCPLayout int + +const ( + packagedMCPSidecarWrapped packagedMCPLayout = iota + packagedMCPInline +) + +const ( + packagedPluginSkillsRef = "./skills/" + packagedPluginMCPRef = "./.mcp.json" + kimiRootManifestRel = "kimi.plugin.json" +) + +type packagedPluginAdapter struct { + id string + displayName string + manifestRel string + mcpLayout packagedMCPLayout +} + +func newGrokPluginAdapter() packagedPluginAdapter { + return packagedPluginAdapter{ + id: "grok", + displayName: "Grok", + manifestRel: ".grok-plugin/plugin.json", + mcpLayout: packagedMCPSidecarWrapped, + } +} + +func newKimiPluginAdapter() packagedPluginAdapter { + return packagedPluginAdapter{ + id: "kimi", + displayName: "Kimi", + manifestRel: ".kimi-plugin/plugin.json", + mcpLayout: packagedMCPInline, + } +} + +func (a packagedPluginAdapter) ID() string { return a.id } + +func (a packagedPluginAdapter) DetectNative(root string) bool { + if fileExists(filepath.Join(root, a.manifestRel)) { + return true + } + if a.id == "kimi" { + return fileExists(filepath.Join(root, kimiRootManifestRel)) + } + return false +} + +func (a packagedPluginAdapter) RefineDiscovery(root string, state *pluginmodel.TargetState) error { + authoredRoot := authoredRootHint(*state, pluginmodel.NewPortableComponents()) + targetDir := filepath.Join(root, authoredRoot, "targets", a.id) + entries, err := os.ReadDir(targetDir) + switch { + case os.IsNotExist(err): + return nil + case err != nil: + return err + case len(entries) == 0: + return nil + default: + return fmt.Errorf("target %s does not support %s/targets/%s/... in phase 1: use %s/skills/** and %s/mcp/servers.yaml", a.id, authoredRoot, a.id, authoredRoot, authoredRoot) + } +} + +func (a packagedPluginAdapter) ManagedPaths(root string, graph pluginmodel.PackageGraph, state pluginmodel.TargetState) ([]string, error) { + return nil, nil +} + +func (a packagedPluginAdapter) Generate(root string, graph pluginmodel.PackageGraph, state pluginmodel.TargetState) ([]pluginmodel.Artifact, error) { + doc := map[string]any{ + "name": graph.Manifest.Name, + "version": graph.Manifest.Version, + "description": graph.Manifest.Description, + } + if len(graph.Portable.Paths("skills")) > 0 { + doc["skills"] = packagedPluginSkillsRef + } + var artifacts []pluginmodel.Artifact + if graph.Portable.MCP != nil { + projected, err := renderPortableMCPForTarget(graph.Portable.MCP, a.id) + if err != nil { + return nil, err + } + switch a.mcpLayout { + case packagedMCPInline: + doc["mcpServers"] = projected + default: + doc["mcpServers"] = packagedPluginMCPRef + body, err := marshalJSON(map[string]any{"mcpServers": projected}) + if err != nil { + return nil, err + } + artifacts = append(artifacts, pluginmodel.Artifact{ + RelPath: ".mcp.json", + Content: body, + }) + } + } + body, err := marshalJSON(doc) + if err != nil { + return nil, err + } + artifacts = append(artifacts, pluginmodel.Artifact{ + RelPath: a.manifestRel, + Content: body, + }) + skillArtifacts, err := renderPortableSkills(root, graph.Portable.Paths("skills"), "skills") + if err != nil { + return nil, err + } + artifacts = append(artifacts, skillArtifacts...) + return compactArtifacts(artifacts), nil +} + +func (a packagedPluginAdapter) Import(root string, seed ImportSeed) (ImportResult, error) { + result := ImportResult{ + Manifest: seed.Manifest, + Launcher: seed.Launcher, + } + manifest, path, ok, err := a.readManifest(root) + if err != nil { + return ImportResult{}, err + } + if !ok { + return ImportResult{}, fmt.Errorf("%s plugin import requires %s", a.displayName, a.manifestRel) + } + if name := stringMapField(manifest, "name"); name != "" { + result.Manifest.Name = name + } + if version := stringMapField(manifest, "version"); version != "" { + result.Manifest.Version = version + } + if description := stringMapField(manifest, "description"); description != "" { + result.Manifest.Description = description + } + if servers, ok, err := a.importedMCP(root, path, manifest); err != nil { + return ImportResult{}, err + } else if ok { + artifact, err := importedPortableMCPArtifact(a.id, servers) + if err != nil { + return ImportResult{}, err + } + result.Artifacts = append(result.Artifacts, artifact) + } + skillArtifacts, err := copyArtifactDirs(root, artifactDir{src: "skills", dst: "skills"}) + if err != nil { + return ImportResult{}, err + } + result.Artifacts = append(result.Artifacts, skillArtifacts...) + result.Artifacts = compactArtifacts(result.Artifacts) + return result, nil +} + +func (a packagedPluginAdapter) Validate(root string, graph pluginmodel.PackageGraph, state pluginmodel.TargetState) ([]Diagnostic, error) { + manifest, diagnostics := a.validateManifestRead(root) + if len(diagnostics) > 0 { + return diagnostics, nil + } + diagnostics = append(diagnostics, a.validateIdentity(manifest, graph)...) + diagnostics = append(diagnostics, a.validateSkills(root, manifest, graph)...) + mcpDiagnostics, err := a.validateMCP(root, manifest, graph) + if err != nil { + return nil, err + } + return append(diagnostics, mcpDiagnostics...), nil +} + +func (a packagedPluginAdapter) readManifest(root string) (map[string]any, string, bool, error) { + candidates := []string{a.manifestRel} + if a.id == "kimi" { + candidates = []string{kimiRootManifestRel, a.manifestRel} + } + var firstMissing error + for _, rel := range candidates { + body, err := os.ReadFile(filepath.Join(root, rel)) + if err != nil { + if os.IsNotExist(err) { + if firstMissing == nil { + firstMissing = err + } + continue + } + return nil, "", false, err + } + doc, err := decodeJSONObject(body, a.displayName+" plugin manifest "+rel) + if err != nil { + return nil, "", false, err + } + return doc, rel, true, nil + } + return nil, "", false, nil +} + +func (a packagedPluginAdapter) importedMCP(root, manifestRel string, manifest map[string]any) (map[string]any, bool, error) { + value, ok := manifest["mcpServers"] + if !ok { + return nil, false, nil + } + switch typed := value.(type) { + case nil: + return nil, false, nil + case map[string]any: + return typed, true, nil + case string: + if a.mcpLayout == packagedMCPInline { + return nil, false, fmt.Errorf("%s plugin field %q must be an inline object; Kimi does not resolve MCP file refs", a.displayName, "mcpServers") + } + ref := strings.TrimSpace(typed) + if ref == "" { + return nil, false, nil + } + rel, err := containedPluginRel(ref) + if err != nil { + return nil, false, fmt.Errorf("unsupported %s plugin mcpServers ref %q: %w", a.displayName, ref, err) + } + return readWrappedMCPServers(filepath.Join(root, rel), rel) + default: + return nil, false, fmt.Errorf("%s plugin field %q must be a string ref or object", a.displayName, "mcpServers") + } +} + +func containedPluginRel(ref string) (string, error) { + cleaned := filepath.Clean(strings.TrimSpace(ref)) + cleaned = strings.TrimPrefix(cleaned, "./") + if cleaned == "" || cleaned == "." || strings.HasPrefix(cleaned, "..") || filepath.IsAbs(cleaned) { + return "", fmt.Errorf("path must stay inside the plugin root") + } + return filepath.ToSlash(cleaned), nil +} + +func readWrappedMCPServers(path, label string) (map[string]any, bool, error) { + body, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, false, nil + } + return nil, false, err + } + doc, err := decodeJSONObject(body, "plugin MCP config "+label) + if err != nil { + return nil, false, err + } + if value, ok := doc["mcpServers"]; ok { + servers, ok := value.(map[string]any) + if !ok { + return nil, false, fmt.Errorf("plugin MCP config %s field %q must be a JSON object", label, "mcpServers") + } + if servers == nil { + return map[string]any{}, true, nil + } + return servers, true, nil + } + return doc, true, nil +} + +func (a packagedPluginAdapter) validateManifestRead(root string) (map[string]any, []Diagnostic) { + manifest, _, ok, err := a.readManifest(root) + if err != nil { + return nil, []Diagnostic{a.manifestDiagnostic(CodeManifestInvalid, fmt.Sprintf("%s plugin manifest %s is invalid: %v", a.displayName, a.manifestRel, err))} + } + if !ok { + return nil, []Diagnostic{a.manifestDiagnostic(CodeGeneratedContractInvalid, fmt.Sprintf("%s plugin manifest %s is not readable", a.displayName, a.manifestRel))} + } + return manifest, nil +} + +func (a packagedPluginAdapter) validateIdentity(manifest map[string]any, graph pluginmodel.PackageGraph) []Diagnostic { + return append( + append( + a.validateIdentityField(manifest, "name", strings.TrimSpace(graph.Manifest.Name)), + a.validateIdentityField(manifest, "version", strings.TrimSpace(graph.Manifest.Version))..., + ), + a.validateIdentityField(manifest, "description", strings.TrimSpace(graph.Manifest.Description))..., + ) +} + +func (a packagedPluginAdapter) validateIdentityField(manifest map[string]any, field, expected string) []Diagnostic { + got := stringMapField(manifest, field) + if got == expected { + return nil + } + return []Diagnostic{a.manifestDiagnostic( + CodeGeneratedContractInvalid, + fmt.Sprintf("%s plugin manifest %s sets %s %q; expected %q from plugin.yaml", a.displayName, a.manifestRel, field, got, expected), + )} +} + +func (a packagedPluginAdapter) validateSkills(root string, manifest map[string]any, graph pluginmodel.PackageGraph) []Diagnostic { + paths := graph.Portable.Paths("skills") + if len(paths) == 0 { + if _, ok := manifest["skills"]; !ok { + return nil + } + return []Diagnostic{a.manifestDiagnostic( + CodeGeneratedContractInvalid, + fmt.Sprintf("%s plugin manifest %s may not define skills when portable skills are absent", a.displayName, a.manifestRel), + )} + } + diagnostics := a.validateSkillsRef(manifest) + for _, rel := range paths { + diagnostics = append(diagnostics, a.validateSkillProjection(root, rel)...) + } + return diagnostics +} + +func (a packagedPluginAdapter) validateSkillsRef(manifest map[string]any) []Diagnostic { + ref, ok := manifest["skills"] + if !ok { + return []Diagnostic{a.manifestDiagnostic( + CodeGeneratedContractInvalid, + fmt.Sprintf("%s plugin manifest %s must reference %q when portable skills are authored", a.displayName, a.manifestRel, packagedPluginSkillsRef), + )} + } + refText, ok := ref.(string) + if !ok || strings.TrimSpace(refText) != packagedPluginSkillsRef { + return []Diagnostic{a.manifestDiagnostic( + CodeGeneratedContractInvalid, + fmt.Sprintf("%s plugin manifest %s must use %q for skills when portable skills are authored", a.displayName, a.manifestRel, packagedPluginSkillsRef), + )} + } + return nil +} + +func (a packagedPluginAdapter) validateSkillProjection(root, rel string) []Diagnostic { + child, err := portableSkillChildPath(rel) + if err != nil { + return []Diagnostic{{ + Severity: SeverityFailure, + Code: CodeGeneratedContractInvalid, + Path: filepath.ToSlash(rel), + Target: a.id, + Message: fmt.Sprintf("%s portable skill path %s is invalid: %v", a.displayName, rel, err), + }} + } + renderedRel := filepath.ToSlash(filepath.Join("skills", child)) + sourceBody, err := os.ReadFile(filepath.Join(root, rel)) + if err != nil { + return []Diagnostic{{ + Severity: SeverityFailure, + Code: CodeGeneratedContractInvalid, + Path: filepath.ToSlash(rel), + Target: a.id, + Message: fmt.Sprintf("%s portable skill source %s is not readable: %v", a.displayName, rel, err), + }} + } + renderedBody, err := os.ReadFile(filepath.Join(root, renderedRel)) + if err != nil { + return []Diagnostic{{ + Severity: SeverityFailure, + Code: CodeGeneratedContractInvalid, + Path: renderedRel, + Target: a.id, + Message: fmt.Sprintf("%s generated skill %s is not readable", a.displayName, renderedRel), + }} + } + if string(sourceBody) == string(renderedBody) { + return nil + } + return []Diagnostic{{ + Severity: SeverityFailure, + Code: CodeGeneratedContractInvalid, + Path: renderedRel, + Target: a.id, + Message: fmt.Sprintf("%s generated skill %s does not match authored portable skill %s", a.displayName, renderedRel, rel), + }} +} + +func (a packagedPluginAdapter) validateMCP(root string, manifest map[string]any, graph pluginmodel.PackageGraph) ([]Diagnostic, error) { + if graph.Portable.MCP == nil { + if _, ok := manifest["mcpServers"]; !ok { + return nil, nil + } + return []Diagnostic{a.manifestDiagnostic( + CodeGeneratedContractInvalid, + fmt.Sprintf("%s plugin manifest %s may not define mcpServers when portable MCP is absent", a.displayName, a.manifestRel), + )}, nil + } + projected, err := renderPortableMCPForTarget(graph.Portable.MCP, a.id) + if err != nil { + return nil, err + } + rendered, diagnostics := a.readGeneratedMCP(root, manifest) + if len(diagnostics) > 0 { + return diagnostics, nil + } + if jsonDocumentsEqual(projected, rendered) { + return nil, nil + } + path := a.manifestRel + if a.mcpLayout == packagedMCPSidecarWrapped { + path = ".mcp.json" + } + return []Diagnostic{{ + Severity: SeverityFailure, + Code: CodeGeneratedContractInvalid, + Path: path, + Target: a.id, + Message: fmt.Sprintf("%s plugin MCP projection does not match authored portable MCP", a.displayName), + }}, nil +} + +func (a packagedPluginAdapter) readGeneratedMCP(root string, manifest map[string]any) (map[string]any, []Diagnostic) { + value, ok := manifest["mcpServers"] + if !ok { + return nil, []Diagnostic{a.manifestDiagnostic( + CodeGeneratedContractInvalid, + fmt.Sprintf("%s plugin manifest %s must declare mcpServers when portable MCP is authored", a.displayName, a.manifestRel), + )} + } + switch a.mcpLayout { + case packagedMCPInline: + servers, ok := value.(map[string]any) + if !ok { + return nil, []Diagnostic{a.manifestDiagnostic( + CodeGeneratedContractInvalid, + fmt.Sprintf("%s plugin manifest %s field %q must be an inline MCP object", a.displayName, a.manifestRel, "mcpServers"), + )} + } + return servers, nil + default: + refText, ok := value.(string) + if !ok || strings.TrimSpace(refText) != packagedPluginMCPRef { + return nil, []Diagnostic{a.manifestDiagnostic( + CodeGeneratedContractInvalid, + fmt.Sprintf("%s plugin manifest %s must use %q for mcpServers when portable MCP is authored", a.displayName, a.manifestRel, packagedPluginMCPRef), + )} + } + servers, readable, err := readWrappedMCPServers(filepath.Join(root, ".mcp.json"), ".mcp.json") + if err != nil { + return nil, []Diagnostic{{ + Severity: SeverityFailure, + Code: CodeGeneratedContractInvalid, + Path: ".mcp.json", + Target: a.id, + Message: fmt.Sprintf("%s plugin MCP manifest .mcp.json is invalid: %v", a.displayName, err), + }} + } + if !readable { + return nil, []Diagnostic{{ + Severity: SeverityFailure, + Code: CodeGeneratedContractInvalid, + Path: ".mcp.json", + Target: a.id, + Message: fmt.Sprintf("%s plugin MCP manifest .mcp.json is not readable", a.displayName), + }} + } + return servers, nil + } +} + +func (a packagedPluginAdapter) manifestDiagnostic(code, message string) Diagnostic { + return Diagnostic{ + Severity: SeverityFailure, + Code: code, + Path: a.manifestRel, + Target: a.id, + Message: message, + } +} diff --git a/cli/internal/platformexec/packaged_plugin_test.go b/cli/internal/platformexec/packaged_plugin_test.go new file mode 100644 index 000000000..bab8c2dc4 --- /dev/null +++ b/cli/internal/platformexec/packaged_plugin_test.go @@ -0,0 +1,186 @@ +package platformexec + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/cli/internal/pluginmodel" +) + +func TestGrokGenerateWritesWrappedMCPSidecar(t *testing.T) { + t.Parallel() + root := t.TempDir() + graph := packagedPluginTestGraph(t, root, "grok") + artifacts, err := newGrokPluginAdapter().Generate(root, graph, pluginmodel.NewTargetState("grok")) + if err != nil { + t.Fatal(err) + } + var sawManifest, sawMCP, sawSkill bool + for _, artifact := range artifacts { + switch artifact.RelPath { + case ".grok-plugin/plugin.json": + sawManifest = true + if !strings.Contains(string(artifact.Content), `"skills": "./skills/"`) { + t.Fatalf("missing skills ref:\n%s", artifact.Content) + } + if !strings.Contains(string(artifact.Content), `"mcpServers": "./.mcp.json"`) { + t.Fatalf("missing MCP ref:\n%s", artifact.Content) + } + case ".mcp.json": + sawMCP = true + if !strings.Contains(string(artifact.Content), `"mcpServers"`) || !strings.Contains(string(artifact.Content), `"docs"`) { + t.Fatalf("Grok MCP sidecar should wrap servers:\n%s", artifact.Content) + } + case filepath.ToSlash(filepath.Join("skills", "release-checks", "SKILL.md")): + sawSkill = true + } + } + if !sawManifest || !sawMCP || !sawSkill { + t.Fatalf("artifacts = %+v", artifacts) + } +} + +func TestGrokImportAcceptsVendorMCPFileRef(t *testing.T) { + t.Parallel() + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".grok-plugin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, "agents", "grok"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".grok-plugin", "plugin.json"), []byte(`{"name":"demo","version":"0.1.0","description":"demo","mcpServers":"./agents/grok/mcp.json"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "agents", "grok", "mcp.json"), []byte(`{"mcpServers":{"docs":{"type":"http","url":"https://example.com/mcp"}}}`), 0o644); err != nil { + t.Fatal(err) + } + imported, err := newGrokPluginAdapter().Import(root, ImportSeed{ + Manifest: pluginmodel.Manifest{Name: "demo", Version: "0.1.0", Description: "demo", Targets: []string{"grok"}}, + }) + if err != nil { + t.Fatal(err) + } + if len(imported.Artifacts) != 1 || !strings.Contains(string(imported.Artifacts[0].Content), "docs:") { + t.Fatalf("imported artifacts = %+v", imported.Artifacts) + } +} + +func TestKimiGenerateInlinesMCP(t *testing.T) { + t.Parallel() + root := t.TempDir() + graph := packagedPluginTestGraph(t, root, "kimi") + artifacts, err := newKimiPluginAdapter().Generate(root, graph, pluginmodel.NewTargetState("kimi")) + if err != nil { + t.Fatal(err) + } + var sawManifest, sawSidecar, sawSkill bool + for _, artifact := range artifacts { + switch artifact.RelPath { + case ".kimi-plugin/plugin.json": + sawManifest = true + if !strings.Contains(string(artifact.Content), `"skills": "./skills/"`) { + t.Fatalf("missing skills ref:\n%s", artifact.Content) + } + if !strings.Contains(string(artifact.Content), `"mcpServers"`) || !strings.Contains(string(artifact.Content), `"docs"`) { + t.Fatalf("Kimi MCP should be inline:\n%s", artifact.Content) + } + if strings.Contains(string(artifact.Content), `"./.mcp.json"`) { + t.Fatalf("Kimi must not use an MCP file ref:\n%s", artifact.Content) + } + case ".mcp.json": + sawSidecar = true + case filepath.ToSlash(filepath.Join("skills", "release-checks", "SKILL.md")): + sawSkill = true + } + } + if !sawManifest || sawSidecar || !sawSkill { + t.Fatalf("artifacts = %+v", artifacts) + } +} + +func TestKimiImportRejectsMCPFileRef(t *testing.T) { + t.Parallel() + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".kimi-plugin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".kimi-plugin", "plugin.json"), []byte(`{"name":"demo","mcpServers":"./.mcp.json"}`), 0o644); err != nil { + t.Fatal(err) + } + if _, err := newKimiPluginAdapter().Import(root, ImportSeed{ + Manifest: pluginmodel.Manifest{Name: "demo", Targets: []string{"kimi"}}, + }); err == nil || !strings.Contains(err.Error(), "does not resolve MCP file refs") { + t.Fatalf("err = %v", err) + } +} + +func TestKimiDetectNativeAcceptsRootManifest(t *testing.T) { + t.Parallel() + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "kimi.plugin.json"), []byte(`{"name":"demo"}`), 0o644); err != nil { + t.Fatal(err) + } + if !newKimiPluginAdapter().DetectNative(root) { + t.Fatal("expected kimi.plugin.json to detect the Kimi plugin") + } +} + +func TestKimiValidateRequiresInlineMCP(t *testing.T) { + t.Parallel() + root := t.TempDir() + graph := packagedPluginTestGraph(t, root, "kimi") + if err := os.MkdirAll(filepath.Join(root, ".kimi-plugin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, "skills", "release-checks"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "skills", "release-checks", "SKILL.md"), []byte("---\nname: release-checks\ndescription: release checks\n---\n\nUse this skill.\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".kimi-plugin", "plugin.json"), []byte(`{"name":"kimi-demo","version":"0.1.0","description":"kimi demo","skills":"./skills/","mcpServers":"./.mcp.json"}`), 0o644); err != nil { + t.Fatal(err) + } + diagnostics, err := newKimiPluginAdapter().Validate(root, graph, pluginmodel.NewTargetState("kimi")) + if err != nil { + t.Fatal(err) + } + if len(diagnostics) == 0 || !strings.Contains(diagnostics[0].Message, "inline MCP object") { + t.Fatalf("diagnostics = %+v", diagnostics) + } +} + +func packagedPluginTestGraph(t *testing.T, root, target string) pluginmodel.PackageGraph { + t.Helper() + skillPath := filepath.Join(root, "plugin", "skills", "release-checks") + if err := os.MkdirAll(skillPath, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillPath, "SKILL.md"), []byte("---\nname: release-checks\ndescription: release checks\n---\n\nUse this skill.\n"), 0o644); err != nil { + t.Fatal(err) + } + parsed, err := pluginmodel.ParsePortableMCP("plugin/mcp/servers.yaml", []byte(`api_version: v1 + +servers: + docs: + type: remote + remote: + protocol: streamable_http + url: "https://example.com/mcp" +`)) + if err != nil { + t.Fatal(err) + } + return pluginmodel.PackageGraph{ + Manifest: pluginmodel.Manifest{Name: target + "-demo", Version: "0.1.0", Description: target + " demo", Targets: []string{target}}, + Portable: pluginmodel.PortableComponents{ + Items: map[string][]string{ + "skills": {filepath.ToSlash(filepath.Join("plugin", "skills", "release-checks", "SKILL.md"))}, + }, + MCP: &pluginmodel.PortableMCP{Path: filepath.ToSlash(filepath.Join("plugin", "mcp", "servers.yaml")), Servers: parsed.Servers, File: parsed.File}, + }, + } +} diff --git a/cli/internal/platformexec/registry.go b/cli/internal/platformexec/registry.go index 09c9ed3ef..d01e0e4fd 100644 --- a/cli/internal/platformexec/registry.go +++ b/cli/internal/platformexec/registry.go @@ -141,6 +141,8 @@ func mustDefaultRegistry() *Registry { geminiAdapter{}, opencodeAdapter{}, qoderAdapter{}, + newGrokPluginAdapter(), + newKimiPluginAdapter(), } { if err := registry.Register(adapter); err != nil { panic(err) diff --git a/cli/internal/scaffold/platforms_gen.go b/cli/internal/scaffold/platforms_gen.go index 784cd7c5b..230c9ffde 100644 --- a/cli/internal/scaffold/platforms_gen.go +++ b/cli/internal/scaffold/platforms_gen.go @@ -92,6 +92,28 @@ var generatedPlatforms = map[string]PlatformDefinition{ {Path: "plugin/skills/{{.ProjectName}}/SKILL.md", Template: "SKILL.md.tmpl", Extra: true}, }, }, + "grok": { + Name: "grok", + Files: []TemplateFile{ + {Path: "plugin/plugin.yaml", Template: "plugin.yaml.tmpl", Extra: false}, + {Path: "plugin/README.md", Template: "grok.README.md.tmpl", Extra: false}, + {Path: "plugin/mcp/servers.yaml", Template: "mcp.servers.yaml.tmpl", Extra: true}, + {Path: "plugin/skills/{{.ProjectName}}/SKILL.md", Template: "SKILL.md.tmpl", Extra: true}, + {Path: "CLAUDE.md", Template: "ROOT.CLAUDE.md.tmpl", Extra: false}, + {Path: "AGENTS.md", Template: "ROOT.AGENTS.md.tmpl", Extra: false}, + }, + }, + "kimi": { + Name: "kimi", + Files: []TemplateFile{ + {Path: "plugin/plugin.yaml", Template: "plugin.yaml.tmpl", Extra: false}, + {Path: "plugin/README.md", Template: "kimi.README.md.tmpl", Extra: false}, + {Path: "plugin/mcp/servers.yaml", Template: "mcp.servers.yaml.tmpl", Extra: true}, + {Path: "plugin/skills/{{.ProjectName}}/SKILL.md", Template: "SKILL.md.tmpl", Extra: true}, + {Path: "CLAUDE.md", Template: "ROOT.CLAUDE.md.tmpl", Extra: false}, + {Path: "AGENTS.md", Template: "ROOT.AGENTS.md.tmpl", Extra: false}, + }, + }, "opencode": { Name: "opencode", Files: []TemplateFile{ diff --git a/cli/internal/scaffold/templates/grok.README.md.tmpl b/cli/internal/scaffold/templates/grok.README.md.tmpl new file mode 100644 index 000000000..c3126602b --- /dev/null +++ b/cli/internal/scaffold/templates/grok.README.md.tmpl @@ -0,0 +1,43 @@ +# {{.ProjectName}} + +Grok Build plugin scaffold generated by [plugin-kit-ai](https://github.com/777genius/plugin-kit-ai) `init`. + +This lane models the observed Grok packaged plugin bundle for portable skills and shared MCP. + +## Status + +- Target lane: `grok` +- Platform family: `ide_plugin` +- Launcher: not used +- Runtime claim: packaging-only plugin lane + +## First Run + +```bash +plugin-kit-ai generate . +plugin-kit-ai generate --check . +plugin-kit-ai validate . --platform grok --strict +``` + +Optional live Grok CLI check, when `grok` is installed: + +```bash +grok plugin validate . +grok plugin install --trust . +grok plugin list --json +``` + +## Target Files + +- `plugin/plugin.yaml`: canonical shared plugin metadata and target list +- `plugin/skills/`: canonical portable skill authored source; `plugin-kit-ai init --extras` pre-seeds a starter and generate mirrors it into plugin-root `skills/` +- `plugin/mcp/servers.yaml`: canonical portable MCP authored source; `plugin-kit-ai init --extras` pre-seeds a starter and generate projects it into managed `.mcp.json` plus `.grok-plugin/plugin.json#mcpServers` +- root `CLAUDE.md` and `AGENTS.md`: boundary docs that tell humans and agents to edit only `plugin/` +- `.grok-plugin/plugin.json`: generated managed Grok plugin manifest +- `.mcp.json`: generated managed MCP sidecar, wrapped as `{ "mcpServers": { ... } }` when portable MCP is authored +- `skills/`: generated managed portable skills + +## Current Boundary + +- Supported in phase 1: portable `plugin/skills/**`, portable `plugin/mcp/servers.yaml`, and generated `.grok-plugin/plugin.json` +- Not yet modeled in this packaged lane: first-class authored Grok `hooks`, `agents`, `commands`, or `lsp` diff --git a/cli/internal/scaffold/templates/kimi.README.md.tmpl b/cli/internal/scaffold/templates/kimi.README.md.tmpl new file mode 100644 index 000000000..f7671ccbd --- /dev/null +++ b/cli/internal/scaffold/templates/kimi.README.md.tmpl @@ -0,0 +1,37 @@ +# {{.ProjectName}} + +Kimi Code plugin scaffold generated by [plugin-kit-ai](https://github.com/777genius/plugin-kit-ai) `init`. + +This lane models the observed Kimi packaged plugin bundle for portable skills and inline MCP. + +## Status + +- Target lane: `kimi` +- Platform family: `ide_plugin` +- Launcher: not used +- Runtime claim: packaging-only plugin lane + +## First Run + +```bash +plugin-kit-ai generate . +plugin-kit-ai generate --check . +plugin-kit-ai validate . --platform kimi --strict +``` + +Kimi Code has no non-interactive plugin CLI. The installer copies the package into `~/.kimi-code/plugins/managed` and records `plugins/installed.json`; reload Kimi or run `/plugins reload`. + +## Target Files + +- `plugin/plugin.yaml`: canonical shared plugin metadata and target list +- `plugin/skills/`: canonical portable skill authored source; `plugin-kit-ai init --extras` pre-seeds a starter and generate mirrors it into plugin-root `skills/` +- `plugin/mcp/servers.yaml`: canonical portable MCP authored source; `plugin-kit-ai init --extras` pre-seeds a starter and generate inlines it into `.kimi-plugin/plugin.json#mcpServers` +- root `CLAUDE.md` and `AGENTS.md`: boundary docs that tell humans and agents to edit only `plugin/` +- `.kimi-plugin/plugin.json`: generated managed Kimi plugin manifest; Kimi also accepts `kimi.plugin.json`, and that file wins when both exist +- `skills/`: generated managed portable skills + +## Current Boundary + +- Supported in phase 1: portable `plugin/skills/**`, portable `plugin/mcp/servers.yaml`, and generated `.kimi-plugin/plugin.json` +- Kimi requires MCP servers inline in the plugin manifest. External MCP file refs are invalid on this target +- Not yet modeled in this packaged lane: first-class authored Kimi `commands`, `hooks`, `agents`, or `systemPrompt` diff --git a/cli/internal/validate/rules_gen.go b/cli/internal/validate/rules_gen.go index 82edf0092..819a584b6 100644 --- a/cli/internal/validate/rules_gen.go +++ b/cli/internal/validate/rules_gen.go @@ -72,6 +72,28 @@ var generatedRules = map[string]Rule{ ForbiddenFiles: []string{}, BuildTargets: []string{}, }, + "grok": { + Platform: "grok", + RequiredFiles: []string{ + "README.md", + ".grok-plugin/plugin.json", + }, + ForbiddenFiles: []string{ + "launcher.yaml", + }, + BuildTargets: []string{}, + }, + "kimi": { + Platform: "kimi", + RequiredFiles: []string{ + "README.md", + ".kimi-plugin/plugin.json", + }, + ForbiddenFiles: []string{ + "launcher.yaml", + }, + BuildTargets: []string{}, + }, "opencode": { Platform: "opencode", RequiredFiles: []string{ diff --git a/docs/generated/target_support_matrix.md b/docs/generated/target_support_matrix.md index 2892d7bd3..b8627bce0 100644 --- a/docs/generated/target_support_matrix.md +++ b/docs/generated/target_support_matrix.md @@ -8,5 +8,7 @@ | cursor | ide_plugin | plugin_package | ignored | plugin | marketplace or /add-plugin install | package authoring workspace | plugin reload or restart | .cursor-plugin/plugin.json | packaging-only target | Cursor marketplace plugin bundle with portable skills and optional portable MCP | yes | yes | yes | skills, mcp_servers | - | - | rules=unsupported, agents_markdown=unsupported, hooks=unsupported, commands=unsupported, subagents=unsupported | .cursor-plugin/plugin.json, skills/** (when portable skills are authored), .mcp.json (when portable MCP is authored) | Cursor plugin packages compile portable skills and optional shared MCP into the current observed .cursor-plugin bundle shape without inventing unsupported target-native authoring surfaces. | | cursor-workspace | code_plugin | workspace_config_lane | ignored | workspace | workspace config files | config authoring workspace | config reload or restart | .cursor/mcp.json | packaging-only target | workspace-config lane with first-class MCP config and project rules | yes | yes | yes | mcp_servers | rules, agents_markdown | agents_markdown=plugin/targets/cursor-workspace/AGENTS.md | mcp=stable, rules=stable, agents_markdown=stable | .cursor/mcp.json (when portable MCP is authored), .cursor/rules/** (when rules are authored) | Cursor workspace compiles as a repo-local config lane with generated .cursor MCP config, project rules, and authored Cursor AGENTS content merged into the generated root AGENTS.md boundary file. | | gemini | extension_package | mcp_extension | optional | extension | copy install | link | restart required | ~/.gemini/extensions/ | production-ready extension packaging lane | production-ready extension packaging plus optional production-ready 9-hook Go runtime and public-beta Notification observer | yes | yes | yes | skills, mcp_servers | package_metadata, hooks, commands, policies, themes, settings, contexts, manifest_extra | package_metadata=plugin/targets/gemini/package.yaml, manifest_extra=plugin/targets/gemini/manifest.extra.json | commands=stable, hooks=stable, policies=stable, settings=stable, themes=stable, contexts=stable, manifest_extra=stable, agents=preview | gemini-extension.json, skills/** (when portable skills are authored), hooks/** (when hooks are authored), commands/** (when commands are authored), policies/** (when policies are authored), contexts/** (when contexts are authored), GEMINI.md or selected root context (when contexts are authored) | Gemini compiles as an official-style extension package with MCP, a primary root context, target-native extension assets, and an optional production-ready Go hook runtime lane. | +| grok | ide_plugin | plugin_package | ignored | plugin | grok plugin install --trust or ~/.grok/plugins copy | package authoring workspace | grok plugin list --json or plugin reload | .grok-plugin/plugin.json | packaging-only target | Grok Build plugin bundle with portable skills and optional portable MCP | yes | yes | yes | skills, mcp_servers | - | - | hooks=unsupported, agents=unsupported, commands=unsupported, lsp=unsupported | .grok-plugin/plugin.json, skills/** (when portable skills are authored), .mcp.json (when portable MCP is authored) | Grok plugin packages compile portable skills and optional MCP into the observed .grok-plugin bundle shape. MCP is projected as a wrapped .mcp.json sidecar; hooks, agents, commands, and LSP stay unsupported until they have a first-class authoring surface. | +| kimi | ide_plugin | plugin_package | ignored | plugin | /plugins install or ~/.kimi-code/plugins/managed copy plus installed.json | package authoring workspace | /plugins reload after managed copy | .kimi-plugin/plugin.json | packaging-only target | Kimi Code plugin bundle with portable skills and inline portable MCP | yes | yes | yes | skills, mcp_servers | - | - | commands=unsupported, hooks=unsupported, agents=unsupported, system_prompt=unsupported | .kimi-plugin/plugin.json, skills/** (when portable skills are authored) | Kimi plugin packages compile portable skills and optional MCP into .kimi-plugin/plugin.json. Kimi requires MCP servers inline in the manifest and does not resolve external MCP file refs. Commands, hooks, agents, and systemPrompt stay unsupported until they have a first-class authoring surface. | | opencode | code_plugin | workspace_config_lane | ignored | workspace | workspace config file | config authoring workspace | config reload or restart | opencode.json | packaging-only target | workspace-config lane with first-class npm plugin refs including tuple-form options, MCP, skills, commands, agents, default_agent, instructions, permission, themes, beta standalone tools with dedicated live evidence, stable official-style local JS/TS plugins plus shared dependency metadata, JSON/JSONC native import, explicit user-scope import, and sanctioned config.extra passthrough for broader product config | yes | yes | yes | skills, mcp_servers | package_metadata, config_extra, default_agent, instructions_config, permission_config, commands, agents, themes, tools, local_plugin_code, local_plugin_dependencies | package_metadata=plugin/targets/opencode/package.yaml, config_extra=plugin/targets/opencode/config.extra.json, default_agent=plugin/targets/opencode/default_agent.txt, instructions_config=plugin/targets/opencode/instructions.yaml, permission_config=plugin/targets/opencode/permission.json, local_plugin_dependencies=plugin/targets/opencode/package.json | plugins=stable, mcp=stable, skills=stable, commands=stable, agents=stable, themes=stable, tools=beta, config_extra=stable, agent_config=stable, default_agent=stable, permission_config=stable, instructions_config=stable, modes=unsupported, local_plugin_code=stable, custom_tools=beta, local_plugin_dependencies=stable | opencode.json, .opencode/package.json, .opencode/skills/** (when portable skills are authored), .opencode/commands/** (when commands are authored), .opencode/agents/** (when agents are authored), .opencode/themes/** (when themes are authored), .opencode/tools/** (when tools are authored), .opencode/plugins/** (when local_plugin_code is authored) | OpenCode compiles as a workspace-config lane with canonical plugin-authored inputs for npm plugin refs including tuple-form options, shared MCP, skills, commands, agents, default_agent, instructions, permission, themes, beta standalone tools with their own live-evidence path, stable official-style local JS/TS plugins plus shared package metadata for tools and plugins, JSON/JSONC native import plus explicit user-scope import, and sanctioned config.extra passthrough for broader product config. | | qoder | ide_plugin | plugin_package | ignored | plugin | qoder plugins install or Desktop marketplace install | package authoring workspace | qoder plugins reload or restart | .qoder-plugin/plugin.json | production-ready package+runtime lane | Qoder plugin bundle with portable skills and optional portable MCP plus target-native hooks and commands, shared by Qoder Desktop and Qoder CLI | yes | yes | yes | skills, mcp_servers | hooks, commands, bin | hooks=plugin/targets/qoder/hooks/hooks.json | commands=stable, agents=unsupported, hooks=stable, output_styles=unsupported | .qoder-plugin/plugin.json, skills/** (when portable skills are authored), hooks/** (when hooks are authored), commands/** (when commands are authored), bin/** (when bin is authored), .mcp.json (when portable MCP is authored) | Qoder plugin packages compile portable skills and optional portable MCP plus target-native hooks and commands into the observed .qoder-plugin bundle shape consumed by both Qoder Desktop and Qoder CLI. | diff --git a/sdk/internal/descriptors/gen/completeness_gen_test.go b/sdk/internal/descriptors/gen/completeness_gen_test.go index a51946d96..f5824615c 100644 --- a/sdk/internal/descriptors/gen/completeness_gen_test.go +++ b/sdk/internal/descriptors/gen/completeness_gen_test.go @@ -8,7 +8,7 @@ import ( func TestGeneratedRegistryCompleteness(t *testing.T) { profiles := defs.Profiles() events := defs.Events() - if len(profiles) != 8 { + if len(profiles) != 10 { t.Fatalf("profiles count = %d", len(profiles)) } if len(events) != 40 { diff --git a/sdk/internal/platforms/grok/doc.go b/sdk/internal/platforms/grok/doc.go new file mode 100644 index 000000000..43fe1fa92 --- /dev/null +++ b/sdk/internal/platforms/grok/doc.go @@ -0,0 +1,6 @@ +// Package grok houses the native Grok Build bundle codec for the scaffold-only +// plugin lane: it models the observed .grok-plugin/plugin.json manifest shape and +// the wrapped .mcp.json MCP sidecar projection. Grok exposes no first-class hook +// runtime, so this package carries manifest/shaping logic only; runtime hook +// dispatch stays unsupported until Grok ships an authorable hook surface. +package grok diff --git a/sdk/internal/platforms/kimi/doc.go b/sdk/internal/platforms/kimi/doc.go new file mode 100644 index 000000000..24c1e03f0 --- /dev/null +++ b/sdk/internal/platforms/kimi/doc.go @@ -0,0 +1,7 @@ +// Package kimi houses the native Kimi Code bundle codec for the scaffold-only +// plugin lane: it models the observed .kimi-plugin/plugin.json manifest shape, +// with MCP servers inlined directly into the manifest because Kimi does not +// resolve external MCP file refs. Kimi exposes no first-class hook runtime, so +// this package carries manifest/shaping logic only; runtime hook dispatch stays +// unsupported until Kimi ships an authorable hook surface. +package kimi diff --git a/sdk/platformmeta/platformmeta_profile_grok.go b/sdk/platformmeta/platformmeta_profile_grok.go new file mode 100644 index 000000000..3f3419a0d --- /dev/null +++ b/sdk/platformmeta/platformmeta_profile_grok.go @@ -0,0 +1,76 @@ +package platformmeta + +func grokProfile() PlatformProfile { + return PlatformProfile{ + ID: "grok", + Contract: TargetContractMeta{ + PlatformFamily: FamilyIDEPlugin, + TargetClass: "plugin_package", + TargetNoun: "plugin", + ProductionClass: "packaging-only target", + RuntimeContract: "Grok Build plugin bundle with portable skills and optional portable MCP", + InstallModel: "grok plugin install --trust or ~/.grok/plugins copy", + DevModel: "package authoring workspace", + ActivationModel: "grok plugin list --json or plugin reload", + NativeRoot: ".grok-plugin/plugin.json", + ImportSupport: true, + GenerateSupport: true, + ValidateSupport: true, + PortableComponentKinds: []string{"skills", "mcp_servers"}, + TargetComponentKinds: []string{}, + Summary: "Grok plugin packages compile portable skills and optional MCP into the observed .grok-plugin bundle shape. MCP is projected as a wrapped .mcp.json sidecar; hooks, agents, commands, and LSP stay unsupported until they have a first-class authoring surface.", + }, + SDK: SDKMeta{ + PublicPackage: "grok", + InternalPackage: "grok", + InternalImport: "github.com/777genius/plugin-kit-ai/sdk/internal/platforms/grok", + Status: StatusScaffoldOnly, + TransportModes: []TransportMode{TransportProcess}, + LiveTestProfile: "grok_plugin", + }, + Launcher: LauncherMeta{Requirement: LauncherIgnored}, + SurfaceTiers: []SurfaceSupport{ + {Kind: "hooks", Tier: SurfaceTierUnsupported}, + {Kind: "agents", Tier: SurfaceTierUnsupported}, + {Kind: "commands", Tier: SurfaceTierUnsupported}, + {Kind: "lsp", Tier: SurfaceTierUnsupported}, + }, + ManagedArtifacts: []ManagedArtifactSpec{ + {Kind: ManagedArtifactStatic, Path: ".grok-plugin/plugin.json"}, + {Kind: ManagedArtifactPortableSkills, SourceRoot: authoredPath("skills"), OutputRoot: "skills"}, + {Kind: ManagedArtifactPortableMCP, Path: ".mcp.json"}, + }, + Scaffold: ScaffoldMeta{ + RequiredFiles: []string{ + authoredPath("plugin.yaml"), + authoredPath("README.md"), + "CLAUDE.md", + "AGENTS.md", + }, + OptionalFiles: []string{ + authoredPath("mcp/servers.yaml"), + authoredPath("skills/{{.ProjectName}}/SKILL.md"), + }, + ForbiddenFiles: []string{ + "launcher.yaml", + }, + TemplateFiles: []TemplateFile{ + {Path: authoredPath("plugin.yaml"), Template: "plugin.yaml.tmpl"}, + {Path: authoredPath("README.md"), Template: "grok.README.md.tmpl"}, + {Path: authoredPath("mcp/servers.yaml"), Template: "mcp.servers.yaml.tmpl", Extra: true}, + {Path: authoredPath("skills/{{.ProjectName}}/SKILL.md"), Template: "SKILL.md.tmpl", Extra: true}, + {Path: "CLAUDE.md", Template: "ROOT.CLAUDE.md.tmpl"}, + {Path: "AGENTS.md", Template: "ROOT.AGENTS.md.tmpl"}, + }, + }, + Validate: ValidateMeta{ + RequiredFiles: []string{ + "README.md", + ".grok-plugin/plugin.json", + }, + ForbiddenFiles: []string{ + "launcher.yaml", + }, + }, + } +} diff --git a/sdk/platformmeta/platformmeta_profile_kimi.go b/sdk/platformmeta/platformmeta_profile_kimi.go new file mode 100644 index 000000000..94178a49b --- /dev/null +++ b/sdk/platformmeta/platformmeta_profile_kimi.go @@ -0,0 +1,75 @@ +package platformmeta + +func kimiProfile() PlatformProfile { + return PlatformProfile{ + ID: "kimi", + Contract: TargetContractMeta{ + PlatformFamily: FamilyIDEPlugin, + TargetClass: "plugin_package", + TargetNoun: "plugin", + ProductionClass: "packaging-only target", + RuntimeContract: "Kimi Code plugin bundle with portable skills and inline portable MCP", + InstallModel: "/plugins install or ~/.kimi-code/plugins/managed copy plus installed.json", + DevModel: "package authoring workspace", + ActivationModel: "/plugins reload after managed copy", + NativeRoot: ".kimi-plugin/plugin.json", + ImportSupport: true, + GenerateSupport: true, + ValidateSupport: true, + PortableComponentKinds: []string{"skills", "mcp_servers"}, + TargetComponentKinds: []string{}, + Summary: "Kimi plugin packages compile portable skills and optional MCP into .kimi-plugin/plugin.json. Kimi requires MCP servers inline in the manifest and does not resolve external MCP file refs. Commands, hooks, agents, and systemPrompt stay unsupported until they have a first-class authoring surface.", + }, + SDK: SDKMeta{ + PublicPackage: "kimi", + InternalPackage: "kimi", + InternalImport: "github.com/777genius/plugin-kit-ai/sdk/internal/platforms/kimi", + Status: StatusScaffoldOnly, + TransportModes: []TransportMode{TransportProcess}, + LiveTestProfile: "kimi_plugin", + }, + Launcher: LauncherMeta{Requirement: LauncherIgnored}, + SurfaceTiers: []SurfaceSupport{ + {Kind: "commands", Tier: SurfaceTierUnsupported}, + {Kind: "hooks", Tier: SurfaceTierUnsupported}, + {Kind: "agents", Tier: SurfaceTierUnsupported}, + {Kind: "system_prompt", Tier: SurfaceTierUnsupported}, + }, + ManagedArtifacts: []ManagedArtifactSpec{ + {Kind: ManagedArtifactStatic, Path: ".kimi-plugin/plugin.json"}, + {Kind: ManagedArtifactPortableSkills, SourceRoot: authoredPath("skills"), OutputRoot: "skills"}, + }, + Scaffold: ScaffoldMeta{ + RequiredFiles: []string{ + authoredPath("plugin.yaml"), + authoredPath("README.md"), + "CLAUDE.md", + "AGENTS.md", + }, + OptionalFiles: []string{ + authoredPath("mcp/servers.yaml"), + authoredPath("skills/{{.ProjectName}}/SKILL.md"), + }, + ForbiddenFiles: []string{ + "launcher.yaml", + }, + TemplateFiles: []TemplateFile{ + {Path: authoredPath("plugin.yaml"), Template: "plugin.yaml.tmpl"}, + {Path: authoredPath("README.md"), Template: "kimi.README.md.tmpl"}, + {Path: authoredPath("mcp/servers.yaml"), Template: "mcp.servers.yaml.tmpl", Extra: true}, + {Path: authoredPath("skills/{{.ProjectName}}/SKILL.md"), Template: "SKILL.md.tmpl", Extra: true}, + {Path: "CLAUDE.md", Template: "ROOT.CLAUDE.md.tmpl"}, + {Path: "AGENTS.md", Template: "ROOT.AGENTS.md.tmpl"}, + }, + }, + Validate: ValidateMeta{ + RequiredFiles: []string{ + "README.md", + ".kimi-plugin/plugin.json", + }, + ForbiddenFiles: []string{ + "launcher.yaml", + }, + }, + } +} diff --git a/sdk/platformmeta/platformmeta_profiles_tooling.go b/sdk/platformmeta/platformmeta_profiles_tooling.go index d58fd2613..234048ebf 100644 --- a/sdk/platformmeta/platformmeta_profiles_tooling.go +++ b/sdk/platformmeta/platformmeta_profiles_tooling.go @@ -7,5 +7,7 @@ func toolingProfiles() []PlatformProfile { cursorWorkspaceProfile(), opencodeProfile(), qoderProfile(), + grokProfile(), + kimiProfile(), } } diff --git a/sdk/platformmeta/platformmeta_test.go b/sdk/platformmeta/platformmeta_test.go index cd817908a..773b0dd04 100644 --- a/sdk/platformmeta/platformmeta_test.go +++ b/sdk/platformmeta/platformmeta_test.go @@ -17,6 +17,8 @@ func TestIDsReturnStableProfileOrder(t *testing.T) { "cursor-workspace", "opencode", "qoder", + "grok", + "kimi", } if got := IDs(); !reflect.DeepEqual(got, want) { t.Fatalf("IDs() = %#v, want %#v", got, want) From 3fe6a7c88a93fa9e44b1c38c2083ff7866919f30 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 19 Oct 2026 14:33:21 +0300 Subject: [PATCH 03/13] fix(grok,kimi): honor manifest read precedence and harden packaged MCP refs Generate now writes the manifest path readManifest actually resolves (root kimi.plugin.json wins when present) so generate/validate/import agree, and diagnostics name that resolved path. MCP file refs accept dotted file names while still rejecting traversal, null mcpServers/skills validate as absent, and the unused importedMCP parameter is now carried into error messages. --- cli/internal/platformexec/packaged_plugin.go | 79 ++++++++------- .../platformexec/packaged_plugin_test.go | 96 +++++++++++++++++++ 2 files changed, 140 insertions(+), 35 deletions(-) diff --git a/cli/internal/platformexec/packaged_plugin.go b/cli/internal/platformexec/packaged_plugin.go index 13211e01d..8054d7687 100644 --- a/cli/internal/platformexec/packaged_plugin.go +++ b/cli/internal/platformexec/packaged_plugin.go @@ -26,6 +26,10 @@ type packagedPluginAdapter struct { id string displayName string manifestRel string + // manifestAlt lists native manifest paths consulted before manifestRel when + // reading: Kimi publishes kimi.plugin.json at the plugin root, and that copy + // wins when both exist. + manifestAlt []string mcpLayout packagedMCPLayout } @@ -43,18 +47,35 @@ func newKimiPluginAdapter() packagedPluginAdapter { id: "kimi", displayName: "Kimi", manifestRel: ".kimi-plugin/plugin.json", + manifestAlt: []string{kimiRootManifestRel}, mcpLayout: packagedMCPInline, } } +func (a packagedPluginAdapter) manifestCandidates() []string { + rels := make([]string, 0, len(a.manifestAlt)+1) + rels = append(rels, a.manifestAlt...) + return append(rels, a.manifestRel) +} + +// manifestWriteRel returns the manifest path Generate must update so that +// readManifest keeps resolving the same file after generation. +func (a packagedPluginAdapter) manifestWriteRel(root string) string { + for _, rel := range a.manifestCandidates() { + if fileExists(filepath.Join(root, rel)) { + return rel + } + } + return a.manifestRel +} + func (a packagedPluginAdapter) ID() string { return a.id } func (a packagedPluginAdapter) DetectNative(root string) bool { - if fileExists(filepath.Join(root, a.manifestRel)) { - return true - } - if a.id == "kimi" { - return fileExists(filepath.Join(root, kimiRootManifestRel)) + for _, rel := range a.manifestCandidates() { + if fileExists(filepath.Join(root, rel)) { + return true + } } return false } @@ -114,7 +135,7 @@ func (a packagedPluginAdapter) Generate(root string, graph pluginmodel.PackageGr return nil, err } artifacts = append(artifacts, pluginmodel.Artifact{ - RelPath: a.manifestRel, + RelPath: a.manifestWriteRel(root), Content: body, }) skillArtifacts, err := renderPortableSkills(root, graph.Portable.Paths("skills"), "skills") @@ -135,7 +156,7 @@ func (a packagedPluginAdapter) Import(root string, seed ImportSeed) (ImportResul return ImportResult{}, err } if !ok { - return ImportResult{}, fmt.Errorf("%s plugin import requires %s", a.displayName, a.manifestRel) + return ImportResult{}, fmt.Errorf("%s plugin import requires %s", a.displayName, strings.Join(a.manifestCandidates(), " or ")) } if name := stringMapField(manifest, "name"); name != "" { result.Manifest.Name = name @@ -165,10 +186,11 @@ func (a packagedPluginAdapter) Import(root string, seed ImportSeed) (ImportResul } func (a packagedPluginAdapter) Validate(root string, graph pluginmodel.PackageGraph, state pluginmodel.TargetState) ([]Diagnostic, error) { - manifest, diagnostics := a.validateManifestRead(root) + manifest, manifestRel, diagnostics := a.validateManifestRead(root) if len(diagnostics) > 0 { return diagnostics, nil } + a.manifestRel = manifestRel // diagnostics must name the manifest actually read (Kimi root manifest wins) diagnostics = append(diagnostics, a.validateIdentity(manifest, graph)...) diagnostics = append(diagnostics, a.validateSkills(root, manifest, graph)...) mcpDiagnostics, err := a.validateMCP(root, manifest, graph) @@ -179,18 +201,10 @@ func (a packagedPluginAdapter) Validate(root string, graph pluginmodel.PackageGr } func (a packagedPluginAdapter) readManifest(root string) (map[string]any, string, bool, error) { - candidates := []string{a.manifestRel} - if a.id == "kimi" { - candidates = []string{kimiRootManifestRel, a.manifestRel} - } - var firstMissing error - for _, rel := range candidates { + for _, rel := range a.manifestCandidates() { body, err := os.ReadFile(filepath.Join(root, rel)) if err != nil { if os.IsNotExist(err) { - if firstMissing == nil { - firstMissing = err - } continue } return nil, "", false, err @@ -206,17 +220,15 @@ func (a packagedPluginAdapter) readManifest(root string) (map[string]any, string func (a packagedPluginAdapter) importedMCP(root, manifestRel string, manifest map[string]any) (map[string]any, bool, error) { value, ok := manifest["mcpServers"] - if !ok { + if !ok || value == nil { return nil, false, nil } switch typed := value.(type) { - case nil: - return nil, false, nil case map[string]any: return typed, true, nil case string: if a.mcpLayout == packagedMCPInline { - return nil, false, fmt.Errorf("%s plugin field %q must be an inline object; Kimi does not resolve MCP file refs", a.displayName, "mcpServers") + return nil, false, fmt.Errorf("%s plugin manifest %s field %q must be an inline object; %s does not resolve MCP file refs", a.displayName, manifestRel, "mcpServers", a.displayName) } ref := strings.TrimSpace(typed) if ref == "" { @@ -224,18 +236,18 @@ func (a packagedPluginAdapter) importedMCP(root, manifestRel string, manifest ma } rel, err := containedPluginRel(ref) if err != nil { - return nil, false, fmt.Errorf("unsupported %s plugin mcpServers ref %q: %w", a.displayName, ref, err) + return nil, false, fmt.Errorf("unsupported %s plugin manifest %s mcpServers ref %q: %w", a.displayName, manifestRel, ref, err) } return readWrappedMCPServers(filepath.Join(root, rel), rel) default: - return nil, false, fmt.Errorf("%s plugin field %q must be a string ref or object", a.displayName, "mcpServers") + return nil, false, fmt.Errorf("%s plugin manifest %s field %q must be a string ref or object", a.displayName, manifestRel, "mcpServers") } } func containedPluginRel(ref string) (string, error) { cleaned := filepath.Clean(strings.TrimSpace(ref)) - cleaned = strings.TrimPrefix(cleaned, "./") - if cleaned == "" || cleaned == "." || strings.HasPrefix(cleaned, "..") || filepath.IsAbs(cleaned) { + cleaned = strings.TrimPrefix(cleaned, "."+string(filepath.Separator)) + if cleaned == "" || cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) || filepath.IsAbs(cleaned) { return "", fmt.Errorf("path must stay inside the plugin root") } return filepath.ToSlash(cleaned), nil @@ -258,23 +270,20 @@ func readWrappedMCPServers(path, label string) (map[string]any, bool, error) { if !ok { return nil, false, fmt.Errorf("plugin MCP config %s field %q must be a JSON object", label, "mcpServers") } - if servers == nil { - return map[string]any{}, true, nil - } return servers, true, nil } return doc, true, nil } -func (a packagedPluginAdapter) validateManifestRead(root string) (map[string]any, []Diagnostic) { - manifest, _, ok, err := a.readManifest(root) +func (a packagedPluginAdapter) validateManifestRead(root string) (map[string]any, string, []Diagnostic) { + manifest, manifestRel, ok, err := a.readManifest(root) if err != nil { - return nil, []Diagnostic{a.manifestDiagnostic(CodeManifestInvalid, fmt.Sprintf("%s plugin manifest %s is invalid: %v", a.displayName, a.manifestRel, err))} + return nil, a.manifestRel, []Diagnostic{a.manifestDiagnostic(CodeManifestInvalid, fmt.Sprintf("%s plugin manifest %s is invalid: %v", a.displayName, a.manifestRel, err))} } if !ok { - return nil, []Diagnostic{a.manifestDiagnostic(CodeGeneratedContractInvalid, fmt.Sprintf("%s plugin manifest %s is not readable", a.displayName, a.manifestRel))} + return nil, a.manifestRel, []Diagnostic{a.manifestDiagnostic(CodeGeneratedContractInvalid, fmt.Sprintf("%s plugin manifest %s is not readable", a.displayName, a.manifestRel))} } - return manifest, nil + return manifest, manifestRel, nil } func (a packagedPluginAdapter) validateIdentity(manifest map[string]any, graph pluginmodel.PackageGraph) []Diagnostic { @@ -301,7 +310,7 @@ func (a packagedPluginAdapter) validateIdentityField(manifest map[string]any, fi func (a packagedPluginAdapter) validateSkills(root string, manifest map[string]any, graph pluginmodel.PackageGraph) []Diagnostic { paths := graph.Portable.Paths("skills") if len(paths) == 0 { - if _, ok := manifest["skills"]; !ok { + if value, ok := manifest["skills"]; !ok || value == nil { return nil } return []Diagnostic{a.manifestDiagnostic( @@ -380,7 +389,7 @@ func (a packagedPluginAdapter) validateSkillProjection(root, rel string) []Diagn func (a packagedPluginAdapter) validateMCP(root string, manifest map[string]any, graph pluginmodel.PackageGraph) ([]Diagnostic, error) { if graph.Portable.MCP == nil { - if _, ok := manifest["mcpServers"]; !ok { + if value, ok := manifest["mcpServers"]; !ok || value == nil { return nil, nil } return []Diagnostic{a.manifestDiagnostic( diff --git a/cli/internal/platformexec/packaged_plugin_test.go b/cli/internal/platformexec/packaged_plugin_test.go index bab8c2dc4..3c3e80733 100644 --- a/cli/internal/platformexec/packaged_plugin_test.go +++ b/cli/internal/platformexec/packaged_plugin_test.go @@ -153,6 +153,102 @@ func TestKimiValidateRequiresInlineMCP(t *testing.T) { } } +func TestKimiGenerateUpdatesWinningManifest(t *testing.T) { + t.Parallel() + root := t.TempDir() + graph := packagedPluginTestGraph(t, root, "kimi") + if err := os.WriteFile(filepath.Join(root, "kimi.plugin.json"), []byte(`{"name":"stale"}`), 0o644); err != nil { + t.Fatal(err) + } + artifacts, err := newKimiPluginAdapter().Generate(root, graph, pluginmodel.NewTargetState("kimi")) + if err != nil { + t.Fatal(err) + } + for _, artifact := range artifacts { + if artifact.RelPath == ".kimi-plugin/plugin.json" { + t.Fatalf("root kimi.plugin.json wins on read, so generate must update it in place: %+v", artifacts) + } + if artifact.RelPath == "kimi.plugin.json" { + if !strings.Contains(string(artifact.Content), `"name": "kimi-demo"`) { + t.Fatalf("generated manifest = %s", artifact.Content) + } + return + } + } + t.Fatalf("artifacts = %+v", artifacts) +} + +func TestKimiValidateDiagnosticsNameResolvedManifest(t *testing.T) { + t.Parallel() + root := t.TempDir() + parsed, err := pluginmodel.ParsePortableMCP("plugin/mcp/servers.yaml", []byte(`api_version: v1 + +servers: + docs: + type: remote + remote: + protocol: streamable_http + url: "https://example.com/mcp" +`)) + if err != nil { + t.Fatal(err) + } + graph := pluginmodel.PackageGraph{ + Manifest: pluginmodel.Manifest{Name: "kimi-demo", Version: "0.1.0", Description: "kimi demo", Targets: []string{"kimi"}}, + Portable: pluginmodel.PortableComponents{ + Items: map[string][]string{}, + MCP: &pluginmodel.PortableMCP{Path: "plugin/mcp/servers.yaml", Servers: parsed.Servers, File: parsed.File}, + }, + } + if err := os.WriteFile(filepath.Join(root, "kimi.plugin.json"), []byte(`{"name":"kimi-demo","version":"0.1.0","description":"kimi demo","mcpServers":{"docs":{"type":"http","url":"https://wrong.example/mcp"}}}`), 0o644); err != nil { + t.Fatal(err) + } + diagnostics, err := newKimiPluginAdapter().Validate(root, graph, pluginmodel.NewTargetState("kimi")) + if err != nil { + t.Fatal(err) + } + if len(diagnostics) != 1 { + t.Fatalf("diagnostics = %+v", diagnostics) + } + if diagnostics[0].Path != "kimi.plugin.json" { + t.Fatalf("diagnostics must name the resolved manifest path: %+v", diagnostics[0]) + } +} + +func TestPackagedValidateTreatsNullMCPAsAbsent(t *testing.T) { + t.Parallel() + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "kimi.plugin.json"), []byte(`{"name":"kimi-demo","version":"0.1.0","description":"kimi demo","mcpServers":null}`), 0o644); err != nil { + t.Fatal(err) + } + diagnostics, err := newKimiPluginAdapter().Validate(root, pluginmodel.PackageGraph{ + Manifest: pluginmodel.Manifest{Name: "kimi-demo", Version: "0.1.0", Description: "kimi demo", Targets: []string{"kimi"}}, + Portable: pluginmodel.PortableComponents{Items: map[string][]string{}}, + }, pluginmodel.NewTargetState("kimi")) + if err != nil { + t.Fatal(err) + } + if len(diagnostics) != 0 { + t.Fatalf("mcpServers null must validate as absent, got %+v", diagnostics) + } +} + +func TestContainedPluginRelKeepsDottedNames(t *testing.T) { + t.Parallel() + rel, err := containedPluginRel("..mcp.json") + if err != nil { + t.Fatalf("dotted file names inside the plugin root must stay valid: %v", err) + } + if rel != "..mcp.json" { + t.Fatalf("rel = %q", rel) + } + for _, ref := range []string{"..", "../escape.json", "/abs.json", "."} { + if _, err := containedPluginRel(ref); err == nil { + t.Fatalf("ref %q must be rejected", ref) + } + } +} + func packagedPluginTestGraph(t *testing.T, root, target string) pluginmodel.PackageGraph { t.Helper() skillPath := filepath.Join(root, "plugin", "skills", "release-checks") From a93c1eb512f119d1b5796bcb4b42887419435c43 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 19 Oct 2026 14:33:36 +0300 Subject: [PATCH 04/13] feat(grok,kimi): wire package-only init lanes and ship grok-basic/kimi-basic examples init/generate/validate/import/inspect/compat now list and route grok and kimi as package-only platforms like qoder (no launcher/runtime), and init success text follows the generate-first path. Adds reference examples with the two MCP projection layouts (Grok wrapped .mcp.json sidecar, Kimi inline manifest MCP) wired into production-examples and import/generate/validate round-trip e2e. --- cli/cmd/plugin-kit-ai/compat.go | 4 +- cli/cmd/plugin-kit-ai/generate.go | 2 +- cli/cmd/plugin-kit-ai/import.go | 2 +- cli/cmd/plugin-kit-ai/init.go | 4 +- cli/cmd/plugin-kit-ai/init_run.go | 4 +- cli/cmd/plugin-kit-ai/init_success.go | 2 +- cli/cmd/plugin-kit-ai/init_test.go | 26 ++- cli/cmd/plugin-kit-ai/inspect.go | 2 +- cli/cmd/plugin-kit-ai/release_compat.go | 2 +- cli/cmd/plugin-kit-ai/validate.go | 2 +- cli/internal/app/init_resolve_launcher.go | 6 +- cli/internal/app/init_resolve_package.go | 2 +- cli/internal/scaffold/runtime_files_base.go | 2 +- .../scaffold/runtime_files_platform.go | 17 ++ cli/internal/scaffold/scaffold_plan.go | 2 +- cli/internal/scaffold/scaffold_plan_build.go | 2 +- examples/plugins/README.md | 4 + .../grok-basic/.grok-plugin/plugin.json | 7 + examples/plugins/grok-basic/.mcp.json | 10 ++ examples/plugins/grok-basic/AGENTS.md | 22 +++ examples/plugins/grok-basic/CLAUDE.md | 9 ++ examples/plugins/grok-basic/GENERATED.md | 31 ++++ examples/plugins/grok-basic/README.md | 13 ++ examples/plugins/grok-basic/plugin/README.md | 17 ++ .../grok-basic/plugin/mcp/servers.yaml | 9 ++ .../plugins/grok-basic/plugin/plugin.yaml | 6 + .../plugin/skills/release-checks/SKILL.md | 15 ++ .../grok-basic/skills/release-checks/SKILL.md | 15 ++ .../kimi-basic/.kimi-plugin/plugin.json | 14 ++ examples/plugins/kimi-basic/AGENTS.md | 22 +++ examples/plugins/kimi-basic/CLAUDE.md | 9 ++ examples/plugins/kimi-basic/GENERATED.md | 30 ++++ examples/plugins/kimi-basic/README.md | 13 ++ examples/plugins/kimi-basic/plugin/README.md | 17 ++ .../kimi-basic/plugin/mcp/servers.yaml | 9 ++ .../plugins/kimi-basic/plugin/plugin.yaml | 6 + .../plugin/skills/release-checks/SKILL.md | 15 ++ .../kimi-basic/skills/release-checks/SKILL.md | 15 ++ ...gin_manifest_lifecycle_integration_test.go | 151 ++++++++++++++++++ .../production_examples_integration_test.go | 120 +++++++++++++- 40 files changed, 640 insertions(+), 20 deletions(-) create mode 100644 examples/plugins/grok-basic/.grok-plugin/plugin.json create mode 100644 examples/plugins/grok-basic/.mcp.json create mode 100644 examples/plugins/grok-basic/AGENTS.md create mode 100644 examples/plugins/grok-basic/CLAUDE.md create mode 100644 examples/plugins/grok-basic/GENERATED.md create mode 100644 examples/plugins/grok-basic/README.md create mode 100644 examples/plugins/grok-basic/plugin/README.md create mode 100644 examples/plugins/grok-basic/plugin/mcp/servers.yaml create mode 100644 examples/plugins/grok-basic/plugin/plugin.yaml create mode 100644 examples/plugins/grok-basic/plugin/skills/release-checks/SKILL.md create mode 100644 examples/plugins/grok-basic/skills/release-checks/SKILL.md create mode 100644 examples/plugins/kimi-basic/.kimi-plugin/plugin.json create mode 100644 examples/plugins/kimi-basic/AGENTS.md create mode 100644 examples/plugins/kimi-basic/CLAUDE.md create mode 100644 examples/plugins/kimi-basic/GENERATED.md create mode 100644 examples/plugins/kimi-basic/README.md create mode 100644 examples/plugins/kimi-basic/plugin/README.md create mode 100644 examples/plugins/kimi-basic/plugin/mcp/servers.yaml create mode 100644 examples/plugins/kimi-basic/plugin/plugin.yaml create mode 100644 examples/plugins/kimi-basic/plugin/skills/release-checks/SKILL.md create mode 100644 examples/plugins/kimi-basic/skills/release-checks/SKILL.md diff --git a/cli/cmd/plugin-kit-ai/compat.go b/cli/cmd/plugin-kit-ai/compat.go index b49632a0b..7ebf61401 100644 --- a/cli/cmd/plugin-kit-ai/compat.go +++ b/cli/cmd/plugin-kit-ai/compat.go @@ -78,9 +78,9 @@ func newCompatCmd(runner compatRunner) *cobra.Command { } }, } - cmd.Flags().StringVar(&compatTarget, "target", "all", `compatibility target ("all", "claude", "codex-package", "codex-runtime", "gemini", "opencode", "cursor", "cursor-workspace", or "qoder")`) + cmd.Flags().StringVar(&compatTarget, "target", "all", `compatibility target ("all", "claude", "codex-package", "codex-runtime", "gemini", "opencode", "cursor", "cursor-workspace", "qoder", "grok", or "kimi")`) cmd.Flags().StringVar(&compatFormat, "format", "text", "output format: text or json") - cmd.Flags().StringVar(&compatFrom, "from", "", `source platform ("claude", "codex-package", "codex-runtime", "gemini", "opencode", "cursor", "cursor-workspace", or "qoder"; omit to auto-detect current native layouts)`) + cmd.Flags().StringVar(&compatFrom, "from", "", `source platform ("claude", "codex-package", "codex-runtime", "gemini", "opencode", "cursor", "cursor-workspace", "qoder", "grok", or "kimi"; omit to auto-detect current native layouts)`) cmd.Flags().BoolVar(&compatIncludeUserScope, "include-user-scope", false, "include explicit user-scope native sources when supported by the detected import target") return cmd } diff --git a/cli/cmd/plugin-kit-ai/generate.go b/cli/cmd/plugin-kit-ai/generate.go index 6dab9de27..563628abd 100644 --- a/cli/cmd/plugin-kit-ai/generate.go +++ b/cli/cmd/plugin-kit-ai/generate.go @@ -50,6 +50,6 @@ Cursor generation now defaults to the packaged plugin lane: it produces .cursor- } func init() { - generateCmd.Flags().StringVar(&generateTarget, "target", "all", `generate target ("all", "claude", "codex-package", "codex-runtime", "gemini", "opencode", "cursor", "cursor-workspace", or "qoder")`) + generateCmd.Flags().StringVar(&generateTarget, "target", "all", `generate target ("all", "claude", "codex-package", "codex-runtime", "gemini", "opencode", "cursor", "cursor-workspace", "qoder", "grok", or "kimi")`) generateCmd.Flags().BoolVar(&generateCheck, "check", false, "fail if generated artifacts are out of date") } diff --git a/cli/cmd/plugin-kit-ai/import.go b/cli/cmd/plugin-kit-ai/import.go index 55b90f7cd..0b7418846 100644 --- a/cli/cmd/plugin-kit-ai/import.go +++ b/cli/cmd/plugin-kit-ai/import.go @@ -52,7 +52,7 @@ Use --source to import from a remote or external source reference such as github func init() { importCmd.Flags().StringVar(&importSource, "source", "", "native source reference to import from (local path, github:owner/repo@ref//subdir, or git URL with optional #ref)") - importCmd.Flags().StringVar(&importFrom, "from", "", `source platform ("claude", "codex-package", "codex-runtime", "gemini", "opencode", "cursor", "cursor-workspace", or "qoder"; omit to auto-detect current native layouts)`) + importCmd.Flags().StringVar(&importFrom, "from", "", `source platform ("claude", "codex-package", "codex-runtime", "gemini", "opencode", "cursor", "cursor-workspace", "qoder", "grok", or "kimi"; omit to auto-detect current native layouts)`) importCmd.Flags().BoolVarP(&importForce, "force", "f", false, "overwrite plugin/plugin.yaml if it already exists") importCmd.Flags().BoolVar(&importIncludeUserScope, "include-user-scope", false, "include explicit user-scope native sources when supported by the import target") } diff --git a/cli/cmd/plugin-kit-ai/init.go b/cli/cmd/plugin-kit-ai/init.go index 027440b69..95c1269c2 100644 --- a/cli/cmd/plugin-kit-ai/init.go +++ b/cli/cmd/plugin-kit-ai/init.go @@ -49,7 +49,7 @@ Already have native config: Public flags: --template Recommended start: "online-service", "local-tool", or "custom-logic". - --platform Advanced override: "codex-runtime" (default), "codex-package", "claude", "gemini", "opencode", "cursor", "cursor-workspace", or "qoder". + --platform Advanced override: "codex-runtime" (default), "codex-package", "claude", "gemini", "opencode", "cursor", "cursor-workspace", "qoder", "grok", or "kimi". --runtime Supported: "go" (default), "python", "node", "shell" for launcher-based targets only. --typescript Generate a TypeScript scaffold on top of the node runtime lane (requires --runtime node). --runtime-package @@ -74,7 +74,7 @@ func newInitCmd(runner initCommandRunner) *cobra.Command { }, } cmd.Flags().StringVar(&flags.template, "template", "", `recommended start ("online-service", "local-tool", or "custom-logic")`) - cmd.Flags().StringVar(&flags.platform, "platform", "codex-runtime", `target lane ("codex-runtime", "codex-package", "claude", "gemini", "opencode", "cursor", "cursor-workspace", or "qoder")`) + cmd.Flags().StringVar(&flags.platform, "platform", "codex-runtime", `target lane ("codex-runtime", "codex-package", "claude", "gemini", "opencode", "cursor", "cursor-workspace", "qoder", "grok", or "kimi")`) cmd.Flags().StringVar(&flags.runtime, "runtime", "go", `runtime ("go", "python", "node", or "shell")`) cmd.Flags().BoolVar(&flags.typescript, "typescript", false, "generate a TypeScript scaffold on top of the node runtime lane") cmd.Flags().BoolVar(&flags.runtimePackage, "runtime-package", false, "for --runtime python or --runtime node, import the shared plugin-kit-ai-runtime package instead of vendoring the helper file") diff --git a/cli/cmd/plugin-kit-ai/init_run.go b/cli/cmd/plugin-kit-ai/init_run.go index 94c3dc04e..0ff449311 100644 --- a/cli/cmd/plugin-kit-ai/init_run.go +++ b/cli/cmd/plugin-kit-ai/init_run.go @@ -16,7 +16,9 @@ func runInit(cmd *cobra.Command, runner initCommandRunner, flags initFlagState, strings.EqualFold(strings.TrimSpace(flags.platform), "opencode") || strings.EqualFold(strings.TrimSpace(flags.platform), "cursor") || strings.EqualFold(strings.TrimSpace(flags.platform), "cursor-workspace") || - strings.EqualFold(strings.TrimSpace(flags.platform), "qoder")) && + strings.EqualFold(strings.TrimSpace(flags.platform), "qoder") || + strings.EqualFold(strings.TrimSpace(flags.platform), "grok") || + strings.EqualFold(strings.TrimSpace(flags.platform), "kimi")) && !cmd.Flags().Changed("runtime") { runtime = "" } diff --git a/cli/cmd/plugin-kit-ai/init_success.go b/cli/cmd/plugin-kit-ai/init_success.go index e85d4d649..4ec912b04 100644 --- a/cli/cmd/plugin-kit-ai/init_success.go +++ b/cli/cmd/plugin-kit-ai/init_success.go @@ -67,7 +67,7 @@ func formatInitSuccess(outDir string, opts app.InitOptions) string { return strings.Join(lines, "\n") + "\n" } - if platform == "gemini" || platform == "codex-package" || platform == "opencode" || platform == "cursor" || platform == "cursor-workspace" || platform == "qoder" { + if platform == "gemini" || platform == "codex-package" || platform == "opencode" || platform == "cursor" || platform == "cursor-workspace" || platform == "qoder" || platform == "grok" || platform == "kimi" { if opts.Extras { lines = append(lines, " Portable MCP starter: plugin/mcp/servers.yaml") } diff --git a/cli/cmd/plugin-kit-ai/init_test.go b/cli/cmd/plugin-kit-ai/init_test.go index 8269567c1..982dff5c6 100644 --- a/cli/cmd/plugin-kit-ai/init_test.go +++ b/cli/cmd/plugin-kit-ai/init_test.go @@ -45,7 +45,7 @@ func TestInitHelpIncludesScenarioLanesAndDefaults(t *testing.T) { "Already have native config", "plugin-kit-ai import", `--template Recommended start: "online-service", "local-tool", or "custom-logic".`, - `--platform Advanced override: "codex-runtime" (default), "codex-package", "claude", "gemini", "opencode", "cursor", "cursor-workspace", or "qoder".`, + `--platform Advanced override: "codex-runtime" (default), "codex-package", "claude", "gemini", "opencode", "cursor", "cursor-workspace", "qoder", "grok", or "kimi".`, `--runtime Supported: "go" (default), "python", "node", "shell" for launcher-based targets only.`, "--typescript Generate a TypeScript scaffold on top of the node runtime lane", "--runtime-package", @@ -217,6 +217,30 @@ func TestInitSuccessOutputByLane(t *testing.T) { "See plugin/README.md for the full first run", }, }, + { + name: "grok", + args: []string{"demo", "--platform", "grok"}, + wantRuntime: "", + wantPlatform: "grok", + want: []string{ + "plugin-kit-ai generate .", + "plugin-kit-ai generate --check .", + "plugin-kit-ai validate . --platform grok --strict", + "See plugin/README.md for the full first run", + }, + }, + { + name: "kimi", + args: []string{"demo", "--platform", "kimi"}, + wantRuntime: "", + wantPlatform: "kimi", + want: []string{ + "plugin-kit-ai generate .", + "plugin-kit-ai generate --check .", + "plugin-kit-ai validate . --platform kimi --strict", + "See plugin/README.md for the full first run", + }, + }, { name: "config-lane-extras", args: []string{"demo", "--platform", "cursor", "--extras"}, diff --git a/cli/cmd/plugin-kit-ai/inspect.go b/cli/cmd/plugin-kit-ai/inspect.go index e3e4b9112..7775105ca 100644 --- a/cli/cmd/plugin-kit-ai/inspect.go +++ b/cli/cmd/plugin-kit-ai/inspect.go @@ -59,7 +59,7 @@ func newInspectCmd(runner inspectRunner) *cobra.Command { } }, } - cmd.Flags().StringVar(&inspectTarget, "target", "all", `inspect target ("all", "claude", "codex-package", "codex-runtime", "gemini", "opencode", "cursor", "cursor-workspace", or "qoder")`) + cmd.Flags().StringVar(&inspectTarget, "target", "all", `inspect target ("all", "claude", "codex-package", "codex-runtime", "gemini", "opencode", "cursor", "cursor-workspace", "qoder", "grok", or "kimi")`) cmd.Flags().StringVar(&inspectFormat, "format", "text", "output format: text or json") cmd.Flags().BoolVar(&inspectAuthoring, "authoring", false, "show a plain-language authoring view instead of the raw contract view") return cmd diff --git a/cli/cmd/plugin-kit-ai/release_compat.go b/cli/cmd/plugin-kit-ai/release_compat.go index df81a0834..ac6ab1ba8 100644 --- a/cli/cmd/plugin-kit-ai/release_compat.go +++ b/cli/cmd/plugin-kit-ai/release_compat.go @@ -158,7 +158,7 @@ func rejectV1(in commands.Invocation) error { for _, value := range in.Values["target"] { for _, target := range strings.Split(value, ",") { switch target { - case "claude", "codex", "gemini", "opencode", "cursor", "qoder": + case "claude", "codex", "gemini", "opencode", "cursor", "qoder", "grok", "kimi": default: supported = false } diff --git a/cli/cmd/plugin-kit-ai/validate.go b/cli/cmd/plugin-kit-ai/validate.go index 51d422c04..89b4b4808 100644 --- a/cli/cmd/plugin-kit-ai/validate.go +++ b/cli/cmd/plugin-kit-ai/validate.go @@ -60,7 +60,7 @@ explicit outcome of "passed", "failed", or "failed_strict_warnings".`, }, } - cmd.Flags().StringVar(&platform, "platform", "", `target override ("codex-package", "codex-runtime", "claude", "gemini", "opencode", "cursor", "cursor-workspace", or "qoder")`) + cmd.Flags().StringVar(&platform, "platform", "", `target override ("codex-package", "codex-runtime", "claude", "gemini", "opencode", "cursor", "cursor-workspace", "qoder", "grok", or "kimi")`) cmd.Flags().BoolVar(&strict, "strict", false, "treat validation warnings as errors") cmd.Flags().StringVar(&format, "format", "text", `output format ("text" or "json")`) return cmd diff --git a/cli/internal/app/init_resolve_launcher.go b/cli/internal/app/init_resolve_launcher.go index 64a0b5a4c..2943074d4 100644 --- a/cli/internal/app/init_resolve_launcher.go +++ b/cli/internal/app/init_resolve_launcher.go @@ -53,7 +53,7 @@ func validateInitPlatformOptions(name string, opts InitOptions, config resolvedI if runtimeFlag := strings.ToLower(strings.TrimSpace(config.Runtime)); runtimeFlag != "" && runtimeFlag != scaffold.RuntimeGo { return fmt.Errorf("--runtime is not supported with --platform %s", config.Platform) } - case "opencode", "cursor", "cursor-workspace", "qoder": + case "opencode", "cursor", "cursor-workspace", "qoder", "grok", "kimi": if opts.TypeScript { return fmt.Errorf("--typescript is not supported with --platform %s", config.Platform) } @@ -68,14 +68,14 @@ func validateInitPlatformOptions(name string, opts InitOptions, config resolvedI return fmt.Errorf("--typescript is not supported with --platform %s", config.Platform) } } - if opts.RuntimePackage && (config.Platform == "gemini" || config.Platform == "codex-package" || config.Platform == "opencode" || config.Platform == "cursor" || config.Platform == "cursor-workspace" || config.Platform == "qoder") { + if opts.RuntimePackage && (config.Platform == "gemini" || config.Platform == "codex-package" || config.Platform == "opencode" || config.Platform == "cursor" || config.Platform == "cursor-workspace" || config.Platform == "qoder" || config.Platform == "grok" || config.Platform == "kimi") { return fmt.Errorf("--runtime-package is not supported with --platform %s", config.Platform) } return nil } func validateInitRuntimeOptions(opts InitOptions, config *resolvedInitConfig) error { - if config.Platform != "codex-package" && config.Platform != "opencode" && config.Platform != "cursor" && config.Platform != "cursor-workspace" && config.Platform != "qoder" { + if config.Platform != "codex-package" && config.Platform != "opencode" && config.Platform != "cursor" && config.Platform != "cursor-workspace" && config.Platform != "qoder" && config.Platform != "grok" && config.Platform != "kimi" { if _, ok := scaffold.LookupRuntime(config.Runtime); !ok { return errUnknownRuntime(opts.Runtime) } diff --git a/cli/internal/app/init_resolve_package.go b/cli/internal/app/init_resolve_package.go index 5e586b6e2..079e9edd9 100644 --- a/cli/internal/app/init_resolve_package.go +++ b/cli/internal/app/init_resolve_package.go @@ -45,7 +45,7 @@ func resolvePackageOnlyTargets(templateName string, opts InitOptions, platform s return nil, errUnknownPlatform(opts.Platform) } switch meta.Name { - case "claude", "codex-package", "gemini", "opencode", "cursor", "cursor-workspace", "qoder": + case "claude", "codex-package", "gemini", "opencode", "cursor", "cursor-workspace", "qoder", "grok", "kimi": return []string{meta.Name}, nil default: return nil, fmt.Errorf("--template %s only supports package and workspace outputs; use --template custom-logic for %s", templateName, meta.Name) diff --git a/cli/internal/scaffold/runtime_files_base.go b/cli/internal/scaffold/runtime_files_base.go index fc3bac4b5..5ca2ac359 100644 --- a/cli/internal/scaffold/runtime_files_base.go +++ b/cli/internal/scaffold/runtime_files_base.go @@ -38,7 +38,7 @@ func baseRuntimeFiles(platform string) []TemplateFile { func platformRequiresLauncher(platform string) bool { switch platform { - case "gemini", "codex-package", "opencode", "cursor", "cursor-workspace", "qoder": + case "gemini", "codex-package", "opencode", "cursor", "cursor-workspace", "qoder", "grok", "kimi": return false default: return true diff --git a/cli/internal/scaffold/runtime_files_platform.go b/cli/internal/scaffold/runtime_files_platform.go index 9fffea559..739d2c1f3 100644 --- a/cli/internal/scaffold/runtime_files_platform.go +++ b/cli/internal/scaffold/runtime_files_platform.go @@ -18,6 +18,10 @@ func filesForPlatform(platform string, extras bool) ([]TemplateFile, bool) { return cursorWorkspacePlatformFiles(extras), true case "qoder": return qoderPlatformFiles(extras), true + case "grok": + return packagedPluginPlatformFiles("grok", extras), true + case "kimi": + return packagedPluginPlatformFiles("kimi", extras), true default: return nil, false } @@ -141,3 +145,16 @@ func qoderPlatformFiles(extras bool) []TemplateFile { TemplateFile{Path: authoredPath("targets/qoder/bin/{{.ProjectName}}-hook"), Template: "targets.qoder.hook.sh.tmpl", Extra: true}, ) } + +func packagedPluginPlatformFiles(platform string, extras bool) []TemplateFile { + files := []TemplateFile{ + {Path: authoredPath("README.md"), Template: platform + ".README.md.tmpl", Extra: false}, + } + if !extras { + return files + } + return append(files, + TemplateFile{Path: authoredPath("mcp/servers.yaml"), Template: "mcp.servers.yaml.tmpl", Extra: true}, + TemplateFile{Path: authoredPath("skills/{{.ProjectName}}/SKILL.md"), Template: "SKILL.md.tmpl", Extra: true}, + ) +} diff --git a/cli/internal/scaffold/scaffold_plan.go b/cli/internal/scaffold/scaffold_plan.go index a736c62d7..345ee2e58 100644 --- a/cli/internal/scaffold/scaffold_plan.go +++ b/cli/internal/scaffold/scaffold_plan.go @@ -8,7 +8,7 @@ import ( // Paths lists relative paths created by Write (for tests and docs). func Paths(platform, name string, extras bool) []string { - if platform == "gemini" || platform == "codex-package" || platform == "opencode" || platform == "cursor" || platform == "cursor-workspace" || platform == "qoder" { + if platform == "gemini" || platform == "codex-package" || platform == "opencode" || platform == "cursor" || platform == "cursor-workspace" || platform == "qoder" || platform == "grok" || platform == "kimi" { return PathsForRuntime(platform, "", name, extras) } return PathsForRuntime(platform, RuntimeGo, name, extras) diff --git a/cli/internal/scaffold/scaffold_plan_build.go b/cli/internal/scaffold/scaffold_plan_build.go index 317e933d1..8f81f5c26 100644 --- a/cli/internal/scaffold/scaffold_plan_build.go +++ b/cli/internal/scaffold/scaffold_plan_build.go @@ -9,7 +9,7 @@ func normalizePlanPlatformData(d Data, platform string) (Data, error) { d.Platform = platform var err error switch platform { - case "codex-package", "opencode", "cursor", "cursor-workspace", "qoder": + case "codex-package", "opencode", "cursor", "cursor-workspace", "qoder", "grok", "kimi": d, err = normalizePackageOnlyPlatformData(d) case "gemini": d, err = normalizeGeminiPlatformData(d) diff --git a/examples/plugins/README.md b/examples/plugins/README.md index 826ef1fbe..1bf918690 100644 --- a/examples/plugins/README.md +++ b/examples/plugins/README.md @@ -18,6 +18,8 @@ Their generated native outputs remain useful client-specific references: | `codex-package-prod` | `.codex-plugin/plugin.json`, Skills, MCP and app metadata | | `cursor-basic` | `.cursor-plugin/plugin.json`, Skills and MCP | | `qoder-basic` | `.qoder-plugin/plugin.json`, Skills, MCP and target-native hooks/commands | +| `grok-basic` | `.grok-plugin/plugin.json`, Skills and wrapped `.mcp.json` sidecar | +| `kimi-basic` | `.kimi-plugin/plugin.json`, Skills and inline MCP | | `gemini-extension-package` | `gemini-extension.json`, extension commands/settings/hooks | | `opencode-basic` | `opencode.json`, workspace configuration and mirrored Skills | @@ -35,6 +37,8 @@ These examples are reference implementations for the historical v1 production pl - [gemini-extension-package](./gemini-extension-package): Gemini CLI extension repo with `plugin/plugin.yaml`, generated `gemini-extension.json`, shared MCP, and packaging-only validation coverage - [cursor-basic](./cursor-basic): Cursor packaged plugin repo with `plugin/plugin.yaml`, portable `plugin/skills/**`, generated `.cursor-plugin/plugin.json`, and shared `.mcp.json` - [qoder-basic](./qoder-basic): Qoder packaged plugin repo with `plugin/plugin.yaml`, portable `plugin/skills/**`, generated `.qoder-plugin/plugin.json`, shared `.mcp.json`, and target-native hooks/commands authored under `plugin/targets/qoder/**` with generated `hooks/**` and `commands/**` mirrors +- [grok-basic](./grok-basic): Grok Build packaged plugin repo with `plugin/plugin.yaml`, portable `plugin/skills/**`, generated `.grok-plugin/plugin.json`, and the wrapped `.mcp.json` sidecar referenced via `"mcpServers": "./.mcp.json"` +- [kimi-basic](./kimi-basic): Kimi Code packaged plugin repo with `plugin/plugin.yaml`, portable `plugin/skills/**`, and generated `.kimi-plugin/plugin.json` with MCP inlined into `mcpServers` (Kimi resolves no MCP file refs) - [opencode-basic](./opencode-basic): OpenCode workspace-config repo with `plugin/plugin.yaml`, generated `opencode.json`, shared MCP, and mirrored portable skills Use them together with [../../docs/PRODUCTION.md](../../docs/PRODUCTION.md). diff --git a/examples/plugins/grok-basic/.grok-plugin/plugin.json b/examples/plugins/grok-basic/.grok-plugin/plugin.json new file mode 100644 index 000000000..8e2794121 --- /dev/null +++ b/examples/plugins/grok-basic/.grok-plugin/plugin.json @@ -0,0 +1,7 @@ +{ + "description": "Grok packaged plugin example", + "mcpServers": "./.mcp.json", + "name": "grok-basic", + "skills": "./skills/", + "version": "0.1.0" +} diff --git a/examples/plugins/grok-basic/.mcp.json b/examples/plugins/grok-basic/.mcp.json new file mode 100644 index 000000000..13a4afe17 --- /dev/null +++ b/examples/plugins/grok-basic/.mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "release-checks": { + "args": [ + "./bin/release-checks.mjs" + ], + "command": "node" + } + } +} diff --git a/examples/plugins/grok-basic/AGENTS.md b/examples/plugins/grok-basic/AGENTS.md new file mode 100644 index 000000000..e92bd9183 --- /dev/null +++ b/examples/plugins/grok-basic/AGENTS.md @@ -0,0 +1,22 @@ + +# Plugin Boundary + +This plugin uses the canonical `plugin/` authoring layout. + +- Edit only files under `plugin/`. +- Start with [`plugin/README.md`](./plugin/README.md) for the full plugin guide. +- Read [`GENERATED.md`](./GENERATED.md) before touching plugin-root files. It is the required inventory of managed generated outputs. +- Treat plugin-root `README.md`, manifests, and config files as generated outputs. +- `plugin-kit-ai generate` may overwrite every path listed in `GENERATED.md`. + +After changing `plugin/`, run: + +```bash +plugin-kit-ai normalize . +plugin-kit-ai generate . +plugin-kit-ai generate --check . +plugin-kit-ai inspect . --format json +``` + +Then run `plugin-kit-ai validate . --platform --strict` for each enabled target listed in the inspection output. + diff --git a/examples/plugins/grok-basic/CLAUDE.md b/examples/plugins/grok-basic/CLAUDE.md new file mode 100644 index 000000000..f8b193ffb --- /dev/null +++ b/examples/plugins/grok-basic/CLAUDE.md @@ -0,0 +1,9 @@ + +# Claude Boundary + +Claude should treat [`AGENTS.md`](./AGENTS.md) as the primary boundary document for this plugin. + +- Edit only files under `plugin/`. +- Use [`plugin/README.md`](./plugin/README.md) for the full plugin guide. +- Use [`GENERATED.md`](./GENERATED.md) for the inventory of generated plugin-root outputs. + diff --git a/examples/plugins/grok-basic/GENERATED.md b/examples/plugins/grok-basic/GENERATED.md new file mode 100644 index 000000000..487b6534a --- /dev/null +++ b/examples/plugins/grok-basic/GENERATED.md @@ -0,0 +1,31 @@ +# Generated Outputs + +This file is generated by `plugin-kit-ai generate`. +Do not edit the paths below by hand. Edit only `plugin/`, then regenerate. + +This inventory covers the full plugin package across all enabled targets. + +## Boundary Docs + +These committed root docs are guidance files with a managed block, not plain generated outputs: + +- `CLAUDE.md` +- `AGENTS.md` + +## Managed Generated Outputs + +`README.md` is a generated root entrypoint that points readers to `plugin/README.md`. + +- `.grok-plugin/plugin.json` +- `.mcp.json` +- `GENERATED.md` +- `README.md` +- `skills/release-checks/SKILL.md` + +## Refresh + +```bash +plugin-kit-ai normalize . +plugin-kit-ai generate . +plugin-kit-ai generate --check . +``` diff --git a/examples/plugins/grok-basic/README.md b/examples/plugins/grok-basic/README.md new file mode 100644 index 000000000..760734add --- /dev/null +++ b/examples/plugins/grok-basic/README.md @@ -0,0 +1,13 @@ + +# grok-basic + +This file is generated by `plugin-kit-ai generate`. +Do not edit it by hand. Edit files under `plugin/`, especially [`plugin/README.md`](./plugin/README.md). + +Start here: + +- full plugin guide: [`plugin/README.md`](./plugin/README.md) +- boundary instructions for humans and agents: [`AGENTS.md`](./AGENTS.md) +- generated root output inventory: [`GENERATED.md`](./GENERATED.md) + +This plugin root is the native/generated output surface for the supported targets. diff --git a/examples/plugins/grok-basic/plugin/README.md b/examples/plugins/grok-basic/plugin/README.md new file mode 100644 index 000000000..61e8a3032 --- /dev/null +++ b/examples/plugins/grok-basic/plugin/README.md @@ -0,0 +1,17 @@ +# grok-basic + +Reference Grok Build packaged-plugin example for `plugin-kit-ai`. + +This example demonstrates the current packaged Grok subset: + +- portable `plugin/skills/**` generated into root `skills/**` +- portable `plugin/mcp/servers.yaml` generated into the wrapped `.mcp.json` sidecar +- generated `.grok-plugin/plugin.json` references the managed sidecar via `"mcpServers": "./.mcp.json"` +- root `CLAUDE.md` and `AGENTS.md` remain boundary docs that point back to `plugin/` + +Validate it with: + +```bash +plugin-kit-ai generate --check . +plugin-kit-ai validate . --platform grok --strict +``` diff --git a/examples/plugins/grok-basic/plugin/mcp/servers.yaml b/examples/plugins/grok-basic/plugin/mcp/servers.yaml new file mode 100644 index 000000000..fba0c20e1 --- /dev/null +++ b/examples/plugins/grok-basic/plugin/mcp/servers.yaml @@ -0,0 +1,9 @@ +api_version: v1 + +servers: + release-checks: + type: stdio + stdio: + command: node + args: + - "${package.root}/bin/release-checks.mjs" diff --git a/examples/plugins/grok-basic/plugin/plugin.yaml b/examples/plugins/grok-basic/plugin/plugin.yaml new file mode 100644 index 000000000..5420dad20 --- /dev/null +++ b/examples/plugins/grok-basic/plugin/plugin.yaml @@ -0,0 +1,6 @@ +api_version: v1 +name: grok-basic +version: 0.1.0 +description: Grok packaged plugin example +targets: + - grok diff --git a/examples/plugins/grok-basic/plugin/skills/release-checks/SKILL.md b/examples/plugins/grok-basic/plugin/skills/release-checks/SKILL.md new file mode 100644 index 000000000..73c3211be --- /dev/null +++ b/examples/plugins/grok-basic/plugin/skills/release-checks/SKILL.md @@ -0,0 +1,15 @@ +--- +name: release-checks +description: Portable release checklist skill for the Grok packaged example. +execution_mode: docs_only +supported_agents: + - claude + - codex + - gemini + - cursor + - grok +--- + +# Release Checks + +Use this skill when you need to review release readiness before shipping. diff --git a/examples/plugins/grok-basic/skills/release-checks/SKILL.md b/examples/plugins/grok-basic/skills/release-checks/SKILL.md new file mode 100644 index 000000000..73c3211be --- /dev/null +++ b/examples/plugins/grok-basic/skills/release-checks/SKILL.md @@ -0,0 +1,15 @@ +--- +name: release-checks +description: Portable release checklist skill for the Grok packaged example. +execution_mode: docs_only +supported_agents: + - claude + - codex + - gemini + - cursor + - grok +--- + +# Release Checks + +Use this skill when you need to review release readiness before shipping. diff --git a/examples/plugins/kimi-basic/.kimi-plugin/plugin.json b/examples/plugins/kimi-basic/.kimi-plugin/plugin.json new file mode 100644 index 000000000..ead0efb6d --- /dev/null +++ b/examples/plugins/kimi-basic/.kimi-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "description": "Kimi packaged plugin example", + "mcpServers": { + "release-checks": { + "args": [ + "./bin/release-checks.mjs" + ], + "command": "node" + } + }, + "name": "kimi-basic", + "skills": "./skills/", + "version": "0.1.0" +} diff --git a/examples/plugins/kimi-basic/AGENTS.md b/examples/plugins/kimi-basic/AGENTS.md new file mode 100644 index 000000000..e92bd9183 --- /dev/null +++ b/examples/plugins/kimi-basic/AGENTS.md @@ -0,0 +1,22 @@ + +# Plugin Boundary + +This plugin uses the canonical `plugin/` authoring layout. + +- Edit only files under `plugin/`. +- Start with [`plugin/README.md`](./plugin/README.md) for the full plugin guide. +- Read [`GENERATED.md`](./GENERATED.md) before touching plugin-root files. It is the required inventory of managed generated outputs. +- Treat plugin-root `README.md`, manifests, and config files as generated outputs. +- `plugin-kit-ai generate` may overwrite every path listed in `GENERATED.md`. + +After changing `plugin/`, run: + +```bash +plugin-kit-ai normalize . +plugin-kit-ai generate . +plugin-kit-ai generate --check . +plugin-kit-ai inspect . --format json +``` + +Then run `plugin-kit-ai validate . --platform --strict` for each enabled target listed in the inspection output. + diff --git a/examples/plugins/kimi-basic/CLAUDE.md b/examples/plugins/kimi-basic/CLAUDE.md new file mode 100644 index 000000000..f8b193ffb --- /dev/null +++ b/examples/plugins/kimi-basic/CLAUDE.md @@ -0,0 +1,9 @@ + +# Claude Boundary + +Claude should treat [`AGENTS.md`](./AGENTS.md) as the primary boundary document for this plugin. + +- Edit only files under `plugin/`. +- Use [`plugin/README.md`](./plugin/README.md) for the full plugin guide. +- Use [`GENERATED.md`](./GENERATED.md) for the inventory of generated plugin-root outputs. + diff --git a/examples/plugins/kimi-basic/GENERATED.md b/examples/plugins/kimi-basic/GENERATED.md new file mode 100644 index 000000000..2ed341734 --- /dev/null +++ b/examples/plugins/kimi-basic/GENERATED.md @@ -0,0 +1,30 @@ +# Generated Outputs + +This file is generated by `plugin-kit-ai generate`. +Do not edit the paths below by hand. Edit only `plugin/`, then regenerate. + +This inventory covers the full plugin package across all enabled targets. + +## Boundary Docs + +These committed root docs are guidance files with a managed block, not plain generated outputs: + +- `CLAUDE.md` +- `AGENTS.md` + +## Managed Generated Outputs + +`README.md` is a generated root entrypoint that points readers to `plugin/README.md`. + +- `.kimi-plugin/plugin.json` +- `GENERATED.md` +- `README.md` +- `skills/release-checks/SKILL.md` + +## Refresh + +```bash +plugin-kit-ai normalize . +plugin-kit-ai generate . +plugin-kit-ai generate --check . +``` diff --git a/examples/plugins/kimi-basic/README.md b/examples/plugins/kimi-basic/README.md new file mode 100644 index 000000000..fb5d8dd31 --- /dev/null +++ b/examples/plugins/kimi-basic/README.md @@ -0,0 +1,13 @@ + +# kimi-basic + +This file is generated by `plugin-kit-ai generate`. +Do not edit it by hand. Edit files under `plugin/`, especially [`plugin/README.md`](./plugin/README.md). + +Start here: + +- full plugin guide: [`plugin/README.md`](./plugin/README.md) +- boundary instructions for humans and agents: [`AGENTS.md`](./AGENTS.md) +- generated root output inventory: [`GENERATED.md`](./GENERATED.md) + +This plugin root is the native/generated output surface for the supported targets. diff --git a/examples/plugins/kimi-basic/plugin/README.md b/examples/plugins/kimi-basic/plugin/README.md new file mode 100644 index 000000000..76e092246 --- /dev/null +++ b/examples/plugins/kimi-basic/plugin/README.md @@ -0,0 +1,17 @@ +# kimi-basic + +Reference Kimi Code packaged-plugin example for `plugin-kit-ai`. + +This example demonstrates the current packaged Kimi subset: + +- portable `plugin/skills/**` generated into root `skills/**` +- portable `plugin/mcp/servers.yaml` inlined into `.kimi-plugin/plugin.json#mcpServers` +- Kimi does not resolve MCP file refs, so no `.mcp.json` sidecar is generated +- root `CLAUDE.md` and `AGENTS.md` remain boundary docs that point back to `plugin/` + +Validate it with: + +```bash +plugin-kit-ai generate --check . +plugin-kit-ai validate . --platform kimi --strict +``` diff --git a/examples/plugins/kimi-basic/plugin/mcp/servers.yaml b/examples/plugins/kimi-basic/plugin/mcp/servers.yaml new file mode 100644 index 000000000..fba0c20e1 --- /dev/null +++ b/examples/plugins/kimi-basic/plugin/mcp/servers.yaml @@ -0,0 +1,9 @@ +api_version: v1 + +servers: + release-checks: + type: stdio + stdio: + command: node + args: + - "${package.root}/bin/release-checks.mjs" diff --git a/examples/plugins/kimi-basic/plugin/plugin.yaml b/examples/plugins/kimi-basic/plugin/plugin.yaml new file mode 100644 index 000000000..3f450a896 --- /dev/null +++ b/examples/plugins/kimi-basic/plugin/plugin.yaml @@ -0,0 +1,6 @@ +api_version: v1 +name: kimi-basic +version: 0.1.0 +description: Kimi packaged plugin example +targets: + - kimi diff --git a/examples/plugins/kimi-basic/plugin/skills/release-checks/SKILL.md b/examples/plugins/kimi-basic/plugin/skills/release-checks/SKILL.md new file mode 100644 index 000000000..bcab740d7 --- /dev/null +++ b/examples/plugins/kimi-basic/plugin/skills/release-checks/SKILL.md @@ -0,0 +1,15 @@ +--- +name: release-checks +description: Portable release checklist skill for the Kimi packaged example. +execution_mode: docs_only +supported_agents: + - claude + - codex + - gemini + - cursor + - kimi +--- + +# Release Checks + +Use this skill when you need to review release readiness before shipping. diff --git a/examples/plugins/kimi-basic/skills/release-checks/SKILL.md b/examples/plugins/kimi-basic/skills/release-checks/SKILL.md new file mode 100644 index 000000000..bcab740d7 --- /dev/null +++ b/examples/plugins/kimi-basic/skills/release-checks/SKILL.md @@ -0,0 +1,15 @@ +--- +name: release-checks +description: Portable release checklist skill for the Kimi packaged example. +execution_mode: docs_only +supported_agents: + - claude + - codex + - gemini + - cursor + - kimi +--- + +# Release Checks + +Use this skill when you need to review release readiness before shipping. diff --git a/repotests/plugin_manifest_lifecycle_integration_test.go b/repotests/plugin_manifest_lifecycle_integration_test.go index db05e8b72..814637dbd 100644 --- a/repotests/plugin_manifest_lifecycle_integration_test.go +++ b/repotests/plugin_manifest_lifecycle_integration_test.go @@ -588,6 +588,157 @@ func mustCopyPluginLifecycleFile(t *testing.T, src, dst string) { } } +func TestPluginKitAIImportGrokPackagedLayoutRoundTrip(t *testing.T) { + pluginKitAIBin := buildPluginKitAI(t) + plugRoot := t.TempDir() + + if err := os.MkdirAll(filepath.Join(plugRoot, ".grok-plugin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(plugRoot, "skills", "release-checks"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(plugRoot, ".grok-plugin", "plugin.json"), []byte(`{"name":"demo","version":"0.1.0","description":"demo","skills":"./skills/","mcpServers":"./.mcp.json"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(plugRoot, ".mcp.json"), []byte(`{"mcpServers":{"release-checks":{"command":"node","args":["./bin/release-checks.mjs"]}}}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(plugRoot, "skills", "release-checks", "SKILL.md"), []byte("---\nname: release-checks\ndescription: release checks\n---\n\nUse this skill.\n"), 0o644); err != nil { + t.Fatal(err) + } + + importCmd := exec.Command(pluginKitAIBin, "import", plugRoot, "--from", "grok") + importCmd.Env = append(os.Environ(), "GOWORK=off") + out, err := importCmd.CombinedOutput() + if err != nil { + t.Fatalf("plugin-kit-ai import grok: %v\n%s", err, out) + } + + serversBody, err := os.ReadFile(filepath.Join(plugRoot, authoredRel("mcp", "servers.yaml"))) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(serversBody), "release-checks:") || !strings.Contains(string(serversBody), "node") { + t.Fatalf("imported Grok portable MCP = %q", serversBody) + } + if _, err := os.Stat(filepath.Join(plugRoot, authoredRel("skills", "release-checks", "SKILL.md"))); err != nil { + t.Fatalf("missing imported Grok portable skill: %v", err) + } + + generateCmd := exec.Command(pluginKitAIBin, "generate", plugRoot) + generateCmd.Env = append(os.Environ(), "GOWORK=off") + if out, err := generateCmd.CombinedOutput(); err != nil { + t.Fatalf("plugin-kit-ai generate after Grok import: %v\n%s", err, out) + } + + generateCmd = exec.Command(pluginKitAIBin, "generate", plugRoot, "--check") + generateCmd.Env = append(os.Environ(), "GOWORK=off") + if out, err := generateCmd.CombinedOutput(); err != nil { + t.Fatalf("plugin-kit-ai generate --check after Grok import: %v\n%s", err, out) + } + + validateCmd := exec.Command(pluginKitAIBin, "validate", plugRoot, "--platform", "grok", "--strict") + validateCmd.Env = append(os.Environ(), "GOWORK=off") + if out, err := validateCmd.CombinedOutput(); err != nil { + t.Fatalf("plugin-kit-ai validate after Grok import: %v\n%s", err, out) + } + + renderedPlugin, err := os.ReadFile(filepath.Join(plugRoot, ".grok-plugin", "plugin.json")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(renderedPlugin), `"mcpServers": "./.mcp.json"`) { + t.Fatalf("generated Grok plugin must reference the managed MCP sidecar:\n%s", renderedPlugin) + } + sidecarBody, err := os.ReadFile(filepath.Join(plugRoot, ".mcp.json")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(sidecarBody), `"mcpServers"`) || !strings.Contains(string(sidecarBody), "release-checks") { + t.Fatalf("generated Grok MCP sidecar must wrap servers:\n%s", sidecarBody) + } +} + +func TestPluginKitAIImportKimiPackagedLayoutRoundTrip(t *testing.T) { + pluginKitAIBin := buildPluginKitAI(t) + plugRoot := t.TempDir() + + if err := os.MkdirAll(filepath.Join(plugRoot, ".kimi-plugin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(plugRoot, "skills", "release-checks"), 0o755); err != nil { + t.Fatal(err) + } + // Root kimi.plugin.json wins over the packaged copy when both exist. + if err := os.WriteFile(filepath.Join(plugRoot, ".kimi-plugin", "plugin.json"), []byte(`{"name":"stale","version":"0.0.0","description":"stale copy"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(plugRoot, "kimi.plugin.json"), []byte(`{"name":"demo","version":"0.1.0","description":"demo","skills":"./skills/","mcpServers":{"docs":{"type":"http","url":"https://example.com/mcp"}}}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(plugRoot, "skills", "release-checks", "SKILL.md"), []byte("---\nname: release-checks\ndescription: release checks\n---\n\nUse this skill.\n"), 0o644); err != nil { + t.Fatal(err) + } + + importCmd := exec.Command(pluginKitAIBin, "import", plugRoot, "--from", "kimi") + importCmd.Env = append(os.Environ(), "GOWORK=off") + out, err := importCmd.CombinedOutput() + if err != nil { + t.Fatalf("plugin-kit-ai import kimi: %v\n%s", err, out) + } + + manifestBody, err := os.ReadFile(filepath.Join(plugRoot, authoredRel("plugin.yaml"))) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(manifestBody), "name: demo") { + t.Fatalf("import must read the root kimi.plugin.json, not the stale packaged copy:\n%s", manifestBody) + } + serversBody, err := os.ReadFile(filepath.Join(plugRoot, authoredRel("mcp", "servers.yaml"))) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(serversBody), "docs:") || !strings.Contains(string(serversBody), "https://example.com/mcp") { + t.Fatalf("imported Kimi portable MCP = %q", serversBody) + } + if _, err := os.Stat(filepath.Join(plugRoot, authoredRel("skills", "release-checks", "SKILL.md"))); err != nil { + t.Fatalf("missing imported Kimi portable skill: %v", err) + } + + generateCmd := exec.Command(pluginKitAIBin, "generate", plugRoot) + generateCmd.Env = append(os.Environ(), "GOWORK=off") + if out, err := generateCmd.CombinedOutput(); err != nil { + t.Fatalf("plugin-kit-ai generate after Kimi import: %v\n%s", err, out) + } + + renderedPlugin, err := os.ReadFile(filepath.Join(plugRoot, "kimi.plugin.json")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(renderedPlugin), "docs") || !strings.Contains(string(renderedPlugin), `"mcpServers"`) { + t.Fatalf("generate must update the winning root kimi.plugin.json with inline MCP:\n%s", renderedPlugin) + } + if _, err := os.Stat(filepath.Join(plugRoot, ".kimi-plugin", "plugin.json")); err == nil { + t.Fatal("generate must prune the stale packaged manifest copy so the root manifest stays authoritative") + } + if _, err := os.Stat(filepath.Join(plugRoot, ".mcp.json")); err == nil { + t.Fatal("Kimi must not generate an MCP sidecar; servers are inline in the manifest") + } + + generateCmd = exec.Command(pluginKitAIBin, "generate", plugRoot, "--check") + generateCmd.Env = append(os.Environ(), "GOWORK=off") + if out, err := generateCmd.CombinedOutput(); err != nil { + t.Fatalf("plugin-kit-ai generate --check after Kimi import: %v\n%s", err, out) + } + + validateCmd := exec.Command(pluginKitAIBin, "validate", plugRoot, "--platform", "kimi", "--strict") + validateCmd.Env = append(os.Environ(), "GOWORK=off") + if out, err := validateCmd.CombinedOutput(); err != nil { + t.Fatalf("plugin-kit-ai validate after Kimi import: %v\n%s", err, out) + } +} + func TestPluginKitAIHelpDoesNotExposeRemovedCommand(t *testing.T) { pluginKitAIBin := buildPluginKitAI(t) cmd := exec.Command(pluginKitAIBin, "--help") diff --git a/repotests/production_examples_integration_test.go b/repotests/production_examples_integration_test.go index b261c0864..7a79c8078 100644 --- a/repotests/production_examples_integration_test.go +++ b/repotests/production_examples_integration_test.go @@ -63,12 +63,22 @@ func TestProductionExamples_RenderValidateBuildAndSmoke(t *testing.T) { dir: filepath.Join(root, "examples", "plugins", "qoder-basic"), platform: "qoder", }, + { + name: "grok", + dir: filepath.Join(root, "examples", "plugins", "grok-basic"), + platform: "grok", + }, + { + name: "kimi", + dir: filepath.Join(root, "examples", "plugins", "kimi-basic"), + platform: "kimi", + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { workDir := tc.dir - if tc.buildGo || tc.platform == "codex-package" || tc.platform == "gemini" || tc.platform == "cursor" || tc.platform == "opencode" || tc.platform == "qoder" { + if tc.buildGo || tc.platform == "codex-package" || tc.platform == "gemini" || tc.platform == "cursor" || tc.platform == "opencode" || tc.platform == "qoder" || tc.platform == "grok" || tc.platform == "kimi" { workDir = filepath.Join(t.TempDir(), filepath.Base(tc.dir)) copyTree(t, tc.dir, workDir) } @@ -99,6 +109,14 @@ func TestProductionExamples_RenderValidateBuildAndSmoke(t *testing.T) { assertQoderConfig(t, workDir, "qoder-basic") return } + if tc.platform == "grok" { + assertGrokConfig(t, workDir, "grok-basic") + return + } + if tc.platform == "kimi" { + assertKimiConfig(t, workDir, "kimi-basic") + return + } if !tc.buildGo { return } @@ -323,6 +341,106 @@ func assertQoderConfig(t *testing.T, root, wantName string) { } } +func assertGrokConfig(t *testing.T, root, wantName string) { + t.Helper() + manifestBody, err := os.ReadFile(filepath.Join(root, ".grok-plugin", "plugin.json")) + if err != nil { + t.Fatal(err) + } + var manifest map[string]any + if err := json.Unmarshal(manifestBody, &manifest); err != nil { + t.Fatalf("parse grok plugin manifest: %v\n%s", err, manifestBody) + } + if manifest["name"] != wantName { + t.Fatalf(".grok-plugin/plugin.json name = %q want %q", manifest["name"], wantName) + } + if manifest["mcpServers"] != "./.mcp.json" { + t.Fatalf(".grok-plugin/plugin.json mcpServers = %+v", manifest["mcpServers"]) + } + if manifest["skills"] != "./skills/" { + t.Fatalf(".grok-plugin/plugin.json skills = %+v", manifest["skills"]) + } + body, err := os.ReadFile(filepath.Join(root, ".mcp.json")) + if err != nil { + t.Fatal(err) + } + var doc struct { + MCPServers map[string]struct { + Command string `json:"command"` + Args []string `json:"args"` + } `json:"mcpServers"` + } + if err := json.Unmarshal(body, &doc); err != nil { + t.Fatalf("parse .mcp.json: %v\n%s", err, body) + } + server, ok := doc.MCPServers["release-checks"] + if !ok { + t.Fatalf(".mcp.json missing release-checks server: %s", body) + } + if server.Command != "node" { + t.Fatalf("release-checks command = %q want %q", server.Command, "node") + } + if len(server.Args) != 1 || server.Args[0] != "./bin/release-checks.mjs" { + t.Fatalf("release-checks args = %v", server.Args) + } + if _, err := os.Stat(filepath.Join(root, "skills", "release-checks", "SKILL.md")); err != nil { + t.Fatalf("stat generated grok skill: %v", err) + } + generatedBody, err := os.ReadFile(filepath.Join(root, "GENERATED.md")) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(generatedBody, []byte(".grok-plugin/plugin.json")) { + t.Fatalf("unexpected GENERATED.md:\n%s", generatedBody) + } +} + +func assertKimiConfig(t *testing.T, root, wantName string) { + t.Helper() + manifestBody, err := os.ReadFile(filepath.Join(root, ".kimi-plugin", "plugin.json")) + if err != nil { + t.Fatal(err) + } + var manifest struct { + Name string `json:"name"` + Skills string `json:"skills"` + MCPServers struct { + ReleaseChecks struct { + Command string `json:"command"` + Args []string `json:"args"` + } `json:"release-checks"` + } `json:"mcpServers"` + } + if err := json.Unmarshal(manifestBody, &manifest); err != nil { + t.Fatalf("parse kimi plugin manifest: %v\n%s", err, manifestBody) + } + if manifest.Name != wantName { + t.Fatalf(".kimi-plugin/plugin.json name = %q want %q", manifest.Name, wantName) + } + if manifest.Skills != "./skills/" { + t.Fatalf(".kimi-plugin/plugin.json skills = %+v", manifest.Skills) + } + if manifest.MCPServers.ReleaseChecks.Command != "node" { + t.Fatalf(".kimi-plugin/plugin.json must inline the release-checks MCP server: %s", manifestBody) + } + if len(manifest.MCPServers.ReleaseChecks.Args) != 1 || manifest.MCPServers.ReleaseChecks.Args[0] != "./bin/release-checks.mjs" { + t.Fatalf("release-checks args = %v", manifest.MCPServers.ReleaseChecks.Args) + } + if _, err := os.Stat(filepath.Join(root, ".mcp.json")); err == nil { + t.Fatal("Kimi must not generate an MCP sidecar; servers are inline in the manifest") + } + if _, err := os.Stat(filepath.Join(root, "skills", "release-checks", "SKILL.md")); err != nil { + t.Fatalf("stat generated kimi skill: %v", err) + } + generatedBody, err := os.ReadFile(filepath.Join(root, "GENERATED.md")) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(generatedBody, []byte(".kimi-plugin/plugin.json")) { + t.Fatalf("unexpected GENERATED.md:\n%s", generatedBody) + } +} + func assertCodexPackageManifest(t *testing.T, root, wantName string) { t.Helper() body, err := os.ReadFile(filepath.Join(root, ".codex-plugin", "plugin.json")) From 0a81e22b705975cd3b4f1e0bcfa7a3de5d050151 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 19 Oct 2026 15:22:12 +0300 Subject: [PATCH 05/13] fix(security): reject symlinked bundle reads and contain generated paths Bundle import and validation now read only regular files with a size bound and report failures by label, so symlinked skills or MCP sidecars can no longer pull host file content into artifacts and os errors no longer leak host paths. Write, prune, and managed-copy paths are checked against the plugin root, dangling mcpServers refs fail import, Grok claims the managed .mcp.json sidecar for stale pruning, and Kimi docs state the root-manifest precedence contract. --- cli/README.md | 14 ++- .../inspect_authoring_summary.go | 4 + .../platformexec/helpers_artifacts_copy.go | 24 ++++-- .../platformexec/helpers_artifacts_paths.go | 52 +++++++++++- cli/internal/platformexec/packaged_plugin.go | 38 ++++++--- .../platformexec/packaged_plugin_test.go | 85 +++++++++++++++++++ cli/internal/pluginmanifest/render_apply.go | 26 +++++- .../pluginmanifest/render_apply_test.go | 58 +++++++++++++ cli/internal/pluginmanifest/shared.go | 6 +- .../scaffold/runtime_files_platform.go | 8 +- docs/generated/target_support_matrix.md | 2 +- ...gin_manifest_lifecycle_integration_test.go | 17 ++-- sdk/platformmeta/platformmeta_profile_kimi.go | 2 +- 13 files changed, 297 insertions(+), 39 deletions(-) create mode 100644 cli/internal/pluginmanifest/render_apply_test.go diff --git a/cli/README.md b/cli/README.md index 3a4fd3c17..f5e3a196f 100644 --- a/cli/README.md +++ b/cli/README.md @@ -95,6 +95,8 @@ go build -o bin/plugin-kit-ai ./cli/cmd/plugin-kit-ai | OpenCode workspace-config lane | `opencode` | | Cursor plugin package lane | `cursor` | | Qoder plugin package lane | `qoder` | +| Grok plugin package lane | `grok` | +| Kimi plugin package lane | `kimi` | Choose the lane that matches the delivery model you need today. Expand later from the same repo when the product needs more outputs. @@ -178,7 +180,7 @@ For migrating current Claude/Codex/Gemini/OpenCode/Cursor native files into the Current behavior and contract details: -- `init`: package-standard scaffold for `codex-runtime`, `codex-package`, `claude`, `gemini`, `opencode`, or `cursor`; launcher-based targets support Go-first or executable runtimes, Gemini defaults to launcher-less packaging but also supports `--runtime go` for the promoted 9-hook runtime lane, while Codex package, OpenCode, and Cursor stay launcher-less; Codex package extras now include first-class `interface.json`, `app.json`, and `manifest.extra.json` starters alongside richer package metadata authoring, and the empty Codex app starter stays inactive until you replace it with a real app manifest +- `init`: package-standard scaffold for `codex-runtime`, `codex-package`, `claude`, `gemini`, `opencode`, `cursor`, `qoder`, `grok`, or `kimi`; launcher-based targets support Go-first or executable runtimes, Gemini defaults to launcher-less packaging but also supports `--runtime go` for the promoted 9-hook runtime lane, while Codex package, OpenCode, Cursor, Qoder, Grok, and Kimi stay launcher-less; Codex package extras now include first-class `interface.json`, `app.json`, and `manifest.extra.json` starters alongside richer package metadata authoring, and the empty Codex app starter stays inactive until you replace it with a real app manifest - `bootstrap`: stable repo-local first-run helper for `python` and `node` launcher-based projects on `codex-runtime` and `claude`; `public-beta` for `shell`; no-op for `go`, `codex-package`, and `gemini` - `doctor`: stable read-only readiness check for `python` and `node` launcher-based projects on `codex-runtime` and `claude`; `public-beta` for `shell` - `dev`: `public-beta` watch loop for launcher-based runtime targets; auto-generates, performs runtime-aware rebuilds, runs strict validation, and reruns the selected stable fixtures; supports `--once` for a single cycle @@ -198,8 +200,11 @@ Current behavior and contract details: - `init --platform gemini`: richer packaging starter with `targets/gemini/package.yaml` and `targets/gemini/contexts/GEMINI.md`; add `--runtime go` for the optional Gemini Go production-ready 9-hook runtime lane - `init --platform opencode`: workspace-config starter with `targets/opencode/package.yaml`, optional OpenCode command/agent/theme starters, and an OpenCode-compatible skill stub when `--extras` is used; OpenCode extras now use official-style named async plugin exports, and helper-based custom tools belong in `targets/opencode/package.json`; no launcher/runtime scaffold - `init --platform cursor`: packaged Cursor plugin starter with `plugin/plugin.yaml`, root boundary docs `CLAUDE.md` and `AGENTS.md`, optional portable `plugin/mcp/servers.yaml` and `plugin/skills/**` when `--extras` is used, and generated `.cursor-plugin/plugin.json`; `cursor-workspace` stays the secondary repo-local `.cursor/` config lane -- `generate`: generate native Claude artifacts, Codex package/runtime lane artifacts, Gemini CLI extension packaging artifacts, OpenCode workspace config artifacts, packaged Cursor plugin artifacts, and the secondary Cursor workspace config artifacts from canonical `plugin/plugin.yaml`, optional `plugin/mcp/servers.yaml`, optional `plugin/skills/**`, and `plugin/targets//...`. When `plugin/publish/codex/marketplace.yaml` is authored for `codex-package`, `generate` also produces repo-level `.agents/plugins/marketplace.json`, and when `plugin/publish/claude/marketplace.yaml` is authored for `claude`, `generate` also produces `.claude-plugin/marketplace.json` -- `import`: create the package-standard authored layout from current native Claude/Codex/Gemini/OpenCode/Cursor artifacts; Gemini import preserves the extension package lane and optional Go runtime metadata when present, OpenCode import remains workspace-config-only with explicit `--include-user-scope` support for home-dir OpenCode sources, and Cursor import remains workspace-config-only for the documented `.cursor` subset +- `init --platform qoder`: packaged Qoder plugin starter with `plugin/plugin.yaml`, root boundary docs, optional portable `plugin/mcp/servers.yaml` and `plugin/skills/**` when `--extras` is used, and generated `.qoder-plugin/plugin.json` +- `init --platform grok`: packaged Grok plugin starter with `plugin/plugin.yaml`, root boundary docs, optional portable `plugin/mcp/servers.yaml` and `plugin/skills/**` when `--extras` is used, and generated `.grok-plugin/plugin.json`; portable MCP is projected as a wrapped `.mcp.json` sidecar referenced from the manifest +- `init --platform kimi`: packaged Kimi plugin starter with `plugin/plugin.yaml`, root boundary docs, optional portable `plugin/mcp/servers.yaml` and `plugin/skills/**` when `--extras` is used, and generated `.kimi-plugin/plugin.json`; Kimi requires MCP servers inline in the manifest and does not resolve external MCP file refs +- `generate`: generate native Claude artifacts, Codex package/runtime lane artifacts, Gemini CLI extension packaging artifacts, OpenCode workspace config artifacts, packaged Cursor plugin artifacts, the secondary Cursor workspace config artifacts, packaged Qoder plugin artifacts, packaged Grok plugin artifacts, and packaged Kimi plugin artifacts from canonical `plugin/plugin.yaml`, optional `plugin/mcp/servers.yaml`, optional `plugin/skills/**`, and `plugin/targets//...`. When `plugin/publish/codex/marketplace.yaml` is authored for `codex-package`, `generate` also produces repo-level `.agents/plugins/marketplace.json`, and when `plugin/publish/claude/marketplace.yaml` is authored for `claude`, `generate` also produces `.claude-plugin/marketplace.json` +- `import`: create the package-standard authored layout from current native Claude/Codex/Gemini/OpenCode/Cursor/Qoder/Grok/Kimi artifacts; Gemini import preserves the extension package lane and optional Go runtime metadata when present, OpenCode import remains workspace-config-only with explicit `--include-user-scope` support for home-dir OpenCode sources, Cursor import remains workspace-config-only for the documented `.cursor` subset, and Grok/Kimi import recovers portable skills plus portable MCP from the packaged plugin manifests - `inspect`: explain the discovered package graph, target class, managed artifacts, and the publication summary; `--format json` now includes a `publication` block with publication-capable package targets and any authored `publish/...` channels discovered in the repo, including channel metadata such as Codex policies and Gemini gallery distribution hints - `publish`: first-class bounded publish workflow for documented publication channels; `publish --channel codex-marketplace|claude-marketplace --dest ` materializes the corresponding local marketplace root, `--dry-run` previews the write, `publish --channel gemini-gallery --dry-run` emits a repository/release publication plan with bounded Git and GitHub readiness diagnostics instead of pretending Gemini has a local marketplace-root flow, `publish --all --dry-run` orchestrates all authored `publish/...` channels in one combined plan, and `publish --format json` emits the versioned `plugin-kit-ai/publish-report` contract; `publish --all` apply mode stays intentionally absent because local materialization channels and repository/release planning channels still have different execution semantics - `publication`: focused publication-layer view for package-capable targets and authored `publish/...` channels; use it when you want only package/channel publication data without the broader inspect surface; `publication --format json` emits the versioned `plugin-kit-ai/publication-report` contract, `publication doctor --format json` emits `plugin-kit-ai/publication-doctor-report` for readiness gating, `publication doctor --dest ` also verifies an already materialized local Codex/Claude marketplace root, `publication materialize --target codex-package|claude --dest ` builds a safe local marketplace root with a copied package bundle plus merged catalog artifact, `publication remove --target codex-package|claude --dest ` prunes that materialized plugin back out of the local marketplace root, and `--dry-run` on materialize/remove previews those local marketplace mutations without writing @@ -251,6 +256,9 @@ Production-ready target boundary in the current contract: - Gemini: full Gemini CLI extension lane through `generate|import|validate` and local `extensions link|config|disable|enable`, plus an optional production-ready Go runtime lane for `SessionStart`, `SessionEnd`, `BeforeModel`, `AfterModel`, `BeforeToolSelection`, `BeforeAgent`, `AfterAgent`, `BeforeTool`, and `AfterTool`, with dedicated opt-in real CLI runtime smoke via `make test-gemini-runtime-live` - OpenCode: workspace-config-only lane through `generate|import|validate`, package refs, inline MCP, validated portable skills, first-class workspace commands/agents/themes, beta standalone tools, stable local plugin code plus stable shared package metadata for tools and plugins, JSON/JSONC plus explicit user-scope import, permission-first passthrough config semantics, and beta `custom_tools` spanning standalone tools and plugin code - Cursor: packaged plugin lane through `generate|import|validate`, `.cursor-plugin/plugin.json`, root `skills/**`, and optional shared `.mcp.json`, with `cursor-workspace` retained only as the secondary repo-local `.cursor/` subset +- Qoder: packaged plugin lane through `generate|import|validate`, `.qoder-plugin/plugin.json`, root `skills/**`, and optional shared `.mcp.json` +- Grok: packaged plugin lane through `generate|import|validate`, `.grok-plugin/plugin.json`, root `skills/**`, and optional wrapped `.mcp.json` sidecar +- Kimi: packaged plugin lane through `generate|import|validate`, `.kimi-plugin/plugin.json` (root `kimi.plugin.json` wins when both exist), root `skills/**`, and inline-only `mcpServers` Canonical production plugin lane: diff --git a/cli/cmd/plugin-kit-ai/inspect_authoring_summary.go b/cli/cmd/plugin-kit-ai/inspect_authoring_summary.go index 5c5dbe162..8d686d207 100644 --- a/cli/cmd/plugin-kit-ai/inspect_authoring_summary.go +++ b/cli/cmd/plugin-kit-ai/inspect_authoring_summary.go @@ -74,6 +74,10 @@ func inspectAuthoringTargetLabel(target string) string { return "Cursor workspace (cursor-workspace)" case "qoder": return "Qoder (qoder)" + case "grok": + return "Grok Build (grok)" + case "kimi": + return "Kimi Code (kimi)" default: trimmed := strings.TrimSpace(target) if trimmed == "" { diff --git a/cli/internal/platformexec/helpers_artifacts_copy.go b/cli/internal/platformexec/helpers_artifacts_copy.go index 425c639ba..ea11918a2 100644 --- a/cli/internal/platformexec/helpers_artifacts_copy.go +++ b/cli/internal/platformexec/helpers_artifacts_copy.go @@ -1,6 +1,7 @@ package platformexec import ( + "fmt" "io/fs" "os" "path/filepath" @@ -29,14 +30,20 @@ func copyArtifacts(root, srcDir, dstRoot string) ([]pluginmodel.Artifact, error) return nil, nil } err := filepath.WalkDir(full, func(path string, d fs.DirEntry, err error) error { - if err != nil || d == nil || d.IsDir() { + if err != nil || d == nil { return err } - body, err := os.ReadFile(path) + rel, err := filepath.Rel(full, path) if err != nil { return err } - rel, err := filepath.Rel(full, path) + if d.Type()&fs.ModeSymlink != 0 { + return fmt.Errorf("imported %s must not contain symlinks: %s is a symlink", srcDir, filepath.ToSlash(rel)) + } + if d.IsDir() { + return nil + } + body, err := readRegularFile(path, filepath.ToSlash(filepath.Join(srcDir, rel))) if err != nil { return err } @@ -57,7 +64,7 @@ func copySingleArtifactIfExists(root, srcRel, dstRel string) ([]pluginmodel.Arti if strings.TrimSpace(srcRel) == "" { return nil, nil } - body, err := os.ReadFile(filepath.Join(root, srcRel)) + body, err := readRegularFile(filepath.Join(root, srcRel), srcRel) if err != nil { if os.IsNotExist(err) { return nil, nil @@ -93,9 +100,12 @@ func copyArtifactsFromRefs(root string, refs []string, dstRoot string) ([]plugin continue } full := filepath.Join(root, ref) - info, err := os.Stat(full) + info, err := os.Lstat(full) if err != nil { - return nil, err + return nil, fileAccessError(err, ref) + } + if info.Mode()&fs.ModeSymlink != 0 { + return nil, fmt.Errorf("imported %s must not be a symlink", ref) } if info.IsDir() { copied, err := copyArtifacts(root, ref, dstRoot) @@ -105,7 +115,7 @@ func copyArtifactsFromRefs(root string, refs []string, dstRoot string) ([]plugin artifacts = append(artifacts, copied...) continue } - body, err := os.ReadFile(full) + body, err := readRegularFile(full, ref) if err != nil { return nil, err } diff --git a/cli/internal/platformexec/helpers_artifacts_paths.go b/cli/internal/platformexec/helpers_artifacts_paths.go index a13757045..6279f193d 100644 --- a/cli/internal/platformexec/helpers_artifacts_paths.go +++ b/cli/internal/platformexec/helpers_artifacts_paths.go @@ -1,13 +1,63 @@ package platformexec import ( + "errors" "fmt" + "io" "io/fs" + "os" "path/filepath" "slices" "strings" ) +// maxBundleFileBytes bounds how much of an untrusted bundle file import and +// validation will read into memory. +const maxBundleFileBytes = 8 << 20 + +// fileAccessError reports a bundle file access failure by label so host paths +// never leak into user-facing errors. The *fs.PathError shape keeps +// os.IsNotExist and os.IsPermission checks working for callers. +func fileAccessError(err error, label string) error { + kind := err + switch { + case os.IsNotExist(err): + kind = fs.ErrNotExist + case os.IsPermission(err): + kind = fs.ErrPermission + default: + if inner := errors.Unwrap(err); inner != nil { + kind = inner + } + } + return &fs.PathError{Op: "read", Path: label, Err: kind} +} + +// readRegularFile reads a bundle file only when it is a regular file: symlinks +// and devices must never pull host file content into generated artifacts. +func readRegularFile(path, label string) ([]byte, error) { + info, err := os.Lstat(path) + if err != nil { + return nil, fileAccessError(err, label) + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("%s must be a regular file", label) + } + if info.Size() > maxBundleFileBytes { + return nil, fmt.Errorf("%s exceeds the %d-byte bundle file limit", label, maxBundleFileBytes) + } + f, err := os.Open(path) + if err != nil { + return nil, fileAccessError(err, label) + } + defer func() { _ = f.Close() }() + body, err := io.ReadAll(io.LimitReader(f, maxBundleFileBytes+1)) + if err != nil { + return nil, fileAccessError(err, label) + } + return body, nil +} + func discoverFiles(root, dir string, allow func(string) bool) []string { full := filepath.Join(root, dir) var out []string @@ -44,7 +94,7 @@ func resolveRelativeRef(root, ref string) (string, error) { if ref == "" { return "", nil } - if filepath.IsAbs(ref) { + if filepath.IsAbs(ref) || filepath.VolumeName(ref) != "" { return "", fmt.Errorf("ref %q must stay within the plugin root", ref) } cleaned := filepath.Clean(ref) diff --git a/cli/internal/platformexec/packaged_plugin.go b/cli/internal/platformexec/packaged_plugin.go index 8054d7687..890c9732c 100644 --- a/cli/internal/platformexec/packaged_plugin.go +++ b/cli/internal/platformexec/packaged_plugin.go @@ -97,6 +97,12 @@ func (a packagedPluginAdapter) RefineDiscovery(root string, state *pluginmodel.T } func (a packagedPluginAdapter) ManagedPaths(root string, graph pluginmodel.PackageGraph, state pluginmodel.TargetState) ([]string, error) { + if a.mcpLayout == packagedMCPSidecarWrapped { + // The sidecar is managed even when portable MCP is absent: it becomes + // stale (and must be removed) when portable MCP is dropped, otherwise a + // later import resurrects servers from the orphaned sidecar. + return []string{".mcp.json"}, nil + } return nil, nil } @@ -202,16 +208,16 @@ func (a packagedPluginAdapter) Validate(root string, graph pluginmodel.PackageGr func (a packagedPluginAdapter) readManifest(root string) (map[string]any, string, bool, error) { for _, rel := range a.manifestCandidates() { - body, err := os.ReadFile(filepath.Join(root, rel)) + body, err := readRegularFile(filepath.Join(root, rel), rel) if err != nil { if os.IsNotExist(err) { continue } - return nil, "", false, err + return nil, rel, false, err } doc, err := decodeJSONObject(body, a.displayName+" plugin manifest "+rel) if err != nil { - return nil, "", false, err + return nil, rel, false, err } return doc, rel, true, nil } @@ -238,7 +244,14 @@ func (a packagedPluginAdapter) importedMCP(root, manifestRel string, manifest ma if err != nil { return nil, false, fmt.Errorf("unsupported %s plugin manifest %s mcpServers ref %q: %w", a.displayName, manifestRel, ref, err) } - return readWrappedMCPServers(filepath.Join(root, rel), rel) + servers, ok, err := readWrappedMCPServers(filepath.Join(root, rel), rel) + if err != nil { + return nil, false, err + } + if !ok { + return nil, false, fmt.Errorf("%s plugin manifest %s mcpServers ref %q does not exist", a.displayName, manifestRel, ref) + } + return servers, true, nil default: return nil, false, fmt.Errorf("%s plugin manifest %s field %q must be a string ref or object", a.displayName, manifestRel, "mcpServers") } @@ -247,14 +260,14 @@ func (a packagedPluginAdapter) importedMCP(root, manifestRel string, manifest ma func containedPluginRel(ref string) (string, error) { cleaned := filepath.Clean(strings.TrimSpace(ref)) cleaned = strings.TrimPrefix(cleaned, "."+string(filepath.Separator)) - if cleaned == "" || cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) || filepath.IsAbs(cleaned) { + if cleaned == "" || cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) || filepath.IsAbs(cleaned) || filepath.VolumeName(cleaned) != "" { return "", fmt.Errorf("path must stay inside the plugin root") } return filepath.ToSlash(cleaned), nil } func readWrappedMCPServers(path, label string) (map[string]any, bool, error) { - body, err := os.ReadFile(path) + body, err := readRegularFile(path, label) if err != nil { if os.IsNotExist(err) { return nil, false, nil @@ -278,7 +291,10 @@ func readWrappedMCPServers(path, label string) (map[string]any, bool, error) { func (a packagedPluginAdapter) validateManifestRead(root string) (map[string]any, string, []Diagnostic) { manifest, manifestRel, ok, err := a.readManifest(root) if err != nil { - return nil, a.manifestRel, []Diagnostic{a.manifestDiagnostic(CodeManifestInvalid, fmt.Sprintf("%s plugin manifest %s is invalid: %v", a.displayName, a.manifestRel, err))} + if manifestRel == "" { + manifestRel = a.manifestRel + } + return nil, manifestRel, []Diagnostic{a.manifestDiagnostic(CodeManifestInvalid, fmt.Sprintf("%s plugin manifest %s is invalid: %v", a.displayName, manifestRel, err))} } if !ok { return nil, a.manifestRel, []Diagnostic{a.manifestDiagnostic(CodeGeneratedContractInvalid, fmt.Sprintf("%s plugin manifest %s is not readable", a.displayName, a.manifestRel))} @@ -355,7 +371,7 @@ func (a packagedPluginAdapter) validateSkillProjection(root, rel string) []Diagn }} } renderedRel := filepath.ToSlash(filepath.Join("skills", child)) - sourceBody, err := os.ReadFile(filepath.Join(root, rel)) + sourceBody, err := readRegularFile(filepath.Join(root, rel), filepath.ToSlash(rel)) if err != nil { return []Diagnostic{{ Severity: SeverityFailure, @@ -365,7 +381,7 @@ func (a packagedPluginAdapter) validateSkillProjection(root, rel string) []Diagn Message: fmt.Sprintf("%s portable skill source %s is not readable: %v", a.displayName, rel, err), }} } - renderedBody, err := os.ReadFile(filepath.Join(root, renderedRel)) + renderedBody, err := readRegularFile(filepath.Join(root, renderedRel), renderedRel) if err != nil { return []Diagnostic{{ Severity: SeverityFailure, @@ -454,7 +470,7 @@ func (a packagedPluginAdapter) readGeneratedMCP(root string, manifest map[string Code: CodeGeneratedContractInvalid, Path: ".mcp.json", Target: a.id, - Message: fmt.Sprintf("%s plugin MCP manifest .mcp.json is invalid: %v", a.displayName, err), + Message: fmt.Sprintf("%s plugin MCP config .mcp.json is invalid: %v", a.displayName, err), }} } if !readable { @@ -463,7 +479,7 @@ func (a packagedPluginAdapter) readGeneratedMCP(root string, manifest map[string Code: CodeGeneratedContractInvalid, Path: ".mcp.json", Target: a.id, - Message: fmt.Sprintf("%s plugin MCP manifest .mcp.json is not readable", a.displayName), + Message: fmt.Sprintf("%s plugin MCP config .mcp.json is not readable", a.displayName), }} } return servers, nil diff --git a/cli/internal/platformexec/packaged_plugin_test.go b/cli/internal/platformexec/packaged_plugin_test.go index 3c3e80733..0540e8468 100644 --- a/cli/internal/platformexec/packaged_plugin_test.go +++ b/cli/internal/platformexec/packaged_plugin_test.go @@ -3,6 +3,7 @@ package platformexec import ( "os" "path/filepath" + "runtime" "strings" "testing" @@ -247,6 +248,90 @@ func TestContainedPluginRelKeepsDottedNames(t *testing.T) { t.Fatalf("ref %q must be rejected", ref) } } + if runtime.GOOS == "windows" { + if _, err := containedPluginRel("C:escape.json"); err == nil { + t.Fatal("drive-relative ref must be rejected") + } + } +} + +func TestGrokManagedPathsClaimMCPSidecar(t *testing.T) { + t.Parallel() + paths, err := newGrokPluginAdapter().ManagedPaths("", pluginmodel.PackageGraph{}, pluginmodel.NewTargetState("grok")) + if err != nil { + t.Fatal(err) + } + if len(paths) != 1 || paths[0] != ".mcp.json" { + t.Fatalf("grok managed paths = %v; orphaned .mcp.json must be prunable", paths) + } + paths, err = newKimiPluginAdapter().ManagedPaths("", pluginmodel.PackageGraph{}, pluginmodel.NewTargetState("kimi")) + if err != nil { + t.Fatal(err) + } + if len(paths) != 0 { + t.Fatalf("kimi managed paths = %v; inline layout never owns a sidecar", paths) + } +} + +func TestImportRejectsSymlinkedBundleFiles(t *testing.T) { + t.Parallel() + root := t.TempDir() + outside := filepath.Join(t.TempDir(), "secret.txt") + if err := os.WriteFile(outside, []byte("host secret"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, ".grok-plugin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".grok-plugin", "plugin.json"), []byte(`{"name":"demo","mcpServers":"./.mcp.json"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(root, ".mcp.json")); err != nil { + t.Fatal(err) + } + _, err := newGrokPluginAdapter().Import(root, ImportSeed{ + Manifest: pluginmodel.Manifest{Name: "demo", Targets: []string{"grok"}}, + }) + if err == nil || !strings.Contains(err.Error(), "regular file") { + t.Fatalf("symlinked MCP sidecar must be rejected, err = %v", err) + } + + skillDir := filepath.Join(root, "skills", "release-checks") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(skillDir, "SKILL.md")); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(root, ".mcp.json")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".grok-plugin", "plugin.json"), []byte(`{"name":"demo"}`), 0o644); err != nil { + t.Fatal(err) + } + _, err = newGrokPluginAdapter().Import(root, ImportSeed{ + Manifest: pluginmodel.Manifest{Name: "demo", Targets: []string{"grok"}}, + }) + if err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("symlinked skill file must be rejected, err = %v", err) + } +} + +func TestImportRejectsDanglingMCPSidecarRef(t *testing.T) { + t.Parallel() + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".grok-plugin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".grok-plugin", "plugin.json"), []byte(`{"name":"demo","mcpServers":"./.mcp.json"}`), 0o644); err != nil { + t.Fatal(err) + } + _, err := newGrokPluginAdapter().Import(root, ImportSeed{ + Manifest: pluginmodel.Manifest{Name: "demo", Targets: []string{"grok"}}, + }) + if err == nil || !strings.Contains(err.Error(), "does not exist") { + t.Fatalf("dangling mcpServers ref must fail import, err = %v", err) + } } func packagedPluginTestGraph(t *testing.T, root, target string) pluginmodel.PackageGraph { diff --git a/cli/internal/pluginmanifest/render_apply.go b/cli/internal/pluginmanifest/render_apply.go index 5c8442d30..9596447dc 100644 --- a/cli/internal/pluginmanifest/render_apply.go +++ b/cli/internal/pluginmanifest/render_apply.go @@ -2,6 +2,7 @@ package pluginmanifest import ( "bytes" + "fmt" "io/fs" "os" "path/filepath" @@ -9,9 +10,26 @@ import ( "unicode/utf8" ) +// containedArtifactRel normalizes a generated artifact path and rejects +// anything that would escape the plugin root when joined with it: artifact +// rel paths originate from imported untrusted bundles. +func containedArtifactRel(rel string) (string, error) { + cleaned := filepath.Clean(filepath.FromSlash(rel)) + if cleaned == "" || cleaned == "." || cleaned == ".." || + strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) || + filepath.IsAbs(cleaned) || filepath.VolumeName(cleaned) != "" { + return "", fmt.Errorf("artifact path %q must stay inside the plugin root", rel) + } + return cleaned, nil +} + func writeArtifacts(root string, artifacts []Artifact) error { for _, artifact := range artifacts { - full := filepath.Join(root, filepath.FromSlash(artifact.RelPath)) + rel, err := containedArtifactRel(artifact.RelPath) + if err != nil { + return err + } + full := filepath.Join(root, rel) if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { return err } @@ -42,7 +60,11 @@ func generatedArtifactMode(rel string) fs.FileMode { func removeArtifacts(root string, relPaths []string) error { for _, relPath := range relPaths { - full := filepath.Join(root, filepath.FromSlash(relPath)) + rel, err := containedArtifactRel(relPath) + if err != nil { + return err + } + full := filepath.Join(root, rel) if err := os.Remove(full); err != nil && !os.IsNotExist(err) { return err } diff --git a/cli/internal/pluginmanifest/render_apply_test.go b/cli/internal/pluginmanifest/render_apply_test.go new file mode 100644 index 000000000..908c3995c --- /dev/null +++ b/cli/internal/pluginmanifest/render_apply_test.go @@ -0,0 +1,58 @@ +package pluginmanifest + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestContainedArtifactRelRejectsEscapes(t *testing.T) { + t.Parallel() + for _, rel := range []string{"..", "../escape.txt", "/abs.txt", "."} { + if _, err := containedArtifactRel(rel); err == nil { + t.Fatalf("artifact path %q must be rejected", rel) + } + } + if runtime.GOOS == "windows" { + if _, err := containedArtifactRel("C:escape.txt"); err == nil { + t.Fatal("drive-relative artifact path must be rejected") + } + } + cleaned, err := containedArtifactRel("skills/demo/SKILL.md") + if err != nil { + t.Fatal(err) + } + if cleaned != filepath.Clean("skills/demo/SKILL.md") { + t.Fatalf("cleaned = %q", cleaned) + } +} + +func TestWriteAndRemoveArtifactsStayInsideRoot(t *testing.T) { + t.Parallel() + root := t.TempDir() + outside := filepath.Join(t.TempDir(), "victim.txt") + if err := os.WriteFile(outside, []byte("safe"), 0o644); err != nil { + t.Fatal(err) + } + rel, err := filepath.Rel(root, outside) + if err != nil { + t.Fatal(err) + } + if err := writeArtifacts(root, []Artifact{{RelPath: filepath.ToSlash(rel), Content: []byte("pwned")}}); err == nil { + t.Fatal("writeArtifacts must reject paths escaping the plugin root") + } + body, err := os.ReadFile(outside) + if err != nil { + t.Fatal(err) + } + if string(body) != "safe" { + t.Fatalf("outside file was modified: %q", body) + } + if err := removeArtifacts(root, []string{filepath.ToSlash(rel)}); err == nil { + t.Fatal("removeArtifacts must reject paths escaping the plugin root") + } + if _, err := os.Stat(outside); err != nil { + t.Fatalf("outside file must survive: %v", err) + } +} diff --git a/cli/internal/pluginmanifest/shared.go b/cli/internal/pluginmanifest/shared.go index 0e73abcbb..2463a169e 100644 --- a/cli/internal/pluginmanifest/shared.go +++ b/cli/internal/pluginmanifest/shared.go @@ -138,7 +138,11 @@ func addManagedCopies(set map[string]struct{}, files []string, srcDir, dstRoot s if err != nil { continue } - set[filepath.ToSlash(filepath.Join(dstRoot, relPath))] = struct{}{} + joined := filepath.ToSlash(filepath.Join(dstRoot, relPath)) + if _, err := containedArtifactRel(joined); err != nil { + continue + } + set[joined] = struct{}{} } } diff --git a/cli/internal/scaffold/runtime_files_platform.go b/cli/internal/scaffold/runtime_files_platform.go index 739d2c1f3..de9269636 100644 --- a/cli/internal/scaffold/runtime_files_platform.go +++ b/cli/internal/scaffold/runtime_files_platform.go @@ -147,14 +147,14 @@ func qoderPlatformFiles(extras bool) []TemplateFile { } func packagedPluginPlatformFiles(platform string, extras bool) []TemplateFile { - files := []TemplateFile{ - {Path: authoredPath("README.md"), Template: platform + ".README.md.tmpl", Extra: false}, - } + files := make([]TemplateFile, 0, 3) + files = append(files, TemplateFile{Path: authoredPath("README.md"), Template: platform + ".README.md.tmpl", Extra: false}) if !extras { return files } - return append(files, + files = append(files, TemplateFile{Path: authoredPath("mcp/servers.yaml"), Template: "mcp.servers.yaml.tmpl", Extra: true}, TemplateFile{Path: authoredPath("skills/{{.ProjectName}}/SKILL.md"), Template: "SKILL.md.tmpl", Extra: true}, ) + return files } diff --git a/docs/generated/target_support_matrix.md b/docs/generated/target_support_matrix.md index b8627bce0..8fa2e3de9 100644 --- a/docs/generated/target_support_matrix.md +++ b/docs/generated/target_support_matrix.md @@ -9,6 +9,6 @@ | cursor-workspace | code_plugin | workspace_config_lane | ignored | workspace | workspace config files | config authoring workspace | config reload or restart | .cursor/mcp.json | packaging-only target | workspace-config lane with first-class MCP config and project rules | yes | yes | yes | mcp_servers | rules, agents_markdown | agents_markdown=plugin/targets/cursor-workspace/AGENTS.md | mcp=stable, rules=stable, agents_markdown=stable | .cursor/mcp.json (when portable MCP is authored), .cursor/rules/** (when rules are authored) | Cursor workspace compiles as a repo-local config lane with generated .cursor MCP config, project rules, and authored Cursor AGENTS content merged into the generated root AGENTS.md boundary file. | | gemini | extension_package | mcp_extension | optional | extension | copy install | link | restart required | ~/.gemini/extensions/ | production-ready extension packaging lane | production-ready extension packaging plus optional production-ready 9-hook Go runtime and public-beta Notification observer | yes | yes | yes | skills, mcp_servers | package_metadata, hooks, commands, policies, themes, settings, contexts, manifest_extra | package_metadata=plugin/targets/gemini/package.yaml, manifest_extra=plugin/targets/gemini/manifest.extra.json | commands=stable, hooks=stable, policies=stable, settings=stable, themes=stable, contexts=stable, manifest_extra=stable, agents=preview | gemini-extension.json, skills/** (when portable skills are authored), hooks/** (when hooks are authored), commands/** (when commands are authored), policies/** (when policies are authored), contexts/** (when contexts are authored), GEMINI.md or selected root context (when contexts are authored) | Gemini compiles as an official-style extension package with MCP, a primary root context, target-native extension assets, and an optional production-ready Go hook runtime lane. | | grok | ide_plugin | plugin_package | ignored | plugin | grok plugin install --trust or ~/.grok/plugins copy | package authoring workspace | grok plugin list --json or plugin reload | .grok-plugin/plugin.json | packaging-only target | Grok Build plugin bundle with portable skills and optional portable MCP | yes | yes | yes | skills, mcp_servers | - | - | hooks=unsupported, agents=unsupported, commands=unsupported, lsp=unsupported | .grok-plugin/plugin.json, skills/** (when portable skills are authored), .mcp.json (when portable MCP is authored) | Grok plugin packages compile portable skills and optional MCP into the observed .grok-plugin bundle shape. MCP is projected as a wrapped .mcp.json sidecar; hooks, agents, commands, and LSP stay unsupported until they have a first-class authoring surface. | -| kimi | ide_plugin | plugin_package | ignored | plugin | /plugins install or ~/.kimi-code/plugins/managed copy plus installed.json | package authoring workspace | /plugins reload after managed copy | .kimi-plugin/plugin.json | packaging-only target | Kimi Code plugin bundle with portable skills and inline portable MCP | yes | yes | yes | skills, mcp_servers | - | - | commands=unsupported, hooks=unsupported, agents=unsupported, system_prompt=unsupported | .kimi-plugin/plugin.json, skills/** (when portable skills are authored) | Kimi plugin packages compile portable skills and optional MCP into .kimi-plugin/plugin.json. Kimi requires MCP servers inline in the manifest and does not resolve external MCP file refs. Commands, hooks, agents, and systemPrompt stay unsupported until they have a first-class authoring surface. | +| kimi | ide_plugin | plugin_package | ignored | plugin | /plugins install or ~/.kimi-code/plugins/managed copy plus installed.json | package authoring workspace | /plugins reload after managed copy | .kimi-plugin/plugin.json | packaging-only target | Kimi Code plugin bundle with portable skills and inline portable MCP | yes | yes | yes | skills, mcp_servers | - | - | commands=unsupported, hooks=unsupported, agents=unsupported, system_prompt=unsupported | .kimi-plugin/plugin.json, skills/** (when portable skills are authored) | Kimi plugin packages compile portable skills and optional MCP into the Kimi plugin manifest (.kimi-plugin/plugin.json, or root kimi.plugin.json when present: the root copy wins and generate updates the winning file). Kimi requires MCP servers inline in the manifest and does not resolve external MCP file refs. Commands, hooks, agents, and systemPrompt stay unsupported until they have a first-class authoring surface. | | opencode | code_plugin | workspace_config_lane | ignored | workspace | workspace config file | config authoring workspace | config reload or restart | opencode.json | packaging-only target | workspace-config lane with first-class npm plugin refs including tuple-form options, MCP, skills, commands, agents, default_agent, instructions, permission, themes, beta standalone tools with dedicated live evidence, stable official-style local JS/TS plugins plus shared dependency metadata, JSON/JSONC native import, explicit user-scope import, and sanctioned config.extra passthrough for broader product config | yes | yes | yes | skills, mcp_servers | package_metadata, config_extra, default_agent, instructions_config, permission_config, commands, agents, themes, tools, local_plugin_code, local_plugin_dependencies | package_metadata=plugin/targets/opencode/package.yaml, config_extra=plugin/targets/opencode/config.extra.json, default_agent=plugin/targets/opencode/default_agent.txt, instructions_config=plugin/targets/opencode/instructions.yaml, permission_config=plugin/targets/opencode/permission.json, local_plugin_dependencies=plugin/targets/opencode/package.json | plugins=stable, mcp=stable, skills=stable, commands=stable, agents=stable, themes=stable, tools=beta, config_extra=stable, agent_config=stable, default_agent=stable, permission_config=stable, instructions_config=stable, modes=unsupported, local_plugin_code=stable, custom_tools=beta, local_plugin_dependencies=stable | opencode.json, .opencode/package.json, .opencode/skills/** (when portable skills are authored), .opencode/commands/** (when commands are authored), .opencode/agents/** (when agents are authored), .opencode/themes/** (when themes are authored), .opencode/tools/** (when tools are authored), .opencode/plugins/** (when local_plugin_code is authored) | OpenCode compiles as a workspace-config lane with canonical plugin-authored inputs for npm plugin refs including tuple-form options, shared MCP, skills, commands, agents, default_agent, instructions, permission, themes, beta standalone tools with their own live-evidence path, stable official-style local JS/TS plugins plus shared package metadata for tools and plugins, JSON/JSONC native import plus explicit user-scope import, and sanctioned config.extra passthrough for broader product config. | | qoder | ide_plugin | plugin_package | ignored | plugin | qoder plugins install or Desktop marketplace install | package authoring workspace | qoder plugins reload or restart | .qoder-plugin/plugin.json | production-ready package+runtime lane | Qoder plugin bundle with portable skills and optional portable MCP plus target-native hooks and commands, shared by Qoder Desktop and Qoder CLI | yes | yes | yes | skills, mcp_servers | hooks, commands, bin | hooks=plugin/targets/qoder/hooks/hooks.json | commands=stable, agents=unsupported, hooks=stable, output_styles=unsupported | .qoder-plugin/plugin.json, skills/** (when portable skills are authored), hooks/** (when hooks are authored), commands/** (when commands are authored), bin/** (when bin is authored), .mcp.json (when portable MCP is authored) | Qoder plugin packages compile portable skills and optional portable MCP plus target-native hooks and commands into the observed .qoder-plugin bundle shape consumed by both Qoder Desktop and Qoder CLI. | diff --git a/repotests/plugin_manifest_lifecycle_integration_test.go b/repotests/plugin_manifest_lifecycle_integration_test.go index 814637dbd..da99b5c39 100644 --- a/repotests/plugin_manifest_lifecycle_integration_test.go +++ b/repotests/plugin_manifest_lifecycle_integration_test.go @@ -1,6 +1,7 @@ package pluginkitairepo_test import ( + "context" "os" "os/exec" "path/filepath" @@ -608,7 +609,7 @@ func TestPluginKitAIImportGrokPackagedLayoutRoundTrip(t *testing.T) { t.Fatal(err) } - importCmd := exec.Command(pluginKitAIBin, "import", plugRoot, "--from", "grok") + importCmd := exec.CommandContext(context.Background(), pluginKitAIBin, "import", plugRoot, "--from", "grok") importCmd.Env = append(os.Environ(), "GOWORK=off") out, err := importCmd.CombinedOutput() if err != nil { @@ -626,19 +627,19 @@ func TestPluginKitAIImportGrokPackagedLayoutRoundTrip(t *testing.T) { t.Fatalf("missing imported Grok portable skill: %v", err) } - generateCmd := exec.Command(pluginKitAIBin, "generate", plugRoot) + generateCmd := exec.CommandContext(context.Background(), pluginKitAIBin, "generate", plugRoot) generateCmd.Env = append(os.Environ(), "GOWORK=off") if out, err := generateCmd.CombinedOutput(); err != nil { t.Fatalf("plugin-kit-ai generate after Grok import: %v\n%s", err, out) } - generateCmd = exec.Command(pluginKitAIBin, "generate", plugRoot, "--check") + generateCmd = exec.CommandContext(context.Background(), pluginKitAIBin, "generate", plugRoot, "--check") generateCmd.Env = append(os.Environ(), "GOWORK=off") if out, err := generateCmd.CombinedOutput(); err != nil { t.Fatalf("plugin-kit-ai generate --check after Grok import: %v\n%s", err, out) } - validateCmd := exec.Command(pluginKitAIBin, "validate", plugRoot, "--platform", "grok", "--strict") + validateCmd := exec.CommandContext(context.Background(), pluginKitAIBin, "validate", plugRoot, "--platform", "grok", "--strict") validateCmd.Env = append(os.Environ(), "GOWORK=off") if out, err := validateCmd.CombinedOutput(); err != nil { t.Fatalf("plugin-kit-ai validate after Grok import: %v\n%s", err, out) @@ -681,7 +682,7 @@ func TestPluginKitAIImportKimiPackagedLayoutRoundTrip(t *testing.T) { t.Fatal(err) } - importCmd := exec.Command(pluginKitAIBin, "import", plugRoot, "--from", "kimi") + importCmd := exec.CommandContext(context.Background(), pluginKitAIBin, "import", plugRoot, "--from", "kimi") importCmd.Env = append(os.Environ(), "GOWORK=off") out, err := importCmd.CombinedOutput() if err != nil { @@ -706,7 +707,7 @@ func TestPluginKitAIImportKimiPackagedLayoutRoundTrip(t *testing.T) { t.Fatalf("missing imported Kimi portable skill: %v", err) } - generateCmd := exec.Command(pluginKitAIBin, "generate", plugRoot) + generateCmd := exec.CommandContext(context.Background(), pluginKitAIBin, "generate", plugRoot) generateCmd.Env = append(os.Environ(), "GOWORK=off") if out, err := generateCmd.CombinedOutput(); err != nil { t.Fatalf("plugin-kit-ai generate after Kimi import: %v\n%s", err, out) @@ -726,13 +727,13 @@ func TestPluginKitAIImportKimiPackagedLayoutRoundTrip(t *testing.T) { t.Fatal("Kimi must not generate an MCP sidecar; servers are inline in the manifest") } - generateCmd = exec.Command(pluginKitAIBin, "generate", plugRoot, "--check") + generateCmd = exec.CommandContext(context.Background(), pluginKitAIBin, "generate", plugRoot, "--check") generateCmd.Env = append(os.Environ(), "GOWORK=off") if out, err := generateCmd.CombinedOutput(); err != nil { t.Fatalf("plugin-kit-ai generate --check after Kimi import: %v\n%s", err, out) } - validateCmd := exec.Command(pluginKitAIBin, "validate", plugRoot, "--platform", "kimi", "--strict") + validateCmd := exec.CommandContext(context.Background(), pluginKitAIBin, "validate", plugRoot, "--platform", "kimi", "--strict") validateCmd.Env = append(os.Environ(), "GOWORK=off") if out, err := validateCmd.CombinedOutput(); err != nil { t.Fatalf("plugin-kit-ai validate after Kimi import: %v\n%s", err, out) diff --git a/sdk/platformmeta/platformmeta_profile_kimi.go b/sdk/platformmeta/platformmeta_profile_kimi.go index 94178a49b..a54dd7cc9 100644 --- a/sdk/platformmeta/platformmeta_profile_kimi.go +++ b/sdk/platformmeta/platformmeta_profile_kimi.go @@ -18,7 +18,7 @@ func kimiProfile() PlatformProfile { ValidateSupport: true, PortableComponentKinds: []string{"skills", "mcp_servers"}, TargetComponentKinds: []string{}, - Summary: "Kimi plugin packages compile portable skills and optional MCP into .kimi-plugin/plugin.json. Kimi requires MCP servers inline in the manifest and does not resolve external MCP file refs. Commands, hooks, agents, and systemPrompt stay unsupported until they have a first-class authoring surface.", + Summary: "Kimi plugin packages compile portable skills and optional MCP into the Kimi plugin manifest (.kimi-plugin/plugin.json, or root kimi.plugin.json when present: the root copy wins and generate updates the winning file). Kimi requires MCP servers inline in the manifest and does not resolve external MCP file refs. Commands, hooks, agents, and systemPrompt stay unsupported until they have a first-class authoring surface.", }, SDK: SDKMeta{ PublicPackage: "kimi", From 80cfe35abf9686f0f3ec91164da41951815cc7bb Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 19 Oct 2026 15:43:23 +0300 Subject: [PATCH 06/13] refactor(init): split platform option validation to bound complexity The grok/kimi launcher-less platform list pushed validateInitPlatformOptions past the gocyclo limit; extract per-lane checks and share the launcher-less platform predicate. --- cli/internal/app/init_resolve_launcher.go | 66 ++++++++++++++--------- 1 file changed, 41 insertions(+), 25 deletions(-) diff --git a/cli/internal/app/init_resolve_launcher.go b/cli/internal/app/init_resolve_launcher.go index 2943074d4..a486c4acf 100644 --- a/cli/internal/app/init_resolve_launcher.go +++ b/cli/internal/app/init_resolve_launcher.go @@ -44,38 +44,54 @@ func normalizeCustomLogicDefaults(opts InitOptions, config *resolvedInitConfig) func validateInitPlatformOptions(name string, opts InitOptions, config resolvedInitConfig) error { switch config.Platform { case "gemini": - if opts.TypeScript { - return fmt.Errorf("--typescript is not supported with --platform %s", config.Platform) - } - if err := pluginmanifest.ValidateGeminiExtensionName(name); err != nil { - return err - } - if runtimeFlag := strings.ToLower(strings.TrimSpace(config.Runtime)); runtimeFlag != "" && runtimeFlag != scaffold.RuntimeGo { - return fmt.Errorf("--runtime is not supported with --platform %s", config.Platform) - } - case "opencode", "cursor", "cursor-workspace", "qoder", "grok", "kimi": - if opts.TypeScript { - return fmt.Errorf("--typescript is not supported with --platform %s", config.Platform) - } - if strings.TrimSpace(config.Runtime) != "" { - return fmt.Errorf("--runtime is not supported with --platform %s", config.Platform) - } - case "codex-package": - if strings.TrimSpace(config.Runtime) != "" { - return fmt.Errorf("--runtime is not supported with --platform %s", config.Platform) - } - if opts.TypeScript { - return fmt.Errorf("--typescript is not supported with --platform %s", config.Platform) - } + return validateGeminiInitOptions(name, opts, config) + case "codex-package", "opencode", "cursor", "cursor-workspace", "qoder", "grok", "kimi": + return validateLauncherlessInitOptions(opts, config) + } + return nil +} + +func validateGeminiInitOptions(name string, opts InitOptions, config resolvedInitConfig) error { + if opts.TypeScript { + return fmt.Errorf("--typescript is not supported with --platform %s", config.Platform) + } + if err := pluginmanifest.ValidateGeminiExtensionName(name); err != nil { + return err } - if opts.RuntimePackage && (config.Platform == "gemini" || config.Platform == "codex-package" || config.Platform == "opencode" || config.Platform == "cursor" || config.Platform == "cursor-workspace" || config.Platform == "qoder" || config.Platform == "grok" || config.Platform == "kimi") { + if runtimeFlag := strings.ToLower(strings.TrimSpace(config.Runtime)); runtimeFlag != "" && runtimeFlag != scaffold.RuntimeGo { + return fmt.Errorf("--runtime is not supported with --platform %s", config.Platform) + } + if opts.RuntimePackage { return fmt.Errorf("--runtime-package is not supported with --platform %s", config.Platform) } return nil } +func validateLauncherlessInitOptions(opts InitOptions, config resolvedInitConfig) error { + if strings.TrimSpace(config.Runtime) != "" { + return fmt.Errorf("--runtime is not supported with --platform %s", config.Platform) + } + if opts.TypeScript { + return fmt.Errorf("--typescript is not supported with --platform %s", config.Platform) + } + if opts.RuntimePackage { + return fmt.Errorf("--runtime-package is not supported with --platform %s", config.Platform) + } + return nil +} + +// platformAllowsNoRuntime marks launcher-less package platforms that never +// scaffold a plugin runtime. +func platformAllowsNoRuntime(platform string) bool { + switch platform { + case "codex-package", "opencode", "cursor", "cursor-workspace", "qoder", "grok", "kimi": + return true + } + return false +} + func validateInitRuntimeOptions(opts InitOptions, config *resolvedInitConfig) error { - if config.Platform != "codex-package" && config.Platform != "opencode" && config.Platform != "cursor" && config.Platform != "cursor-workspace" && config.Platform != "qoder" && config.Platform != "grok" && config.Platform != "kimi" { + if !platformAllowsNoRuntime(config.Platform) { if _, ok := scaffold.LookupRuntime(config.Runtime); !ok { return errUnknownRuntime(opts.Runtime) } From 437a1971203036aef153c4b381ef22dd453dc5b9 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 19 Oct 2026 16:17:40 +0300 Subject: [PATCH 07/13] fix(gemini): stop comparing contextFileName against raw package meta generate projects the manifest contextFileName from the authored context selection (with the GEMINI.md fallback), so a gemini plugin without targets/gemini/package.yaml rendered a manifest that validate --strict rejected. The authored-context projection check already enforces contextFileName, so the meta contract now only compares the fields that render raw from package.yaml. --- .../platformexec/gemini_validate_mcp.go | 7 +++-- .../platformexec/gemini_validate_test.go | 30 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/cli/internal/platformexec/gemini_validate_mcp.go b/cli/internal/platformexec/gemini_validate_mcp.go index ea2234d91..760cd1d65 100644 --- a/cli/internal/platformexec/gemini_validate_mcp.go +++ b/cli/internal/platformexec/gemini_validate_mcp.go @@ -7,8 +7,11 @@ import ( ) func geminiPackageMetaEqual(left, right geminiPackageMeta) bool { - return strings.TrimSpace(left.ContextFileName) == strings.TrimSpace(right.ContextFileName) && - slices.Equal(normalizeGeminiExcludeTools(left.ExcludeTools), normalizeGeminiExcludeTools(right.ExcludeTools)) && + // context_file_name is not compared here: generate renders the manifest's + // contextFileName from the authored context selection (with the GEMINI.md + // fallback), and validateGeminiContextFileNameProjection already enforces + // that projection against the selection. + return slices.Equal(normalizeGeminiExcludeTools(left.ExcludeTools), normalizeGeminiExcludeTools(right.ExcludeTools)) && strings.TrimSpace(left.MigratedTo) == strings.TrimSpace(right.MigratedTo) && strings.TrimSpace(left.PlanDirectory) == strings.TrimSpace(right.PlanDirectory) } diff --git a/cli/internal/platformexec/gemini_validate_test.go b/cli/internal/platformexec/gemini_validate_test.go index b7c78eaab..64e635340 100644 --- a/cli/internal/platformexec/gemini_validate_test.go +++ b/cli/internal/platformexec/gemini_validate_test.go @@ -73,6 +73,36 @@ func TestGeminiValidateReportsHookEntrypointMismatch(t *testing.T) { } } +func TestGeminiGenerateFallbackContextPassesValidateWithoutPackageMeta(t *testing.T) { + t.Parallel() + root := t.TempDir() + name := filepath.Base(root) + + writeGeminiValidateFile(t, filepath.Join(root, "targets", "gemini", "contexts", "GEMINI.md"), "# Gemini\n") + + state := pluginmodel.NewTargetState("gemini") + state.AddComponent("contexts", filepath.Join("targets", "gemini", "contexts", "GEMINI.md")) + + graph := pluginmodel.PackageGraph{ + Manifest: pluginmodel.Manifest{Name: name, Version: "0.1.0", Description: "demo", Targets: []string{"gemini"}}, + } + artifacts, err := (geminiAdapter{}).Generate(root, graph, state) + if err != nil { + t.Fatal(err) + } + for _, artifact := range artifacts { + writeGeminiValidateFile(t, filepath.Join(root, filepath.FromSlash(artifact.RelPath)), string(artifact.Content)) + } + + diagnostics, err := (geminiAdapter{}).Validate(root, graph, state) + if err != nil { + t.Fatalf("Validate error = %v", err) + } + if joined := diagnosticsText(diagnostics); joined != "" { + t.Fatalf("diagnostics = %s", joined) + } +} + func writeGeminiValidateFile(t *testing.T, path string, body string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { From c9d482362a9c108ab54f1a63f044c007e217c1f5 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 19 Oct 2026 16:17:56 +0300 Subject: [PATCH 08/13] fix(repotests): route live cursor rules through the cursor-workspace lane The live portable-MCP and Cursor CLI fixtures still authored rules under plugin/targets/cursor/..., which the cursor package lane rejects in phase 1, so generate failed before any live agent ran. Rules now come from plugin/targets/cursor-workspace/rules/** (the sanctioned repo-local lane, rendered to .cursor/rules/), the fixtures declare the cursor-workspace target, and the portable MCP is projected into .cursor/mcp.json alongside the shared .mcp.json package manifest so the Cursor CLI sees it from either scope. The portable fixture now asserts the rendered .cursor/rules projection so this cannot rot silently again. --- repotests/cursor_cli_e2e_test.go | 5 +++-- repotests/portable_mcp_live_agents_e2e_test.go | 14 ++++++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/repotests/cursor_cli_e2e_test.go b/repotests/cursor_cli_e2e_test.go index 65a2cc864..c5a550a67 100644 --- a/repotests/cursor_cli_e2e_test.go +++ b/repotests/cursor_cli_e2e_test.go @@ -133,7 +133,7 @@ func newCursorSmokeWorkspace(t *testing.T, pluginKitAIBin string, opts cursorSmo if err := os.MkdirAll(filepath.Join(dir, "plugin"), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dir, "plugin", "plugin.yaml"), []byte("api_version: v1\nname: \"cursor-cli-smoke\"\nversion: \"0.1.0\"\ndescription: \"Cursor CLI live smoke workspace\"\ntargets:\n - \"cursor\"\n"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(dir, "plugin", "plugin.yaml"), []byte("api_version: v1\nname: \"cursor-cli-smoke\"\nversion: \"0.1.0\"\ndescription: \"Cursor CLI live smoke workspace\"\ntargets:\n - \"cursor\"\n - \"cursor-workspace\"\n"), 0o644); err != nil { t.Fatal(err) } if strings.TrimSpace(opts.AgentsBody) != "" { @@ -143,7 +143,7 @@ func newCursorSmokeWorkspace(t *testing.T, pluginKitAIBin string, opts cursorSmo } } if strings.TrimSpace(opts.RuleBody) != "" { - path := filepath.Join(dir, "plugin", "targets", "cursor", "rules", "project.mdc") + path := filepath.Join(dir, "plugin", "targets", "cursor-workspace", "rules", "project.mdc") if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } @@ -163,6 +163,7 @@ func newCursorSmokeWorkspace(t *testing.T, pluginKitAIBin string, opts cursorSmo root := RepoRoot(t) runCmd(t, root, exec.Command(pluginKitAIBin, "generate", dir)) runCmd(t, root, exec.Command(pluginKitAIBin, "validate", dir, "--platform", "cursor", "--strict")) + runCmd(t, root, exec.CommandContext(context.Background(), pluginKitAIBin, "validate", dir, "--platform", "cursor-workspace", "--strict")) return dir } diff --git a/repotests/portable_mcp_live_agents_e2e_test.go b/repotests/portable_mcp_live_agents_e2e_test.go index e25597328..4c02c4689 100644 --- a/repotests/portable_mcp_live_agents_e2e_test.go +++ b/repotests/portable_mcp_live_agents_e2e_test.go @@ -137,12 +137,13 @@ targets: - "gemini" - "opencode" - "cursor" + - "cursor-workspace" `) mustWriteRepoFile(t, workDir, filepath.Join("plugin", "launcher.yaml"), "runtime: shell\nentrypoint: ./scripts/main.sh\n") mustWriteRepoExecutable(t, workDir, filepath.Join("scripts", "main.sh"), "#!/usr/bin/env bash\nexit 0\n") mustWriteRepoFile(t, workDir, filepath.Join("plugin", "targets", "gemini", "contexts", "GEMINI.md"), "# Gemini\n") mustWriteRepoFile(t, workDir, filepath.Join("plugin", "targets", "opencode", "package.yaml"), "plugins:\n - \"@acme/portable-mcp-live\"\n") - mustWriteRepoFile(t, workDir, filepath.Join("plugin", "targets", "cursor", "rules", "project.mdc"), `--- + mustWriteRepoFile(t, workDir, filepath.Join("plugin", "targets", "cursor-workspace", "rules", "project.mdc"), `--- description: "Portable MCP live smoke" alwaysApply: true --- @@ -162,12 +163,13 @@ servers: - gemini - opencode - cursor + - cursor-workspace `, filepath.ToSlash(mcpBin))) root := RepoRoot(t) runCmd(t, root, exec.Command(pluginKitAIBin, "generate", workDir)) runCmd(t, root, exec.Command(pluginKitAIBin, "generate", workDir, "--check")) - for _, platform := range []string{"claude", "codex-package", "gemini", "opencode", "cursor"} { + for _, platform := range []string{"claude", "codex-package", "gemini", "opencode", "cursor", "cursor-workspace"} { runCmd(t, root, exec.Command(pluginKitAIBin, "validate", workDir, "--platform", platform, "--strict")) } assertPortableMCPLiveRenderedArtifacts(t, workDir, mcpBin) @@ -289,6 +291,14 @@ func assertPortableMCPLiveRenderedArtifacts(t *testing.T, workDir, mcpBin string if got := strings.TrimSpace(fmt.Sprint(cursorServer["command"])); got != filepath.ToSlash(mcpBin) { t.Fatalf("cursor release-checks command = %q want %q\n%s", got, filepath.ToSlash(mcpBin), cursorBody) } + + cursorRulesBody, err := os.ReadFile(filepath.Join(workDir, ".cursor", "rules", "project.mdc")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(cursorRulesBody), "release_checks") { + t.Fatalf(".cursor/rules/project.mdc missing authored rule projection:\n%s", cursorRulesBody) + } } func runClaudePrintWithMCPConfig(t *testing.T, claudeBin, pluginDir, markerPath, model, prompt string) { From 330a9c0fae4e823b47150029f2c081f3aedf9acd Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 19 Oct 2026 17:11:28 +0300 Subject: [PATCH 09/13] fix(security): contain bundle reads and writes against symlink escapes Harden every bundled-file read with component-wise symlink rejection, a regular-file re-check after open (TOCTOU) and a size ceiling, and apply the same containment to artifact writes and removals. Reject duplicate JSON keys in decoded objects and unknown packaged-manifest fields, treat an empty MCP projection as absence, normalize wrapped MCP ref acceptance to match validation, fix stale manifest diagnostic paths, and document that projected MCP headers/env are committed verbatim so secrets stay out of packages. --- cli/internal/platformexec/helpers.go | 69 +++++++++ .../platformexec/helpers_artifacts_copy.go | 11 +- .../platformexec/helpers_artifacts_paths.go | 45 +++++- .../platformexec/opencode_generate_skills.go | 3 +- cli/internal/platformexec/packaged_plugin.go | 63 +++++++-- .../platformexec/packaged_plugin_test.go | 133 +++++++++++++++++- cli/internal/pluginmanifest/render_apply.go | 27 ++++ .../pluginmanifest/render_apply_test.go | 32 +++++ cli/internal/pluginmodel/mcp_test.go | 42 ++++++ examples/plugins/README.md | 6 + 10 files changed, 407 insertions(+), 24 deletions(-) diff --git a/cli/internal/platformexec/helpers.go b/cli/internal/platformexec/helpers.go index 5819f2473..22b30145e 100644 --- a/cli/internal/platformexec/helpers.go +++ b/cli/internal/platformexec/helpers.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "io" "os" "path/filepath" "strings" @@ -45,6 +46,9 @@ func jsonDocumentsEqual(left, right any) bool { } func decodeJSONObject(body []byte, label string) (map[string]any, error) { + if err := rejectDuplicateJSONKeys(body, label); err != nil { + return nil, err + } var doc map[string]any if err := json.Unmarshal(body, &doc); err != nil { return nil, fmt.Errorf("%s is invalid JSON: %w", label, err) @@ -55,6 +59,71 @@ func decodeJSONObject(body []byte, label string) (map[string]any, error) { return doc, nil } +// rejectDuplicateJSONKeys rejects repeated object keys: json.Unmarshal silently +// keeps the last one while client parsers may keep the first, so a duplicate is +// a parser differential waiting to happen. +func rejectDuplicateJSONKeys(body []byte, label string) error { + decoder := json.NewDecoder(bytes.NewReader(body)) + type frame struct { + isObject bool + expectKey bool + seen map[string]bool + } + var stack []*frame + for { + token, err := decoder.Token() + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("%s is invalid JSON: %w", label, err) + } + switch typed := token.(type) { + case json.Delim: + switch typed { + case '{': + if len(stack) > 0 { + stack[len(stack)-1].expectKey = true + } + stack = append(stack, &frame{isObject: true, expectKey: true, seen: map[string]bool{}}) + case '[': + if len(stack) > 0 { + stack[len(stack)-1].expectKey = true + } + stack = append(stack, &frame{}) + case '}', ']': + if len(stack) == 0 { + return fmt.Errorf("%s is invalid JSON: unbalanced delimiters", label) + } + stack = stack[:len(stack)-1] + } + case string: + if len(stack) == 0 { + continue + } + top := stack[len(stack)-1] + if top.isObject && top.expectKey { + if top.seen[typed] { + return fmt.Errorf("%s is invalid JSON: duplicate object key %q", label, typed) + } + top.seen[typed] = true + top.expectKey = false + continue + } + if top.isObject { + top.expectKey = true + } + default: + if len(stack) > 0 { + top := stack[len(stack)-1] + if top.isObject { + top.expectKey = true + } + } + } + } +} + func jsonStringArray(values []any) []string { var out []string for _, value := range values { diff --git a/cli/internal/platformexec/helpers_artifacts_copy.go b/cli/internal/platformexec/helpers_artifacts_copy.go index ea11918a2..6aad40dc5 100644 --- a/cli/internal/platformexec/helpers_artifacts_copy.go +++ b/cli/internal/platformexec/helpers_artifacts_copy.go @@ -24,6 +24,12 @@ func copyArtifactDirs(root string, dirs ...artifactDir) ([]pluginmodel.Artifact, } func copyArtifacts(root, srcDir, dstRoot string) ([]pluginmodel.Artifact, error) { + if err := rejectSymlinkComponents(root, srcDir); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } full := filepath.Join(root, srcDir) var artifacts []pluginmodel.Artifact if _, err := os.Stat(full); err != nil { @@ -43,7 +49,8 @@ func copyArtifacts(root, srcDir, dstRoot string) ([]pluginmodel.Artifact, error) if d.IsDir() { return nil } - body, err := readRegularFile(path, filepath.ToSlash(filepath.Join(srcDir, rel))) + srcRel := filepath.ToSlash(filepath.Join(srcDir, rel)) + body, err := readBundleFile(root, srcRel, srcRel) if err != nil { return err } @@ -64,7 +71,7 @@ func copySingleArtifactIfExists(root, srcRel, dstRel string) ([]pluginmodel.Arti if strings.TrimSpace(srcRel) == "" { return nil, nil } - body, err := readRegularFile(filepath.Join(root, srcRel), srcRel) + body, err := readBundleFile(root, srcRel, srcRel) if err != nil { if os.IsNotExist(err) { return nil, nil diff --git a/cli/internal/platformexec/helpers_artifacts_paths.go b/cli/internal/platformexec/helpers_artifacts_paths.go index 6279f193d..a7b716895 100644 --- a/cli/internal/platformexec/helpers_artifacts_paths.go +++ b/cli/internal/platformexec/helpers_artifacts_paths.go @@ -34,7 +34,9 @@ func fileAccessError(err error, label string) error { } // readRegularFile reads a bundle file only when it is a regular file: symlinks -// and devices must never pull host file content into generated artifacts. +// and devices must never pull host file content into generated artifacts. The +// opened handle is re-checked against the earlier Lstat so a path swapped for a +// symlink between the two calls is rejected instead of followed. func readRegularFile(path, label string) ([]byte, error) { info, err := os.Lstat(path) if err != nil { @@ -51,13 +53,54 @@ func readRegularFile(path, label string) ([]byte, error) { return nil, fileAccessError(err, label) } defer func() { _ = f.Close() }() + opened, err := f.Stat() + if err != nil { + return nil, fileAccessError(err, label) + } + if !opened.Mode().IsRegular() || !os.SameFile(info, opened) { + return nil, fmt.Errorf("%s must be a regular file", label) + } body, err := io.ReadAll(io.LimitReader(f, maxBundleFileBytes+1)) if err != nil { return nil, fileAccessError(err, label) } + if len(body) > maxBundleFileBytes { + return nil, fmt.Errorf("%s exceeds the %d-byte bundle file limit", label, maxBundleFileBytes) + } return body, nil } +// rejectSymlinkComponents fails when any path component under root is a +// symlink: containment must hold on disk, not just lexically, or a bundle can +// smuggle host file content in through a symlinked directory. +func rejectSymlinkComponents(root, rel string) error { + current := root + for _, part := range strings.Split(filepath.FromSlash(rel), string(filepath.Separator)) { + current = filepath.Join(current, part) + info, err := os.Lstat(current) + if err != nil { + return fileAccessError(err, rel) + } + if info.Mode()&fs.ModeSymlink != 0 { + return fmt.Errorf("%s must not traverse symlinks", rel) + } + } + return nil +} + +// readBundleFile reads root/rel after lexical containment and a symlink-free +// component walk. Every untrusted bundle read must go through it. +func readBundleFile(root, rel, label string) ([]byte, error) { + contained, err := resolveRelativeRef(root, rel) + if err != nil || contained == "" { + return nil, fmt.Errorf("%s must stay inside the plugin root", label) + } + if err := rejectSymlinkComponents(root, contained); err != nil { + return nil, err + } + return readRegularFile(filepath.Join(root, filepath.FromSlash(contained)), label) +} + func discoverFiles(root, dir string, allow func(string) bool) []string { full := filepath.Join(root, dir) var out []string diff --git a/cli/internal/platformexec/opencode_generate_skills.go b/cli/internal/platformexec/opencode_generate_skills.go index 9faa34eef..b76faad29 100644 --- a/cli/internal/platformexec/opencode_generate_skills.go +++ b/cli/internal/platformexec/opencode_generate_skills.go @@ -2,7 +2,6 @@ package platformexec import ( "fmt" - "os" "path/filepath" "strings" @@ -22,7 +21,7 @@ func renderPortableSkills(root string, paths []string, outputRoot string) ([]plu } func portableSkillArtifact(root, rel, outputRoot string) (pluginmodel.Artifact, error) { - body, err := os.ReadFile(filepath.Join(root, rel)) + body, err := readBundleFile(root, rel, filepath.ToSlash(rel)) if err != nil { return pluginmodel.Artifact{}, err } diff --git a/cli/internal/platformexec/packaged_plugin.go b/cli/internal/platformexec/packaged_plugin.go index 890c9732c..f34697490 100644 --- a/cli/internal/platformexec/packaged_plugin.go +++ b/cli/internal/platformexec/packaged_plugin.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" "github.com/777genius/plugin-kit-ai/cli/internal/pluginmodel" @@ -197,6 +198,7 @@ func (a packagedPluginAdapter) Validate(root string, graph pluginmodel.PackageGr return diagnostics, nil } a.manifestRel = manifestRel // diagnostics must name the manifest actually read (Kimi root manifest wins) + diagnostics = append(diagnostics, a.validateManifestKeys(manifest)...) diagnostics = append(diagnostics, a.validateIdentity(manifest, graph)...) diagnostics = append(diagnostics, a.validateSkills(root, manifest, graph)...) mcpDiagnostics, err := a.validateMCP(root, manifest, graph) @@ -208,7 +210,7 @@ func (a packagedPluginAdapter) Validate(root string, graph pluginmodel.PackageGr func (a packagedPluginAdapter) readManifest(root string) (map[string]any, string, bool, error) { for _, rel := range a.manifestCandidates() { - body, err := readRegularFile(filepath.Join(root, rel), rel) + body, err := readBundleFile(root, rel, rel) if err != nil { if os.IsNotExist(err) { continue @@ -229,9 +231,10 @@ func (a packagedPluginAdapter) importedMCP(root, manifestRel string, manifest ma if !ok || value == nil { return nil, false, nil } + var servers map[string]any switch typed := value.(type) { case map[string]any: - return typed, true, nil + servers = typed case string: if a.mcpLayout == packagedMCPInline { return nil, false, fmt.Errorf("%s plugin manifest %s field %q must be an inline object; %s does not resolve MCP file refs", a.displayName, manifestRel, "mcpServers", a.displayName) @@ -244,17 +247,22 @@ func (a packagedPluginAdapter) importedMCP(root, manifestRel string, manifest ma if err != nil { return nil, false, fmt.Errorf("unsupported %s plugin manifest %s mcpServers ref %q: %w", a.displayName, manifestRel, ref, err) } - servers, ok, err := readWrappedMCPServers(filepath.Join(root, rel), rel) + wrapped, ok, err := readWrappedMCPServers(root, rel, rel) if err != nil { return nil, false, err } if !ok { return nil, false, fmt.Errorf("%s plugin manifest %s mcpServers ref %q does not exist", a.displayName, manifestRel, ref) } - return servers, true, nil + servers = wrapped default: return nil, false, fmt.Errorf("%s plugin manifest %s field %q must be a string ref or object", a.displayName, manifestRel, "mcpServers") } + if len(servers) == 0 { + // An empty projection round-trips as absence, matching the null case. + return nil, false, nil + } + return servers, true, nil } func containedPluginRel(ref string) (string, error) { @@ -266,8 +274,8 @@ func containedPluginRel(ref string) (string, error) { return filepath.ToSlash(cleaned), nil } -func readWrappedMCPServers(path, label string) (map[string]any, bool, error) { - body, err := readRegularFile(path, label) +func readWrappedMCPServers(root, rel, label string) (map[string]any, bool, error) { + body, err := readBundleFile(root, rel, label) if err != nil { if os.IsNotExist(err) { return nil, false, nil @@ -294,7 +302,7 @@ func (a packagedPluginAdapter) validateManifestRead(root string) (map[string]any if manifestRel == "" { manifestRel = a.manifestRel } - return nil, manifestRel, []Diagnostic{a.manifestDiagnostic(CodeManifestInvalid, fmt.Sprintf("%s plugin manifest %s is invalid: %v", a.displayName, manifestRel, err))} + return nil, manifestRel, []Diagnostic{a.manifestDiagnosticAt(manifestRel, CodeManifestInvalid, fmt.Sprintf("%s plugin manifest %s is invalid: %v", a.displayName, manifestRel, err))} } if !ok { return nil, a.manifestRel, []Diagnostic{a.manifestDiagnostic(CodeGeneratedContractInvalid, fmt.Sprintf("%s plugin manifest %s is not readable", a.displayName, a.manifestRel))} @@ -302,6 +310,27 @@ func (a packagedPluginAdapter) validateManifestRead(root string) (map[string]any return manifest, manifestRel, nil } +// packagedManifestFields are the only managed-manifest fields this lane reads +// and rewrites; anything else would be silently dropped by generate. +var packagedManifestFields = []string{"name", "version", "description", "skills", "mcpServers"} + +func (a packagedPluginAdapter) validateManifestKeys(manifest map[string]any) []Diagnostic { + var unknown []string + for key := range manifest { + if !slices.Contains(packagedManifestFields, key) { + unknown = append(unknown, key) + } + } + if len(unknown) == 0 { + return nil + } + slices.Sort(unknown) + return []Diagnostic{a.manifestDiagnostic( + CodeManifestInvalid, + fmt.Sprintf("%s plugin manifest %s defines unsupported fields: %s", a.displayName, a.manifestRel, strings.Join(unknown, ", ")), + )} +} + func (a packagedPluginAdapter) validateIdentity(manifest map[string]any, graph pluginmodel.PackageGraph) []Diagnostic { return append( append( @@ -371,7 +400,7 @@ func (a packagedPluginAdapter) validateSkillProjection(root, rel string) []Diagn }} } renderedRel := filepath.ToSlash(filepath.Join("skills", child)) - sourceBody, err := readRegularFile(filepath.Join(root, rel), filepath.ToSlash(rel)) + sourceBody, err := readBundleFile(root, rel, filepath.ToSlash(rel)) if err != nil { return []Diagnostic{{ Severity: SeverityFailure, @@ -381,7 +410,7 @@ func (a packagedPluginAdapter) validateSkillProjection(root, rel string) []Diagn Message: fmt.Sprintf("%s portable skill source %s is not readable: %v", a.displayName, rel, err), }} } - renderedBody, err := readRegularFile(filepath.Join(root, renderedRel), renderedRel) + renderedBody, err := readBundleFile(root, renderedRel, renderedRel) if err != nil { return []Diagnostic{{ Severity: SeverityFailure, @@ -457,13 +486,19 @@ func (a packagedPluginAdapter) readGeneratedMCP(root string, manifest map[string return servers, nil default: refText, ok := value.(string) - if !ok || strings.TrimSpace(refText) != packagedPluginMCPRef { + if !ok { + return nil, []Diagnostic{a.manifestDiagnostic( + CodeGeneratedContractInvalid, + fmt.Sprintf("%s plugin manifest %s must use %q for mcpServers when portable MCP is authored", a.displayName, a.manifestRel, packagedPluginMCPRef), + )} + } + if ref, err := containedPluginRel(refText); err != nil || ref != strings.TrimPrefix(packagedPluginMCPRef, "./") { return nil, []Diagnostic{a.manifestDiagnostic( CodeGeneratedContractInvalid, fmt.Sprintf("%s plugin manifest %s must use %q for mcpServers when portable MCP is authored", a.displayName, a.manifestRel, packagedPluginMCPRef), )} } - servers, readable, err := readWrappedMCPServers(filepath.Join(root, ".mcp.json"), ".mcp.json") + servers, readable, err := readWrappedMCPServers(root, ".mcp.json", ".mcp.json") if err != nil { return nil, []Diagnostic{{ Severity: SeverityFailure, @@ -487,10 +522,14 @@ func (a packagedPluginAdapter) readGeneratedMCP(root string, manifest map[string } func (a packagedPluginAdapter) manifestDiagnostic(code, message string) Diagnostic { + return a.manifestDiagnosticAt(a.manifestRel, code, message) +} + +func (a packagedPluginAdapter) manifestDiagnosticAt(path, code, message string) Diagnostic { return Diagnostic{ Severity: SeverityFailure, Code: code, - Path: a.manifestRel, + Path: path, Target: a.id, Message: message, } diff --git a/cli/internal/platformexec/packaged_plugin_test.go b/cli/internal/platformexec/packaged_plugin_test.go index 0540e8468..23a454918 100644 --- a/cli/internal/platformexec/packaged_plugin_test.go +++ b/cli/internal/platformexec/packaged_plugin_test.go @@ -1,6 +1,7 @@ package platformexec import ( + "bytes" "os" "path/filepath" "runtime" @@ -34,6 +35,9 @@ func TestGrokGenerateWritesWrappedMCPSidecar(t *testing.T) { if !strings.Contains(string(artifact.Content), `"mcpServers"`) || !strings.Contains(string(artifact.Content), `"docs"`) { t.Fatalf("Grok MCP sidecar should wrap servers:\n%s", artifact.Content) } + if !strings.Contains(string(artifact.Content), `"type": "http"`) || !strings.Contains(string(artifact.Content), `"url": "https://example.com/mcp"`) { + t.Fatalf("Grok MCP sidecar must project the remote transport:\n%s", artifact.Content) + } case filepath.ToSlash(filepath.Join("skills", "release-checks", "SKILL.md")): sawSkill = true } @@ -273,6 +277,13 @@ func TestGrokManagedPathsClaimMCPSidecar(t *testing.T) { } } +func symlinkOrSkip(t *testing.T, oldname, newname string) { + t.Helper() + if err := os.Symlink(oldname, newname); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } +} + func TestImportRejectsSymlinkedBundleFiles(t *testing.T) { t.Parallel() root := t.TempDir() @@ -286,13 +297,11 @@ func TestImportRejectsSymlinkedBundleFiles(t *testing.T) { if err := os.WriteFile(filepath.Join(root, ".grok-plugin", "plugin.json"), []byte(`{"name":"demo","mcpServers":"./.mcp.json"}`), 0o644); err != nil { t.Fatal(err) } - if err := os.Symlink(outside, filepath.Join(root, ".mcp.json")); err != nil { - t.Fatal(err) - } + symlinkOrSkip(t, outside, filepath.Join(root, ".mcp.json")) _, err := newGrokPluginAdapter().Import(root, ImportSeed{ Manifest: pluginmodel.Manifest{Name: "demo", Targets: []string{"grok"}}, }) - if err == nil || !strings.Contains(err.Error(), "regular file") { + if err == nil || !strings.Contains(err.Error(), "symlink") { t.Fatalf("symlinked MCP sidecar must be rejected, err = %v", err) } @@ -300,9 +309,7 @@ func TestImportRejectsSymlinkedBundleFiles(t *testing.T) { if err := os.MkdirAll(skillDir, 0o755); err != nil { t.Fatal(err) } - if err := os.Symlink(outside, filepath.Join(skillDir, "SKILL.md")); err != nil { - t.Fatal(err) - } + symlinkOrSkip(t, outside, filepath.Join(skillDir, "SKILL.md")) if err := os.Remove(filepath.Join(root, ".mcp.json")); err != nil { t.Fatal(err) } @@ -315,6 +322,118 @@ func TestImportRejectsSymlinkedBundleFiles(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "symlink") { t.Fatalf("symlinked skill file must be rejected, err = %v", err) } + if strings.Contains(err.Error(), root) { + t.Fatalf("bundle read errors must not leak host paths, err = %v", err) + } +} + +func TestImportRejectsSymlinkedBundleDirectory(t *testing.T) { + t.Parallel() + root := t.TempDir() + outsideDir := t.TempDir() + if err := os.WriteFile(filepath.Join(outsideDir, "config.json"), []byte(`{"mcpServers":{"leak":{"command":"host"}}}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, ".grok-plugin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".grok-plugin", "plugin.json"), []byte(`{"name":"demo","mcpServers":"./evil/config.json"}`), 0o644); err != nil { + t.Fatal(err) + } + symlinkOrSkip(t, outsideDir, filepath.Join(root, "evil")) + _, err := newGrokPluginAdapter().Import(root, ImportSeed{ + Manifest: pluginmodel.Manifest{Name: "demo", Targets: []string{"grok"}}, + }) + if err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("mcpServers ref through a symlinked directory must be rejected, err = %v", err) + } + if strings.Contains(err.Error(), outsideDir) { + t.Fatalf("bundle read errors must not leak host paths, err = %v", err) + } +} + +func TestGenerateRejectsSymlinkedSkillSource(t *testing.T) { + t.Parallel() + root := t.TempDir() + outside := filepath.Join(t.TempDir(), "secret.md") + if err := os.WriteFile(outside, []byte("host secret"), 0o644); err != nil { + t.Fatal(err) + } + skillDir := filepath.Join(root, "plugin", "skills", "release-checks") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + symlinkOrSkip(t, outside, filepath.Join(skillDir, "SKILL.md")) + _, err := portableSkillArtifact(root, filepath.Join("plugin", "skills", "release-checks", "SKILL.md"), "skills") + if err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("generate must not read symlinked skill sources, err = %v", err) + } +} + +func TestReadBundleFileEnforcesSizeLimit(t *testing.T) { + t.Parallel() + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "big.json"), bytes.Repeat([]byte("a"), maxBundleFileBytes+1), 0o644); err != nil { + t.Fatal(err) + } + if _, err := readBundleFile(root, "big.json", "big.json"); err == nil || !strings.Contains(err.Error(), "bundle file limit") { + t.Fatalf("oversized bundle file must be rejected, err = %v", err) + } +} + +func TestValidateRejectsUnsupportedManifestFields(t *testing.T) { + t.Parallel() + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".grok-plugin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".grok-plugin", "plugin.json"), []byte(`{"name":"grok-demo","version":"0.1.0","description":"grok demo","hooks":{"cmd":"x"}}`), 0o644); err != nil { + t.Fatal(err) + } + diagnostics, err := newGrokPluginAdapter().Validate(root, packagedPluginTestGraph(t, root, "grok"), pluginmodel.NewTargetState("grok")) + if err != nil { + t.Fatal(err) + } + joined := diagnosticsText(diagnostics) + if !strings.Contains(joined, "unsupported fields: hooks") { + t.Fatalf("unknown managed-manifest fields must fail validate, diagnostics = %s", joined) + } +} + +func TestDecodeJSONObjectRejectsDuplicateKeys(t *testing.T) { + t.Parallel() + if _, err := decodeJSONObject([]byte(`{"name":"a","name":"b"}`), "doc"); err == nil || !strings.Contains(err.Error(), "duplicate object key") { + t.Fatalf("duplicate JSON keys must be rejected, err = %v", err) + } + if _, err := decodeJSONObject([]byte(`{"servers":[{"x":1,"x":2}]}`), "doc"); err == nil { + t.Fatal("duplicate nested keys must be rejected") + } + if _, err := decodeJSONObject([]byte(`{"servers":[{"x":1},{"x":2}],"y":"a"}`), "doc"); err != nil { + t.Fatalf("distinct keys must stay valid: %v", err) + } +} + +func TestGrokImportTreatsEmptyMCPProjectionAsAbsent(t *testing.T) { + t.Parallel() + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".grok-plugin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".grok-plugin", "plugin.json"), []byte(`{"name":"demo","mcpServers":"./.mcp.json"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".mcp.json"), []byte(`{"mcpServers":{}}`), 0o644); err != nil { + t.Fatal(err) + } + result, err := newGrokPluginAdapter().Import(root, ImportSeed{ + Manifest: pluginmodel.Manifest{Name: "demo", Targets: []string{"grok"}}, + }) + if err != nil { + t.Fatalf("empty projection must import as absence: %v", err) + } + if len(result.Artifacts) != 0 { + t.Fatalf("empty projection must not emit an MCP artifact, artifacts = %v", result.Artifacts) + } } func TestImportRejectsDanglingMCPSidecarRef(t *testing.T) { diff --git a/cli/internal/pluginmanifest/render_apply.go b/cli/internal/pluginmanifest/render_apply.go index 9596447dc..6dc5bdadc 100644 --- a/cli/internal/pluginmanifest/render_apply.go +++ b/cli/internal/pluginmanifest/render_apply.go @@ -23,12 +23,36 @@ func containedArtifactRel(rel string) (string, error) { return cleaned, nil } +// rejectSymlinkedDest fails when any existing path component under root is a +// symlink, so generated artifacts can never be written or removed through a +// link that points outside the plugin tree. +func rejectSymlinkedDest(root, rel string) error { + current := root + for _, part := range strings.Split(rel, string(filepath.Separator)) { + current = filepath.Join(current, part) + info, err := os.Lstat(current) + if err != nil { + if os.IsNotExist(err) { + continue + } + return err + } + if info.Mode()&fs.ModeSymlink != 0 { + return fmt.Errorf("artifact path %q must not traverse symlinks", rel) + } + } + return nil +} + func writeArtifacts(root string, artifacts []Artifact) error { for _, artifact := range artifacts { rel, err := containedArtifactRel(artifact.RelPath) if err != nil { return err } + if err := rejectSymlinkedDest(root, rel); err != nil { + return err + } full := filepath.Join(root, rel) if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { return err @@ -64,6 +88,9 @@ func removeArtifacts(root string, relPaths []string) error { if err != nil { return err } + if err := rejectSymlinkedDest(root, rel); err != nil { + return err + } full := filepath.Join(root, rel) if err := os.Remove(full); err != nil && !os.IsNotExist(err) { return err diff --git a/cli/internal/pluginmanifest/render_apply_test.go b/cli/internal/pluginmanifest/render_apply_test.go index 908c3995c..347a9421f 100644 --- a/cli/internal/pluginmanifest/render_apply_test.go +++ b/cli/internal/pluginmanifest/render_apply_test.go @@ -56,3 +56,35 @@ func TestWriteAndRemoveArtifactsStayInsideRoot(t *testing.T) { t.Fatalf("outside file must survive: %v", err) } } + +func TestWriteAndRemoveRejectSymlinkedDestinations(t *testing.T) { + t.Parallel() + root := t.TempDir() + outsideDir := t.TempDir() + outside := filepath.Join(outsideDir, "victim.txt") + if err := os.WriteFile(outside, []byte("safe"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outsideDir, filepath.Join(root, "skills")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + err := writeArtifacts(root, []Artifact{{RelPath: "skills/pwned.txt", Content: []byte("pwned")}}) + if err == nil { + t.Fatal("writeArtifacts must reject symlinked destination components") + } + if _, statErr := os.Stat(filepath.Join(outsideDir, "pwned.txt")); !os.IsNotExist(statErr) { + t.Fatalf("outside dir must stay untouched: %v", statErr) + } + if err := writeArtifacts(root, []Artifact{{RelPath: "skills/victim.txt", Content: []byte("pwned")}}); err == nil { + t.Fatal("writeArtifacts must not write through a symlinked directory") + } + if body, readErr := os.ReadFile(outside); readErr != nil || string(body) != "safe" { + t.Fatalf("outside file was modified: %q, %v", body, readErr) + } + if err := removeArtifacts(root, []string{"skills/victim.txt"}); err == nil { + t.Fatal("removeArtifacts must not remove through a symlinked directory") + } + if _, statErr := os.Stat(outside); statErr != nil { + t.Fatalf("outside file must survive: %v", statErr) + } +} diff --git a/cli/internal/pluginmodel/mcp_test.go b/cli/internal/pluginmodel/mcp_test.go index 3bfd8e674..a9aba68f0 100644 --- a/cli/internal/pluginmodel/mcp_test.go +++ b/cli/internal/pluginmodel/mcp_test.go @@ -380,3 +380,45 @@ func TestUnwrapNativeMCPServersAcceptsEnvelopeAndBareMaps(t *testing.T) { t.Fatal("expected envelope type error") } } + +func TestParsePortableMCPRejectsUnknownTransport(t *testing.T) { + t.Parallel() + _, err := ParsePortableMCP("mcp/servers.yaml", []byte(`api_version: v1 + +servers: + weird: + type: websocket + remote: + url: "https://example.com/mcp" +`)) + if err == nil || !strings.Contains(err.Error(), "type must be stdio or remote") { + t.Fatalf("unknown transports must be rejected at parse time, err = %v", err) + } +} + +func TestRenderPortableMCPForTargetProjectsRemoteHTTP(t *testing.T) { + t.Parallel() + parsed, err := ParsePortableMCP("mcp/servers.yaml", []byte(`api_version: v1 + +servers: + docs: + type: remote + remote: + protocol: streamable_http + url: "https://example.com/mcp" +`)) + if err != nil { + t.Fatal(err) + } + projected, err := renderPortableMCPForTarget(&PortableMCP{File: parsed.File}, "grok") + if err != nil { + t.Fatal(err) + } + docs, ok := projected["docs"].(map[string]any) + if !ok { + t.Fatalf("projection = %#v", projected) + } + if docs["type"] != "http" || docs["url"] != "https://example.com/mcp" { + t.Fatalf("remote projection = %#v", docs) + } +} diff --git a/examples/plugins/README.md b/examples/plugins/README.md index 1bf918690..ef8f55300 100644 --- a/examples/plugins/README.md +++ b/examples/plugins/README.md @@ -28,6 +28,12 @@ Skills and MCP components may be reusable; hooks, app bindings, themes, and client settings retain their client-specific boundaries. The external Context7 link below is a historical reference, not a locally verified standard package. +MCP `headers` and `env` values from `plugin/mcp/servers.yaml` are projected +verbatim into the generated client configs (`.mcp.json`, `kimi.plugin.json`, +and similar), which these examples commit. Never put tokens or OAuth secrets +in them: keep secrets out of the package and inject them at runtime (for +example `${env.VAR}` style references resolved by the client). + These examples are reference implementations for the historical v1 production plugin workflow. - [`context7` in universal-plugins-for-ai-agents](https://github.com/777genius/universal-plugins-for-ai-agents/tree/main/plugins/context7): canonical multi-target MCP-first example with `plugin/` as the only authored root, package-only Claude, official Codex package output, Gemini extension packaging, and workspace-config output for OpenCode and Cursor From 21700119b0334a16a41aeff0c43d58374e03d0bc Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 19 Oct 2026 17:11:36 +0300 Subject: [PATCH 10/13] test(authoring): assert dev session lock under the platform cache root os.UserCacheDir resolves $HOME/Library/Caches on macOS while the assertion assumed the Linux $XDG_CACHE_HOME layout, so the suite failed on darwin. Resolve the expected lock directory through os.UserCacheDir instead. --- cli/internal/authoring/commands/dev_session_test.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cli/internal/authoring/commands/dev_session_test.go b/cli/internal/authoring/commands/dev_session_test.go index e1e508640..fa9fb51df 100644 --- a/cli/internal/authoring/commands/dev_session_test.go +++ b/cli/internal/authoring/commands/dev_session_test.go @@ -48,7 +48,14 @@ func TestDevSessionLockConflictsDeterministicallyAndReleases(t *testing.T) { if err := releaseAgain(); err != nil { t.Fatalf("release reacquired session: %v", err) } - entries, err := os.ReadDir(filepath.Join(cache, "agentplugins", "author-dev-locks")) + // os.UserCacheDir resolves the platform cache root ($XDG_CACHE_HOME or + // $HOME/.cache on Linux, $HOME/Library/Caches on macOS), so assert against + // the resolved root rather than assuming one platform layout. + userCache, err := os.UserCacheDir() + if err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(filepath.Join(userCache, "agentplugins", "author-dev-locks")) if err != nil || len(entries) != 1 { t.Fatalf("expected one stable private lock file: %v %v", entries, err) } From 95ef9611036425b8294840e83dab16085bddc60a Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 19 Oct 2026 19:26:42 +0300 Subject: [PATCH 11/13] fix(qoder): forward and gate OpenAI MCP auth hints in Qoder projection Qoder's OpenAI-shaped .mcp.json should carry the catalog OpenAI auth hints (oauth_resource, bearer_token_env_var) so remote MCP servers get the RFC9728 resource indicator. A declared generic compatibility entry for qoder makes those hints non-authoritative: suppress them instead of projecting stale auth fields. --- .../agentplugins/clients/qoder/project.go | 9 +++ .../providers/stager_qoder_auth_hint_test.go | 77 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 install/integrationctl/agentplugins/providers/stager_qoder_auth_hint_test.go diff --git a/install/integrationctl/agentplugins/clients/qoder/project.go b/install/integrationctl/agentplugins/clients/qoder/project.go index 9ca0a1a49..cfba98162 100644 --- a/install/integrationctl/agentplugins/clients/qoder/project.go +++ b/install/integrationctl/agentplugins/clients/qoder/project.go @@ -32,9 +32,18 @@ func (*Adapter) Project(_ context.Context, in clients.ProjectionInput) ([]domain if err := shared.WriteJSON(filepath.Join(in.StagingPath, ".qoder-plugin", "plugin.json"), manifest); err != nil { return nil, fmt.Errorf("write Qoder plugin manifest: %w", err) } + // Qoder's OpenAI-shaped .mcp.json carries the catalog OpenAI auth hints, + // but a declared generic compatibility entry for qoder makes those hints + // non-authoritative (the usecase openAIOAuthApplies gate): they are + // suppressed instead of projected. + hints := in.Hints + if _, declared := hints.Compatibility[string(domain.ClientQoder)]; declared { + hints.OpenAIMCPAuth = nil + } if err := shared.ProjectMCPServers(shared.MCPProjection{ Root: in.StagingPath, Envelope: in.Envelope, Names: names, Dialect: shared.MCPDialectOpenAI, PluginRoot: in.Plan.ActivePath, DataPath: in.PluginDataPath, + Hints: hints, }); err != nil { return nil, err } diff --git a/install/integrationctl/agentplugins/providers/stager_qoder_auth_hint_test.go b/install/integrationctl/agentplugins/providers/stager_qoder_auth_hint_test.go new file mode 100644 index 000000000..4b02dd65e --- /dev/null +++ b/install/integrationctl/agentplugins/providers/stager_qoder_auth_hint_test.go @@ -0,0 +1,77 @@ +package providers + +import ( + "context" + "path/filepath" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +// Qoder's plugin contract is an OpenAI-shaped .mcp.json: a remote +// streamable-http MCP server must reach it with the OpenAI transport spelling, +// its url, and the catalog OpenAI auth hint, mirroring the Codex projection. +func TestStagerProjectsQoderRemoteMCPWithOpenAIAuthHints(t *testing.T) { + t.Parallel() + envelope := stagingEnvelope(t) + plan := stagingPlan(t, domain.ClientQoder, domain.PackageNative) + delivery, err := testStager(Stager{}).Stage(context.Background(), envelope, plan, "operation-qoder-auth", domain.CompatibilityHints{ + OpenAIMCPAuth: map[string]domain.OpenAIMCPAuthHint{ + "notion": {OAuthResource: "https://mcp.notion.com"}, + }, + }) + if err != nil { + t.Fatal(err) + } + manifest := readObject(t, filepath.Join(delivery.StagingPath, ".qoder-plugin", "plugin.json")) + if manifest["mcpServers"] != "./.mcp.json" { + t.Fatalf("Qoder manifest = %+v", manifest) + } + mcp := readObject(t, filepath.Join(delivery.StagingPath, ".mcp.json")) + servers, ok := mcp["mcpServers"].(map[string]any) + if !ok { + t.Fatalf("Qoder .mcp.json must wrap servers in mcpServers: %+v", mcp) + } + notion, ok := servers["notion"].(map[string]any) + if !ok { + t.Fatalf("Qoder .mcp.json missing notion server: %+v", servers) + } + if notion["type"] != "http" || notion["url"] != "https://mcp.notion.com/mcp" || notion["oauth_resource"] != "https://mcp.notion.com" { + t.Fatalf("Qoder remote MCP projection = %+v", notion) + } +} + +// A generic catalog compatibility entry for qoder makes the legacy OpenAI auth +// hints non-authoritative (the usecase openAIOAuthApplies gate), so the hint +// must never reach the staged .mcp.json while the server projection stays. +func TestStagerSuppressesQoderOpenAIAuthHintsWithCompatibilityEntry(t *testing.T) { + t.Parallel() + envelope := stagingEnvelope(t) + plan := stagingPlan(t, domain.ClientQoder, domain.PackageNative) + delivery, err := testStager(Stager{}).Stage(context.Background(), envelope, plan, "operation-qoder-compat", domain.CompatibilityHints{ + Compatibility: map[string]domain.CatalogCompatibility{ + "qoder": {Package: "native", Verification: "tested", Authentication: domain.AuthenticationRequirementNotRequired}, + }, + OpenAIMCPAuth: map[string]domain.OpenAIMCPAuthHint{ + "notion": {OAuthResource: "https://mcp.notion.com"}, + }, + }) + if err != nil { + t.Fatal(err) + } + mcp := readObject(t, filepath.Join(delivery.StagingPath, ".mcp.json")) + servers, ok := mcp["mcpServers"].(map[string]any) + if !ok { + t.Fatalf("Qoder .mcp.json must wrap servers in mcpServers: %+v", mcp) + } + notion, ok := servers["notion"].(map[string]any) + if !ok { + t.Fatalf("Qoder .mcp.json missing notion server: %+v", servers) + } + if notion["type"] != "http" || notion["url"] != "https://mcp.notion.com/mcp" { + t.Fatalf("Qoder remote MCP projection = %+v", notion) + } + if _, suppressed := notion["oauth_resource"]; suppressed { + t.Fatalf("hints.Compatibility[qoder] must suppress the auth hint: %+v", notion) + } +} From 3cd2218f60e8990d62748bf9017c0b1e18c1adfb Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 19 Oct 2026 19:26:52 +0300 Subject: [PATCH 12/13] test: cover grok/kimi init round-trips, cursor-workspace rules, packaged negatives End-to-end init -> generate --check -> validate --strict round trips for grok (sidecar mcpServers ref) and kimi (inline mcpServers), cursor workspace-rules projection with the phase-1 target-rules guard, and packaged-lane negative cases for malformed managed manifests, non-string/non-object mcpServers, and authored target dirs. --- .../packaged_plugin_negative_test.go | 109 ++++++++++++++++ repotests/cursor_workspace_rules_e2e_test.go | 108 ++++++++++++++++ repotests/grok_kimi_init_e2e_test.go | 117 ++++++++++++++++++ 3 files changed, 334 insertions(+) create mode 100644 cli/internal/platformexec/packaged_plugin_negative_test.go create mode 100644 repotests/cursor_workspace_rules_e2e_test.go create mode 100644 repotests/grok_kimi_init_e2e_test.go diff --git a/cli/internal/platformexec/packaged_plugin_negative_test.go b/cli/internal/platformexec/packaged_plugin_negative_test.go new file mode 100644 index 000000000..41f8394a3 --- /dev/null +++ b/cli/internal/platformexec/packaged_plugin_negative_test.go @@ -0,0 +1,109 @@ +package platformexec + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/cli/internal/pluginmodel" +) + +// packagedNegativeLane identifies the Grok/Kimi packaged lanes under their +// managed manifest paths for the negative import/validate contracts. +type packagedNegativeLane struct { + id string + adapter func() packagedPluginAdapter + manifestRel string +} + +func packagedNegativeLanes() []packagedNegativeLane { + return []packagedNegativeLane{ + {id: "grok", adapter: newGrokPluginAdapter, manifestRel: filepath.Join(".grok-plugin", "plugin.json")}, + {id: "kimi", adapter: newKimiPluginAdapter, manifestRel: filepath.Join(".kimi-plugin", "plugin.json")}, + } +} + +func writePackagedNegativeFile(t *testing.T, root, rel, body string) { + t.Helper() + path := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func packagedNegativeGraph(target string) pluginmodel.PackageGraph { + return pluginmodel.PackageGraph{ + Manifest: pluginmodel.Manifest{Name: target + "-demo", Version: "0.1.0", Description: target + " demo", Targets: []string{target}}, + Portable: pluginmodel.PortableComponents{Items: map[string][]string{}}, + } +} + +// A managed manifest with malformed JSON must fail import and surface an +// "invalid JSON" diagnostic on validate instead of being treated as absent. +func TestPackagedPluginMalformedManagedManifestJSONFailsImportAndValidate(t *testing.T) { + t.Parallel() + for _, lane := range packagedNegativeLanes() { + t.Run(lane.id, func(t *testing.T) { + t.Parallel() + root := t.TempDir() + writePackagedNegativeFile(t, root, lane.manifestRel, `{"name": "demo"`) + _, err := lane.adapter().Import(root, ImportSeed{ + Manifest: pluginmodel.Manifest{Name: lane.id + "-demo", Targets: []string{lane.id}}, + }) + if err == nil || !strings.Contains(err.Error(), "invalid JSON") { + t.Fatalf("import malformed managed manifest err = %v", err) + } + diagnostics, err := lane.adapter().Validate(root, packagedNegativeGraph(lane.id), pluginmodel.NewTargetState(lane.id)) + if err != nil { + t.Fatal(err) + } + if text := diagnosticsText(diagnostics); !strings.Contains(text, "invalid JSON") { + t.Fatalf("validate malformed managed manifest diagnostics = %s", text) + } + }) + } +} + +// mcpServers must be a string ref or an inline object; a scalar like 5 is a +// contract violation, not a silent skip. +func TestPackagedPluginRejectsNonStringNonObjectMCPServers(t *testing.T) { + t.Parallel() + for _, lane := range packagedNegativeLanes() { + t.Run(lane.id, func(t *testing.T) { + t.Parallel() + root := t.TempDir() + writePackagedNegativeFile(t, root, lane.manifestRel, `{"name":"demo","mcpServers":5}`) + _, err := lane.adapter().Import(root, ImportSeed{ + Manifest: pluginmodel.Manifest{Name: lane.id + "-demo", Targets: []string{lane.id}}, + }) + if err == nil || !strings.Contains(err.Error(), "must be a string ref or object") { + t.Fatalf("import scalar mcpServers err = %v", err) + } + }) + } +} + +// Phase 1 rejects authored plugin/targets//... trees: the packaged +// lanes only accept portable skills and MCP sources. +func TestPackagedPluginRefineDiscoveryRejectsAuthoredTargetDir(t *testing.T) { + t.Parallel() + for _, lane := range packagedNegativeLanes() { + t.Run(lane.id, func(t *testing.T) { + t.Parallel() + root := t.TempDir() + writePackagedNegativeFile(t, root, filepath.Join("plugin", "targets", lane.id, "rules", "extra.mdc"), "authored\n") + state := pluginmodel.NewTargetState(lane.id) + err := lane.adapter().RefineDiscovery(root, &state) + if err == nil || !strings.Contains(err.Error(), "does not support") { + t.Fatalf("authored plugin/targets/%s err = %v", lane.id, err) + } + if !strings.Contains(err.Error(), "plugin/targets/"+lane.id) { + t.Fatalf("refusal must name the authored tree: %v", err) + } + }) + } +} diff --git a/repotests/cursor_workspace_rules_e2e_test.go b/repotests/cursor_workspace_rules_e2e_test.go new file mode 100644 index 000000000..d66f69dad --- /dev/null +++ b/repotests/cursor_workspace_rules_e2e_test.go @@ -0,0 +1,108 @@ +package pluginkitairepo_test + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +const cursorWorkspaceRuleBody = `--- +description: "Cursor workspace rules projection guard" +alwaysApply: true +--- + +- RULE MARKER: when explicitly asked to use the MCP tool release_checks, do so exactly once.` + +// TestCursorWorkspaceRulesProjectionWithoutLiveAgents is the non-live guard for +// the cursor-workspace rules lane used by the portable MCP live smoke: rules +// authored under plugin/targets/cursor-workspace/rules/** must render verbatim +// into .cursor/rules/project.mdc and pass generate --check plus +// validate --strict without any live agent. +func TestCursorWorkspaceRulesProjectionWithoutLiveAgents(t *testing.T) { + pluginKitAIBin := buildPluginKitAI(t) + workDir := filepath.Join(t.TempDir(), "cursor-workspace-rules") + mustWriteRepoFile(t, workDir, filepath.Join("plugin", "plugin.yaml"), `api_version: v1 +name: "cursor-workspace-rules" +version: "0.1.0" +description: "cursor workspace rules projection guard" +targets: + - "cursor-workspace" +`) + mustWriteRepoFile(t, workDir, filepath.Join("plugin", "targets", "cursor-workspace", "rules", "project.mdc"), cursorWorkspaceRuleBody) + + env := append(os.Environ(), "GOWORK=off") + generate := exec.CommandContext(context.Background(), pluginKitAIBin, "generate", workDir) + generate.Env = env + if out, err := generate.CombinedOutput(); err != nil { + t.Fatalf("plugin-kit-ai generate: %v\n%s", err, out) + } + generateCheck := exec.CommandContext(context.Background(), pluginKitAIBin, "generate", workDir, "--check") + generateCheck.Env = env + if out, err := generateCheck.CombinedOutput(); err != nil { + t.Fatalf("plugin-kit-ai generate --check: %v\n%s", err, out) + } + validate := exec.CommandContext(context.Background(), pluginKitAIBin, "validate", workDir, "--platform", "cursor-workspace", "--strict") + validate.Env = env + if out, err := validate.CombinedOutput(); err != nil { + t.Fatalf("plugin-kit-ai validate --strict: %v\n%s", err, out) + } + + body, err := os.ReadFile(filepath.Join(workDir, ".cursor", "rules", "project.mdc")) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + `description: "Cursor workspace rules projection guard"`, + "alwaysApply: true", + "RULE MARKER: when explicitly asked to use the MCP tool release_checks, do so exactly once.", + } { + if !strings.Contains(string(body), want) { + t.Fatalf(".cursor/rules/project.mdc missing authored rule %q:\n%s", want, body) + } + } +} + +// TestCursorTargetRulesAreRejectedInPhaseOne pins the phase-1 rejection path: +// repo-local Cursor rules must not be authored under plugin/targets/cursor/ +// (that lane only accepts skills and MCP); they belong in +// plugin/targets/cursor-workspace/rules/**. +func TestCursorTargetRulesAreRejectedInPhaseOne(t *testing.T) { + pluginKitAIBin := buildPluginKitAI(t) + workDir := filepath.Join(t.TempDir(), "cursor-target-rules") + mustWriteRepoFile(t, workDir, filepath.Join("plugin", "plugin.yaml"), `api_version: v1 +name: "cursor-target-rules" +version: "0.1.0" +description: "cursor target rules rejection" +targets: + - "cursor" +`) + mustWriteRepoFile(t, workDir, filepath.Join("plugin", "targets", "cursor", "rules", "project.mdc"), cursorWorkspaceRuleBody) + + env := append(os.Environ(), "GOWORK=off") + for _, args := range [][]string{ + {"generate", workDir}, + {"validate", workDir, "--platform", "cursor", "--strict"}, + } { + cmd := exec.CommandContext(context.Background(), pluginKitAIBin, args...) + cmd.Env = env + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("plugin-kit-ai %s accepted plugin/targets/cursor/rules:\n%s", strings.Join(args, " "), out) + } + for _, want := range []string{ + "target cursor does not support", + "plugin/targets/cursor/...", + "in phase 1", + } { + if !strings.Contains(string(out), want) { + t.Fatalf("plugin-kit-ai %s rejection missing %q:\n%s", strings.Join(args, " "), want, out) + } + } + } + if _, err := os.Stat(filepath.Join(workDir, ".cursor", "rules", "project.mdc")); !os.IsNotExist(err) { + t.Fatalf("rejected plugin/targets/cursor/rules must not render .cursor/rules/project.mdc; stat err = %v", err) + } +} diff --git a/repotests/grok_kimi_init_e2e_test.go b/repotests/grok_kimi_init_e2e_test.go new file mode 100644 index 000000000..66e528763 --- /dev/null +++ b/repotests/grok_kimi_init_e2e_test.go @@ -0,0 +1,117 @@ +package pluginkitairepo_test + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" +) + +// TestPluginKitAIInitGrokPackagedRoundTrip drives the packaged Grok lane the +// way the other init round-trips do: init --extras authors portable MCP, +// generate --check proves the rendered artifacts are current, and +// validate --strict passes. Grok wraps the portable MCP projection into a +// managed .mcp.json sidecar referenced from .grok-plugin/plugin.json. +func TestPluginKitAIInitGrokPackagedRoundTrip(t *testing.T) { + pluginKitAIBin := buildPluginKitAI(t) + plugRoot := runtimeProjectRoot(t) + run := exec.CommandContext(context.Background(), pluginKitAIBin, "init", "demo", "--platform", "grok", "-o", plugRoot, "--extras") + if out, err := run.CombinedOutput(); err != nil { + t.Fatalf("plugin-kit-ai init: %v\n%s", err, out) + } + + env := append(os.Environ(), "GOWORK=off") + generateCheck := exec.CommandContext(context.Background(), pluginKitAIBin, "generate", plugRoot, "--check") + generateCheck.Env = env + if out, err := generateCheck.CombinedOutput(); err != nil { + t.Fatalf("plugin-kit-ai generate --check: %v\n%s", err, out) + } + validateStrictProject(t, pluginKitAIBin, plugRoot, "grok", "grok init validate --strict", env) + + manifest := readInitManifest(t, filepath.Join(plugRoot, ".grok-plugin", "plugin.json")) + if manifest["name"] != "demo" { + t.Fatalf(".grok-plugin/plugin.json name = %v", manifest["name"]) + } + if manifest["mcpServers"] != "./.mcp.json" { + t.Fatalf(".grok-plugin/plugin.json must reference the managed MCP sidecar: %+v", manifest) + } + if manifest["skills"] != "./skills/" { + t.Fatalf(".grok-plugin/plugin.json skills = %+v", manifest["skills"]) + } + + sidecar := readInitManifest(t, filepath.Join(plugRoot, ".mcp.json")) + servers, ok := sidecar["mcpServers"].(map[string]any) + if !ok { + t.Fatalf("generated Grok .mcp.json must wrap servers in mcpServers:\n%v", sidecar) + } + docs, ok := servers["docs"].(map[string]any) + if !ok { + t.Fatalf("generated Grok .mcp.json missing docs server:\n%v", sidecar) + } + if docs["type"] != "http" || docs["url"] != "https://example.com/mcp" { + t.Fatalf("Grok docs MCP projection = %+v", docs) + } + if _, err := os.Stat(filepath.Join(plugRoot, "skills", "demo", "SKILL.md")); err != nil { + t.Fatalf("stat generated grok skill: %v", err) + } +} + +// TestPluginKitAIInitKimiPackagedRoundTrip drives the packaged Kimi lane: Kimi +// requires MCP servers inline in the managed manifest and never generates an +// .mcp.json sidecar. +func TestPluginKitAIInitKimiPackagedRoundTrip(t *testing.T) { + pluginKitAIBin := buildPluginKitAI(t) + plugRoot := runtimeProjectRoot(t) + run := exec.CommandContext(context.Background(), pluginKitAIBin, "init", "demo", "--platform", "kimi", "-o", plugRoot, "--extras") + if out, err := run.CombinedOutput(); err != nil { + t.Fatalf("plugin-kit-ai init: %v\n%s", err, out) + } + + env := append(os.Environ(), "GOWORK=off") + generateCheck := exec.CommandContext(context.Background(), pluginKitAIBin, "generate", plugRoot, "--check") + generateCheck.Env = env + if out, err := generateCheck.CombinedOutput(); err != nil { + t.Fatalf("plugin-kit-ai generate --check: %v\n%s", err, out) + } + validateStrictProject(t, pluginKitAIBin, plugRoot, "kimi", "kimi init validate --strict", env) + + manifest := readInitManifest(t, filepath.Join(plugRoot, ".kimi-plugin", "plugin.json")) + if manifest["name"] != "demo" { + t.Fatalf(".kimi-plugin/plugin.json name = %v", manifest["name"]) + } + if manifest["skills"] != "./skills/" { + t.Fatalf(".kimi-plugin/plugin.json skills = %+v", manifest["skills"]) + } + servers, ok := manifest["mcpServers"].(map[string]any) + if !ok { + t.Fatalf("Kimi manifest MCP must be inline, not a file ref:\n%v", manifest) + } + docs, ok := servers["docs"].(map[string]any) + if !ok { + t.Fatalf("Kimi manifest missing inline docs server:\n%v", manifest) + } + if docs["type"] != "http" || docs["url"] != "https://example.com/mcp" { + t.Fatalf("Kimi docs MCP projection = %+v", docs) + } + if _, err := os.Stat(filepath.Join(plugRoot, ".mcp.json")); !os.IsNotExist(err) { + t.Fatalf("Kimi must not generate an MCP sidecar; stat err = %v", err) + } + if _, err := os.Stat(filepath.Join(plugRoot, "skills", "demo", "SKILL.md")); err != nil { + t.Fatalf("stat generated kimi skill: %v", err) + } +} + +func readInitManifest(t *testing.T, path string) map[string]any { + t.Helper() + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var doc map[string]any + if err := json.Unmarshal(body, &doc); err != nil { + t.Fatalf("parse %s: %v\n%s", path, err, body) + } + return doc +} From d3bab3687a9c8a880bf690304b3e28165036aa06 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 19 Oct 2026 19:27:13 +0300 Subject: [PATCH 13/13] refactor(platformexec): bound duplicate-key checker complexity Split the JSON duplicate-key scan into a frame stack so each step stays within the funlen/gocognit budgets while keeping the error strings and semantics unchanged. --- cli/internal/platformexec/helpers.go | 121 ++++++++++++++++----------- 1 file changed, 73 insertions(+), 48 deletions(-) diff --git a/cli/internal/platformexec/helpers.go b/cli/internal/platformexec/helpers.go index 22b30145e..4f0e49e1b 100644 --- a/cli/internal/platformexec/helpers.go +++ b/cli/internal/platformexec/helpers.go @@ -3,6 +3,7 @@ package platformexec import ( "bytes" "encoding/json" + "errors" "fmt" "io" "os" @@ -64,12 +65,7 @@ func decodeJSONObject(body []byte, label string) (map[string]any, error) { // a parser differential waiting to happen. func rejectDuplicateJSONKeys(body []byte, label string) error { decoder := json.NewDecoder(bytes.NewReader(body)) - type frame struct { - isObject bool - expectKey bool - seen map[string]bool - } - var stack []*frame + var stack jsonKeyStack for { token, err := decoder.Token() if err == io.EOF { @@ -78,49 +74,78 @@ func rejectDuplicateJSONKeys(body []byte, label string) error { if err != nil { return fmt.Errorf("%s is invalid JSON: %w", label, err) } - switch typed := token.(type) { - case json.Delim: - switch typed { - case '{': - if len(stack) > 0 { - stack[len(stack)-1].expectKey = true - } - stack = append(stack, &frame{isObject: true, expectKey: true, seen: map[string]bool{}}) - case '[': - if len(stack) > 0 { - stack[len(stack)-1].expectKey = true - } - stack = append(stack, &frame{}) - case '}', ']': - if len(stack) == 0 { - return fmt.Errorf("%s is invalid JSON: unbalanced delimiters", label) - } - stack = stack[:len(stack)-1] - } - case string: - if len(stack) == 0 { - continue - } - top := stack[len(stack)-1] - if top.isObject && top.expectKey { - if top.seen[typed] { - return fmt.Errorf("%s is invalid JSON: duplicate object key %q", label, typed) - } - top.seen[typed] = true - top.expectKey = false - continue - } - if top.isObject { - top.expectKey = true - } - default: - if len(stack) > 0 { - top := stack[len(stack)-1] - if top.isObject { - top.expectKey = true - } - } + if err := stack.observe(token); err != nil { + return fmt.Errorf("%s is invalid JSON: %w", label, err) + } + } +} + +type jsonKeyFrame struct { + isObject bool + expectKey bool + seen map[string]bool +} + +// jsonKeyStack walks decoded tokens frame by frame so each step stays small. +type jsonKeyStack []*jsonKeyFrame + +func (s *jsonKeyStack) observe(token json.Token) error { + switch typed := token.(type) { + case json.Delim: + return s.observeDelim(typed) + case string: + return s.observeString(typed) + default: + s.markValue() + return nil + } +} + +func (s *jsonKeyStack) observeDelim(delim json.Delim) error { + switch delim { + case '{': + s.push(&jsonKeyFrame{isObject: true, expectKey: true, seen: map[string]bool{}}) + case '[': + s.push(&jsonKeyFrame{}) + case '}', ']': + if len(*s) == 0 { + return errors.New("unbalanced delimiters") } + *s = (*s)[:len(*s)-1] + } + return nil +} + +func (s *jsonKeyStack) push(frame *jsonKeyFrame) { + if n := len(*s); n > 0 { + (*s)[n-1].expectKey = true + } + *s = append(*s, frame) +} + +func (s *jsonKeyStack) observeString(value string) error { + if len(*s) == 0 { + return nil + } + top := (*s)[len(*s)-1] + if !top.isObject { + return nil + } + if !top.expectKey { + top.expectKey = true + return nil + } + if top.seen[value] { + return fmt.Errorf("duplicate object key %q", value) + } + top.seen[value] = true + top.expectKey = false + return nil +} + +func (s *jsonKeyStack) markValue() { + if n := len(*s); n > 0 && (*s)[n-1].isObject { + (*s)[n-1].expectKey = true } }