Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

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[:])
}