diff --git a/.codex-plugin/check_compatibility.py b/.codex-plugin/check_compatibility.py new file mode 100644 index 0000000..f2b3744 --- /dev/null +++ b/.codex-plugin/check_compatibility.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Read-only package preflight; never probes hosts, grants access, or dispatches.""" + +from __future__ import annotations + +import argparse +import json +import stat +import tomllib +from pathlib import Path + +SCHEMA = "2718lab-devkit/package-compatibility-v1" +IDENTITY = ( + "name", + "version", + "description", + "author", + "homepage", + "repository", + "license", + "keywords", +) +NAME = "2718lab-devkit" +MAX_BYTES = 262144 + + +class PackageError(ValueError): + pass + + +def _pairs(items): + result = {} + for key, value in items: + if key in result: + raise PackageError("duplicate_json_key") + result[key] = value + return result + + +def _read(root: Path, relative: str) -> str: + path = root + for part in Path(relative).parts: + path = path / part + if path.is_symlink(): + raise PackageError("symlink_not_allowed") + info = path.stat() + if not stat.S_ISREG(info.st_mode) or info.st_size > MAX_BYTES: + raise PackageError("file_type_or_size_invalid") + return path.read_text(encoding="utf-8") + + +def inspect_package(root: Path) -> dict: + """Validate this package's dual manifests without invoking any command.""" + checks = [] + if root.is_symlink() or not root.is_dir(): + return { + "schema": SCHEMA, + "ok": False, + "checks": [{"check": "root", "ok": False, "reason": "directory_required"}], + "execution_authorized": False, + } + + def check(name, operation): + try: + operation() + checks.append({"check": name, "ok": True}) + except ( + OSError, + UnicodeError, + ValueError, + KeyError, + TypeError, + AttributeError, + ) as exc: + reason = ( + str(exc) + if isinstance(exc, PackageError) + else "invalid_or_missing_package_data" + ) + checks.append({"check": name, "ok": False, "reason": reason}) + + data = {} + for label, relative in ( + ("portable_manifest", "plugin.json"), + ("codex_manifest", ".codex-plugin/plugin.json"), + ("portable_mcp", "mcp.json"), + ("codex_mcp", ".mcp.json"), + ): + + def load(label=label, relative=relative): + value = json.loads(_read(root, relative), object_pairs_hook=_pairs) + if type(value) is not dict: + raise PackageError("object_required") + data[label] = value + + check(label, load) + + def identity(): + portable, legacy = data["portable_manifest"], data["codex_manifest"] + allowed = {"$schema", *IDENTITY, "extensions"} + if ( + set(portable) != allowed + or portable["$schema"] + != "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" + ): + raise PackageError("portable_manifest_fields_differ") + if portable["name"] != NAME or any( + portable[key] != legacy[key] for key in IDENTITY + ): + raise PackageError("manifest_identity_drift") + extension = portable["extensions"] + if extension != {"com.openai": {"interface": legacy["interface"]}}: + raise PackageError("openai_overlay_drift") + if legacy.get("mcpServers") != "./.mcp.json": + raise PackageError("legacy_mcp_pointer_drift") + version = tomllib.loads(_read(root, "mcp-tools/pyproject.toml"))["project"][ + "version" + ] + if version != portable["version"]: + raise PackageError("python_version_drift") + + check("identity_and_overlay", identity) + + def transports(): + portable, legacy = data["portable_mcp"], data["codex_mcp"] + if ( + set(portable) != {"$schema", "mcpServers"} + or portable["$schema"] + != "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json" + ): + raise PackageError("portable_mcp_fields_differ") + if set(portable["mcpServers"]) != {NAME} or set(legacy["mcpServers"]) != {NAME}: + raise PackageError("server_inventory_drift") + new, old = portable["mcpServers"][NAME], legacy["mcpServers"][NAME] + expected = { + "type": "stdio", + "command": "uv", + "args": ["run", "--locked", "--no-dev", "python", "server.py"], + "cwd": "./mcp-tools", + "env": { + "CODEX_DEVKIT_DATA_ROOT": "${PLUGIN_DATA}/runtime", + "UV_PROJECT_ENVIRONMENT": "${PLUGIN_DATA}/python", + "UV_CACHE_DIR": "${PLUGIN_DATA}/uv-cache", + "PYTHONDONTWRITEBYTECODE": "1", + }, + } + if new != expected: + raise PackageError("portable_launch_contract_drift") + if ( + old.get("command") != new["command"] + or old.get("args") != new["args"] + or old.get("cwd") != "mcp-tools" + ): + raise PackageError("legacy_launch_contract_drift") + _read(root, "mcp-tools/server.py") + _read(root, "mcp-tools/uv.lock") + + check("stdio_launch_equivalence", transports) + + def skills(): + folder = root / "skills" + if folder.is_symlink() or not folder.is_dir(): + raise PackageError("skills_directory_missing") + entries = list(folder.iterdir()) + if len(entries) > 32: + raise PackageError("skills_inventory_unbounded") + names = [] + for path in entries: + if path.is_symlink(): + raise PackageError("symlink_not_allowed") + if not path.is_dir(): + continue + text = _read(root, "skills/" + path.name + "/SKILL.md") + if ( + not text.startswith("---\n") + or "\nname: " + path.name + "\n" not in text.split("\n---", 1)[0] + "\n" + ): + raise PackageError("skill_identity_drift") + names.append(path.name) + if not {"fast-lane-routing", "code-atlas", "workflow-design"}.issubset(names): + raise PackageError("required_manual_missing") + + check("packaged_skill_identity", skills) + return { + "schema": SCHEMA, + "ok": all(item["ok"] for item in checks), + "checks": checks, + "execution_authorized": False, + "not_verified": [ + "client_installation", + "host_environment_forwarding", + "private_broker_attestation", + "model_or_effort_availability", + "agent_dispatch", + ], + "required_host_inputs": [ + "durable_data_root", + "project_or_thread_scope", + "actual_dispatch_capabilities", + ], + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--plugin-root", type=Path, required=True) + args = parser.parse_args() + result = inspect_package(args.plugin_root) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 if result["ok"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.codex-plugin/main-artifact-allowlist.json b/.codex-plugin/main-artifact-allowlist.json index 4bb7975..4f35edc 100644 --- a/.codex-plugin/main-artifact-allowlist.json +++ b/.codex-plugin/main-artifact-allowlist.json @@ -4,6 +4,10 @@ ".codex-plugin/plugin.json", ".mcp.json", "LICENSE", + "plugin.json", + "mcp.json", + ".codex-plugin/check_compatibility.py", + "docs/compatibility/codex-2026-09.md", ".codex-plugin/fastlane_todo_projection.py", "mcp-tools/pyproject.toml", "mcp-tools/server.py", diff --git a/.codex-plugin/marketplace-artifact-allowlist.json b/.codex-plugin/marketplace-artifact-allowlist.json index 9dee881..3802240 100644 --- a/.codex-plugin/marketplace-artifact-allowlist.json +++ b/.codex-plugin/marketplace-artifact-allowlist.json @@ -4,6 +4,10 @@ ".codex-plugin/plugin.json", ".mcp.json", "LICENSE", + "plugin.json", + "mcp.json", + ".codex-plugin/check_compatibility.py", + "docs/compatibility/codex-2026-09.md", ".codex-plugin/fastlane_todo_projection.py", "mcp-tools/pyproject.toml", "mcp-tools/server.py", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 37b27bd..d037893 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "2718lab-devkit", - "version": "1.1.5", + "version": "1.2.0", "description": "Local MCP server for developer workflow coordination, indexing, and evidence handling.", "author": { "name": "2718lab", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 072322e..16d690a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -196,7 +196,7 @@ jobs: shell: bash run: | python -m pip install --disable-pip-version-check pytest==9.1.1 - python -m pytest -q mcp-tools/tests/test_primary_artifact.py + python -m pytest -q mcp-tools/tests/test_primary_artifact.py mcp-tools/tests/test_package_compatibility.py - name: Build deterministic artifact id: build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4591eab..3ea2666 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,6 +61,7 @@ jobs: git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main git merge-base --is-ancestor "${GITHUB_SHA}" origin/main test "${GITHUB_SHA}" = "$(git rev-parse origin/main)" + python .codex-plugin/check_compatibility.py --plugin-root . RELEASE_TAG_STATE=new if git ls-remote --exit-code --tags origin "refs/tags/${RELEASE_TAG}" >/dev/null 2>&1; then git fetch --no-tags origin "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 1213f69..372be9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The project follows Keep a Changelog conventions. A maintainer dispatches the repository release workflow from current `main`; it creates a new annotated tag only after the CI and artifact checks pass. +## [1.2.0] — Current Codex packaging and compatibility preflight + +- Add portable Agent Plugins 1.0 root manifests alongside the retained Codex compatibility package. +- Include both entry points and a read-only compatibility checker in deterministic lean and marketplace artifacts. +- Detect metadata/version/launch/manual drift without probing credentials, changing host settings or authorizing dispatch. +- Document current plugin discovery, file-backed image workflow boundaries and current-catalog model selection with primary sources. +- Preserve the 17-tool MCP surface, model-neutral planning, legacy replay and private-host fail-closed gates. + ## [Unreleased] ## [1.1.5] - 2026-09-05 diff --git a/README.md b/README.md index 06e451d..4289a30 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ [简体中文](README.zh-CN.md) -# 2718lab DevKit — Codex + MCP v1.1.5 +# 2718lab DevKit — Codex + MCP v1.2.0 -[![version](https://img.shields.io/badge/version-v1.1.5-blue)](./.codex-plugin/plugin.json) +[![version](https://img.shields.io/badge/version-v1.2.0-blue)](./.codex-plugin/plugin.json) [![license](https://img.shields.io/badge/license-AGPL--3.0-blue)](LICENSE) 2718lab DevKit is a Codex-first engineering toolkit: a local, stdio-only MCP runtime for bounded project indexing, Atlas evidence, Relay lifecycle coordination, and deterministic Fast Lane planning, plus a compact Skill bundle -of reference manuals. This repository carries the versioned v1.1.5 package. +of reference manuals. This repository carries the versioned v1.2.0 package. The checked-in manifest and allowlist define the executable runtime surface; the manual map, install, build, and verification sections below describe the supported workflow. @@ -49,6 +49,18 @@ and continue to fail closed. > work requires a declared-child split that strictly reduces conflict, or is > UNSPLITTABLE. +## What is new in 1.2.0 + +Portable Agent Plugins entry points now accompany the retained Codex manifests. +Run the read-only package preflight before installing an extracted artifact: + + python .codex-plugin/check_compatibility.py --plugin-root + +The JSON report checks package consistency, not host permissions or model +availability. See [the September 2026 compatibility guide](docs/compatibility/codex-2026-09.md) +for current plugin packaging, dynamic model selection and actual host boundaries. +The 17-tool runtime and fail-closed execution contracts remain intact. + ## What is shipped - Project Index exposes opaque workspace registration, bounded snapshots, @@ -185,7 +197,7 @@ source of record remains `main` and immutable release tags. Maintainers build that snapshot with the dedicated marketplace allowlist: - python .codex-plugin/build_main_artifact.py --plugin-root . --allowlist .codex-plugin/marketplace-artifact-allowlist.json --output /2718lab-devkit-marketplace-v1.1.5.zip + python .codex-plugin/build_main_artifact.py --plugin-root . --allowlist .codex-plugin/marketplace-artifact-allowlist.json --output /2718lab-devkit-marketplace-v1.2.0.zip ## Install and run locally @@ -232,7 +244,7 @@ handles or falls back to an unrelated local start. The allowlisted builder creates a deterministic ZIP outside the plugin source tree. Choose an output directory outside the source tree: - python .codex-plugin/build_main_artifact.py --plugin-root . --output /2718lab-devkit-v1.1.5.zip + python .codex-plugin/build_main_artifact.py --plugin-root . --output /2718lab-devkit-v1.2.0.zip The artifact contains the manifest, .mcp.json, LICENSE, the locked Python project, and the runtime files selected by @@ -408,7 +420,7 @@ freeze a transient regression count. ## Version -This repository represents the versioned v1.1.5 package. Release notes are +This repository represents the versioned v1.2.0 package. Release notes are in [CHANGELOG.md](CHANGELOG.md); build and install from the checked-in manifest, artifact allowlist, and locked dependency set. A maintainer dispatches Release from current `main`; it validates all declared gates, creates the annotated tag, diff --git a/README.zh-CN.md b/README.zh-CN.md index 71f475c..d68a802 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,14 +1,14 @@ [English](README.md) -# 2718lab DevKit —— Codex + MCP v1.1.5 +# 2718lab DevKit —— Codex + MCP v1.2.0 -[![版本](https://img.shields.io/badge/version-v1.1.5-blue)](./.codex-plugin/plugin.json) +[![版本](https://img.shields.io/badge/version-v1.2.0-blue)](./.codex-plugin/plugin.json) [![许可证](https://img.shields.io/badge/license-AGPL--3.0-blue)](LICENSE) 2718lab DevKit 是一个 Codex-first 工程工具包:它包含一个本地、仅 stdio 传输的 MCP 运行时,用于有边界的项目索引、Atlas 证据、Relay 生命周期协调和 确定性的 Fast Lane 规划;同时还包含一组精简的 Skill 说明书。本仓库承载版本化的 -v1.1.5 包;已提交的 manifest 和 allowlist 定义可执行运行时范围,说明书导航、 +v1.2.0 包;已提交的 manifest 和 allowlist 定义可执行运行时范围,说明书导航、 安装、构建和验证章节共同给出支持的工作流。 公共 Python 编译器和 CLI 继续保留刻意 fail-closed 的 @@ -38,6 +38,17 @@ stock Codex Host 没有经过证明的 protected broker,继续 fail-closed。 > slot、host capability 和 safety gates 约束。跨 scope 只能 declared-child split, > 且必须严格降低冲突,否则为 UNSPLITTABLE。 +## 1.2.0 更新 + +新增 Agent Plugins 标准入口 `plugin.json` 与 `mcp.json`,保留原 Codex 兼容配置。 +解压后可运行只读包体检查: + + python .codex-plugin/check_compatibility.py --plugin-root <解压后的插件目录> + +它检查版本、配置、启动参数和手册的一致性,不代表已验证客户端安装、模型可用性或执行权限。 +详见 [2026年9月兼容说明](docs/compatibility/codex-2026-09.md)。公开 MCP 仍为17个工具, +模型选择继续读取当前任务能力;缺少真实受信 Host 时仍拒绝执行。 + ## 已交付内容 - Project Index 提供不透明工作区注册、受限快照、状态和图查询。 @@ -161,7 +172,7 @@ Fast Lane 不含额度协调器合同;公共编译器和 CLI 不读取、协 维护者使用专用的 marketplace allowlist 构建该快照: - python .codex-plugin/build_main_artifact.py --plugin-root . --allowlist .codex-plugin/marketplace-artifact-allowlist.json --output /2718lab-devkit-marketplace-v1.1.5.zip + python .codex-plugin/build_main_artifact.py --plugin-root . --allowlist .codex-plugin/marketplace-artifact-allowlist.json --output /2718lab-devkit-marketplace-v1.2.0.zip ## 本地安装与运行 @@ -201,7 +212,7 @@ RELAY_CAPABILITY_BROKER_UNAVAILABLE。服务器不会暴露原始 handle,也 allowlist builder 会在插件源码树之外生成确定性的 ZIP。请选择源码树之外的输出目录: - python .codex-plugin/build_main_artifact.py --plugin-root . --output /2718lab-devkit-v1.1.5.zip + python .codex-plugin/build_main_artifact.py --plugin-root . --output /2718lab-devkit-v1.2.0.zip 产物包含 manifest、.mcp.json、LICENSE、锁定的 Python 项目,以及 .codex-plugin/main-artifact-allowlist.json 选中的运行时文件。它的可执行运行时 @@ -351,7 +362,7 @@ CI 和全新产物检查才是当前测试计数的唯一来源。它们验证 ## 版本 -本仓库代表版本化的 v1.1.5 包。发布说明见 +本仓库代表版本化的 v1.2.0 包。发布说明见 [CHANGELOG.md](CHANGELOG.md);构建和安装请以已提交的 manifest、产物 allowlist 和锁定依赖为准。维护者从 current `main` 手动 dispatch Release;它通过全部 gates 后才创建注释 tag 并发布匹配的 GitHub Release。单独 push tag 不会触发发布。 diff --git a/docs/compatibility/codex-2026-09.md b/docs/compatibility/codex-2026-09.md new file mode 100644 index 0000000..b56107b --- /dev/null +++ b/docs/compatibility/codex-2026-09.md @@ -0,0 +1,106 @@ +# Codex compatibility baseline — 2026-09-30 + +DevKit 1.2.0 updates the **package boundary and compatibility checks** for the +current ChatGPT/Codex plugin ecosystem. It does not replace the existing +model-neutral planner with a hard-coded GPT model list or claim that a stock +host implements the private DevKit execution broker. + +## What changed + +- A portable Agent Plugins 1.0 manifest (`plugin.json`) and typed local MCP + configuration (`mcp.json`) accompany the retained Codex compatibility files. +- Both release artifacts include the portable entry points. The lean artifact + still contains only its three named manuals; the marketplace artifact keeps + the complete eight-manual bundle. The public server remains exactly 17 tools. +- `python .codex-plugin/check_compatibility.py --plugin-root .` performs a + bounded, read-only package check. It catches identity/version drift, changed + launch commands, mismatched OpenAI metadata, duplicate JSON keys, missing + manuals and symlinked package components. It emits JSON and exits nonzero on + failure. Run it on the extracted artifact as well as the source checkout. +- The report explicitly lists what it did **not** verify: installation in a + client, host environment forwarding, private broker attestation, current + model/effort availability and dispatch. A green report grants no authority. + +## Two configuration formats, one runtime + +The portable manifest owns identity and `extensions.com.openai.interface`. +According to the OpenAI packaging documentation, an inline `com.openai` object +replaces the compatibility overlay rather than merging with it. We therefore +check its entire interface for equality with the compatibility manifest. + +Portable MCP uses `type: "stdio"` and `cwd: "./mcp-tools"`. The legacy Codex +configuration keeps its existing `cwd` and `env_vars` contract. `env_vars` is +**not** part of the portable MCP schema and is deliberately absent from +`mcp.json`; simply renaming the old file is invalid. Portable configuration explicitly locates the runtime database root, Python +environment and uv cache below client-managed `${PLUGIN_DATA}` and disables +bytecode writes into the installed package. It does not rely on arbitrary +ambient environment variables being inherited. Both launch the same locked +`uv run --locked --no-dev python server.py` command. Hosts must choose their +supported configuration path, not launch both server entries. + +A portable manifest is not evidence of a working installation on every host. +The operator must configure a durable data root and project/thread scope in the +host's supported environment mechanism and verify that those values reach the +server. Do not invent private bridge selectors, copy descriptor/handle values +between processes or silently fall back to an unscoped project. A host without +an attested broker continues to receive `RELAY_CAPABILITY_BROKER_UNAVAILABLE`. +Installing the plugin on ChatGPT web does not provision a local stdio process, +Python/uv, or a private execution bridge. This release does not add a remote +server or publish anything to the universal directory. + +## Current models and agent tools + +Fast Lane request-v2/plan-v3 was already model-neutral in 1.1.5. Keep that +property: obtain exact model IDs, supported efforts and tool input schemas from +**the current task's** capability metadata. A model appearing in an API release +note does not prove that this account's Codex dispatch tool exposes it. + +Record the explicit choice with `record_model_selection`; it remains a +selection-only, not-dispatched record. Respect explicit user model intent, +actual remaining host capacity and disjoint writer ownership. Tool discovery +or a previous successful spawn does not establish a current free slot. On a +thread-capacity failure, retain work and continue serially; do not fabricate a +successful spawn or repeatedly retry an unchanged capacity limit. + +Preserve the existing worker-effort contract (including its `ultra` exclusion) +and immutable legacy replay. Do not rewrite historical Spark/other-model +receipts just because a model was deprecated. Current work must use the current +catalog; old evidence describes the actual historical selection. + +For image-dependent work, preserve file-backed references and transparency +requirements in the active image tool's supported arguments. Inspect the actual +image output before recording success. Plugin compatibility does not establish +image availability or billing. Never treat generated images as validated tests. + +## Upgrade and verification + +1. Refresh the marketplace snapshot and reinstall using the existing README + workflow; start a new task to load refreshed skills/MCP configuration. +2. Check both extracted artifacts with the package preflight. Keep the lockfile + and compare the exact tool inventory using the stdio protocol tests. +3. Run the repository's Windows CI gates. A Linux sandbox can test portable + packaging and the pure compiler, but its mount type and Windows path policy + can intentionally prevent protected storage/host integration tests. +4. Verify the selected host's actual startup and scope before enabling workflow + mutations. No new credential, OAuth grant, hook trust or security-setting + change is performed by this release. + +## Primary sources checked + +- [OpenAI: Package your plugin](https://developers.openai.com/plugins/build/plugins) + — portable root files, overlay replacement, host-specific capabilities +- [Agent Plugins specification](https://agent-plugins.org/specification) — + client-managed persistent data, environment overlay and literal expansion +- [Agent Plugins manifest schema](https://agent-plugins.org/schemas/1.0.0/plugin.schema.json) + and [MCP schema](https://agent-plugins.org/schemas/1.0.0/mcp.schema.json) + — exact field names and typed stdio configuration +- [OpenAI: Build skills](https://learn.chatgpt.com/docs/build-skills) + — skill discovery and `agents/openai.yaml` metadata +- [ChatGPT & Codex changelog](https://learn.chatgpt.com/docs/changelog) + — CLI 0.158.0 (2026-09-28), file-backed image edits and transparency support; + earlier September multi-agent metadata/lifecycle changes +- [OpenAI API changelog](https://developers.openai.com/api/docs/changelog) + — September model updates are API facts, not a frozen Codex availability list + +These links explain the migration decisions. They are not runtime authority or +permission receipts, and future availability must be checked again. diff --git a/mcp-tools/pyproject.toml b/mcp-tools/pyproject.toml index c1c0c8f..f7e379b 100644 --- a/mcp-tools/pyproject.toml +++ b/mcp-tools/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "2718lab-devkit-mcp" -version = "1.1.5" +version = "1.2.0" description = "MCP runtime for the 2718lab DevKit primary plugin." requires-python = ">=3.11" dependencies = [ diff --git a/mcp-tools/tests/test_bugkiller_metadata.py b/mcp-tools/tests/test_bugkiller_metadata.py index 4967c10..dc0cfc7 100644 --- a/mcp-tools/tests/test_bugkiller_metadata.py +++ b/mcp-tools/tests/test_bugkiller_metadata.py @@ -39,7 +39,7 @@ def test_primary_plugin_manifest_is_stable_v1_and_has_no_prompt_runtime_surface( self, ) -> None: codex = load_json(".codex-plugin/plugin.json") - self.assertEqual("1.1.5", codex["version"]) + self.assertEqual("1.2.0", codex["version"]) self.assertEqual("./.mcp.json", codex["mcpServers"]) for legacy_surface in ("skills", "agents", "commands", "hooks"): self.assertNotIn(legacy_surface, codex) diff --git a/mcp-tools/tests/test_mcp_stdio.py b/mcp-tools/tests/test_mcp_stdio.py index 8536c1b..dae4348 100644 --- a/mcp-tools/tests/test_mcp_stdio.py +++ b/mcp-tools/tests/test_mcp_stdio.py @@ -12,6 +12,8 @@ from pathlib import Path from typing import cast +import pytest + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from mcp import ClientSession, StdioServerParameters @@ -68,9 +70,7 @@ def _build_and_extract_primary_artifact(tmp_path: Path) -> Path: assert ( extracted_root / "mcp-tools" / "devkit_runtime" / "composition.py" ).is_file() - assert ( - extracted_root / "mcp-tools" / "devkit_continuity" / "service.py" - ).is_file() + assert (extracted_root / "mcp-tools" / "devkit_continuity" / "service.py").is_file() return extracted_root @@ -157,8 +157,10 @@ async def exercise(): assert "ERROR" not in stderr_text +@pytest.mark.parametrize("configuration_name", [".mcp.json", "mcp.json"]) def test_extracted_primary_artifact_starts_without_source_checkout_dependency( tmp_path: Path, + configuration_name: str, ) -> None: task_root = Path(os.environ["CODEX_TASK_TEMP"]).resolve() assert tmp_path.resolve().is_relative_to(task_root) @@ -166,7 +168,10 @@ def test_extracted_primary_artifact_starts_without_source_checkout_dependency( artifact_mcp_root = extracted_root / "mcp-tools" scratch = tmp_path / "artifact-scratch" scratch.mkdir() - data_root = tmp_path / "artifact-data" + plugin_data = tmp_path / "artifact-data" + data_root = ( + plugin_data / "runtime" if configuration_name == "mcp.json" else plugin_data + ) config = RuntimeConfig.load( environ={"PLUGIN_DATA": str(data_root), "CODEX_TASK_TEMP": str(scratch)} ) @@ -203,8 +208,16 @@ def test_extracted_primary_artifact_starts_without_source_checkout_dependency( } ) assert str(ROOT) not in child_environment["PYTHONPATH"] - configuration = json.loads((extracted_root / ".mcp.json").read_text("utf-8")) + configuration = json.loads((extracted_root / configuration_name).read_text("utf-8")) server_configuration = configuration["mcpServers"]["2718lab-devkit"] + child_environment["PLUGIN_ROOT"] = str(extracted_root) + child_environment["PLUGIN_DATA"] = str(plugin_data) + plugin_data.mkdir(parents=True, exist_ok=True) + for key, value in server_configuration.get("env", {}).items(): + child_environment[key] = value.replace( + "${PLUGIN_ROOT}", str(extracted_root) + ).replace("${PLUGIN_DATA}", str(plugin_data)) + expected_environment = Path(child_environment["UV_PROJECT_ENVIRONMENT"]) parameters = StdioServerParameters( command=server_configuration["command"], args=server_configuration["args"], @@ -258,7 +271,9 @@ async def exercise(): relay_after = hashlib.sha256(relay_database.read_bytes()).hexdigest() assert initialized.serverInfo.name == "2718lab-devkit" - assert (artifact_mcp_root / ".venv").is_dir() + assert expected_environment.is_dir() + if configuration_name == "mcp.json": + assert not (artifact_mcp_root / ".venv").exists() assert {tool.name for tool in listed.tools} == EXPECTED_TOOL_NAMES assert prompts.prompts == [] assert resources.resources == [] diff --git a/mcp-tools/tests/test_package_compatibility.py b/mcp-tools/tests/test_package_compatibility.py new file mode 100644 index 0000000..c50d2aa --- /dev/null +++ b/mcp-tools/tests/test_package_compatibility.py @@ -0,0 +1,177 @@ +"""The dual-package preflight is diagnostic and cannot grant host capabilities.""" + +import importlib.util +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location( + "package_compatibility", ROOT / ".codex-plugin/check_compatibility.py" +) +CHECK = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(CHECK) + + +@pytest.fixture +def package(tmp_path): + root = tmp_path / "package" + for rel in ( + "plugin.json", + "mcp.json", + ".codex-plugin/plugin.json", + ".mcp.json", + "mcp-tools/pyproject.toml", + "mcp-tools/uv.lock", + "mcp-tools/server.py", + ): + target = root / rel + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(ROOT / rel, target) + for name in ("code-atlas", "fast-lane-routing", "workflow-design"): + target = root / "skills" / name + target.mkdir(parents=True) + (target / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: Test manual\n---\n", encoding="utf-8" + ) + return root + + +def test_checkout_preflight_is_read_only_and_never_claims_host_support(): + result = CHECK.inspect_package(ROOT) + assert result["ok"] + assert result["execution_authorized"] is False + assert "agent_dispatch" in result["not_verified"] + assert "model_or_effort_availability" in result["not_verified"] + + +def test_artifact_subset_is_valid(package): + assert CHECK.inspect_package(package)["ok"] + + +@pytest.mark.parametrize( + "file,key,value", + [ + ("plugin.json", "version", "0.0.0"), + ("plugin.json", "name", "other"), + ("plugin.json", "skills", "../outside"), + ("plugin.json", "extensions", {"com.openai": {"hooks": "./unreviewed.json"}}), + ("mcp.json", "$schema", "https://invalid.example/schema"), + ("mcp.json", "env_vars", ["SECRET"]), + ], +) +def test_manifest_drift_is_rejected(package, file, key, value): + path = package / file + data = json.loads(path.read_text()) + data[key] = value + path.write_text(json.dumps(data)) + assert CHECK.inspect_package(package)["ok"] is False + + +@pytest.mark.parametrize( + "update", + [ + {"cwd": "../escape"}, + {"command": "sh"}, + {"env_vars": ["PRIVATE"]}, + {"type": "streamable-http"}, + {"args": ["run", "--unlocked"]}, + ], +) +def test_portable_launch_cannot_drift(package, update): + path = package / "mcp.json" + data = json.loads(path.read_text()) + data["mcpServers"]["2718lab-devkit"].update(update) + path.write_text(json.dumps(data)) + assert not CHECK.inspect_package(package)["ok"] + + +def test_duplicate_keys_and_oversized_inputs_are_bounded(package): + path = package / "plugin.json" + path.write_text('{"name":"first","name":"second"}') + report = CHECK.inspect_package(package) + assert report["checks"][0]["reason"] == "duplicate_json_key" + path.write_text("x" * (CHECK.MAX_BYTES + 1)) + assert ( + CHECK.inspect_package(package)["checks"][0]["reason"] + == "file_type_or_size_invalid" + ) + + +def test_missing_private_data_is_not_leaked(package): + (package / "mcp.json").unlink() + text = json.dumps(CHECK.inspect_package(package)) + assert str(package) not in text + assert "invalid_or_missing_package_data" in text + + +def test_symlinked_component_is_rejected(package, tmp_path): + path = package / "plugin.json" + outside = tmp_path / "outside.json" + path.replace(outside) + try: + path.symlink_to(outside) + except OSError: + pytest.skip("symlinks unavailable") + assert ( + CHECK.inspect_package(package)["checks"][0]["reason"] == "symlink_not_allowed" + ) + + +def test_cli_only_prints_a_diagnostic(tmp_path): + result = subprocess.run( + [ + sys.executable, + str(ROOT / ".codex-plugin/check_compatibility.py"), + "--plugin-root", + str(tmp_path), + ], + capture_output=True, + text=True, + ) + assert result.returncode == 1 + assert json.loads(result.stdout)["execution_authorized"] is False + assert list(tmp_path.iterdir()) == [] + + +@pytest.mark.parametrize( + "allowlist", ["main-artifact-allowlist.json", "marketplace-artifact-allowlist.json"] +) +def test_extracted_artifact_passes_self_contained_check(tmp_path, allowlist): + import zipfile + + archive = tmp_path / "plugin.zip" + built = subprocess.run( + [ + sys.executable, + str(ROOT / ".codex-plugin/build_main_artifact.py"), + "--plugin-root", + str(ROOT), + "--allowlist", + str(ROOT / ".codex-plugin" / allowlist), + "--output", + str(archive), + ], + capture_output=True, + text=True, + ) + assert built.returncode == 0, built.stderr + extracted = tmp_path / "extracted" + with zipfile.ZipFile(archive) as bundle: + bundle.extractall(extracted) + report = subprocess.run( + [ + sys.executable, + str(extracted / ".codex-plugin/check_compatibility.py"), + "--plugin-root", + str(extracted), + ], + capture_output=True, + text=True, + ) + assert report.returncode == 0, report.stdout + report.stderr + assert json.loads(report.stdout)["execution_authorized"] is False diff --git a/mcp-tools/tests/test_primary_artifact.py b/mcp-tools/tests/test_primary_artifact.py index 2f618af..b4ef563 100644 --- a/mcp-tools/tests/test_primary_artifact.py +++ b/mcp-tools/tests/test_primary_artifact.py @@ -28,6 +28,10 @@ ".codex-plugin/plugin.json", ".mcp.json", "LICENSE", + "plugin.json", + "mcp.json", + ".codex-plugin/check_compatibility.py", + "docs/compatibility/codex-2026-09.md", ".codex-plugin/fastlane_todo_projection.py", "mcp-tools/pyproject.toml", "mcp-tools/server.py", @@ -231,14 +235,14 @@ def test_python_project_and_lock_use_pep440_stable_v1_metadata() -> None: with project_path.open("rb") as project_file: project = tomllib.load(project_file) - assert project["project"]["version"] == "1.1.5" + assert project["project"]["version"] == "1.2.0" assert project["project"]["dependencies"] == ["mcp[cli]>=1,<2"] assert "devkit_atlas" in project["tool"]["pyright"]["include"] assert "devkit_runtime" in project["tool"]["pyright"]["include"] assert "code_atlas" not in project["tool"]["pyright"]["include"] lock_text = lock_path.read_text(encoding="utf-8") assert 'name = "2718lab-devkit-mcp"' in lock_text - assert 'version = "1.1.5"' in lock_text + assert 'version = "1.2.0"' in lock_text def test_two_builds_are_byte_identical_with_normalized_zip_metadata( diff --git a/mcp-tools/uv.lock b/mcp-tools/uv.lock index 4a26f6f..1d3c18f 100644 --- a/mcp-tools/uv.lock +++ b/mcp-tools/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ [[package]] name = "2718lab-devkit-mcp" -version = "1.1.5" +version = "1.2.0" source = { virtual = "." } dependencies = [ { name = "mcp", extra = ["cli"] }, diff --git a/mcp.json b/mcp.json new file mode 100644 index 0000000..a28b065 --- /dev/null +++ b/mcp.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "2718lab-devkit": { + "type": "stdio", + "command": "uv", + "args": [ + "run", + "--locked", + "--no-dev", + "python", + "server.py" + ], + "cwd": "./mcp-tools", + "env": { + "CODEX_DEVKIT_DATA_ROOT": "${PLUGIN_DATA}/runtime", + "UV_PROJECT_ENVIRONMENT": "${PLUGIN_DATA}/python", + "UV_CACHE_DIR": "${PLUGIN_DATA}/uv-cache", + "PYTHONDONTWRITEBYTECODE": "1" + } + } + } +} diff --git a/plugin.json b/plugin.json new file mode 100644 index 0000000..a930d7d --- /dev/null +++ b/plugin.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "2718lab-devkit", + "version": "1.2.0", + "description": "Local MCP server for developer workflow coordination, indexing, and evidence handling.", + "author": { + "name": "2718lab", + "url": "https://github.com/2718labs" + }, + "homepage": "https://github.com/2718labs/2718lab-devkit", + "repository": "https://github.com/2718labs/2718lab-devkit", + "license": "AGPL-3.0", + "keywords": [ + "mcp", + "developer-tools", + "local" + ], + "extensions": { + "com.openai": { + "interface": { + "displayName": "2718lab DevKit", + "shortDescription": "Local MCP server for developer workflows", + "longDescription": "A local MCP server for workflow coordination, indexing, and evidence handling.", + "developerName": "2718lab", + "category": "Developer Tools", + "capabilities": [ + "MCP server" + ], + "websiteURL": "https://github.com/2718labs/2718lab-devkit", + "defaultPrompt": [] + } + } + } +} diff --git a/skills/devkit-overview/SKILL.md b/skills/devkit-overview/SKILL.md index 0a61077..e1432a0 100644 --- a/skills/devkit-overview/SKILL.md +++ b/skills/devkit-overview/SKILL.md @@ -20,3 +20,11 @@ task, cache, project, or workflow state into another project. Skills are reference manuals, not executable tools. The MCP runtime and its contracts live under \`mcp-tools/\`; use the repository README for the full map. + +## Current host compatibility + +For package-format or post-upgrade diagnosis, use the repository's +`docs/compatibility/codex-2026-09.md` and read-only +`.codex-plugin/check_compatibility.py` on the actual extracted package. +A package check does not establish host broker authority, model availability or +successful dispatch. Use current tool metadata; retain legacy evidence unchanged.