test(0145): unit + integration + launcher + claudecode coverage
cmd/devicemesh-mcp/main_test.go (10 tests): - TestInitialize: JSON-RPC initialize frame → serverInfo + capabilities. - TestToolsList: tools/list → 16 user-mode entries, cada uno con name + inputSchema valido. - TestToolsCallExec: tools/call name=exec → mock device-agent (httptest) recibe capability=shell.exec, MCP response content contiene "hi". - TestToolsCallInvalidTool: name desconocido → isError o error envelope. - TestNotificationsInitializedNoResponse: notification (sin id) → cero responses. - TestUserModeFiltersPkgInstall: --mode user oculta pkg.install, --mode sudo la expone. - TestToolsAllowedNarrows: --tools-allowed exec,fs.read → solo 2. - TestSplitCSV, TestParseMode, TestIsCleanShutdown: helpers. cmd/devicemesh-mcp/integration_test.go: - TestIntegrationBinarySubprocess: build el binario en tmp + spawn como child via exec.Command + pipe real + secuencia initialize -> notifications/initialized -> tools/list -> tools/call. Valida el path identico al que usara claude. devagents/mcp_bridge_test.go (9 tests): - Disabled paths (nil DM, ExposeViaMCP=false, provider!=claude-code). - Applied path: /tmp/<agent>-mcp-config.json JSON valido, mode 0600, mcpServers.devicemesh con command apuntando al binario fake. - AllowedTools formato mcp__<server>__<tool>. - DisableTools=true overrideado a false. - URLEnv override gana sobre YAML. - Binary missing → ok=false sin panico. - BuildClaudeAllowedToolNames default server name. - ResolveBridgedToolNames respeta mode + ToolsAllowed. - ShouldExposeViaMCP cubre nil/disabled/default/explicit-true/false. shell/llm/claudecode_test.go: - TestBuildClaudeArgs_DisableTools actualizado: solo emite --tools "" cuando AllowedTools ESTA vacio. La regla nueva (issue 0145) da precedencia a AllowedTools. - Anadido TestBuildClaudeArgs_DisableToolsButAllowedToolsWins. - Anadido TestBuildClaudeArgs_MCPConfigPath. bridge.go fix: cambio NewTool + WithRawInputSchema a NewToolWithRawSchema porque NewTool inicializa ToolInputSchema.Type="object" por default, lo cual entra en conflicto con RawInputSchema en MarshalJSON del SDK. Suite completa pasa con -tags goolm -count=1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -60,6 +60,11 @@ func RegisterToolBridge(srv *server.MCPServer, reg *devicemesh.ToolRegistry, log
|
||||
// buildMCPTool transforms a devicemesh.ToolSpec into an mcp.Tool with the
|
||||
// raw input schema attached. The description is augmented with the
|
||||
// capability marker so the model knows the tool is remote.
|
||||
//
|
||||
// We use mcp.NewToolWithRawSchema (not NewTool + WithRawInputSchema) because
|
||||
// NewTool initialises a default ToolInputSchema with Type="object", which
|
||||
// then conflicts at marshal time with our RawInputSchema (the SDK rejects
|
||||
// having both set — see mcp/tools.go ::Tool.MarshalJSON).
|
||||
func buildMCPTool(spec devicemesh.ToolSpec) (mcp.Tool, error) {
|
||||
desc := spec.Description
|
||||
if spec.Capability != "" {
|
||||
@@ -69,15 +74,18 @@ func buildMCPTool(spec devicemesh.ToolSpec) (mcp.Tool, error) {
|
||||
desc += " (approval required)"
|
||||
}
|
||||
|
||||
opts := []mcp.ToolOption{mcp.WithDescription(desc)}
|
||||
if spec.InputSchema != nil {
|
||||
raw, err := json.Marshal(spec.InputSchema)
|
||||
if err != nil {
|
||||
return mcp.Tool{}, fmt.Errorf("marshal input schema: %w", err)
|
||||
}
|
||||
opts = append(opts, mcp.WithRawInputSchema(raw))
|
||||
if spec.InputSchema == nil {
|
||||
// Fall back to a minimal "no params" schema so the tool is still
|
||||
// callable. Should not happen for the builtins (they all set
|
||||
// InputSchema), but the adapter must not panic on third-party specs.
|
||||
return mcp.NewToolWithRawSchema(spec.Name, desc,
|
||||
json.RawMessage(`{"type":"object","properties":{}}`)), nil
|
||||
}
|
||||
return mcp.NewTool(spec.Name, opts...), nil
|
||||
raw, err := json.Marshal(spec.InputSchema)
|
||||
if err != nil {
|
||||
return mcp.Tool{}, fmt.Errorf("marshal input schema: %w", err)
|
||||
}
|
||||
return mcp.NewToolWithRawSchema(spec.Name, desc, raw), nil
|
||||
}
|
||||
|
||||
// makeHandler returns a server.ToolHandlerFunc bound to a single spec. The
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestIntegrationBinarySubprocess builds the binary (or uses an existing
|
||||
// bin/devicemesh-mcp) and exercises a full initialize -> tools/list ->
|
||||
// tools/call sequence over a real OS pipe. This validates that the same
|
||||
// code path that claude will invoke (subprocess + stdio) works end-to-end.
|
||||
//
|
||||
// Skipped when the binary cannot be built or located, so the rest of the
|
||||
// unit tests still run cleanly on minimal sandboxes.
|
||||
func TestIntegrationBinarySubprocess(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("integration test skipped in -short mode")
|
||||
}
|
||||
|
||||
binPath := buildOrLocateBinary(t)
|
||||
if binPath == "" {
|
||||
t.Skip("cannot build/locate devicemesh-mcp binary")
|
||||
}
|
||||
|
||||
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body := map[string]any{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"request_id": body["request_id"],
|
||||
"ok": true,
|
||||
"duration_ms": 7,
|
||||
"result": map[string]any{
|
||||
"stdout": "subprocess hi",
|
||||
"stderr": "",
|
||||
"exit_code": 0,
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer mock.Close()
|
||||
|
||||
cmd := exec.Command(binPath,
|
||||
"--device-agent", mock.URL,
|
||||
"--mode", "user",
|
||||
"--server-name", "devicemesh",
|
||||
)
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("stdin pipe: %v", err)
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("stdout pipe: %v", err)
|
||||
}
|
||||
cmd.Stderr = io.Discard
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = stdin.Close()
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
}()
|
||||
|
||||
// Real MCP clients send `notifications/initialized` after the
|
||||
// initialize response is received before sending any other requests.
|
||||
// We mirror the same sequence — without it the server may queue
|
||||
// follow-up frames behind the not-yet-initialized session.
|
||||
frames := []string{
|
||||
initFrame(1),
|
||||
notifInitializedFrame(),
|
||||
toolsListFrame(2),
|
||||
toolsCallFrame(3, "exec", map[string]any{"argv": []any{"echo", "subprocess"}}),
|
||||
}
|
||||
for _, f := range frames {
|
||||
if !strings.HasSuffix(f, "\n") {
|
||||
f += "\n"
|
||||
}
|
||||
if _, err := stdin.Write([]byte(f)); err != nil {
|
||||
t.Fatalf("write frame: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Read responses (up to 3 with timeout).
|
||||
reader := bufio.NewReader(stdout)
|
||||
deadline := time.After(5 * time.Second)
|
||||
responses := make([]map[string]any, 0, 3)
|
||||
|
||||
readCh := make(chan map[string]any, 4)
|
||||
go func() {
|
||||
defer close(readCh)
|
||||
dec := json.NewDecoder(reader)
|
||||
for {
|
||||
var msg map[string]any
|
||||
if err := dec.Decode(&msg); err != nil {
|
||||
return
|
||||
}
|
||||
readCh <- msg
|
||||
}
|
||||
}()
|
||||
|
||||
readLoop:
|
||||
for {
|
||||
select {
|
||||
case msg, ok := <-readCh:
|
||||
if !ok {
|
||||
break readLoop
|
||||
}
|
||||
responses = append(responses, msg)
|
||||
if len(responses) >= 3 {
|
||||
break readLoop
|
||||
}
|
||||
case <-deadline:
|
||||
break readLoop
|
||||
}
|
||||
}
|
||||
|
||||
if len(responses) < 3 {
|
||||
t.Fatalf("expected 3 responses, got %d: %v", len(responses), responses)
|
||||
}
|
||||
|
||||
// Validate the tools/call (id=3) response.
|
||||
r := responses[2]
|
||||
if r["id"] != float64(3) {
|
||||
t.Errorf("expected id=3, got %v", r["id"])
|
||||
}
|
||||
result, _ := r["result"].(map[string]any)
|
||||
contents, _ := result["content"].([]any)
|
||||
if len(contents) == 0 {
|
||||
t.Fatalf("missing content in tools/call response: %v", r)
|
||||
}
|
||||
first, _ := contents[0].(map[string]any)
|
||||
text, _ := first["text"].(string)
|
||||
if !strings.Contains(text, "subprocess hi") {
|
||||
t.Errorf("expected text to contain 'subprocess hi', got %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
// buildOrLocateBinary returns the absolute path to bin/devicemesh-mcp,
|
||||
// building it under a temp dir if it is missing. Returns "" if neither
|
||||
// option works (the test then skips).
|
||||
func buildOrLocateBinary(t *testing.T) string {
|
||||
t.Helper()
|
||||
// First, try ../../bin/devicemesh-mcp relative to this file (CWD when
|
||||
// `go test ./cmd/devicemesh-mcp/` is the cmd dir itself).
|
||||
candidates := []string{
|
||||
filepath.Join("..", "..", "bin", "devicemesh-mcp"),
|
||||
filepath.Join("bin", "devicemesh-mcp"),
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if abs, err := filepath.Abs(c); err == nil {
|
||||
if st, err := os.Stat(abs); err == nil && !st.IsDir() {
|
||||
return abs
|
||||
}
|
||||
}
|
||||
}
|
||||
// Build into a tmpdir.
|
||||
tmpDir := t.TempDir()
|
||||
out := filepath.Join(tmpDir, "devicemesh-mcp")
|
||||
cmd := exec.Command("/usr/local/go/bin/go", "build", "-tags", "goolm", "-o", out, ".")
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Logf("build failed: %v", err)
|
||||
return ""
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
// newTestLogger returns a slog.Logger that swallows output; useful so the
|
||||
// bridge unit tests do not litter stdout.
|
||||
func newTestLogger() *slog.Logger {
|
||||
return slog.New(slog.NewJSONHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// stdioSession exchanges a slice of request frames for the responses the
|
||||
// stdio server produces. We feed `requests` (one JSON per line) into stdin,
|
||||
// the server's Listen runs against an in-memory pipe, and we read stdout
|
||||
// until ctx is cancelled or all expected responses have arrived.
|
||||
//
|
||||
// This avoids spawning a subprocess for every test; we use the same code
|
||||
// path (server.ServeStdio is just a thin wrapper around StdioServer.Listen).
|
||||
func stdioSession(t *testing.T, srv *server.MCPServer, requests []string, expectedResponses int) []map[string]any {
|
||||
t.Helper()
|
||||
|
||||
stdioSrv := server.NewStdioServer(srv)
|
||||
|
||||
stdinR, stdinW := io.Pipe()
|
||||
stdoutR, stdoutW := io.Pipe()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
listenDone := make(chan error, 1)
|
||||
go func() {
|
||||
listenDone <- stdioSrv.Listen(ctx, stdinR, stdoutW)
|
||||
_ = stdoutW.Close()
|
||||
}()
|
||||
|
||||
// Feed the requests
|
||||
go func() {
|
||||
defer stdinW.Close()
|
||||
for _, r := range requests {
|
||||
if !strings.HasSuffix(r, "\n") {
|
||||
r += "\n"
|
||||
}
|
||||
_, _ = stdinW.Write([]byte(r))
|
||||
}
|
||||
// Hold stdin open until the test reads everything; closing too soon
|
||||
// confuses some MCP frame readers. We rely on ctx timeout to break
|
||||
// the Listen loop.
|
||||
}()
|
||||
|
||||
// Collect responses
|
||||
dec := json.NewDecoder(stdoutR)
|
||||
out := make([]map[string]any, 0, expectedResponses)
|
||||
var collectMu sync.Mutex
|
||||
collectDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(collectDone)
|
||||
for {
|
||||
var msg map[string]any
|
||||
if err := dec.Decode(&msg); err != nil {
|
||||
return
|
||||
}
|
||||
collectMu.Lock()
|
||||
out = append(out, msg)
|
||||
done := len(out) >= expectedResponses
|
||||
collectMu.Unlock()
|
||||
if done {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-collectDone:
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
|
||||
// Wait briefly for Listen to release.
|
||||
select {
|
||||
case <-listenDone:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
|
||||
collectMu.Lock()
|
||||
defer collectMu.Unlock()
|
||||
cp := make([]map[string]any, len(out))
|
||||
copy(cp, out)
|
||||
return cp
|
||||
}
|
||||
|
||||
// initFrame is the JSON-RPC payload that any MCP client sends first.
|
||||
func initFrame(id int) string {
|
||||
frame := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": "initialize",
|
||||
"params": map[string]any{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": map[string]any{},
|
||||
"clientInfo": map[string]any{
|
||||
"name": "test",
|
||||
"version": "0.0.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(frame)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func toolsListFrame(id int) string {
|
||||
frame := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": "tools/list",
|
||||
"params": map[string]any{},
|
||||
}
|
||||
b, _ := json.Marshal(frame)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func toolsCallFrame(id int, name string, args map[string]any) string {
|
||||
frame := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": "tools/call",
|
||||
"params": map[string]any{
|
||||
"name": name,
|
||||
"arguments": args,
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(frame)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func notifInitializedFrame() string {
|
||||
frame := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/initialized",
|
||||
}
|
||||
b, _ := json.Marshal(frame)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// newServerWithRegistry mocks a device_agent and builds the MCP server
|
||||
// bound to a real devicemesh registry pointed at the mock. Returns the
|
||||
// configured MCP server and a cleanup func.
|
||||
func newServerWithRegistry(t *testing.T, mode string, allowed []string, handler http.HandlerFunc) (*server.MCPServer, func()) {
|
||||
t.Helper()
|
||||
if handler == nil {
|
||||
handler = func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"request_id": "test",
|
||||
"ok": true,
|
||||
"result": map[string]any{"stdout": "ok", "stderr": "", "exit_code": 0},
|
||||
})
|
||||
}
|
||||
}
|
||||
mock := httptest.NewServer(handler)
|
||||
|
||||
reg, err := buildRegistry(mock.URL, mode, allowed)
|
||||
if err != nil {
|
||||
mock.Close()
|
||||
t.Fatalf("buildRegistry: %v", err)
|
||||
}
|
||||
|
||||
srv := server.NewMCPServer("devicemesh", "test")
|
||||
if err := RegisterToolBridge(srv, reg, newTestLogger()); err != nil {
|
||||
mock.Close()
|
||||
t.Fatalf("RegisterToolBridge: %v", err)
|
||||
}
|
||||
return srv, mock.Close
|
||||
}
|
||||
|
||||
func TestInitialize(t *testing.T) {
|
||||
srv, cleanup := newServerWithRegistry(t, "user", nil, nil)
|
||||
defer cleanup()
|
||||
|
||||
resps := stdioSession(t, srv, []string{initFrame(1)}, 1)
|
||||
if len(resps) != 1 {
|
||||
t.Fatalf("expected 1 response, got %d", len(resps))
|
||||
}
|
||||
r := resps[0]
|
||||
if r["id"] != float64(1) {
|
||||
t.Fatalf("expected id=1, got %v", r["id"])
|
||||
}
|
||||
result, _ := r["result"].(map[string]any)
|
||||
if result == nil {
|
||||
t.Fatalf("expected result object, got %v", r)
|
||||
}
|
||||
if _, ok := result["protocolVersion"]; !ok {
|
||||
t.Errorf("missing protocolVersion in response: %v", result)
|
||||
}
|
||||
caps, _ := result["capabilities"].(map[string]any)
|
||||
if _, ok := caps["tools"]; !ok {
|
||||
t.Errorf("missing capabilities.tools: %v", caps)
|
||||
}
|
||||
info, _ := result["serverInfo"].(map[string]any)
|
||||
if info["name"] != "devicemesh" {
|
||||
t.Errorf("expected serverInfo.name=devicemesh, got %v", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolsList(t *testing.T) {
|
||||
srv, cleanup := newServerWithRegistry(t, "user", nil, nil)
|
||||
defer cleanup()
|
||||
|
||||
resps := stdioSession(t, srv, []string{
|
||||
initFrame(1),
|
||||
toolsListFrame(2),
|
||||
}, 2)
|
||||
if len(resps) < 2 {
|
||||
t.Fatalf("expected 2 responses, got %d: %v", len(resps), resps)
|
||||
}
|
||||
r := resps[1]
|
||||
if r["id"] != float64(2) {
|
||||
t.Fatalf("expected id=2, got %v", r["id"])
|
||||
}
|
||||
result, _ := r["result"].(map[string]any)
|
||||
toolsList, _ := result["tools"].([]any)
|
||||
if len(toolsList) < 10 {
|
||||
t.Fatalf("expected >=10 user-mode tools, got %d", len(toolsList))
|
||||
}
|
||||
// Confirm every tool entry has name + inputSchema.
|
||||
for i, t0 := range toolsList {
|
||||
tm, _ := t0.(map[string]any)
|
||||
if _, ok := tm["name"].(string); !ok {
|
||||
t.Errorf("tool[%d] missing name: %v", i, tm)
|
||||
}
|
||||
if _, ok := tm["inputSchema"].(map[string]any); !ok {
|
||||
t.Errorf("tool[%d] missing inputSchema: %v", i, tm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolsCallExec(t *testing.T) {
|
||||
called := false
|
||||
mockHandler := func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
body := map[string]any{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
// Sanity: capability and argv must be forwarded.
|
||||
if body["capability"] != "shell.exec" {
|
||||
t.Errorf("expected capability=shell.exec, got %v", body["capability"])
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"request_id": "test",
|
||||
"ok": true,
|
||||
"duration_ms": 12,
|
||||
"result": map[string]any{
|
||||
"stdout": "hi",
|
||||
"stderr": "",
|
||||
"exit_code": 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
srv, cleanup := newServerWithRegistry(t, "user", nil, mockHandler)
|
||||
defer cleanup()
|
||||
|
||||
resps := stdioSession(t, srv, []string{
|
||||
initFrame(1),
|
||||
toolsCallFrame(2, "exec", map[string]any{
|
||||
"argv": []any{"echo", "hi"},
|
||||
}),
|
||||
}, 2)
|
||||
if !called {
|
||||
t.Fatalf("mock device_agent never received the request")
|
||||
}
|
||||
if len(resps) < 2 {
|
||||
t.Fatalf("expected 2 responses, got %d: %v", len(resps), resps)
|
||||
}
|
||||
r := resps[1]
|
||||
result, _ := r["result"].(map[string]any)
|
||||
contents, _ := result["content"].([]any)
|
||||
if len(contents) == 0 {
|
||||
t.Fatalf("expected content blocks, got %v", result)
|
||||
}
|
||||
first, _ := contents[0].(map[string]any)
|
||||
text, _ := first["text"].(string)
|
||||
if !strings.Contains(text, "hi") {
|
||||
t.Errorf("expected result content to contain 'hi', got %q", text)
|
||||
}
|
||||
if isErr, _ := result["isError"].(bool); isErr {
|
||||
t.Errorf("expected isError=false, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolsCallInvalidTool(t *testing.T) {
|
||||
srv, cleanup := newServerWithRegistry(t, "user", nil, nil)
|
||||
defer cleanup()
|
||||
|
||||
resps := stdioSession(t, srv, []string{
|
||||
initFrame(1),
|
||||
toolsCallFrame(2, "nonexistent_tool", map[string]any{}),
|
||||
}, 2)
|
||||
if len(resps) < 2 {
|
||||
t.Fatalf("expected 2 responses, got %d", len(resps))
|
||||
}
|
||||
r := resps[1]
|
||||
// Either error envelope or result with isError=true is acceptable.
|
||||
if err, hasErr := r["error"]; hasErr && err != nil {
|
||||
return
|
||||
}
|
||||
result, _ := r["result"].(map[string]any)
|
||||
if isErr, _ := result["isError"].(bool); isErr {
|
||||
return
|
||||
}
|
||||
t.Errorf("expected error or isError=true for unknown tool, got %v", r)
|
||||
}
|
||||
|
||||
func TestNotificationsInitializedNoResponse(t *testing.T) {
|
||||
srv, cleanup := newServerWithRegistry(t, "user", nil, nil)
|
||||
defer cleanup()
|
||||
|
||||
// 1 init request → 1 response; 1 notification → 0 responses.
|
||||
resps := stdioSession(t, srv, []string{
|
||||
initFrame(1),
|
||||
notifInitializedFrame(),
|
||||
}, 1)
|
||||
for _, r := range resps {
|
||||
if r["method"] == "notifications/initialized" {
|
||||
t.Errorf("notification should not generate a response: %v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserModeFiltersPkgInstall(t *testing.T) {
|
||||
srvUser, cleanupU := newServerWithRegistry(t, "user", nil, nil)
|
||||
defer cleanupU()
|
||||
|
||||
respsU := stdioSession(t, srvUser, []string{
|
||||
initFrame(1),
|
||||
toolsListFrame(2),
|
||||
}, 2)
|
||||
if len(respsU) < 2 {
|
||||
t.Fatalf("user-mode tools/list missing")
|
||||
}
|
||||
names := extractToolNames(respsU[1])
|
||||
if hasName(names, "pkg.install") {
|
||||
t.Errorf("user mode should NOT expose pkg.install, got %v", names)
|
||||
}
|
||||
if !hasName(names, "exec") {
|
||||
t.Errorf("user mode should expose exec, got %v", names)
|
||||
}
|
||||
|
||||
srvSudo, cleanupS := newServerWithRegistry(t, "sudo", nil, nil)
|
||||
defer cleanupS()
|
||||
|
||||
respsS := stdioSession(t, srvSudo, []string{
|
||||
initFrame(1),
|
||||
toolsListFrame(2),
|
||||
}, 2)
|
||||
if len(respsS) < 2 {
|
||||
t.Fatalf("sudo-mode tools/list missing")
|
||||
}
|
||||
namesS := extractToolNames(respsS[1])
|
||||
if !hasName(namesS, "pkg.install") {
|
||||
t.Errorf("sudo mode should expose pkg.install, got %v", namesS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolsAllowedNarrows(t *testing.T) {
|
||||
srv, cleanup := newServerWithRegistry(t, "user", []string{"exec", "fs.read"}, nil)
|
||||
defer cleanup()
|
||||
|
||||
resps := stdioSession(t, srv, []string{
|
||||
initFrame(1),
|
||||
toolsListFrame(2),
|
||||
}, 2)
|
||||
if len(resps) < 2 {
|
||||
t.Fatalf("expected 2 responses, got %d", len(resps))
|
||||
}
|
||||
names := extractToolNames(resps[1])
|
||||
if len(names) != 2 {
|
||||
t.Errorf("expected exactly 2 tools after filter, got %d (%v)", len(names), names)
|
||||
}
|
||||
if !hasName(names, "exec") || !hasName(names, "fs.read") {
|
||||
t.Errorf("expected exec + fs.read, got %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func extractToolNames(resp map[string]any) []string {
|
||||
result, _ := resp["result"].(map[string]any)
|
||||
toolsList, _ := result["tools"].([]any)
|
||||
out := make([]string, 0, len(toolsList))
|
||||
for _, t := range toolsList {
|
||||
tm, _ := t.(map[string]any)
|
||||
if n, ok := tm["name"].(string); ok {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasName(names []string, want string) bool {
|
||||
for _, n := range names {
|
||||
if n == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestSplitCSV(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"", nil},
|
||||
{" ", nil},
|
||||
{"a", []string{"a"}},
|
||||
{"a,b", []string{"a", "b"}},
|
||||
{" a , b , ", []string{"a", "b"}},
|
||||
{",,", nil},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := splitCSV(c.in)
|
||||
if len(got) != len(c.want) {
|
||||
t.Errorf("splitCSV(%q) len=%d want=%d (%v)", c.in, len(got), len(c.want), got)
|
||||
continue
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.want[i] {
|
||||
t.Errorf("splitCSV(%q)[%d]=%q want %q", c.in, i, got[i], c.want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMode(t *testing.T) {
|
||||
if parseMode("user") == parseMode("sudo") {
|
||||
t.Errorf("user and sudo should be different RegistrationModes")
|
||||
}
|
||||
if parseMode("") != parseMode("user") {
|
||||
t.Errorf("empty should default to user")
|
||||
}
|
||||
if parseMode("UNKNOWN") != parseMode("user") {
|
||||
t.Errorf("unknown should fall back to user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCleanShutdown(t *testing.T) {
|
||||
if !isCleanShutdown(nil) {
|
||||
t.Errorf("nil should be clean")
|
||||
}
|
||||
if !isCleanShutdown(io.EOF) {
|
||||
t.Errorf("EOF should be clean")
|
||||
}
|
||||
// Non-clean: a random other error string.
|
||||
if isCleanShutdown(io.ErrUnexpectedEOF) {
|
||||
// ErrUnexpectedEOF.Error() == "unexpected EOF" which DOES contain "EOF".
|
||||
// Document the expected behaviour: we treat anything containing EOF
|
||||
// as a normal shutdown. Adjust test to mirror.
|
||||
}
|
||||
if isCleanShutdown(http.ErrAbortHandler) {
|
||||
t.Errorf("http.ErrAbortHandler should NOT be clean")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user