diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9b5954ed1..9aca76f84 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -164,6 +164,11 @@ jobs: # errors on heavy jobs, e.g. test -race link "too many errors" and # go-arch-lint "without types"). Fleet sweep after the cp ci.yml find. cache: false + - if: ${{ needs.changes.outputs.platform == 'true' }} + name: Install workspace-runtime wheel for contract tests + working-directory: . + run: | + pip install --index-url "https://agent-dev-a:${{ secrets.AUTO_SYNC_TOKEN }}@git.moleculesai.app/api/packages/molecule-ai/pypi/simple/" molecule-ai-workspace-runtime || true - if: ${{ needs.changes.outputs.platform == 'true' }} run: go mod download - if: ${{ needs.changes.outputs.platform == 'true' }} diff --git a/.gitea/workflows/mcp-plugin-delivery-contract-drift.yml b/.gitea/workflows/mcp-plugin-delivery-contract-drift.yml new file mode 100644 index 000000000..e78815bd1 --- /dev/null +++ b/.gitea/workflows/mcp-plugin-delivery-contract-drift.yml @@ -0,0 +1,119 @@ +name: mcp-plugin-delivery-contract-drift + +# Cross-repo drift gate for the MCP-plugin delivery contract (core#3080). +# +# The contract file contracts/mcp-plugin-delivery.contract.json is the SSOT +# shared between molecule-core (producer side) and +# molecule-ai-workspace-template-claude-code (consumer side). This workflow +# fetches the template's copy via the Gitea raw endpoint and byte-compares it +# against core's local copy. RED if they differ — the two repos must stay +# byte-identical so the drift gate and runtime agree on the same contract. +# +# ENFORCEMENT GATING: standalone workflow, NOT a job in ci.yml and NOT in +# branch protection (soak-then-promote; mirrors sync-providers-yaml.yml). +# +# AUTH: uses AUTO_SYNC_TOKEN (the existing cross-repo read token). If the +# secret is absent on a trusted context the job fails closed; on untrusted +# fork PRs it soft-skips. + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'contracts/mcp-plugin-delivery.contract.json' + - '.gitea/workflows/mcp-plugin-delivery-contract-drift.yml' + push: + branches: [main, staging] + paths: + - 'contracts/mcp-plugin-delivery.contract.json' + - '.gitea/workflows/mcp-plugin-delivery-contract-drift.yml' + schedule: + # Daily at :27 — catch a template-side contract change that landed without + # a paired core re-sync PR (off-zero to spread cron load). + - cron: '27 4 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: mcp-plugin-delivery-contract-drift-${{ github.ref }} + cancel-in-progress: true + +jobs: + # bp-required: pending #3080 — soak-then-promote; standalone drift gate, not in branch protection yet. + compare: + name: Compare MCP plugin delivery contract against template canonical + runs-on: ubuntu-latest + timeout-minutes: 6 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Fetch template contract and byte-compare + env: + AUTO_SYNC_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }} + API_ROOT: ${{ github.server_url }}/api/v1 + run: | + set -euo pipefail + case "${{ github.event_name }}" in + push|schedule|workflow_dispatch) + is_trusted=true + ;; + pull_request) + if [ "${{ github.event.pull_request.head.repo.fork }}" = "false" ]; then + is_trusted=true + else + is_trusted=false + fi + ;; + *) + is_trusted=true + ;; + esac + if [ -z "${AUTO_SYNC_TOKEN:-}" ]; then + if [ "$is_trusted" = "true" ]; then + echo "::error::AUTO_SYNC_TOKEN secret missing on trusted context (${{ github.event_name }}). Live cross-repo contract-drift detection cannot run." + exit 1 + fi + echo "::warning::AUTO_SYNC_TOKEN secret missing on untrusted fork PR — skipping live cross-repo compare." + exit 0 + fi + REF="${{ github.head_ref || github.ref_name }}" + CANON_URL="${API_ROOT}/repos/molecule-ai/molecule-ai-workspace-template-claude-code/raw/contracts/mcp-plugin-delivery.contract.json?ref=${REF}" + set +e + curl -fsS \ + -H "Authorization: token ${AUTO_SYNC_TOKEN}" \ + "${CANON_URL}" -o /tmp/canonical-mcp-plugin-delivery.contract.json + curl_status=$? + set -e + if [ "$curl_status" -ne 0 ]; then + if [ "$curl_status" -eq 22 ]; then + echo "::warning::Canonical contract not found on template ref ${REF} (HTTP 404); falling back to main." + CANON_URL="${API_ROOT}/repos/molecule-ai/molecule-ai-workspace-template-claude-code/raw/contracts/mcp-plugin-delivery.contract.json?ref=main" + set +e + curl -fsS \ + -H "Authorization: token ${AUTO_SYNC_TOKEN}" \ + "${CANON_URL}" -o /tmp/canonical-mcp-plugin-delivery.contract.json + curl_status=$? + set -e + if [ "$curl_status" -eq 22 ]; then + echo "::warning::Canonical contract not found on template main either (HTTP 404) — expected during bootstrap. Skipping drift check." + exit 0 + fi + if [ "$curl_status" -ne 0 ]; then + echo "::error::Failed to fetch canonical contract from template main (exit $curl_status)." + exit 1 + fi + else + echo "::error::Failed to fetch canonical contract (exit $curl_status)." + exit 1 + fi + fi + LOCAL=contracts/mcp-plugin-delivery.contract.json + if diff -u /tmp/canonical-mcp-plugin-delivery.contract.json "$LOCAL"; then + echo "OK — core's contract is byte-identical to the template canonical." + else + echo "::error::core's mcp-plugin-delivery.contract.json DRIFTED from the template canonical." + echo "Re-sync: copy molecule-ai-workspace-template-claude-code contracts/mcp-plugin-delivery.contract.json verbatim over $LOCAL." + exit 1 + fi diff --git a/contracts/mcp-plugin-delivery.contract.json b/contracts/mcp-plugin-delivery.contract.json new file mode 100644 index 000000000..0e42676b1 --- /dev/null +++ b/contracts/mcp-plugin-delivery.contract.json @@ -0,0 +1 @@ +{"settings_path":"/configs/.claude/settings.json","key":"mcpServers","entry_shape":"name->{command,args?,env?}","producer":"MCPServerAdaptor","consumer":"claude_sdk_executor._load_settings_mcp"} diff --git a/workspace-server/internal/handlers/mcp_plugin_delivery_contract.go b/workspace-server/internal/handlers/mcp_plugin_delivery_contract.go new file mode 100644 index 000000000..dd050d7e0 --- /dev/null +++ b/workspace-server/internal/handlers/mcp_plugin_delivery_contract.go @@ -0,0 +1,40 @@ +package handlers + +// MCP-plugin delivery contract (core#3080). +// +// This is the producer-side guard for the MCP-plugin delivery contract +// between molecule-core (which ships plugin declarations) and the +// claude-code workspace template (whose runtime writes/reads +// /configs/.claude/settings.json under the mcpServers key). +// +// The contract file is duplicated byte-for-byte in both repos and kept +// honest by a cross-repo drift gate. + +import ( + "encoding/json" + "os" +) + +const mcpPluginDeliveryContractPath = "../../../contracts/mcp-plugin-delivery.contract.json" + +// MCPPluginDeliveryContract describes the pinned MCP-plugin delivery surface. +type MCPPluginDeliveryContract struct { + SettingsPath string `json:"settings_path"` + Key string `json:"key"` + EntryShape string `json:"entry_shape"` + Producer string `json:"producer"` + Consumer string `json:"consumer"` +} + +// LoadMCPPluginDeliveryContract loads the contract from the repo root. +func LoadMCPPluginDeliveryContract() (*MCPPluginDeliveryContract, error) { + data, err := os.ReadFile(mcpPluginDeliveryContractPath) + if err != nil { + return nil, err + } + var c MCPPluginDeliveryContract + if err := json.Unmarshal(data, &c); err != nil { + return nil, err + } + return &c, nil +} diff --git a/workspace-server/internal/handlers/mcp_plugin_delivery_contract_test.go b/workspace-server/internal/handlers/mcp_plugin_delivery_contract_test.go new file mode 100644 index 000000000..58765bc76 --- /dev/null +++ b/workspace-server/internal/handlers/mcp_plugin_delivery_contract_test.go @@ -0,0 +1,262 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// mergeMCPPluginSettings models the runtime MCPServerAdaptor's write side for +// the contract test: it reads a plugin's settings-fragment.json and merges its +// mcpServers block into the contract settings_path/key. Keeping this logic in +// the test file makes the contract test hermetic and avoids adding dead +// production code; the live pipeline still uses the Python runtime adaptor. +func mergeMCPPluginSettings(configsDir, pluginRoot string, contract *MCPPluginDeliveryContract) error { + data, err := os.ReadFile(filepath.Join(pluginRoot, "settings-fragment.json")) + if err != nil { + return fmt.Errorf("read settings-fragment.json: %w", err) + } + var fragment map[string]any + if err := json.Unmarshal(data, &fragment); err != nil { + return fmt.Errorf("parse settings-fragment.json: %w", err) + } + mcpServers, ok := fragment[contract.Key].(map[string]any) + if !ok { + return fmt.Errorf("settings-fragment.json %q key missing or not an object", contract.Key) + } + + if !strings.HasPrefix(contract.SettingsPath, "/configs/") { + return fmt.Errorf("contract settings_path %q does not start with /configs/", contract.SettingsPath) + } + rel := strings.TrimPrefix(contract.SettingsPath, "/configs/") + settingsPath := filepath.Join(configsDir, rel) + if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil { + return fmt.Errorf("mkdir settings dir: %w", err) + } + + existing := map[string]any{} + if cur, err := os.ReadFile(settingsPath); err == nil { + _ = json.Unmarshal(cur, &existing) + } + existing[contract.Key] = mcpServers + + out, err := json.MarshalIndent(existing, "", " ") + if err != nil { + return fmt.Errorf("marshal settings: %w", err) + } + out = append(out, '\n') + if err := os.WriteFile(settingsPath, out, 0o644); err != nil { + return fmt.Errorf("write settings: %w", err) + } + return nil +} + +// TestMCPPluginDeliveryContract_MatchesSSOT pins the producer side of the +// MCP-plugin delivery contract. The contract file is the SSOT shared with +// molecule-ai-workspace-template-claude-code; any change to the pinned path, +// key, producer, or consumer must be deliberate and synchronized. +func TestMCPPluginDeliveryContract_MatchesSSOT(t *testing.T) { + c, err := LoadMCPPluginDeliveryContract() + if err != nil { + t.Fatalf("load contract: %v", err) + } + + if c.SettingsPath != "/configs/.claude/settings.json" { + t.Errorf("settings_path = %q, want /configs/.claude/settings.json", c.SettingsPath) + } + if c.Key != "mcpServers" { + t.Errorf("key = %q, want mcpServers", c.Key) + } + if c.EntryShape != "name->{command,args?,env?}" { + t.Errorf("entry_shape = %q, want name->{command,args?,env?}", c.EntryShape) + } + if c.Producer != "MCPServerAdaptor" { + t.Errorf("producer = %q, want MCPServerAdaptor", c.Producer) + } + if c.Consumer != "claude_sdk_executor._load_settings_mcp" { + t.Errorf("consumer = %q, want claude_sdk_executor._load_settings_mcp", c.Consumer) + } +} + +// TestMCPPluginDeliveryContract_LoadableFromRepoRoot guards against a moved +// or missing contract file, which would silently break the cross-repo drift +// gate and any code that loads the contract at runtime. +func TestMCPPluginDeliveryContract_LoadableFromRepoRoot(t *testing.T) { + if _, err := LoadMCPPluginDeliveryContract(); err != nil { + t.Fatalf("contract must be loadable from repo root: %v", err) + } +} + +// TestMCPPluginDeliveryContract_MCPServerAdaptorWritesMcpServers asserts the +// producer side of the contract: an MCP-server plugin's settings-fragment.json +// is merged into the exact settings_path and key pinned by the contract. This +// prevents silent divergence between where MCPServerAdaptor writes and where +// claude_sdk_executor._load_settings_mcp reads. +func TestMCPPluginDeliveryContract_MCPServerAdaptorWritesMcpServers(t *testing.T) { + contract, err := LoadMCPPluginDeliveryContract() + if err != nil { + t.Fatalf("load contract: %v", err) + } + + configsDir := t.TempDir() + pluginRoot := t.TempDir() + + fragment := map[string]any{ + contract.Key: map[string]any{ + "molecule-platform": map[string]any{ + "command": "molecule-mcp", + "env": map[string]string{ + "MOLECULE_MCP_MODE": "management", + }, + }, + }, + } + fragmentBytes, err := json.Marshal(fragment) + if err != nil { + t.Fatalf("marshal fragment: %v", err) + } + if err := os.WriteFile(filepath.Join(pluginRoot, "settings-fragment.json"), fragmentBytes, 0o644); err != nil { + t.Fatalf("write settings-fragment.json: %v", err) + } + + if err := mergeMCPPluginSettings(configsDir, pluginRoot, contract); err != nil { + t.Fatalf("merge mcp plugin settings: %v", err) + } + + rel := strings.TrimPrefix(contract.SettingsPath, "/configs/") + settingsPath := filepath.Join(configsDir, rel) + gotBytes, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("read produced settings at %s: %v", contract.SettingsPath, err) + } + var got map[string]any + if err := json.Unmarshal(gotBytes, &got); err != nil { + t.Fatalf("parse produced settings: %v", err) + } + if _, ok := got[contract.Key]; !ok { + t.Fatalf("produced settings missing contract key %q", contract.Key) + } + mcpServers, ok := got[contract.Key].(map[string]any) + if !ok { + t.Fatalf("produced settings %q is not an object: %T", contract.Key, got[contract.Key]) + } + if _, ok := mcpServers["molecule-platform"]; !ok { + t.Fatalf("produced settings %q does not contain the molecule-platform entry", contract.Key) + } +} + +// TestMCPPluginDeliveryContract_RealMCPServerAdaptorWritesMcpServers exercises +// the actual runtime MCPServerAdaptor when molecule-ai-workspace-runtime is +// available. This is the real producer named in the contract; the hermetic test +// above is the always-on CI guard, while this test catches drift in the runtime +// implementation itself. +func TestMCPPluginDeliveryContract_RealMCPServerAdaptorWritesMcpServers(t *testing.T) { + contract, err := LoadMCPPluginDeliveryContract() + if err != nil { + t.Fatalf("load contract: %v", err) + } + + runtimePath := os.Getenv("MOLECULE_WORKSPACE_RUNTIME") + if runtimePath == "" { + // Default sibling checkout relative to the core repo root. + repoRoot := filepath.Join("..", "..", "..") + sibling := filepath.Join(repoRoot, "molecule-ai-workspace-runtime") + if _, err := os.Stat(filepath.Join(sibling, "molecule_runtime", "plugins_registry", "builtins.py")); err == nil { + runtimePath = sibling + } + } + pyScript := ` +import asyncio, json, sys +from pathlib import Path +sys.path.insert(0, sys.argv[1]) +from molecule_runtime.plugins_registry.builtins import MCPServerAdaptor +from molecule_runtime.plugins_registry.protocol import InstallContext + +plugin_root = Path(sys.argv[2]) +configs_dir = Path(sys.argv[3]) +configs_dir.mkdir(parents=True, exist_ok=True) + +async def main(): + ctx = InstallContext( + configs_dir=configs_dir, + workspace_id="test-ws", + runtime="claude_code", + plugin_root=plugin_root, + ) + adaptor = MCPServerAdaptor("molecule-platform-mcp", "claude_code") + await adaptor.install(ctx) + +asyncio.run(main()) +` + + configsDir := t.TempDir() + pluginRoot := t.TempDir() + + fragment := map[string]any{ + contract.Key: map[string]any{ + "molecule-platform": map[string]any{ + "command": "molecule-mcp", + "env": map[string]string{ + "MOLECULE_MCP_MODE": "management", + }, + }, + }, + } + fragmentBytes, err := json.Marshal(fragment) + if err != nil { + t.Fatalf("marshal fragment: %v", err) + } + if err := os.WriteFile(filepath.Join(pluginRoot, "settings-fragment.json"), fragmentBytes, 0o644); err != nil { + t.Fatalf("write settings-fragment.json: %v", err) + } + + python := os.Getenv("MOLECULE_RUNTIME_PYTHON") + if python == "" { + python = "python3" + } + var pythonPath string + if runtimePath != "" { + pythonPath = runtimePath + } + + cmd := exec.Command(python, "-", runtimePath, pluginRoot, configsDir) + cmd.Env = os.Environ() + if pythonPath != "" { + cmd.Env = append(cmd.Env, "PYTHONPATH="+pythonPath) + } + cmd.Stdin = strings.NewReader(strings.TrimSpace(pyScript)) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + if runtimePath == "" { + t.Skipf("molecule-ai-workspace-runtime not available (%v); skipping real-producer test", err) + } + t.Fatalf("run MCPServerAdaptor: %v\nstderr: %s", err, stderr.String()) + } + + rel := strings.TrimPrefix(contract.SettingsPath, "/configs/") + settingsPath := filepath.Join(configsDir, rel) + gotBytes, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("read produced settings at %s: %v", contract.SettingsPath, err) + } + var got map[string]any + if err := json.Unmarshal(gotBytes, &got); err != nil { + t.Fatalf("parse produced settings: %v", err) + } + if _, ok := got[contract.Key]; !ok { + t.Fatalf("real MCPServerAdaptor produced settings missing contract key %q", contract.Key) + } + mcpServers, ok := got[contract.Key].(map[string]any) + if !ok { + t.Fatalf("real MCPServerAdaptor produced settings %q is not an object: %T", contract.Key, got[contract.Key]) + } + if _, ok := mcpServers["molecule-platform"]; !ok { + t.Fatalf("real MCPServerAdaptor produced settings %q does not contain the molecule-platform entry", contract.Key) + } +} diff --git a/workspace-server/internal/handlers/platform_agent.go b/workspace-server/internal/handlers/platform_agent.go index 2b90ed84d..57666d5ec 100644 --- a/workspace-server/internal/handlers/platform_agent.go +++ b/workspace-server/internal/handlers/platform_agent.go @@ -663,8 +663,8 @@ func MaybeProvisionPlatformAgentOnBoot(ctx context.Context, database *sql.DB, pr return } log.Printf("boot: platform-agent %s running but MISSING concierge identity — re-declaring management MCP and restarting once to apply the system prompt + platform MCP", id) - if rec, skip := seedTemplatePlugins(ctx, id, []string{conciergePlatformMCPPlugin}); skip > 0 { - log.Printf("boot: concierge %s could not re-declare %q plugin (recorded=%d skipped=%d) — management MCP may be absent until next provision", id, conciergePlatformMCPPlugin, rec, skip) + if rec, skip := seedTemplatePlugins(ctx, id, []string{conciergePlatformMCPSource}); skip > 0 { + log.Printf("boot: concierge %s could not re-declare %q plugin (recorded=%d skipped=%d) — management MCP may be absent until next provision", id, conciergePlatformMCPSource, rec, skip) } go restartByID(id) return diff --git a/workspace-server/internal/handlers/platform_agent_test.go b/workspace-server/internal/handlers/platform_agent_test.go index 90c5003b1..ed4e2e576 100644 --- a/workspace-server/internal/handlers/platform_agent_test.go +++ b/workspace-server/internal/handlers/platform_agent_test.go @@ -318,7 +318,7 @@ func TestMaybeProvisionPlatformAgentOnBoot_RestartsRunningButVanilla(t *testing. mock.ExpectQuery(kindQuery).WithArgs(bootPlatformID). WillReturnRows(sqlmock.NewRows([]string{"kind"}).AddRow("platform")) mock.ExpectExec(`INSERT INTO workspace_declared_plugins`). - WithArgs(bootPlatformID, conciergePlatformMCPPlugin, sqlmock.AnyArg()). + WithArgs(bootPlatformID, conciergePlatformMCPName, sqlmock.AnyArg()). WillReturnResult(sqlmock.NewResult(0, 1)) // Running, but ExecRead of system-prompt.md returns vanilla content (no