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
210 lines
6.7 KiB
Go
210 lines
6.7 KiB
Go
package daemon
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// Client is one end of a daemon connection. Used by:
|
|
// - `gortex mcp --proxy` — stdio MCP → daemon relay.
|
|
// - `gortex daemon status / reload / track / untrack / stop` — control RPC.
|
|
//
|
|
// A Client is not safe for concurrent use — each caller owns its own.
|
|
type Client struct {
|
|
Conn net.Conn
|
|
reader *bufio.Reader
|
|
Ack HandshakeAck
|
|
}
|
|
|
|
// Dial connects to the daemon on the default socket path and completes a
|
|
// handshake. Returns (nil, err) if the socket doesn't exist, the daemon
|
|
// isn't reachable, or the handshake is rejected.
|
|
//
|
|
// Callers in a fallback path (proxy mode, CLI commands that can work
|
|
// standalone) should treat ErrDaemonUnavailable distinctly from other
|
|
// errors and continue as if the daemon isn't there.
|
|
func Dial(h Handshake) (*Client, error) {
|
|
return DialTo(SocketPath(), h)
|
|
}
|
|
|
|
// DialTo is Dial with an explicit socket path. Useful for tests and for
|
|
// clients that want to talk to a daemon on a non-default socket.
|
|
func DialTo(socketPath string, h Handshake) (*Client, error) {
|
|
d := &net.Dialer{Timeout: 500 * time.Millisecond}
|
|
conn, err := d.Dial("unix", socketPath)
|
|
if err != nil {
|
|
if isNoDaemonErr(err) {
|
|
return nil, fmt.Errorf("%w: %v", ErrDaemonUnavailable, err)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if h.Version == 0 {
|
|
h.Version = ProtocolVersion
|
|
}
|
|
if h.PID == 0 {
|
|
h.PID = os.Getpid()
|
|
}
|
|
if err := WriteJSONLine(conn, h); err != nil {
|
|
_ = conn.Close()
|
|
return nil, fmt.Errorf("send handshake: %w", err)
|
|
}
|
|
reader := bufio.NewReader(conn)
|
|
ackLine, err := reader.ReadBytes('\n')
|
|
if err != nil {
|
|
_ = conn.Close()
|
|
return nil, fmt.Errorf("read handshake ack: %w", err)
|
|
}
|
|
var ack HandshakeAck
|
|
if err := json.Unmarshal(ackLine, &ack); err != nil {
|
|
_ = conn.Close()
|
|
return nil, fmt.Errorf("parse handshake ack: %w", err)
|
|
}
|
|
if !ack.OK {
|
|
_ = conn.Close()
|
|
// A protocol-version rejection is recoverable: wrap the sentinel so the
|
|
// proxy can fall back to the embedded server instead of failing.
|
|
if ack.ErrorCode == ErrProtocolMismatch {
|
|
return nil, fmt.Errorf("%w: %s", ErrProtocolVersionMismatch, ack.ErrorMsg)
|
|
}
|
|
return nil, fmt.Errorf("daemon rejected handshake [%s]: %s", ack.ErrorCode, ack.ErrorMsg)
|
|
}
|
|
return &Client{Conn: conn, reader: reader, Ack: ack}, nil
|
|
}
|
|
|
|
// Close tears down the connection.
|
|
func (c *Client) Close() error {
|
|
if c == nil || c.Conn == nil {
|
|
return nil
|
|
}
|
|
return c.Conn.Close()
|
|
}
|
|
|
|
// Control sends one control request and returns the paired response.
|
|
// Fails if the Client was opened in ModeMCP.
|
|
func (c *Client) Control(kind string, params any) (ControlResponse, error) {
|
|
var raw json.RawMessage
|
|
if params != nil {
|
|
var err error
|
|
raw, err = json.Marshal(params)
|
|
if err != nil {
|
|
return ControlResponse{}, fmt.Errorf("marshal params: %w", err)
|
|
}
|
|
}
|
|
req := ControlRequest{Kind: kind, Params: raw}
|
|
if err := WriteJSONLine(c.Conn, req); err != nil {
|
|
return ControlResponse{}, fmt.Errorf("send control request: %w", err)
|
|
}
|
|
line, err := c.reader.ReadBytes('\n')
|
|
if err != nil {
|
|
return ControlResponse{}, fmt.Errorf("read control response: %w", err)
|
|
}
|
|
var resp ControlResponse
|
|
if err := json.Unmarshal(line, &resp); err != nil {
|
|
return ControlResponse{}, fmt.Errorf("parse control response: %w", err)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// WriteMCPFrame writes a raw MCP JSON-RPC frame to the daemon. Caller is
|
|
// responsible for ensuring the frame is valid JSON-RPC; the daemon
|
|
// passes it through to the session's MCPDispatcher.
|
|
func (c *Client) WriteMCPFrame(frame []byte) error {
|
|
if _, err := c.Conn.Write(frame); err != nil {
|
|
return err
|
|
}
|
|
// Ensure newline-delimited framing.
|
|
if n := len(frame); n == 0 || frame[n-1] != '\n' {
|
|
if _, err := c.Conn.Write([]byte{'\n'}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReadMCPFrame reads one MCP JSON-RPC frame from the daemon. Returns
|
|
// io.EOF when the daemon closes the connection.
|
|
func (c *Client) ReadMCPFrame() ([]byte, error) {
|
|
line, err := c.reader.ReadBytes('\n')
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return line, nil
|
|
}
|
|
|
|
// ErrDaemonUnavailable is the sentinel Dial returns when the daemon
|
|
// isn't reachable. Callers in fallback paths should errors.Is() against
|
|
// this to distinguish "daemon just isn't running" from real errors
|
|
// (permissions, handshake rejection, etc.) where silent fallback would
|
|
// mask a bug.
|
|
var ErrDaemonUnavailable = errors.New("daemon unavailable")
|
|
|
|
// ErrProtocolVersionMismatch is returned by Dial when the daemon answers the
|
|
// handshake with a protocol_mismatch rejection — the running daemon speaks a
|
|
// different wire version than this binary (a stale daemon after an upgrade).
|
|
// Like ErrDaemonUnavailable it is recoverable: the caller should fall back to
|
|
// the embedded in-process server rather than fail, so an in-flight editor
|
|
// session keeps working across a version skew.
|
|
var ErrProtocolVersionMismatch = errors.New("daemon protocol version mismatch")
|
|
|
|
// ShouldFallBackToEmbedded reports whether a Dial error is one the MCP proxy
|
|
// can recover from by running the embedded in-process server instead: the
|
|
// daemon isn't running, or it is running a mismatched protocol version. Any
|
|
// other error (permissions, a genuinely broken socket) is a real failure the
|
|
// caller must surface.
|
|
func ShouldFallBackToEmbedded(err error) bool {
|
|
return errors.Is(err, ErrDaemonUnavailable) || errors.Is(err, ErrProtocolVersionMismatch)
|
|
}
|
|
|
|
// isNoDaemonErr returns true for the subset of dial errors that mean
|
|
// "the daemon probably isn't running" as opposed to "your system is
|
|
// broken." We treat connection-refused, no-such-file, and timeout as
|
|
// "unavailable" because spinning up the daemon or falling back is the
|
|
// right answer to all three.
|
|
func isNoDaemonErr(err error) bool {
|
|
var se syscall.Errno
|
|
if errors.As(err, &se) {
|
|
switch se {
|
|
case syscall.ECONNREFUSED, syscall.ENOENT:
|
|
return true
|
|
}
|
|
}
|
|
var ne net.Error
|
|
if errors.As(err, &ne) && ne.Timeout() {
|
|
return true
|
|
}
|
|
// net.OpError wrapping "no such file or directory" is how Go reports
|
|
// a missing socket path on some platforms.
|
|
var op *net.OpError
|
|
if errors.As(err, &op) {
|
|
if errors.Is(op.Err, syscall.ECONNREFUSED) || errors.Is(op.Err, syscall.ENOENT) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsRunning returns true when a daemon is reachable on the default socket.
|
|
// A thin convenience for CLI paths that want to branch on availability
|
|
// without constructing a full handshake.
|
|
func IsRunning() bool {
|
|
return IsRunningAt(SocketPath())
|
|
}
|
|
|
|
// IsRunningAt is IsRunning with an explicit socket path.
|
|
func IsRunningAt(socketPath string) bool {
|
|
d := &net.Dialer{Timeout: 200 * time.Millisecond}
|
|
conn, err := d.Dial("unix", socketPath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
_ = conn.Close()
|
|
return true
|
|
}
|