From a8344037a387a6aae9d70915a606c5e226f9e62a Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Fri, 19 Jun 2026 12:40:02 +0530 Subject: [PATCH 1/2] refactor: remove legacy API server and localize nix sibling deps Drop the unused internal API server wiring, update the changelog to match the current daemon surface, and make Nix builds resolve sibling modules locally. --- CHANGELOG.md | 4 +- cmd/hawk/main.go | 2 - flake.nix | 74 +++++++++-- internal/api/server.go | 253 ------------------------------------ internal/api/server_test.go | 221 ------------------------------- 5 files changed, 67 insertions(+), 487 deletions(-) delete mode 100644 internal/api/server.go delete mode 100644 internal/api/server_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cba453c..b3805493 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Changed -- **Version re-baselined to `0.1.0`** across `main.go`, `api/server.go`, `flake.nix`, - `.github/workflows/release.yml`, and the `update`/`api` test suites, aligning hawk +- **Version re-baselined to `0.1.0`** across `cmd/hawk/main.go`, `cmd/daemon.go`, + `flake.nix`, `.github/workflows/release.yml`, and the `update`/daemon test suites, aligning hawk with the rest of the GrayCodeAI ecosystem (`eyrie`, `tok`, `yaad`, `sight`, `inspect`). ### Added diff --git a/cmd/hawk/main.go b/cmd/hawk/main.go index dd5bd0c7..b1472a3c 100644 --- a/cmd/hawk/main.go +++ b/cmd/hawk/main.go @@ -6,7 +6,6 @@ import ( "os" "github.com/GrayCodeAI/hawk/cmd" - "github.com/GrayCodeAI/hawk/internal/api" "github.com/GrayCodeAI/hawk/internal/hawkerr" "github.com/GrayCodeAI/hawk/internal/mcp" "github.com/GrayCodeAI/hawk/internal/sandbox" @@ -37,7 +36,6 @@ func main() { // to avoid an import cycle with main. cmd.SetVersion(Version) cmd.SetBuildDate(BuildDate) - api.SetVersion(Version) mcp.SetClientVersion(Version) sandbox.ContainerImageTag = Version diff --git a/flake.nix b/flake.nix index a343d4dd..838d63ad 100644 --- a/flake.nix +++ b/flake.nix @@ -4,30 +4,86 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; + # GrayCodeAI sibling repos — the public Go proxy has stale v0.1.0 tags + # (post-history-rewrite), so resolve them locally like the Dockerfile. + eyrie = { url = "github:GrayCodeAI/eyrie"; flake = false; }; + inspect = { url = "github:GrayCodeAI/inspect"; flake = false; }; + sight = { url = "github:GrayCodeAI/sight"; flake = false; }; + tok = { url = "github:GrayCodeAI/tok"; flake = false; }; + trace = { url = "github:GrayCodeAI/trace"; flake = false; }; + yaad = { url = "github:GrayCodeAI/yaad"; flake = false; }; }; - outputs = { self, nixpkgs, flake-utils }: + outputs = { self, nixpkgs, flake-utils, eyrie, inspect, sight, tok, trace, yaad }: flake-utils.lib.eachDefaultSystem (system: let pkgs = nixpkgs.legacyPackages.${system}; - + inherit (pkgs) lib; + + siblings = { + "github.com/GrayCodeAI/eyrie" = eyrie; + "github.com/GrayCodeAI/inspect" = inspect; + "github.com/GrayCodeAI/sight" = sight; + "github.com/GrayCodeAI/tok" = tok; + "github.com/GrayCodeAI/trace" = trace; + "github.com/GrayCodeAI/yaad" = yaad; + }; + + dirOf = mod: lib.last (lib.splitString "/" mod); + goVer = lib.removePrefix "go" "${pkgs.go_1_26.version}"; + + # Copy sibling sources into external/, patch the go.mod go directive + # to match the nixpkgs Go version, and add replace directives so the + # build resolves siblings locally instead of hitting the stale proxy. + setupReplace = '' + sed -i 's/^go [0-9.]\+/go ${goVer}/' go.mod + rm -f go.work go.work.sum + mkdir -p external + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (mod: src: + "cp -r ${src} external/${dirOf mod}" + ) siblings)} + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (mod: _: + "echo \"replace ${mod} => ./external/${dirOf mod}\" >> go.mod" + ) siblings)} + ''; + hawk = pkgs.buildGoModule rec { pname = "hawk"; version = "0.1.0"; - + src = ./.; - + + # The public Go proxy has stale v0.1.0 tags for GrayCodeAI sibling + # modules (post-history-rewrite). We resolve siblings locally via + # replace directives in go.mod (added in preBuild below). External + # deps (charmbracelet, cobra, …) are fetched from the proxy. The + # vendor FOD is skipped (null) because the proxy version mismatch + # prevents `go mod vendor` from passing checksum verification. vendorHash = null; - + + env = { + GOPRIVATE = "github.com/GrayCodeAI/*"; + GONOSUMDB = "github.com/GrayCodeAI/*"; + GONOSUMCHECK = "1"; + GOFLAGS = "-mod=mod"; + }; + + # Add replace directives to go.mod so the build and vendor FOD + # resolve sibling modules locally instead of from the stale proxy. + preBuild = setupReplace; + overrideModAttrs = old: { + preBuild = setupReplace; + }; + ldflags = [ "-s" "-w" "-X main.Version=${version}" ]; - + nativeBuildInputs = [ pkgs.git ]; - - meta = with pkgs.lib; { + + meta = with lib; { description = "AI coding agent that reads, writes, and runs code in your terminal"; homepage = "https://github.com/GrayCodeAI/hawk"; license = licenses.mit; @@ -38,7 +94,7 @@ { packages = { default = hawk; - hawk = hawk; + inherit hawk; }; devShells.default = pkgs.mkShell { diff --git a/internal/api/server.go b/internal/api/server.go deleted file mode 100644 index 604c6ebf..00000000 --- a/internal/api/server.go +++ /dev/null @@ -1,253 +0,0 @@ -// Package api provides an HTTP API server for hawk, consumable by SDKs. -package api - -import ( - "context" - "crypto/subtle" - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "strings" - "sync" - "time" -) - -const maxRequestBodyBytes = 1 << 20 - -// Version is the current hawk API surface version, exposed in the GET /version -// endpoint. It is wired at startup by main.go from the canonical version -// (the VERSION file at the repo root, injected via ldflags). The "dev" -// default applies only to local builds without ldflags. -var Version = "dev" - -// SetVersion lets main.go propagate the canonical hawk version into this -// package without creating an import cycle with cmd. -func SetVersion(v string) { Version = v } - -// Server is the HTTP API server for hawk. -type Server struct { - addr string - mux *http.ServeMux - server *http.Server - mu sync.Mutex - apiKey string -} - -// ChatRequest is the request body for POST /chat. -type ChatRequest struct { - Message string `json:"message"` - Model string `json:"model"` -} - -// ChatResponse is the response body for POST /chat. -type ChatResponse struct { - Response string `json:"response"` -} - -// HealthResponse is the response body for GET /health. -type HealthResponse struct { - Status string `json:"status"` -} - -// VersionResponse is the response body for GET /version. -type VersionResponse struct { - Version string `json:"version"` -} - -// New creates a new API server listening on the given address. -func New(addr string) *Server { - return NewWithAPIKey(addr, "") -} - -// NewWithAPIKey creates a new API server and protects mutating endpoints when -// apiKey is non-empty. -func NewWithAPIKey(addr, apiKey string) *Server { - mux := http.NewServeMux() - s := &Server{ - addr: addr, - mux: mux, - apiKey: apiKey, - } - s.registerRoutes() - return s -} - -// registerRoutes sets up the HTTP endpoints. -func (s *Server) registerRoutes() { - s.mux.HandleFunc("GET /health", securityHeaders(s.handleHealth)) - s.mux.HandleFunc("GET /version", securityHeaders(s.handleVersion)) - s.mux.HandleFunc("POST /chat", securityHeaders(s.auth(s.handleChat))) -} - -// securityHeaders sets standard HTTP security headers on every response. -func securityHeaders(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("X-Frame-Options", "DENY") - w.Header().Set("Cache-Control", "no-store") - next(w, r) - } -} - -func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if s.apiKey == "" { - next(w, r) - return - } - token := r.Header.Get("Authorization") - token = strings.TrimPrefix(token, "Bearer ") - if token == "" { - token = r.Header.Get("X-API-Key") - } - if !constantTimeEqual(token, s.apiKey) { - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) - return - } - next(w, r) - } -} - -func constantTimeEqual(a, b string) bool { - // Compare lengths in constant time first, then content. - // This avoids null-byte padding which could produce false positives. - if len(a) != len(b) { - // Still do a comparison to avoid early-return timing leak. - // Compare against a to consume the same time as a matching-length path. - subtle.ConstantTimeCompare([]byte(a), []byte(a)) - return false - } - return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 -} - -// validateAuthConfig refuses to start the server with no API key on a -// non-loopback bind. The auth middleware silently allows every request when -// the API key is empty, so a misconfigured server would be wide open. The -// only safe no-key mode is loopback bind. -func (s *Server) validateAuthConfig() error { - if s.apiKey != "" { - return nil - } - host, _, err := net.SplitHostPort(s.addr) - if err != nil { - return fmt.Errorf("api: invalid bind address %q: %w", s.addr, err) - } - if !isLoopbackHost(host) { - return fmt.Errorf("api: apiKey is empty and bind address %q is not loopback; refusing to start. Set apiKey or bind to 127.0.0.1", s.addr) - } - return nil -} - -// isLoopbackHost reports whether host is a loopback address. -func isLoopbackHost(host string) bool { - if host == "" || host == "localhost" { - return host == "localhost" // "" is unsafe; "localhost" is loopback - } - if ip := net.ParseIP(host); ip != nil { - return ip.IsLoopback() - } - return false -} - -func decodeJSONBody(w http.ResponseWriter, r *http.Request, dst any) bool { - r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes) - dec := json.NewDecoder(r.Body) - dec.DisallowUnknownFields() - if err := dec.Decode(dst); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) - return false - } - if err := dec.Decode(&struct{}{}); err != io.EOF { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "request body must contain a single JSON object"}) - return false - } - return true -} - -// Start starts the HTTP server. It blocks until the context is cancelled or an error occurs. -func (s *Server) Start(ctx context.Context) error { - if err := s.validateAuthConfig(); err != nil { - return err - } - s.mu.Lock() - s.server = &http.Server{ - Addr: s.addr, - Handler: s.mux, - ReadHeaderTimeout: 10 * time.Second, - ReadTimeout: 30 * time.Second, - WriteTimeout: 30 * time.Second, - IdleTimeout: 120 * time.Second, - } - s.mu.Unlock() - - ln, err := new(net.ListenConfig).Listen(ctx, "tcp", s.addr) - if err != nil { - return err - } - - go func() { - <-ctx.Done() - _ = s.server.Shutdown(context.Background()) - }() - - err = s.server.Serve(ln) - if err == http.ErrServerClosed { - return nil - } - return err -} - -// Stop gracefully shuts down the HTTP server with a 15-second timeout. -func (s *Server) Stop(ctx context.Context) error { - s.mu.Lock() - srv := s.server - s.mu.Unlock() - - if srv == nil { - return nil - } - // Use a bounded timeout so Stop cannot hang indefinitely if a - // client keeps a connection open. The caller's ctx is respected - // if it has a shorter deadline. - shutdownCtx, cancel := context.WithTimeout(ctx, 15*time.Second) - defer cancel() - return srv.Shutdown(shutdownCtx) -} - -// Handler returns the underlying http.Handler for testing purposes. -func (s *Server) Handler() http.Handler { - return s.mux -} - -func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, HealthResponse{Status: "ok"}) -} - -func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, VersionResponse{Version: Version}) -} - -func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { - var req ChatRequest - if !decodeJSONBody(w, r, &req) { - return - } - if req.Message == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "message is required"}) - return - } - - // Stub response - resp := ChatResponse{ - Response: "This is a stub response. Model: " + req.Model + ", Message: " + req.Message, - } - writeJSON(w, http.StatusOK, resp) -} - -func writeJSON(w http.ResponseWriter, status int, v interface{}) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(v) -} diff --git a/internal/api/server_test.go b/internal/api/server_test.go deleted file mode 100644 index 237aff73..00000000 --- a/internal/api/server_test.go +++ /dev/null @@ -1,221 +0,0 @@ -package api - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestHealthEndpoint(t *testing.T) { - srv := New(":0") - req := httptest.NewRequest(http.MethodGet, "/health", nil) - w := httptest.NewRecorder() - - srv.Handler().ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } - - var resp HealthResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("decode response: %v", err) - } - if resp.Status != "ok" { - t.Fatalf("expected status 'ok', got %q", resp.Status) - } -} - -func TestVersionEndpoint(t *testing.T) { - srv := New(":0") - req := httptest.NewRequest(http.MethodGet, "/version", nil) - w := httptest.NewRecorder() - - srv.Handler().ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } - - var resp VersionResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("decode response: %v", err) - } - if resp.Version != Version { - t.Fatalf("expected version %q, got %q", Version, resp.Version) - } -} - -func TestChatEndpoint_Success(t *testing.T) { - srv := New(":0") - - body := ChatRequest{Message: "hello", Model: "gpt-4"} - b, _ := json.Marshal(body) - req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader(b)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - srv.Handler().ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } - - var resp ChatResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("decode response: %v", err) - } - if resp.Response == "" { - t.Fatal("expected non-empty response") - } -} - -func TestChatEndpoint_EmptyMessage(t *testing.T) { - srv := New(":0") - - body := ChatRequest{Message: "", Model: "gpt-4"} - b, _ := json.Marshal(body) - req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader(b)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - srv.Handler().ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", w.Code) - } -} - -func TestChatEndpoint_InvalidJSON(t *testing.T) { - srv := New(":0") - - req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader([]byte("not json"))) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - srv.Handler().ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", w.Code) - } -} - -func TestChatEndpoint_AuthRequired(t *testing.T) { - srv := NewWithAPIKey(":0", "secret") - - body := ChatRequest{Message: "hello", Model: "gpt-4"} - b, _ := json.Marshal(body) - req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader(b)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - srv.Handler().ServeHTTP(w, req) - if w.Code != http.StatusUnauthorized { - t.Fatalf("expected 401 without key, got %d", w.Code) - } - - req = httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader(b)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer secret") - w = httptest.NewRecorder() - - srv.Handler().ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200 with key, got %d", w.Code) - } -} - -func TestChatEndpoint_RejectsOversizedBody(t *testing.T) { - srv := New(":0") - req := httptest.NewRequest(http.MethodPost, "/chat", strings.NewReader(`{"message":"`+strings.Repeat("x", maxRequestBodyBytes+1)+`"}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - srv.Handler().ServeHTTP(w, req) - if w.Code != http.StatusBadRequest { - t.Fatalf("expected 400 for oversized body, got %d", w.Code) - } -} - -func TestChatEndpoint_RejectsUnknownFields(t *testing.T) { - srv := New(":0") - req := httptest.NewRequest(http.MethodPost, "/chat", strings.NewReader(`{"message":"hello","unknown":true}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - srv.Handler().ServeHTTP(w, req) - if w.Code != http.StatusBadRequest { - t.Fatalf("expected 400 for unknown field, got %d", w.Code) - } -} - -func TestHealthEndpoint_ContentType(t *testing.T) { - srv := New(":0") - req := httptest.NewRequest(http.MethodGet, "/health", nil) - w := httptest.NewRecorder() - - srv.Handler().ServeHTTP(w, req) - - ct := w.Header().Get("Content-Type") - if ct != "application/json" { - t.Fatalf("expected Content-Type 'application/json', got %q", ct) - } -} - -func TestWriteJSON(t *testing.T) { - w := httptest.NewRecorder() - data := map[string]string{"key": "value"} - writeJSON(w, http.StatusCreated, data) - - if w.Code != http.StatusCreated { - t.Errorf("status = %d, want 201", w.Code) - } - if w.Header().Get("Content-Type") != "application/json" { - t.Error("Content-Type should be application/json") - } - var result map[string]string - if err := json.NewDecoder(w.Body).Decode(&result); err != nil { - t.Fatalf("decode error: %v", err) - } - if result["key"] != "value" { - t.Errorf("key = %q, want 'value'", result["key"]) - } -} - -func TestNew(t *testing.T) { - srv := New(":8080") - if srv == nil { - t.Fatal("New returned nil") - } - if srv.Handler() == nil { - t.Error("Handler() should not be nil") - } -} - -func TestChatEndpoint_MethodNotAllowed(t *testing.T) { - srv := New(":0") - req := httptest.NewRequest(http.MethodGet, "/chat", nil) - w := httptest.NewRecorder() - - srv.Handler().ServeHTTP(w, req) - - // GET on /chat should return error (405 or similar) - if w.Code == http.StatusOK { - t.Error("GET /chat should not return 200") - } -} - -func TestUnknownEndpoint(t *testing.T) { - srv := New(":0") - req := httptest.NewRequest(http.MethodGet, "/unknown-path", nil) - w := httptest.NewRecorder() - - srv.Handler().ServeHTTP(w, req) - - if w.Code == http.StatusOK { - t.Error("unknown path should not return 200") - } -} From 941777c313cff249f568e358ec23432724b12468 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Fri, 19 Jun 2026 13:14:56 +0530 Subject: [PATCH 2/2] fix: make nix sibling replace setup idempotent Strip any existing sibling replace directives before appending them so the preBuild logic can run multiple times without duplicating go.mod entries. --- flake.nix | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/flake.nix b/flake.nix index 838d63ad..6e8452bc 100644 --- a/flake.nix +++ b/flake.nix @@ -29,6 +29,8 @@ "github.com/GrayCodeAI/yaad" = yaad; }; + # GrayCode modules currently follow github.com//, so the + # last path segment matches the sibling checkout directory name. dirOf = mod: lib.last (lib.splitString "/" mod); goVer = lib.removePrefix "go" "${pkgs.go_1_26.version}"; @@ -42,6 +44,9 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList (mod: src: "cp -r ${src} external/${dirOf mod}" ) siblings)} + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (mod: _: + "tmp_go_mod=$(mktemp) && awk '$0 != \"replace ${mod} => ./external/${dirOf mod}\"' go.mod > \"$tmp_go_mod\" && mv \"$tmp_go_mod\" go.mod" + ) siblings)} ${lib.concatStringsSep "\n" (lib.mapAttrsToList (mod: _: "echo \"replace ${mod} => ./external/${dirOf mod}\" >> go.mod" ) siblings)}