diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9aca76f84..04d838c32 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -165,10 +165,48 @@ jobs: # 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 + # CR2 #12653 fix: the previous step tried to `pip install` the runtime + # wheel and used `|| true` to swallow install failures. With + # AUTO_SYNC_TOKEN lacking the package-install scope (HTTP 401), + # the install silently failed; the contract test then SKIPPED with + # "runtime not available" — a false-green because the green + # Platform (Go) job was NOT exercising the real MCPServerAdaptor. + # + # The contract test imports the runtime from its SOURCE TREE + # (PYTHONPATH=/) so the settings-fragment.json layout is + # preserved. The runtime's own import chain pulls `a2a-sdk` (and + # other declared deps) — those MUST be available in the Python env + # where the test runs, otherwise `import molecule_runtime.*` + # fails with `ModuleNotFoundError: No module named 'a2a'`. We + # therefore `pip install` the runtime package from the checked-out + # source (no `|| true` — failures must be loud). The pip install + # resolves deps from PyPI (not the org package index that + # AUTO_SYNC_TOKEN couldn't reach) and the test's explicit + # PYTHONPATH override keeps the source on the import path so the + # source wins over the site-packages copy. + name: Checkout molecule-ai-workspace-runtime source for contract tests + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: molecule-ai/molecule-ai-workspace-runtime + path: molecule-ai-workspace-runtime + token: ${{ secrets.AUTO_SYNC_TOKEN }} + - if: ${{ needs.changes.outputs.platform == 'true' }} + name: Install molecule-ai-workspace-runtime and its Python deps for contract tests + working-directory: ./molecule-ai-workspace-runtime + run: | + set -euo pipefail + # Ensure pip is available (bare ubuntu-latest runners may not have it + # by default; `ensurepip` is the standard-library bootstrap). + if ! python3 -m pip --version >/dev/null 2>&1; then + python3 -m ensurepip --upgrade + fi + python3 -m pip install --upgrade pip + python3 -m pip install . + - if: ${{ needs.changes.outputs.platform == 'true' }} + name: Export MOLECULE_WORKSPACE_RUNTIME 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 + echo "MOLECULE_WORKSPACE_RUNTIME=$GITHUB_WORKSPACE/molecule-ai-workspace-runtime" >> "$GITHUB_ENV" - if: ${{ needs.changes.outputs.platform == 'true' }} run: go mod download - if: ${{ needs.changes.outputs.platform == 'true' }} diff --git a/workspace-server/internal/handlers/mcp_plugin_delivery_contract_test.go b/workspace-server/internal/handlers/mcp_plugin_delivery_contract_test.go index 58765bc76..cf54ca5ae 100644 --- a/workspace-server/internal/handlers/mcp_plugin_delivery_contract_test.go +++ b/workspace-server/internal/handlers/mcp_plugin_delivery_contract_test.go @@ -3,7 +3,6 @@ package handlers import ( "bytes" "encoding/json" - "fmt" "os" "os/exec" "path/filepath" @@ -11,51 +10,6 @@ import ( "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, @@ -93,74 +47,17 @@ func TestMCPPluginDeliveryContract_LoadableFromRepoRoot(t *testing.T) { } // 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. +// producer side of the contract by exercising the REAL production +// MCPServerAdaptor from molecule-ai-workspace-runtime. It merges an MCP-server +// plugin's settings-fragment.json into the exact settings_path and key pinned +// by the contract. This catches real producer drift; the previous test-local +// helper that modelled the adaptor has been removed. 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. @@ -170,6 +67,7 @@ func TestMCPPluginDeliveryContract_RealMCPServerAdaptorWritesMcpServers(t *testi runtimePath = sibling } } + pyScript := ` import asyncio, json, sys from pathlib import Path @@ -233,8 +131,19 @@ asyncio.run(main()) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { + // CR2 #12653 fix: this test is the real-producer gate for the + // MCP-plugin delivery contract. The previous behavior of + // `t.Skipf` when runtimePath was empty turned the green Platform + // (Go) job into a false-green whenever the runtime wasn't + // checked out (HTTP 401 on the old `pip install ... || true` step). + // The skip counted as a pass, so the test was not actually + // exercising the real MCPServerAdaptor. A skip here is a + // production-blocking false-green; the test must FAIL so the + // missing runtime is visible in the gate. if runtimePath == "" { - t.Skipf("molecule-ai-workspace-runtime not available (%v); skipping real-producer test", err) + t.Fatalf("CR2 #12653: molecule-ai-workspace-runtime source not found — this test must exercise the REAL MCPServerAdaptor. "+ + "Set MOLECULE_WORKSPACE_RUNTIME=/path/to/molecule-ai-workspace-runtime or check out the runtime as a sibling of the repo root. "+ + "Underlying python error (if any): %v", err) } t.Fatalf("run MCPServerAdaptor: %v\nstderr: %s", err, stderr.String()) }