a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
207 lines
6.4 KiB
Go
207 lines
6.4 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// TestRouter_LocalFastPath: when the resolved server slug equals the
|
|
// router's localSlug, RouteToolCall must invoke the local executor
|
|
// instead of dialing out. This is the common case for a single-host
|
|
// developer setup with one declared default server.
|
|
func TestRouter_LocalFastPath(t *testing.T) {
|
|
cfg := &ServersConfig{
|
|
Server: []ServerEntry{
|
|
{Slug: "local", URL: "unix:///tmp/local.sock", Default: true},
|
|
},
|
|
}
|
|
calledLocal := false
|
|
r := NewRouter(RouterConfig{
|
|
Servers: cfg,
|
|
Rosters: NewWorkspaceRosterCache(time.Minute),
|
|
LocalSlug: "local",
|
|
LocalExecute: func(_ context.Context, name string, _ []byte) ([]byte, int, error) {
|
|
calledLocal = true
|
|
if name != "search_symbols" {
|
|
t.Fatalf("expected search_symbols, got %q", name)
|
|
}
|
|
return []byte(`{"ok":true}`), 200, nil
|
|
},
|
|
Logger: zap.NewNop(),
|
|
})
|
|
out, status, err := r.RouteToolCall(context.Background(), "search_symbols", []byte(`{}`), RouteContext{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !calledLocal {
|
|
t.Fatal("local executor was not called")
|
|
}
|
|
if status != 200 {
|
|
t.Fatalf("status = %d, want 200", status)
|
|
}
|
|
if string(out) != `{"ok":true}` {
|
|
t.Fatalf("body = %q", out)
|
|
}
|
|
}
|
|
|
|
// TestRouter_LocalFastPath_CtxPropagates: callLocal must forward the
|
|
// caller's ctx verbatim. A previous regression replaced ctx with
|
|
// context.Background() inside callLocal, which silently dropped every
|
|
// per-session value attached upstream — most visibly the
|
|
// `mcp.WithSessionID` value the daemon dispatcher uses to route a
|
|
// tool call to the right per-session state. With ctx lost, the
|
|
// session-default wire-format negotiation (claude-code → gcx) saw an
|
|
// empty session ID and fell through to JSON. This test pins the
|
|
// invariant: whatever ctx the caller passes, the local executor sees.
|
|
func TestRouter_LocalFastPath_CtxPropagates(t *testing.T) {
|
|
type ctxKey struct{}
|
|
cfg := &ServersConfig{
|
|
Server: []ServerEntry{
|
|
{Slug: "local", URL: "unix:///tmp/local.sock", Default: true},
|
|
},
|
|
}
|
|
var seen string
|
|
r := NewRouter(RouterConfig{
|
|
Servers: cfg,
|
|
Rosters: NewWorkspaceRosterCache(time.Minute),
|
|
LocalSlug: "local",
|
|
LocalExecute: func(ctx context.Context, _ string, _ []byte) ([]byte, int, error) {
|
|
if v, ok := ctx.Value(ctxKey{}).(string); ok {
|
|
seen = v
|
|
}
|
|
return []byte(`{}`), 200, nil
|
|
},
|
|
Logger: zap.NewNop(),
|
|
})
|
|
want := "session-marker"
|
|
ctx := context.WithValue(context.Background(), ctxKey{}, want)
|
|
if _, _, err := r.RouteToolCall(ctx, "search_symbols", []byte(`{}`), RouteContext{}); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if seen != want {
|
|
t.Fatalf("local executor saw ctx value %q, want %q — ctx was discarded", seen, want)
|
|
}
|
|
}
|
|
|
|
// TestRouter_ProxyToRemote: when the scope override targets a slug
|
|
// that is NOT the localSlug, the router must proxy to the upstream
|
|
// server's POST /v1/tools/<name>. The upstream is a httptest server
|
|
// returning a synthetic body; the router's response must contain it
|
|
// verbatim.
|
|
func TestRouter_ProxyToRemote(t *testing.T) {
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
t.Fatalf("expected POST, got %s", r.Method)
|
|
}
|
|
if !strings.HasSuffix(r.URL.Path, "/v1/tools/search_symbols") {
|
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprint(w, `{"ok":true,"server":"remote"}`)
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
cfg := &ServersConfig{
|
|
Server: []ServerEntry{
|
|
{Slug: "remote", URL: upstream.URL, Workspaces: []string{"tuck"}},
|
|
{Slug: "local", URL: "unix:///tmp/local.sock", Default: true},
|
|
},
|
|
}
|
|
r := NewRouter(RouterConfig{
|
|
Servers: cfg,
|
|
Rosters: NewWorkspaceRosterCache(time.Minute),
|
|
LocalSlug: "local",
|
|
LocalExecute: func(_ context.Context, _ string, _ []byte) ([]byte, int, error) {
|
|
t.Fatal("local executor should not have been called for remote scope")
|
|
return nil, 0, nil
|
|
},
|
|
Logger: zap.NewNop(),
|
|
})
|
|
out, status, err := r.RouteToolCall(context.Background(), "search_symbols", []byte(`{}`), RouteContext{
|
|
ScopeOverride: "tuck",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if status != 200 {
|
|
t.Fatalf("status = %d, want 200", status)
|
|
}
|
|
var body map[string]any
|
|
if err := json.Unmarshal(out, &body); err != nil {
|
|
t.Fatalf("decode body: %v", err)
|
|
}
|
|
if body["server"] != "remote" {
|
|
t.Fatalf("body did not come from upstream: %v", body)
|
|
}
|
|
}
|
|
|
|
// TestRouter_CwdResolution: a session whose cwd's `.gortex.yaml`
|
|
// declares `workspace: tuck` must route to the server whose
|
|
// `workspaces` list claims tuck.
|
|
func TestRouter_CwdResolution(t *testing.T) {
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprint(w, `{"ok":true}`)
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
cwd := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(cwd, ".gortex.yaml"), []byte("workspace: tuck\n"), 0o644); err != nil {
|
|
t.Fatalf("write yaml: %v", err)
|
|
}
|
|
|
|
cfg := &ServersConfig{
|
|
Server: []ServerEntry{
|
|
{Slug: "remote", URL: upstream.URL, Workspaces: []string{"tuck"}},
|
|
{Slug: "local", URL: "unix:///tmp/local.sock", Default: true},
|
|
},
|
|
}
|
|
r := NewRouter(RouterConfig{
|
|
Servers: cfg,
|
|
Rosters: NewWorkspaceRosterCache(time.Minute),
|
|
LocalSlug: "local",
|
|
LocalExecute: func(_ context.Context, _ string, _ []byte) ([]byte, int, error) {
|
|
t.Fatal("local executor should not have been called for cwd-resolved scope")
|
|
return nil, 0, nil
|
|
},
|
|
Logger: zap.NewNop(),
|
|
})
|
|
_, status, err := r.RouteToolCall(context.Background(), "search_symbols", []byte(`{}`), RouteContext{Cwd: cwd})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if status != 200 {
|
|
t.Fatalf("status = %d, want 200", status)
|
|
}
|
|
}
|
|
|
|
// TestRouter_NoConfig_LocalOnly: without a ServersConfig the router
|
|
// must always run locally. Validates the "single-server daemon"
|
|
// fallback path.
|
|
func TestRouter_NoConfig_LocalOnly(t *testing.T) {
|
|
called := false
|
|
r := NewRouter(RouterConfig{
|
|
LocalExecute: func(_ context.Context, _ string, _ []byte) ([]byte, int, error) {
|
|
called = true
|
|
return []byte(`{"ok":true}`), 200, nil
|
|
},
|
|
})
|
|
_, _, err := r.RouteToolCall(context.Background(), "x", []byte(`{}`), RouteContext{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !called {
|
|
t.Fatal("local executor was not called")
|
|
}
|
|
}
|