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
327 lines
9.8 KiB
Go
327 lines
9.8 KiB
Go
package daemon
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"net"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Session represents one proxy or CLI connection to the daemon. Per-session
|
|
// state (recent activity, symbol history, token stats for this client)
|
|
// lives here; shared state (the graph, feedback store, cumulative savings)
|
|
// lives on the Server.
|
|
//
|
|
// A Session is created on a successful handshake and destroyed when its
|
|
// socket connection closes. The daemon routes every inbound frame to its
|
|
// session by looking up the net.Conn in the session registry.
|
|
type Session struct {
|
|
ID string
|
|
Mode ConnectionMode
|
|
CWD string
|
|
ClientName string
|
|
// ClientVersion is the version reported by the MCP client in its
|
|
// `initialize` request (`params.clientInfo.version`). Empty until
|
|
// the daemon dispatcher sees that frame; the env-var sniff in
|
|
// `cmd/gortex/proxy.go::detectClientName` only fills ClientName.
|
|
ClientVersion string
|
|
// ClientNameSource records where ClientName came from so the
|
|
// MCP-frame snooper can decide whether to overwrite it. "handshake"
|
|
// is the env-var fallback the proxy posts at connect time;
|
|
// "initialize" is the authoritative MCP-protocol value. Anything
|
|
// from "initialize" wins over any "handshake" — including the
|
|
// "unknown" string the proxy uses when env-var detection fails.
|
|
ClientNameSource string
|
|
ClientPID int
|
|
DefaultRepo string
|
|
ActiveProject string
|
|
StartedAt time.Time
|
|
|
|
// ToolSpec / ToolMode are the client-forwarded tool-surface
|
|
// preference (GORTEX_TOOLS / --tools + mode) from the handshake. The
|
|
// daemon resolves the effective per-session tool surface from these
|
|
// so a client can scope (or widen) its own pipe while the daemon keeps
|
|
// serving the full graph to everyone else. Empty = no preference.
|
|
ToolSpec string
|
|
ToolMode string
|
|
|
|
// Conn is the underlying socket. Kept for close-on-shutdown and
|
|
// logging; handlers should not read from or write to it directly —
|
|
// framing is the transport's job.
|
|
Conn net.Conn
|
|
|
|
// Per-session mutable state that will move over from internal/mcp's
|
|
// Server during the session-isolation refactor. Left as interface{}
|
|
// for now so the types can evolve without churning this file every
|
|
// iteration — the refactor will replace this with concrete pointers.
|
|
SessionState any
|
|
SymHistory any
|
|
TokenStats any
|
|
|
|
// remoteOverrides is the per-session enable/disable layer over the
|
|
// global roster: slug -> enabled. An absent slug means "no
|
|
// override" (the global Enabled state wins). It is ephemeral by
|
|
// construction — freed when the *Session is dropped on disconnect
|
|
// via either teardown path (Remove for an AF_UNIX session,
|
|
// RemoveByID for a detached /mcp session) — so no explicit cleanup
|
|
// is needed. Guarded by mu.
|
|
remoteOverrides map[string]bool
|
|
|
|
// mu protects ClientName / ClientVersion / ClientNameSource and
|
|
// remoteOverrides, which can be updated by the dispatcher and the
|
|
// proxy-toggle tools mid-session.
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// SetClientInfo updates the session's client metadata from the MCP
|
|
// `initialize` frame. Called by the daemon dispatcher when it sees
|
|
// the first `initialize` request on this session. Idempotent — a
|
|
// second call (e.g. on protocol re-init) just overwrites.
|
|
func (s *Session) SetClientInfo(name, version string) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
if name != "" {
|
|
s.ClientName = name
|
|
s.ClientNameSource = "initialize"
|
|
}
|
|
if version != "" {
|
|
s.ClientVersion = version
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// SetRemoteOverride sets a per-session enable/disable override for a
|
|
// remote slug. It wins over the remote's global Enabled state for the
|
|
// lifetime of this session only.
|
|
func (s *Session) SetRemoteOverride(slug string, enabled bool) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
if s.remoteOverrides == nil {
|
|
s.remoteOverrides = make(map[string]bool)
|
|
}
|
|
s.remoteOverrides[slug] = enabled
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// ClearRemoteOverride removes a per-session override so the remote
|
|
// reverts to its global Enabled state.
|
|
func (s *Session) ClearRemoteOverride(slug string) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
delete(s.remoteOverrides, slug)
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// RemoteOverrides returns a copy of the per-session override map under
|
|
// the read lock, so callers can iterate without racing a concurrent
|
|
// toggle. nil when no override has been set.
|
|
func (s *Session) RemoteOverrides() map[string]bool {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if len(s.remoteOverrides) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]bool, len(s.remoteOverrides))
|
|
for k, v := range s.remoteOverrides {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
// SnapshotClientInfo returns the current client name/version pair
|
|
// safely under the session lock. Used by the status path which reads
|
|
// while the dispatcher may be writing.
|
|
func (s *Session) SnapshotClientInfo() (name, version string) {
|
|
if s == nil {
|
|
return "", ""
|
|
}
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.ClientName, s.ClientVersion
|
|
}
|
|
|
|
// SessionRegistry tracks active sessions. Safe for concurrent access from
|
|
// the accept goroutine and the control-surface handlers.
|
|
type SessionRegistry struct {
|
|
mu sync.RWMutex
|
|
sessions map[string]*Session // session_id → Session
|
|
byConn map[net.Conn]*Session
|
|
}
|
|
|
|
func NewSessionRegistry() *SessionRegistry {
|
|
return &SessionRegistry{
|
|
sessions: make(map[string]*Session),
|
|
byConn: make(map[net.Conn]*Session),
|
|
}
|
|
}
|
|
|
|
// Register creates and stores a new session for the given connection.
|
|
// Called after a successful handshake. Generates the session ID.
|
|
func (r *SessionRegistry) Register(conn net.Conn, h Handshake) *Session {
|
|
s := &Session{
|
|
ID: newSessionID(),
|
|
Mode: h.Mode,
|
|
CWD: h.CWD,
|
|
ClientName: h.ClientName,
|
|
ClientNameSource: "handshake",
|
|
ClientPID: h.PID,
|
|
ToolSpec: h.Tools,
|
|
ToolMode: h.ToolsMode,
|
|
StartedAt: time.Now(),
|
|
Conn: conn,
|
|
}
|
|
r.mu.Lock()
|
|
r.sessions[s.ID] = s
|
|
r.byConn[conn] = s
|
|
r.mu.Unlock()
|
|
return s
|
|
}
|
|
|
|
// RegisterDetached creates a session that isn't bound to a net.Conn —
|
|
// used by HTTP-side transports (Streamable HTTP, future SSE/WebSocket
|
|
// adapters) where the daemon doesn't own a persistent socket. The
|
|
// supplied id becomes the session ID verbatim so the transport's own
|
|
// session-id space (e.g. streamable.SessionStore) lines up with the
|
|
// daemon's status/metrics surface; pass "" to auto-generate one.
|
|
func (r *SessionRegistry) RegisterDetached(id string, h Handshake) *Session {
|
|
if id == "" {
|
|
id = newSessionID()
|
|
}
|
|
s := &Session{
|
|
ID: id,
|
|
Mode: h.Mode,
|
|
CWD: h.CWD,
|
|
ClientName: h.ClientName,
|
|
ClientNameSource: "handshake",
|
|
ClientPID: h.PID,
|
|
ToolSpec: h.Tools,
|
|
ToolMode: h.ToolsMode,
|
|
StartedAt: time.Now(),
|
|
}
|
|
r.mu.Lock()
|
|
r.sessions[s.ID] = s
|
|
r.mu.Unlock()
|
|
return s
|
|
}
|
|
|
|
// RemoveByID deletes a session by id (used by detached sessions which
|
|
// have no net.Conn to key off). Idempotent.
|
|
func (r *SessionRegistry) RemoveByID(id string) *Session {
|
|
if id == "" {
|
|
return nil
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
s := r.sessions[id]
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
delete(r.sessions, id)
|
|
if s.Conn != nil {
|
|
delete(r.byConn, s.Conn)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// GetByID returns a session by its id, or nil when no session is
|
|
// registered under that id. Used by detached-session lookup paths.
|
|
func (r *SessionRegistry) GetByID(id string) *Session {
|
|
if id == "" {
|
|
return nil
|
|
}
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return r.sessions[id]
|
|
}
|
|
|
|
// Remove deletes the session for a connection. Idempotent — safe to call
|
|
// from both the accept-loop's defer and the shutdown path.
|
|
func (r *SessionRegistry) Remove(conn net.Conn) *Session {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
s := r.byConn[conn]
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
delete(r.byConn, conn)
|
|
delete(r.sessions, s.ID)
|
|
return s
|
|
}
|
|
|
|
// Get returns the session for a connection, or nil if the connection hasn't
|
|
// completed its handshake yet (or was already removed).
|
|
func (r *SessionRegistry) Get(conn net.Conn) *Session {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return r.byConn[conn]
|
|
}
|
|
|
|
// Count returns the number of live sessions — used by the status command
|
|
// and for metrics.
|
|
func (r *SessionRegistry) Count() int {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return len(r.sessions)
|
|
}
|
|
|
|
// SweepDead removes every session whose originating client process (by its
|
|
// handshake PID) is no longer alive, closing the session's connection. Sessions
|
|
// with no recorded PID (0 — detached/HTTP transports, or a client that reported
|
|
// none) are left untouched, since liveness can't be judged from a PID we don't
|
|
// have. Returns the removed sessions so the caller can log / adjust metrics.
|
|
// isAlive is injected (platform.ProcessAlive in production) so the sweep is
|
|
// testable without spawning real processes.
|
|
func (r *SessionRegistry) SweepDead(isAlive func(int) bool) []*Session {
|
|
r.mu.Lock()
|
|
var dead []*Session
|
|
for id, s := range r.sessions {
|
|
if s.ClientPID <= 0 || isAlive(s.ClientPID) {
|
|
continue
|
|
}
|
|
dead = append(dead, s)
|
|
delete(r.sessions, id)
|
|
if s.Conn != nil {
|
|
delete(r.byConn, s.Conn)
|
|
}
|
|
}
|
|
r.mu.Unlock()
|
|
// Close connections outside the lock — Close can block.
|
|
for _, s := range dead {
|
|
if s.Conn != nil {
|
|
_ = s.Conn.Close()
|
|
}
|
|
}
|
|
return dead
|
|
}
|
|
|
|
// All returns a snapshot of every live session. The caller must not
|
|
// mutate the returned Session objects; they're shared with the registry.
|
|
func (r *SessionRegistry) All() []*Session {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
out := make([]*Session, 0, len(r.sessions))
|
|
for _, s := range r.sessions {
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// newSessionID generates a short URL-safe identifier. 8 bytes of entropy
|
|
// gives us 16 hex chars — collision-resistant enough for a per-user
|
|
// single-process registry without bloating log lines.
|
|
func newSessionID() string {
|
|
var b [8]byte
|
|
_, _ = rand.Read(b[:])
|
|
return "sess_" + hex.EncodeToString(b[:])
|
|
}
|