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
189 lines
6.2 KiB
Go
189 lines
6.2 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// echoDispatcher returns a canned JSON-RPC response whenever a frame
|
|
// comes in. Used to prove that (a) the daemon delivers frames to the
|
|
// dispatcher with the right session context, and (b) the dispatcher's
|
|
// reply round-trips back to the client over the socket. Swapping in a
|
|
// real *mcp.Server-backed dispatcher is the job of package main, not
|
|
// the daemon package itself — that's where the tool registry lives.
|
|
type echoDispatcher struct {
|
|
seen chan echoFrame
|
|
}
|
|
|
|
type echoFrame struct {
|
|
sessionID string
|
|
cwd string
|
|
frame string
|
|
}
|
|
|
|
func (e *echoDispatcher) Dispatch(_ context.Context, sess *Session, frame []byte) ([]byte, error) {
|
|
e.seen <- echoFrame{sessionID: sess.ID, cwd: sess.CWD, frame: string(frame)}
|
|
// Synthesize a well-formed JSON-RPC response so the proxy side can
|
|
// parse it without extra helpers.
|
|
resp := map[string]any{
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"result": map[string]any{"session_id": sess.ID, "echoed": string(frame)},
|
|
}
|
|
return json.Marshal(resp)
|
|
}
|
|
|
|
// TestDaemon_MCPRoundTrip verifies the full happy path: proxy dials in
|
|
// MCP mode, sends a frame, daemon delivers it to the dispatcher with
|
|
// session context, response flows back to the proxy.
|
|
func TestDaemon_MCPRoundTrip(t *testing.T) {
|
|
disp := &echoDispatcher{seen: make(chan echoFrame, 4)}
|
|
dir, err := os.MkdirTemp("/tmp", "gx")
|
|
require.NoError(t, err)
|
|
defer func() { _ = os.RemoveAll(dir) }()
|
|
socket := filepath.Join(dir, "s")
|
|
t.Setenv("GORTEX_DAEMON_SOCKET", socket)
|
|
t.Setenv("GORTEX_DAEMON_PIDFILE", filepath.Join(dir, "p"))
|
|
|
|
srv := New(socket, "test", zap.NewNop())
|
|
srv.MCPDispatcher = disp
|
|
srv.Controller = &fakeController{}
|
|
require.NoError(t, srv.Listen())
|
|
go func() { _ = srv.Serve() }()
|
|
defer func() { _ = srv.Shutdown() }()
|
|
|
|
require.Eventually(t, func() bool { return IsRunningAt(socket) },
|
|
2*time.Second, 10*time.Millisecond)
|
|
|
|
client, err := DialTo(socket, Handshake{
|
|
Mode: ModeMCP,
|
|
CWD: "/tmp/fake-repo",
|
|
ClientName: "claude-code",
|
|
})
|
|
require.NoError(t, err)
|
|
defer client.Close()
|
|
|
|
require.NotEmpty(t, client.Ack.SessionID)
|
|
|
|
// Send a frame; proxy expects to read the paired response.
|
|
rpc := `{"jsonrpc":"2.0","id":1,"method":"graph_stats","params":{}}`
|
|
require.NoError(t, client.WriteMCPFrame([]byte(rpc)))
|
|
|
|
// Dispatcher saw the frame with the right session context.
|
|
select {
|
|
case f := <-disp.seen:
|
|
assert.Equal(t, client.Ack.SessionID, f.sessionID)
|
|
assert.Equal(t, "/tmp/fake-repo", f.cwd)
|
|
assert.Contains(t, f.frame, `"method":"graph_stats"`)
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("dispatcher never received the frame")
|
|
}
|
|
|
|
// Response made it back through the socket. Read one line.
|
|
replyBytes, err := client.ReadMCPFrame()
|
|
require.NoError(t, err)
|
|
var reply map[string]any
|
|
require.NoError(t, json.Unmarshal(replyBytes, &reply))
|
|
result, ok := reply["result"].(map[string]any)
|
|
require.True(t, ok, "result must be an object: %v", reply)
|
|
assert.Equal(t, client.Ack.SessionID, result["session_id"])
|
|
}
|
|
|
|
// TestDaemon_MCPNoDispatcher verifies the daemon rejects MCP traffic
|
|
// cleanly when no dispatcher is attached — caller sees a JSON-RPC
|
|
// error frame rather than a hang or a broken connection.
|
|
func TestDaemon_MCPNoDispatcher(t *testing.T) {
|
|
dir, err := os.MkdirTemp("/tmp", "gx")
|
|
require.NoError(t, err)
|
|
defer func() { _ = os.RemoveAll(dir) }()
|
|
socket := filepath.Join(dir, "s")
|
|
t.Setenv("GORTEX_DAEMON_SOCKET", socket)
|
|
t.Setenv("GORTEX_DAEMON_PIDFILE", filepath.Join(dir, "p"))
|
|
|
|
srv := New(socket, "test", zap.NewNop())
|
|
srv.Controller = &fakeController{}
|
|
// Intentionally: no MCPDispatcher.
|
|
require.NoError(t, srv.Listen())
|
|
go func() { _ = srv.Serve() }()
|
|
defer func() { _ = srv.Shutdown() }()
|
|
|
|
require.Eventually(t, func() bool { return IsRunningAt(socket) },
|
|
2*time.Second, 10*time.Millisecond)
|
|
|
|
client, err := DialTo(socket, Handshake{Mode: ModeMCP, CWD: "/tmp/x"})
|
|
require.NoError(t, err)
|
|
defer client.Close()
|
|
|
|
replyBytes, err := client.ReadMCPFrame()
|
|
require.NoError(t, err)
|
|
var reply map[string]any
|
|
require.NoError(t, json.Unmarshal(replyBytes, &reply))
|
|
errObj, ok := reply["error"].(map[string]any)
|
|
require.True(t, ok, "expected JSON-RPC error, got: %v", reply)
|
|
msg, _ := errObj["message"].(string)
|
|
assert.Contains(t, msg, "control-only")
|
|
}
|
|
|
|
// assertNoPanic keeps imports tidy for the fmt package when tests grow.
|
|
var _ = fmt.Sprintf
|
|
|
|
// hookDispatcher satisfies both MCPDispatcher and SessionEndedHook so we
|
|
// can prove the daemon fires the disconnect callback when a proxy closes.
|
|
type hookDispatcher struct {
|
|
ended chan string // receives the session ID that ended
|
|
}
|
|
|
|
func (h *hookDispatcher) Dispatch(_ context.Context, _ *Session, _ []byte) ([]byte, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (h *hookDispatcher) SessionEnded(sess *Session) {
|
|
h.ended <- sess.ID
|
|
}
|
|
|
|
// TestDaemon_SessionEndedHook_FiresOnDisconnect pins the contract that
|
|
// dispatchers implementing SessionEndedHook get notified when a proxy
|
|
// closes its connection. Without this, per-session state allocated in
|
|
// the dispatcher would leak for the daemon's lifetime.
|
|
func TestDaemon_SessionEndedHook_FiresOnDisconnect(t *testing.T) {
|
|
dir, err := os.MkdirTemp("/tmp", "gx")
|
|
require.NoError(t, err)
|
|
defer func() { _ = os.RemoveAll(dir) }()
|
|
socket := filepath.Join(dir, "s")
|
|
t.Setenv("GORTEX_DAEMON_SOCKET", socket)
|
|
t.Setenv("GORTEX_DAEMON_PIDFILE", filepath.Join(dir, "p"))
|
|
|
|
hook := &hookDispatcher{ended: make(chan string, 2)}
|
|
srv := New(socket, "test", zap.NewNop())
|
|
srv.MCPDispatcher = hook
|
|
srv.Controller = &fakeController{}
|
|
require.NoError(t, srv.Listen())
|
|
go func() { _ = srv.Serve() }()
|
|
defer func() { _ = srv.Shutdown() }()
|
|
|
|
require.Eventually(t, func() bool { return IsRunningAt(socket) },
|
|
2*time.Second, 10*time.Millisecond)
|
|
|
|
client, err := DialTo(socket, Handshake{Mode: ModeMCP, CWD: "/tmp/x"})
|
|
require.NoError(t, err)
|
|
sessionID := client.Ack.SessionID
|
|
require.NoError(t, client.Close())
|
|
|
|
select {
|
|
case got := <-hook.ended:
|
|
assert.Equal(t, sessionID, got,
|
|
"SessionEnded must receive the ID of the disconnected session")
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("SessionEnded hook never fired after client close")
|
|
}
|
|
}
|