Files
zzet--gortex/cmd/gortex/proxy.go
T
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

428 lines
15 KiB
Go

package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sync"
"time"
"github.com/zzet/gortex/internal/daemon"
gortexmcp "github.com/zzet/gortex/internal/mcp"
)
// coldStartTools is the static core catalogue the proxy answers a cold-start
// tools/list with — the hot tools every client needs first — before the
// daemon's full live list arrives. Deliberately small; the client refreshes on
// the daemon's tools/list_changed notification once the connection is live.
var coldStartTools = []string{
"smart_context", "search_symbols", "find_usages", "get_callers",
"get_symbol_source", "get_file_summary", "read_file", "get_repo_outline",
}
// answerColdStart returns a locally-synthesized JSON-RPC response for a frame
// that can be answered without the daemon — initialize and tools/list — so an
// MCP client completes its handshake immediately while the daemon connects in
// the background. ok is false for any other frame (notably tools/call), which
// must reach the daemon (or the embedded fallback). The active tool-surface
// preset is applied to the cold-start list so a restricted session never sees a
// tool it isn't allowed to call.
func answerColdStart(frame []byte, surface *gortexmcp.ToolSurface) (reply []byte, ok bool) {
var peek struct {
Method string `json:"method"`
ID json.RawMessage `json:"id"`
}
if json.Unmarshal(frame, &peek) != nil {
return nil, false
}
switch peek.Method {
case "initialize":
return staticInitializeResult(peek.ID), true
case "tools/list":
return staticToolsListResult(peek.ID, surface), true
default:
return nil, false
}
}
// staticInitializeResult builds the cold-start initialize response: enough for
// the client to proceed, with instructions that the full catalogue is arriving.
func staticInitializeResult(id json.RawMessage) []byte {
return jsonRPCResult(id, map[string]any{
"protocolVersion": "2025-06-18",
"serverInfo": map[string]any{"name": "gortex", "version": gortexmcp.Version},
"capabilities": map[string]any{"tools": map[string]any{"listChanged": true}},
"instructions": "Gortex is connecting to its daemon — the full tool catalogue arrives momentarily.",
})
}
// staticToolsListResult answers tools/list with the cold-start core set, minus
// anything an active surface preset disallows.
func staticToolsListResult(id json.RawMessage, surface *gortexmcp.ToolSurface) []byte {
tools := make([]map[string]any, 0, len(coldStartTools))
for _, n := range coldStartTools {
if surface != nil && surface.Active() && !surface.Allows(n) {
continue
}
tools = append(tools, map[string]any{"name": n})
}
return jsonRPCResult(id, map[string]any{"tools": tools})
}
// jsonRPCResult marshals a JSON-RPC 2.0 success response echoing the request id.
func jsonRPCResult(id json.RawMessage, result any) []byte {
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": id,
"result": result,
})
return body
}
// runProxy relays MCP JSON-RPC traffic between stdio (the MCP client) and
// the daemon's Unix socket. Exactly what `gortex mcp` does when it
// detects a running daemon and isn't forced to embedded mode.
//
// Returns (true, nil) when the proxy ran and finished cleanly. Returns
// (false, nil) when the daemon isn't reachable — the caller should fall
// back to embedded mode. Any other error is a real problem.
func runProxy(ctx context.Context, surface *gortexmcp.ToolSurface) (ran bool, err error) {
cwd, wdErr := resolveLaunchCWD()
if wdErr != nil {
return false, fmt.Errorf("cwd: %w", wdErr)
}
toolSpec, toolMode := clientToolPreference()
h := daemon.Handshake{
Mode: daemon.ModeMCP,
CWD: cwd,
ClientName: detectClientName(),
Tools: toolSpec,
ToolsMode: toolMode,
}
client, recoverable, err := dialDaemonWithRetry(ctx, h)
if err != nil && !recoverable {
return false, fmt.Errorf("dial daemon: %w", err)
}
if client == nil {
// The daemon isn't reachable (even after the retry window) or it's
// running a mismatched protocol version — both are recoverable by
// falling back to the embedded in-process server.
if errors.Is(err, daemon.ErrProtocolVersionMismatch) {
fmt.Fprintln(os.Stderr, "[gortex mcp] daemon protocol mismatch; falling back to embedded server")
} else {
fmt.Fprintln(os.Stderr, "[gortex mcp] daemon unreachable after retry window; falling back to embedded server")
}
return false, nil
}
defer client.Close()
// A daemon that is still warming up acks the handshake immediately and
// serves whatever the graph holds so far, filling in as warmup completes —
// so staying connected is strictly better than dead-ending on an empty
// embedded server. Surface the state so the launch log isn't misleading.
if client.Ack.Warming {
fmt.Fprintf(os.Stderr,
"[gortex mcp] proxying to daemon (session %s, daemon warming up — phase %q; graph still filling)\n",
client.Ack.SessionID, client.Ack.WarmupPhase)
} else {
fmt.Fprintf(os.Stderr,
"[gortex mcp] proxying to daemon (session %s, default_repo=%q)\n",
client.Ack.SessionID, client.Ack.DefaultRepo)
}
// Bidirectional pump:
// stdin → socket (MCP requests from the client)
// socket → stdout (MCP responses + notifications)
//
// We run both on goroutines and exit when either side hits EOF.
errCh := make(chan error, 2)
var wg sync.WaitGroup
var outMu sync.Mutex // serialises the two writers into os.Stdout
wg.Add(2)
if surface.Active() {
fmt.Fprintf(os.Stderr, "[gortex mcp] tool surface restricted (preset %q)\n", surface.Preset())
go func() {
defer wg.Done()
errCh <- pumpRequestsFiltered(os.Stdin, client.Conn, os.Stdout, &outMu, surface)
}()
go func() {
defer wg.Done()
errCh <- pumpResponsesFiltered(client.Conn, os.Stdout, &outMu, surface)
}()
} else {
go func() {
defer wg.Done()
errCh <- pumpLines(os.Stdin, client.Conn)
}()
go func() {
defer wg.Done()
errCh <- pumpLines(client.Conn, os.Stdout)
}()
}
// Orphan watchdog: if our parent (the MCP client) dies, stdin EOF is the
// normal shutdown signal — but a client that is SIGKILLed, or whose stdin
// pipe is inherited and held open elsewhere, can leave this proxy wedged
// forever, pinning a daemon session. Poll the parent PID and unblock the
// select when we get reparented (to init or a subreaper).
orphanCh := make(chan struct{}, 1)
watchCtx, cancelWatch := context.WithCancel(ctx)
defer cancelWatch()
go orphanWatch(watchCtx, orphanPollInterval, os.Getppid, func() {
fmt.Fprintln(os.Stderr, "[gortex mcp] parent process exited; closing proxy")
select {
case orphanCh <- struct{}{}:
default:
}
})
// Wait for first completion; exit on context cancellation or orphaning too.
select {
case pumpErr := <-errCh:
if pumpErr != nil && !errors.Is(pumpErr, io.EOF) {
return true, fmt.Errorf("proxy pump: %w", pumpErr)
}
case <-orphanCh:
case <-ctx.Done():
}
cancelWatch()
_ = client.Close()
// Bound the drain: a pump blocked reading a never-closing stdin (the exact
// orphan case) must not pin shutdown — the process is exiting regardless.
drained := make(chan struct{})
go func() { wg.Wait(); close(drained) }()
select {
case <-drained:
case <-time.After(proxyDrainTimeout):
}
return true, nil
}
// orphanPollInterval is how often the proxy checks whether its parent
// process is still alive; proxyDrainTimeout bounds the post-close drain.
// Both are vars so tests can shorten them.
var (
orphanPollInterval = 5 * time.Second
proxyDrainTimeout = 2 * time.Second
)
// dialDaemon is the seam runProxy dials through. A package var so tests can
// substitute a fake without a real socket.
var dialDaemon = daemon.Dial
// proxyDialRetryWindow bounds how long dialDaemonWithRetry keeps retrying a
// recoverable "daemon unavailable" dial error before conceding to the embedded
// server; proxyDialRetryInterval is the gap between attempts. By the time the
// proxy dials, resolveDaemonDecision has already confirmed the socket is up
// (daemonReady) or waited for it (daemonAutostarted) — but under a CPU- and
// GC-saturated warmup the daemon's accept() can briefly exceed the 500ms dial
// timeout, surfacing as ErrDaemonUnavailable. Retrying rides that window out so
// a connecting session lands on the real daemon (and self-heals as warmup
// fills the graph) instead of dead-ending on an empty embedded graph. Vars so
// tests can shorten them.
var (
proxyDialRetryWindow = 20 * time.Second
proxyDialRetryInterval = 250 * time.Millisecond
)
// dialDaemonWithRetry dials the daemon, retrying transient "unavailable"
// errors (socket up but accept() starved by warmup) for a bounded window.
// Returns:
// - (client, false, nil) on success.
// - (nil, false, err) on a non-recoverable error — the caller surfaces it.
// - (nil, true, lastErr) when the window expires with the daemon still
// unreachable, or on a protocol-version mismatch (which never resolves by
// waiting) — the caller falls back to the embedded server. lastErr lets the
// caller distinguish the mismatch case for logging.
func dialDaemonWithRetry(ctx context.Context, h daemon.Handshake) (client *daemon.Client, recoverable bool, lastErr error) {
deadline := time.Now().Add(proxyDialRetryWindow)
for {
c, err := dialDaemon(h)
if err == nil {
return c, false, nil
}
if !daemon.ShouldFallBackToEmbedded(err) {
return nil, false, err
}
// A protocol-version mismatch is a stale daemon after an upgrade —
// waiting can't fix it, so concede to the embedded server now.
if errors.Is(err, daemon.ErrProtocolVersionMismatch) {
return nil, true, err
}
if time.Now().After(deadline) {
return nil, true, err
}
select {
case <-ctx.Done():
return nil, true, err
case <-time.After(proxyDialRetryInterval):
}
}
}
// orphanWatch polls getppid every interval and invokes onOrphan exactly
// once when the proxy's parent process has gone away — detected as a change
// of parent PID (reparented to init=1 on classic Unix, or to the nearest
// subreaper). Watching for a *change* is strictly more robust than testing
// for PID 1 alone, which misses subreaper reparenting (containers, systemd
// user sessions, a wrapping CLI that calls prctl(PR_SET_CHILD_SUBREAPER)).
// It self-disarms when there is no meaningful parent to watch (orig <= 1),
// and is an inert no-op on platforms that never reparent — there the parent
// PID stays equal to orig for the whole process lifetime.
func orphanWatch(ctx context.Context, interval time.Duration, getppid func() int, onOrphan func()) {
orig := getppid()
if orig <= 1 || interval <= 0 {
return
}
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if getppid() != orig {
onOrphan()
return
}
}
}
}
// pumpLines copies newline-delimited frames from src to dst. Uses a
// line-aware scanner so partial reads don't split a single MCP message
// between two writes (which would confuse the peer's parser).
func pumpLines(src io.Reader, dst io.Writer) error {
r := bufio.NewReaderSize(src, 1<<20) // 1 MB — some MCP replies are chunky
for {
line, err := r.ReadBytes('\n')
if len(line) > 0 {
if _, werr := dst.Write(line); werr != nil {
return werr
}
}
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
}
}
// detectClientName makes a best-effort guess at which MCP client spawned
// us. Purely for the initial handshake telemetry — the authoritative
// answer comes from the MCP `initialize` request's clientInfo, which
// the daemon dispatcher (cmd/gortex/daemon_mcp.go::maybeSnoopInitialize)
// applies once the first frame arrives. The handshake-time guess
// only matters for the few hundred milliseconds before initialize
// reaches us.
//
// Env-var sniffing here favours the actual variables current MCP
// hosts set. Claude Code: CLAUDECODE=1 (current builds set this) plus
// CLAUDE_CODE_ENTRYPOINT=cli|sdk|... Other hosts kept best-effort.
func detectClientName() string {
switch {
case os.Getenv("CLAUDECODE") != "" || os.Getenv("CLAUDE_CODE_ENTRYPOINT") != "" || os.Getenv("CLAUDE_CODE_WORKSPACE") != "":
return "claude-code"
case os.Getenv("CURSOR_TRACE_ID") != "" || os.Getenv("CURSOR_WORKSPACE") != "":
return "cursor"
case os.Getenv("KIRO_WORKSPACE") != "":
return "kiro"
case os.Getenv("WINDSURF_WORKSPACE") != "":
return "windsurf"
case os.Getenv("CODEX_WORKSPACE") != "":
return "codex"
case os.Getenv("ANTIGRAVITY_AGENT") != "":
return "antigravity"
case os.Getenv("VSCODE_PID") != "" || os.Getenv("VSCODE_IPC_HOOK") != "":
// VS Code with the MCP extension. Coarse — Continue / Cline
// embedders run inside VS Code too, so this is just a hint
// until the MCP initialize frame lands and overrides it.
return "vscode"
case os.Getenv("ZED_TERM") != "" || os.Getenv("ZED_TERMINAL") != "":
return "zed"
}
return "unknown"
}
// resolveLaunchCWD picks the most plausible project cwd for an MCP
// launch, defending against editors that spawn the MCP server with
// cwd unset or set to a non-project directory:
//
// - Antigravity sometimes spawns with cwd=`/`.
// - Cursor launches user-level `~/.cursor/mcp.json` entries with
// cwd=$HOME (see gortexhq/gortex#19).
//
// Resolution order:
// 1. os.Getwd() when it looks like a project root (not `/` or $HOME).
// 2. $PWD when it differs and isn't `/` or $HOME.
// 3. The first non-empty editor workspace env var (CURSOR_WORKSPACE,
// CLAUDE_CODE_WORKSPACE, WINDSURF_WORKSPACE, KIRO_WORKSPACE,
// CODEX_WORKSPACE, ANTIGRAVITY_WORKSPACE, VSCODE_WORKSPACE).
// 4. Fall through to whatever Getwd() returned — the daemon resolves
// it per session (ScopeForCWD) and surfaces a clear repo_not_tracked
// error when the cwd maps to no tracked repo, isolating the session
// to nothing rather than the whole graph.
func resolveLaunchCWD() (string, error) {
cwd, err := os.Getwd()
if err != nil {
return "", err
}
if !isAmbiguousLaunchCWD(cwd) {
return cwd, nil
}
if pwd := os.Getenv("PWD"); pwd != cwd && !isAmbiguousLaunchCWD(pwd) {
return pwd, nil
}
for _, key := range []string{
"CURSOR_WORKSPACE",
"CLAUDE_CODE_WORKSPACE",
"WINDSURF_WORKSPACE",
"KIRO_WORKSPACE",
"CODEX_WORKSPACE",
"ANTIGRAVITY_WORKSPACE",
"VSCODE_WORKSPACE",
} {
if v := os.Getenv(key); !isAmbiguousLaunchCWD(v) {
return v, nil
}
}
return cwd, nil
}
// isAmbiguousLaunchCWD returns true when `p` is an editor-launch cwd
// we can't trust to point at the active project — empty, `/`, or the
// user's home directory.
//
// The home comparison goes through filepath.EvalSymlinks so the
// macOS `/var → /private/var` redirect (and similar symlinks) don't
// cause a false negative when an editor sets cwd via os.Chdir and
// then Getwd reports the resolved form.
func isAmbiguousLaunchCWD(p string) bool {
if p == "" || p == "/" {
return true
}
home, err := os.UserHomeDir()
if err != nil || home == "" {
return false
}
if p == home {
return true
}
resP, errP := filepath.EvalSymlinks(p)
resH, errH := filepath.EvalSymlinks(home)
return errP == nil && errH == nil && resP == resH
}
// The former shouldTryProxy stdin-TTY heuristic was removed: `gortex mcp`
// is now daemon-first via resolveDaemonDecision (ensure-daemon → relay,
// with an embedded fallback) regardless of whether stdin is a terminal
// or a pipe.