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..6e8452bc 100644 --- a/flake.nix +++ b/flake.nix @@ -4,30 +4,91 @@ 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; + }; + + # 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}"; + + # 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: _: + "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)} + ''; + 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 +99,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") - } -}