Files
multica/server/pkg/agent/browser_mcp_config_test.go
Multica Eve 083e045ec6 MUL-4103: harden Windows browser MCP config (#4976)
* fix: harden Windows browser MCP config

Co-authored-by: multica-agent <github@multica.ai>

* fix: address browser mcp review nits

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-06 16:14:03 +08:00

190 lines
5.8 KiB
Go

package agent
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func withBrowserMcpTestHost(t *testing.T, goos string, env map[string]string, existing map[string]bool) {
t.Helper()
oldGOOS := browserMcpGOOS
oldEnv := browserMcpEnv
oldStat := browserMcpStat
browserMcpGOOS = goos
browserMcpEnv = func(key string) string { return env[key] }
browserMcpStat = func(path string) (os.FileInfo, error) {
if existing[path] {
return nil, nil
}
return nil, os.ErrNotExist
}
t.Cleanup(func() {
browserMcpGOOS = oldGOOS
browserMcpEnv = oldEnv
browserMcpStat = oldStat
})
}
func TestHardenBrowserMcpConfigNoopOffWindows(t *testing.T) {
t.Parallel()
raw := json.RawMessage(`{"mcpServers":{"playwright":{"command":"node","args":["@playwright/mcp","--headless"]}}}`)
got, err := hardenBrowserMcpConfig(raw, t.TempDir())
if err != nil {
t.Fatalf("hardenBrowserMcpConfig: %v", err)
}
if string(got) != string(raw) {
t.Fatalf("non-Windows config changed:\n got %s\nwant %s", got, raw)
}
}
func TestHardenWindowsBrowserMcpConfigAddsPlaywrightLaunchConfigAndEdgeFallback(t *testing.T) {
edgePath := `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`
withBrowserMcpTestHost(t, "windows", map[string]string{
"ProgramFiles(x86)": `C:\Program Files (x86)`,
}, map[string]bool{edgePath: true})
tempDir := t.TempDir()
raw := json.RawMessage(`{"mcpServers":{
"playwright":{"command":"node","args":["C:\\npm\\node_modules\\@playwright\\mcp\\cli.js","--headless","--isolated"]},
"chrome-devtools":{"command":"node","args":["C:\\npm\\node_modules\\chrome-devtools-mcp\\dist\\index.js","--headless","--isolated"]}
}}`)
got, err := hardenBrowserMcpConfig(raw, tempDir)
if err != nil {
t.Fatalf("hardenBrowserMcpConfig: %v", err)
}
servers := decodeMcpServers(t, got)
playwrightArgs := decodeArgs(t, servers["playwright"])
configIndex := indexOfString(playwrightArgs, "--config")
if configIndex < 0 || configIndex+1 >= len(playwrightArgs) {
t.Fatalf("playwright args missing --config path: %v", playwrightArgs)
}
configPath := playwrightArgs[configIndex+1]
if !strings.HasPrefix(configPath, tempDir) {
t.Fatalf("playwright config path %q is not under temp dir %q", configPath, tempDir)
}
configData, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("read playwright config: %v", err)
}
if !strings.Contains(string(configData), "--disable-gpu") {
t.Fatalf("playwright config missing --disable-gpu: %s", configData)
}
chromeArgs := decodeArgs(t, servers["chrome-devtools"])
if !contains(chromeArgs, "--executablePath="+edgePath) {
t.Fatalf("chrome-devtools args missing Edge executable fallback:\n%v", chromeArgs)
}
}
func TestHardenWindowsBrowserMcpConfigRespectsExplicitBrowserArgs(t *testing.T) {
withBrowserMcpTestHost(t, "windows", map[string]string{
"MULTICA_CHROME_DEVTOOLS_EXECUTABLE_PATH": `D:\Browsers\Chrome\chrome.exe`,
}, nil)
tempDir := t.TempDir()
raw := json.RawMessage(`{"mcpServers":{
"playwright":{"command":"npx","args":["@playwright/mcp@latest","--config=custom.json"]},
"chrome-devtools":{"command":"npx","args":["chrome-devtools-mcp@latest","-e","D:\\Browsers\\Chrome\\chrome.exe"]}
}}`)
got, err := hardenBrowserMcpConfig(raw, tempDir)
if err != nil {
t.Fatalf("hardenBrowserMcpConfig: %v", err)
}
if string(got) != string(raw) {
t.Fatalf("explicit browser args should not be changed:\n got %s\nwant %s", got, raw)
}
if entries, err := os.ReadDir(tempDir); err != nil {
t.Fatalf("read temp dir: %v", err)
} else if len(entries) != 0 {
t.Fatalf("explicit config should not create sidecar files: %v", entries)
}
}
func TestHardenWindowsBrowserMcpConfigKeepsRawOnMalformedInput(t *testing.T) {
withBrowserMcpTestHost(t, "windows", nil, nil)
raw := json.RawMessage(`not json`)
got, err := hardenBrowserMcpConfig(raw, t.TempDir())
if err != nil {
t.Fatalf("hardenBrowserMcpConfig: %v", err)
}
if string(got) != string(raw) {
t.Fatalf("malformed config changed: got %s want %s", got, raw)
}
}
func decodeMcpServers(t *testing.T, raw []byte) map[string]json.RawMessage {
t.Helper()
var top struct {
McpServers map[string]json.RawMessage `json:"mcpServers"`
}
if err := json.Unmarshal(raw, &top); err != nil {
t.Fatalf("unmarshal mcp config: %v\n%s", err, raw)
}
return top.McpServers
}
func decodeArgs(t *testing.T, raw json.RawMessage) []string {
t.Helper()
var entry struct {
Args []string `json:"args"`
}
if err := json.Unmarshal(raw, &entry); err != nil {
t.Fatalf("unmarshal mcp server: %v\n%s", err, raw)
}
return entry.Args
}
func indexOfString(items []string, item string) int {
for i, candidate := range items {
if candidate == item {
return i
}
}
return -1
}
func contains(items []string, item string) bool {
return indexOfString(items, item) >= 0
}
func TestWindowsChromiumFallbackExecutableSkipsMissingInstallDirs(t *testing.T) {
withBrowserMcpTestHost(t, "windows", map[string]string{
"ProgramFiles(x86)": "",
"ProgramFiles": "",
"LocalAppData": "",
}, nil)
if got, ok := windowsChromiumFallbackExecutable(); ok {
t.Fatalf("fallback executable = %q, want none", got)
}
}
func TestWindowsChromiumFallbackExecutablePropagatesOverride(t *testing.T) {
override := filepath.Clean(`D:\Browsers\Chromium\chrome.exe`)
withBrowserMcpTestHost(t, "windows", map[string]string{
"MULTICA_CHROME_DEVTOOLS_EXECUTABLE_PATH": override,
}, map[string]bool{})
got, ok := windowsChromiumFallbackExecutable()
if !ok || got != override {
t.Fatalf("fallback executable = %q, %v; want %q, true", got, ok, override)
}
}
func TestWithBrowserMcpTestHostUsesMissingStat(t *testing.T) {
withBrowserMcpTestHost(t, "windows", nil, nil)
_, err := browserMcpStat("missing")
if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("browserMcpStat missing error = %v, want os.ErrNotExist", err)
}
}