From f7dbe81f87e9ee0610e5b7639867f3a130bf901e Mon Sep 17 00:00:00 2001 From: Peyton Spencer Date: Sat, 31 Oct 2026 20:48:15 -0400 Subject: [PATCH 1/4] feat: install Kimi Code from the GitHub URL via a root manifest [DITTO-1799] Kimi Code reads a GitHub URL only from the repository root (kimi.plugin.json or .kimi-plugin/plugin.json). Its documented URL forms take a ref (tree/, releases/tag, commit) but no subdirectory, and a marketplace entry's source is again a repository, zip or local path. Add a root .kimi-plugin/plugin.json that is the plugin's own Kimi manifest with `skills` rebased to ./plugins/ditto/skills. No other host reads that path. The validator requires the root manifest to equal plugins/ditto/.kimi-plugin/plugin.json apart from that one path, so the two cannot drift. The vendored generator comes from ditto-skills 47d6b32, which versions root manifests listed under "root_manifests" in scripts/plugins.json with their plugin. README documents the GitHub install. All manifests move to 0.3.7. Co-Authored-By: Claude Opus 5.5 --- .kimi-plugin/plugin.json | 36 +++++++++++++++++++ CONTRIBUTING.md | 3 ++ README.md | 20 ++++++++--- plugins/ditto/.antigravity-plugin/plugin.json | 2 +- plugins/ditto/.claude-plugin/plugin.json | 2 +- plugins/ditto/.codex-plugin/plugin.json | 2 +- plugins/ditto/.cursor-plugin/plugin.json | 2 +- plugins/ditto/.github/plugin/plugin.json | 2 +- plugins/ditto/.grok-plugin/plugin.json | 2 +- plugins/ditto/.kimi-plugin/plugin.json | 2 +- plugins/ditto/shared-skills.lock.json | 4 +-- scripts/shared_skills.py | 35 ++++++++++++++---- scripts/validate_package.py | 17 +++++++++ tests/test_package.py | 20 ++++++++++- 14 files changed, 128 insertions(+), 21 deletions(-) create mode 100644 .kimi-plugin/plugin.json diff --git a/.kimi-plugin/plugin.json b/.kimi-plugin/plugin.json new file mode 100644 index 0000000..3e464e3 --- /dev/null +++ b/.kimi-plugin/plugin.json @@ -0,0 +1,36 @@ +{ + "name": "ditto", + "version": "0.3.7", + "author": { + "name": "Omni Aura LLC", + "email": "support@heyditto.ai", + "url": "https://heyditto.ai" + }, + "homepage": "https://heyditto.ai", + "repository": "https://github.com/ditto-assistant/ditto-plugins", + "license": "MIT", + "keywords": [ + "ditto", + "heyditto", + "memory", + "recall", + "knowledge-graph", + "mcp" + ], + "description": "Search and save Ditto memories through hosted MCP, curate music, and hand off chats with verified continuation notes. Archival and owned workspace cleanup depend on host capabilities and authorization. Sign in through Kimi Code.", + "skills": [ + "./plugins/ditto/skills" + ], + "mcpServers": { + "ditto": { + "transport": "http", + "url": "https://api.heyditto.ai/mcp" + } + }, + "interface": { + "displayName": "Ditto", + "shortDescription": "Recall and save your Ditto memories, curate music, and hand off chats.", + "developerName": "Omni Aura LLC", + "websiteURL": "https://heyditto.ai" + } +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 26d84f8..f4bb6a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,9 @@ host. Every root marketplace lists the same plugins, and out its `skills` field; `agy plugin validate --strict` needs both. - rename the MCP server key to `` in `.mcp.json`, `mcp.json`, `mcp_config.json` and the inline Copilot and Kimi entries. + - Kimi Code installs a GitHub URL only from the repository root, and the root + `.kimi-plugin/plugin.json` serves the `ditto` plugin. Other plugins install in + Kimi from a clone of `plugins/`. 3. **Allow its endpoint.** Add `: ` to `HOSTED_MCP` in `scripts/validate_package.py`. Only hosted HTTP servers are allowed: no commands, headers, env or credentials. diff --git a/README.md b/README.md index dde7b6d..a377092 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ own OAuth flow: no API keys, npm helpers, CLI bootstrap or lifecycle hooks. | Codex | `.agents/plugins/marketplace.json` | `codex plugin marketplace add ditto-assistant/ditto-plugins` then `codex plugin add ditto@ditto-plugins` (or install from `/plugins`) | | GitHub Copilot CLI / VS Code | `.github/plugin/marketplace.json` | `copilot plugin marketplace add ditto-assistant/ditto-plugins` then `copilot plugin install ditto@ditto-plugins` | | Antigravity | `plugins/ditto/.antigravity-plugin/` | `agy plugin install https://github.com/ditto-assistant/ditto-plugins`, or `/plugin install ./plugins/ditto` from a clone | -| Kimi Code | `plugins/ditto/.kimi-plugin/` | Local only: install `plugins/ditto` from a clone (installing from the GitHub URL finds no manifest at the repository root) | +| Kimi Code | `.kimi-plugin/plugin.json` (root, points into `plugins/ditto`) | `/plugins install https://github.com/ditto-assistant/ditto-plugins` in Kimi Code, or install `plugins/ditto` from a clone | ### Claude @@ -112,9 +112,20 @@ Antigravity treats `.mcp.json` as another host's file and from `mcp_config.json` today, and from the portable `mcp.json` once its `enable-agent-plugins-spec` flag is on; both name the same Ditto endpoint. -Kimi Code installs only from a local clone of `plugins/ditto`. Installing from the -GitHub URL looks for a manifest at the repository root, which a multi-plugin -repository does not have. Kimi reads the server from `.kimi-plugin/plugin.json`. +Kimi Code installs straight from GitHub. In a Kimi Code session: + +```text +/plugins install https://github.com/ditto-assistant/ditto-plugins +``` + +Choose **Trust and install**, then `/reload`. `/plugins list` shows Ditto with three +skills and one MCP server, and `/mcp` lists `plugin-ditto:ditto` as needing auth; sign +in with `/mcp-config login plugin-ditto:ditto`. Kimi reads a GitHub URL only from the +repository root, so the root `.kimi-plugin/plugin.json` points its `skills` at +`./plugins/ditto/skills`; it is the plugin's own Kimi manifest with that one path +rebased, and the validator keeps the two identical. To pin a branch or tag, use +`https://github.com/ditto-assistant/ditto-plugins/tree/`. From a clone, install +`./plugins/ditto` instead. The bundled skills are the verified loading path. The hosted MCP server also offers the canonical skill files through `skills/list`, `skills/get` and @@ -145,6 +156,7 @@ the host-labelled sections of [`adapters/ditto-memory.md`](adapters/ditto-memory .grok-plugin/marketplace.json # Grok Build .agents/plugins/marketplace.json # Codex .github/plugin/marketplace.json # GitHub Copilot / VS Code +.kimi-plugin/plugin.json # Kimi Code GitHub installs (ditto, skills rebased into plugins/ditto) adapters/ditto-memory.md # per-host connection guidance (source for the generated skill) plugins// .claude-plugin/plugin.json .cursor-plugin/plugin.json .grok-plugin/plugin.json diff --git a/plugins/ditto/.antigravity-plugin/plugin.json b/plugins/ditto/.antigravity-plugin/plugin.json index d768eee..a6f9c47 100644 --- a/plugins/ditto/.antigravity-plugin/plugin.json +++ b/plugins/ditto/.antigravity-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "ditto", "displayName": "Ditto", - "version": "0.3.6", + "version": "0.3.7", "description": "Search and save Ditto memories through hosted MCP, curate music, and hand off chats with verified continuation notes. Archival and owned workspace cleanup depend on host capabilities and authorization. Sign in through Antigravity.", "logo": "assets/icon.png", "suggestedPrompts": [ diff --git a/plugins/ditto/.claude-plugin/plugin.json b/plugins/ditto/.claude-plugin/plugin.json index f0b7348..1f036cc 100644 --- a/plugins/ditto/.claude-plugin/plugin.json +++ b/plugins/ditto/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/plugin.schema.json", "name": "ditto", - "version": "0.3.6", + "version": "0.3.7", "description": "Search and save Ditto memories through hosted MCP, curate music, and hand off chats with verified continuation notes. Archival and owned workspace cleanup depend on host capabilities and authorization. Sign in through Claude.", "author": { "name": "Omni Aura LLC", diff --git a/plugins/ditto/.codex-plugin/plugin.json b/plugins/ditto/.codex-plugin/plugin.json index a65ac42..c679f03 100644 --- a/plugins/ditto/.codex-plugin/plugin.json +++ b/plugins/ditto/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ditto", - "version": "0.3.6", + "version": "0.3.7", "author": { "name": "Omni Aura LLC", "email": "support@heyditto.ai", diff --git a/plugins/ditto/.cursor-plugin/plugin.json b/plugins/ditto/.cursor-plugin/plugin.json index 9f902a0..72a53bb 100644 --- a/plugins/ditto/.cursor-plugin/plugin.json +++ b/plugins/ditto/.cursor-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "ditto", "displayName": "Ditto", "description": "Search and save Ditto memories through hosted MCP, curate music, and hand off chats with verified continuation notes. Archival and owned workspace cleanup depend on host capabilities and authorization. Sign in through Cursor.", - "version": "0.3.6", + "version": "0.3.7", "author": { "name": "Omni Aura LLC", "email": "support@heyditto.ai" diff --git a/plugins/ditto/.github/plugin/plugin.json b/plugins/ditto/.github/plugin/plugin.json index f5e7b63..620d437 100644 --- a/plugins/ditto/.github/plugin/plugin.json +++ b/plugins/ditto/.github/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ditto", - "version": "0.3.6", + "version": "0.3.7", "displayName": "Ditto", "description": "Search and save Ditto memories through hosted MCP, curate music, and hand off chats with verified continuation notes. Archival and owned workspace cleanup depend on host capabilities and authorization. Sign in through GitHub Copilot.", "author": { diff --git a/plugins/ditto/.grok-plugin/plugin.json b/plugins/ditto/.grok-plugin/plugin.json index 50139ef..c48ff93 100644 --- a/plugins/ditto/.grok-plugin/plugin.json +++ b/plugins/ditto/.grok-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ditto", - "version": "0.3.6", + "version": "0.3.7", "author": { "name": "Omni Aura LLC", "email": "support@heyditto.ai", diff --git a/plugins/ditto/.kimi-plugin/plugin.json b/plugins/ditto/.kimi-plugin/plugin.json index ef1f3f3..1ea5d1a 100644 --- a/plugins/ditto/.kimi-plugin/plugin.json +++ b/plugins/ditto/.kimi-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ditto", - "version": "0.3.6", + "version": "0.3.7", "author": { "name": "Omni Aura LLC", "email": "support@heyditto.ai", diff --git a/plugins/ditto/shared-skills.lock.json b/plugins/ditto/shared-skills.lock.json index 02780ef..a45d14e 100644 --- a/plugins/ditto/shared-skills.lock.json +++ b/plugins/ditto/shared-skills.lock.json @@ -1,11 +1,11 @@ { "schema_version": 1, "source_repository": "https://github.com/ditto-assistant/ditto-skills", - "source_commit": "9a2a3ee2af084fe2833cec7b093cfd371098f867", + "source_commit": "47d6b32a5598c5908207f61d4bff2a82ec7ba395", "host": "plugins", "core_sha256": "5912af5483c469ec15db27858122fc9128fc25cd239202c0fdd9727de7dd5f6b", "adapter_sha256": "7cd895ab3feb0581e7e782de46b8b8220e4281a9ebfe6f6b651753e3a545bac1", - "generator_sha256": "6d23fa054bfa19203783fd7bdf6a6949915a5e62b6c71349af7031d5caae1500", + "generator_sha256": "231691821ed93c599c487fd894cac814f3359859198cee4a346a9bd8990e6cec", "skill_sha256": "dc4a46fc6de30ceac400ced65b122895738757cdcdaf1be529d63bec165e5835", "files_sha256": { "plugins/ditto/skills/curate-music/SKILL.md": "b498f8639aeb64e59f88830ac162a894f9f128b7e1f2db6a7c528092904c1eb4", diff --git a/scripts/shared_skills.py b/scripts/shared_skills.py index 737166b..c9b0fa7 100644 --- a/scripts/shared_skills.py +++ b/scripts/shared_skills.py @@ -43,6 +43,11 @@ ], } MANIFESTS = {host: f"plugins/{DEFAULT_PLUGIN}/{paths[0]}" for host, paths in PLUGIN_MANIFESTS.items()} +# Repository-root host manifests that point into one plugin, for hosts that only +# install from a repository root (Kimi Code reads /.kimi-plugin/plugin.json +# when given a GitHub URL). A plugin opts in with "root_manifests" in plugins.json; +# they are versioned and bumped with that plugin's own manifests. +ROOT_MANIFESTS = {"plugins": {".kimi-plugin/plugin.json"}} def digest(value): @@ -109,6 +114,13 @@ def plugin_registry(source): names = catalog_names(source, entry["catalog"]) if adapted is not None and names is not None and adapted not in names: raise ValueError("Adapted skill must belong to the plugin catalog") + roots = entry.get("root_manifests", []) + allowed = set().union(*ROOT_MANIFESTS.values()) + if not isinstance(roots, list) or any(r not in allowed for r in roots) or len(set(roots)) != len(roots): + raise ValueError("Invalid plugin registry: root_manifests must list known repository-root host manifests") + claimed = [r for entry in data.values() for r in entry.get("root_manifests", [])] + if len(set(claimed)) != len(claimed): + raise ValueError("Invalid plugin registry: a root manifest can point into only one plugin") return data @@ -161,11 +173,19 @@ def additional_files(source, plugin=DEFAULT_PLUGIN, catalog="memory", adapted=DE return result -def bump_manifests(repo, host, plugin): - files = [file_at(repo, f"plugins/{plugin}/{rel}") for rel in PLUGIN_MANIFESTS[host]] - if not files[0].exists(): +def host_manifests(repo, host, plugin, root_manifests=()): + """Repository-relative versioned manifests of one plugin that exist in repo.""" + if any(r not in ROOT_MANIFESTS.get(host, set()) for r in root_manifests): + raise ValueError("Root manifests are not supported for this host") + paths = [f"plugins/{plugin}/{rel}" for rel in PLUGIN_MANIFESTS[host]] + list(root_manifests) + return [p for p in paths if file_at(repo, p).exists()] + + +def bump_manifests(repo, host, plugin, root_manifests=()): + if not file_at(repo, f"plugins/{plugin}/{PLUGIN_MANIFESTS[host][0]}").exists(): raise ValueError("Missing primary host manifest") - present = [(f, json.loads(f.read_text())) for f in files if f.exists()] + files = [file_at(repo, p) for p in host_manifests(repo, host, plugin, root_manifests)] + present = [(f, json.loads(f.read_text())) for f in files] if any("version" not in m for _, m in present): raise ValueError("Every host manifest in one plugin must carry a version before bumping") versions = {m["version"] for _, m in present} @@ -181,7 +201,7 @@ def bump_manifests(repo, host, plugin): return [f.relative_to(Path(repo).resolve()).as_posix() for f, _ in present] -def build(source, repo, host, bump_version=False, plugin=DEFAULT_PLUGIN, catalog="memory", adapted=DEFAULT_ADAPTED): +def build(source, repo, host, bump_version=False, plugin=DEFAULT_PLUGIN, catalog="memory", adapted=DEFAULT_ADAPTED, root_manifests=()): paths = layout(plugin, adapted) sha = source_identity(source) generator = file_at(source, GENERATOR).read_bytes() @@ -213,7 +233,7 @@ def build(source, repo, host, bump_version=False, plugin=DEFAULT_PLUGIN, catalog return False output_changed = skill is not None and (not file_at(repo, paths["output"]).exists() or file_at(repo, paths["output"]).read_bytes() != skill) if bump_version and (output_changed or skill is None and not old or old.get("files_sha256", {}) != extra_hashes): - bump_manifests(repo, host, plugin) + bump_manifests(repo, host, plugin, root_manifests) # A renamed or dropped adapted skill leaves its previous output behind; # old locks without the key used the default adapted skill. if old: @@ -241,7 +261,8 @@ def build_all(source, repo, host, bump_version=False): continue # single-host repositories carry only the original plugin if not file_at(repo, f"plugins/{plugin}/{PLUGIN_MANIFESTS[host][0]}").exists(): raise ValueError(f"Distribution lacks the {plugin} plugin manifest; scaffold it before generating") - changed[plugin] = build(source, repo, host, bump_version, plugin, entry["catalog"], entry.get("adapted_skill")) + roots = entry.get("root_manifests", []) if host == "plugins" else [] + changed[plugin] = build(source, repo, host, bump_version, plugin, entry["catalog"], entry.get("adapted_skill"), roots) return changed diff --git a/scripts/validate_package.py b/scripts/validate_package.py index e9fe12c..c69a26d 100644 --- a/scripts/validate_package.py +++ b/scripts/validate_package.py @@ -28,6 +28,9 @@ "antigravity": ".antigravity-plugin/plugin.json", "kimi": ".kimi-plugin/plugin.json", } +# Hosts that install a GitHub URL only from the repository root get a root +# manifest that points into one plugin. Kimi Code reads /.kimi-plugin/plugin.json. +ROOT_MANIFESTS = {".kimi-plugin/plugin.json": ("kimi", "ditto")} MCP_FILES = {".mcp.json": "type", "mcp.json": "type", "mcp_config.json": None} # Antigravity loads a manifest that declares the portable Agent Plugins schema as # an Agent Plugins package: it then treats .mcp.json and mcp.json as other hosts' @@ -114,6 +117,18 @@ def check_manifest(host, manifest, plugin, folder): raise ValueError(f"{host} manifest must use the shared skills folder") +def check_root_manifest(root, relative, host, plugin): + """A root manifest is the plugin's own host manifest with paths rebased into plugins/.""" + path = root / relative + if not path.is_file(): + raise ValueError(f"Missing root {host} manifest {relative}") + manifest = load(path) + expected = load(root / "plugins" / plugin / MANIFESTS[host]) + expected["skills"] = [f"./plugins/{plugin}/skills"] + if manifest != expected: + raise ValueError(f"Root {relative} must equal plugins/{plugin}/{MANIFESTS[host]} with skills ./plugins/{plugin}/skills") + + def validate(root): root = Path(root) lists = {} @@ -167,6 +182,8 @@ def validate(root): raise ValueError(f"Unexpected release file: {relative}") if not any((folder / "skills").glob("*/SKILL.md")): raise ValueError(f"{plugin} ships no skills") + for relative, (host, plugin) in ROOT_MANIFESTS.items(): + check_root_manifest(root, relative, host, plugin) for name in ("ditto-memory", "curate-music", "handoff"): if not (root / "plugins/ditto/skills" / name / "SKILL.md").is_file(): raise ValueError(f"Missing skill: {name}") diff --git a/tests/test_package.py b/tests/test_package.py index 98e7421..1fc0033 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -17,7 +17,7 @@ def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.root = Path(self.tmp.name) - for folder in ("plugins", ".claude-plugin", ".cursor-plugin", ".grok-plugin", ".agents", ".github/plugin"): + for folder in ("plugins", ".claude-plugin", ".cursor-plugin", ".grok-plugin", ".agents", ".github/plugin", ".kimi-plugin"): shutil.copytree(ROOT / folder, self.root / folder) def edit(self, relative, change): @@ -78,6 +78,24 @@ def test_portable_mcp_file_declares_its_schema(self): with self.assertRaisesRegex(ValueError, "portable Agent Plugins MCP file"): package.validate(self.root) + def test_root_kimi_manifest_points_into_the_plugin(self): + # Kimi Code installs a GitHub URL from the repository root only (DITTO-1799). + cases = ( + (lambda d: d.__setitem__("skills", ["./skills"]), "skills ./plugins/ditto/skills"), + (lambda d: d.__setitem__("version", "9.9.9"), "must equal"), + (lambda d: d["mcpServers"]["ditto"].__setitem__("url", "https://example.com/mcp"), "must equal"), + ) + for change, message in cases: + with self.subTest(message=message): + self.setUp() + self.edit(".kimi-plugin/plugin.json", change) + with self.assertRaisesRegex(ValueError, message): + package.validate(self.root) + self.setUp() + (self.root / ".kimi-plugin/plugin.json").unlink() + with self.assertRaisesRegex(ValueError, "Missing root kimi manifest"): + package.validate(self.root) + def test_other_endpoint_is_rejected(self): f = self.root / "plugins/ditto/.mcp.json" f.write_text(f.read_text().replace("api.heyditto.ai", "example.com")) From 6fcbbd4173c7522f2a0be9b97f5cda80a7c2a50e Mon Sep 17 00:00:00 2001 From: Peyton Spencer Date: Sat, 31 Oct 2026 21:13:39 -0400 Subject: [PATCH 2/4] chore: vendor the generator from ditto-skills b65d94a [DITTO-1799] Picks up the Ditto Review fixes in ditto-skills#15: non-string root manifests are a registry error, and an unclaimed root manifest fails the build instead of keeping its version. No skill bytes change. Co-Authored-By: Claude Opus 5.5 --- plugins/ditto/shared-skills.lock.json | 4 ++-- scripts/shared_skills.py | 11 +++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/plugins/ditto/shared-skills.lock.json b/plugins/ditto/shared-skills.lock.json index a45d14e..bbed476 100644 --- a/plugins/ditto/shared-skills.lock.json +++ b/plugins/ditto/shared-skills.lock.json @@ -1,11 +1,11 @@ { "schema_version": 1, "source_repository": "https://github.com/ditto-assistant/ditto-skills", - "source_commit": "47d6b32a5598c5908207f61d4bff2a82ec7ba395", + "source_commit": "b65d94a9efaad70f0677550a030bb09ec57c0d91", "host": "plugins", "core_sha256": "5912af5483c469ec15db27858122fc9128fc25cd239202c0fdd9727de7dd5f6b", "adapter_sha256": "7cd895ab3feb0581e7e782de46b8b8220e4281a9ebfe6f6b651753e3a545bac1", - "generator_sha256": "231691821ed93c599c487fd894cac814f3359859198cee4a346a9bd8990e6cec", + "generator_sha256": "c3cab1c78bcbd50b81bc80698d87567921326ee9886d220ff61c0d5bd18128fe", "skill_sha256": "dc4a46fc6de30ceac400ced65b122895738757cdcdaf1be529d63bec165e5835", "files_sha256": { "plugins/ditto/skills/curate-music/SKILL.md": "b498f8639aeb64e59f88830ac162a894f9f128b7e1f2db6a7c528092904c1eb4", diff --git a/scripts/shared_skills.py b/scripts/shared_skills.py index c9b0fa7..92e0f34 100644 --- a/scripts/shared_skills.py +++ b/scripts/shared_skills.py @@ -116,7 +116,7 @@ def plugin_registry(source): raise ValueError("Adapted skill must belong to the plugin catalog") roots = entry.get("root_manifests", []) allowed = set().union(*ROOT_MANIFESTS.values()) - if not isinstance(roots, list) or any(r not in allowed for r in roots) or len(set(roots)) != len(roots): + if not isinstance(roots, list) or any(not isinstance(r, str) or r not in allowed for r in roots) or len(set(roots)) != len(roots): raise ValueError("Invalid plugin registry: root_manifests must list known repository-root host manifests") claimed = [r for entry in data.values() for r in entry.get("root_manifests", [])] if len(set(claimed)) != len(claimed): @@ -256,7 +256,14 @@ def build(source, repo, host, bump_version=False, plugin=DEFAULT_PLUGIN, catalog def build_all(source, repo, host, bump_version=False): """Build every plugin in the source registry that the distribution hosts.""" changed = {} - for plugin, entry in plugin_registry(source).items(): + registry = plugin_registry(source) + if host == "plugins": + # A root manifest no plugin claims would silently keep its old version. + claimed = {r for entry in registry.values() for r in entry.get("root_manifests", [])} + for root in sorted(ROOT_MANIFESTS[host] - claimed): + if file_at(repo, root).exists(): + raise ValueError(f"{root} exists but no plugin in scripts/plugins.json lists it under root_manifests") + for plugin, entry in registry.items(): if host != "plugins" and plugin != DEFAULT_PLUGIN: continue # single-host repositories carry only the original plugin if not file_at(repo, f"plugins/{plugin}/{PLUGIN_MANIFESTS[host][0]}").exists(): From 51d16bb6c07b6f2cc78c7b6b3afbe6c1cb815157 Mon Sep 17 00:00:00 2001 From: Peyton Spencer Date: Sun, 1 Nov 2026 16:26:33 -0500 Subject: [PATCH 3/4] fix(validate): reject a symlinked root Kimi manifest [DITTO-1799] Path.is_file() follows links, and the root manifest sits outside the plugin-tree scan that already refuses them. Co-Authored-By: Claude Opus 5.5 --- scripts/validate_package.py | 2 ++ tests/test_package.py | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/scripts/validate_package.py b/scripts/validate_package.py index c69a26d..e6d8b1b 100644 --- a/scripts/validate_package.py +++ b/scripts/validate_package.py @@ -120,6 +120,8 @@ def check_manifest(host, manifest, plugin, folder): def check_root_manifest(root, relative, host, plugin): """A root manifest is the plugin's own host manifest with paths rebased into plugins/.""" path = root / relative + if path.is_symlink(): + raise ValueError("Linked release input") if not path.is_file(): raise ValueError(f"Missing root {host} manifest {relative}") manifest = load(path) diff --git a/tests/test_package.py b/tests/test_package.py index 1fc0033..199cd72 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -95,6 +95,14 @@ def test_root_kimi_manifest_points_into_the_plugin(self): (self.root / ".kimi-plugin/plugin.json").unlink() with self.assertRaisesRegex(ValueError, "Missing root kimi manifest"): package.validate(self.root) + self.setUp() + linked = self.root / ".kimi-plugin/plugin.json" + target = self.root / "kimi-root.json" + target.write_text(linked.read_text()) + linked.unlink() + linked.symlink_to(target) + with self.assertRaisesRegex(ValueError, "Linked release input"): + package.validate(self.root) def test_other_endpoint_is_rejected(self): f = self.root / "plugins/ditto/.mcp.json" From 140fac0347892e9d2bc0542cabe7faee3fee2f23 Mon Sep 17 00:00:00 2001 From: Peyton Spencer Date: Sun, 1 Nov 2026 16:30:51 -0500 Subject: [PATCH 4/4] fix(validate): refuse a root manifest reached through a linked folder [DITTO-1799] Check every path component under the repository root, not only the manifest file, so a symlinked .kimi-plugin directory is refused too. Co-Authored-By: Claude Opus 5.5 --- scripts/validate_package.py | 3 ++- tests/test_package.py | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/validate_package.py b/scripts/validate_package.py index e6d8b1b..dccba80 100644 --- a/scripts/validate_package.py +++ b/scripts/validate_package.py @@ -120,7 +120,8 @@ def check_manifest(host, manifest, plugin, folder): def check_root_manifest(root, relative, host, plugin): """A root manifest is the plugin's own host manifest with paths rebased into plugins/.""" path = root / relative - if path.is_symlink(): + parts = Path(relative).parts + if any((root / Path(*parts[:i])).is_symlink() for i in range(1, len(parts) + 1)): raise ValueError("Linked release input") if not path.is_file(): raise ValueError(f"Missing root {host} manifest {relative}") diff --git a/tests/test_package.py b/tests/test_package.py index 199cd72..f29655d 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -103,6 +103,15 @@ def test_root_kimi_manifest_points_into_the_plugin(self): linked.symlink_to(target) with self.assertRaisesRegex(ValueError, "Linked release input"): package.validate(self.root) + self.setUp() + folder = self.root / ".kimi-plugin" + outside = Path(self.tmp.name + "-outside") + shutil.copytree(folder, outside) + self.addCleanup(shutil.rmtree, outside) + shutil.rmtree(folder) + folder.symlink_to(outside, target_is_directory=True) + with self.assertRaisesRegex(ValueError, "Linked release input"): + package.validate(self.root) def test_other_endpoint_is_rejected(self): f = self.root / "plugins/ditto/.mcp.json"