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
113 lines
3.4 KiB
Go
113 lines
3.4 KiB
Go
package hooks
|
|
|
|
import (
|
|
"encoding/json"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/zzet/gortex/internal/daemon"
|
|
)
|
|
|
|
// hookCWD carries the working directory of the in-flight hook invocation so the
|
|
// daemon-socket fallback in callServerTool can scope its tools/call handshake to
|
|
// the right workspace. A hook binary is single-shot — it reads one stdin
|
|
// payload, handles one event, and exits — so a package-level value set once at
|
|
// dispatch entry is race-free in production; tests set-and-restore it. The
|
|
// mutex is only for tidiness under `go test -race`, where several dispatchers
|
|
// run in one process.
|
|
var (
|
|
hookCWDMu sync.RWMutex
|
|
hookCWD string
|
|
)
|
|
|
|
// setHookCWD records the payload CWD for the current hook invocation. Pass ""
|
|
// to clear it (dispatchers defer a clear so a value never leaks across the
|
|
// sequential tests sharing one process).
|
|
func setHookCWD(cwd string) {
|
|
hookCWDMu.Lock()
|
|
hookCWD = cwd
|
|
hookCWDMu.Unlock()
|
|
}
|
|
|
|
// loadHookCWD returns the CWD recorded for the current hook invocation, or ""
|
|
// when none was set (the pure-HTTP unit tests, or a dispatcher that opted out
|
|
// of the socket fallback).
|
|
func loadHookCWD() string {
|
|
hookCWDMu.RLock()
|
|
defer hookCWDMu.RUnlock()
|
|
return hookCWD
|
|
}
|
|
|
|
// callServerToolDaemonFn is the seam production uses to reach the daemon over
|
|
// its unix socket; tests swap it so a hook never touches a real daemon.
|
|
var callServerToolDaemonFn = callServerToolViaDaemon
|
|
|
|
// callServerToolTimeout bounds the whole daemon round-trip (dial + handshake +
|
|
// tools/call) per call, so a wedged daemon can never stall a Stop / subagent
|
|
// hook past the host's own hook timeout.
|
|
const callServerToolTimeout = 5 * time.Second
|
|
|
|
// callServerToolViaDaemon runs one MCP tools/call against the local daemon over
|
|
// its AF_UNIX socket and returns the first text content block, or "" on any
|
|
// error. cwd scopes the handshake to the caller's workspace so tools that read
|
|
// the working tree (detect_changes) or the active project (analyze) resolve the
|
|
// right repo. Mirrors fileIndexedViaDaemon's transport; kept separate so the
|
|
// file-indexed probe stays a tight count-only path.
|
|
func callServerToolViaDaemon(cwd, name string, args map[string]any) string {
|
|
if args == nil {
|
|
args = map[string]any{}
|
|
}
|
|
client, err := daemon.Dial(daemon.Handshake{
|
|
Mode: daemon.ModeMCP,
|
|
ClientName: "gortex-hook",
|
|
CWD: cwd,
|
|
})
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer client.Close()
|
|
_ = client.Conn.SetDeadline(time.Now().Add(callServerToolTimeout))
|
|
|
|
frame, err := json.Marshal(map[string]any{
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "tools/call",
|
|
"params": map[string]any{
|
|
"name": name,
|
|
"arguments": args,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
if err := client.WriteMCPFrame(frame); err != nil {
|
|
return ""
|
|
}
|
|
resp, err := client.ReadMCPFrame()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return parseToolCallText(resp)
|
|
}
|
|
|
|
// parseToolCallText unwraps a tools/call JSON-RPC response to the first content
|
|
// block's text. Returns "" on a tool error or a shape mismatch so the caller
|
|
// treats it the same as an unreachable bridge.
|
|
func parseToolCallText(resp []byte) string {
|
|
var rpc struct {
|
|
Result struct {
|
|
Content []struct {
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
IsError bool `json:"isError"`
|
|
} `json:"result"`
|
|
}
|
|
if err := json.Unmarshal(resp, &rpc); err != nil {
|
|
return ""
|
|
}
|
|
if rpc.Result.IsError || len(rpc.Result.Content) == 0 {
|
|
return ""
|
|
}
|
|
return rpc.Result.Content[0].Text
|
|
}
|