-
Notifications
You must be signed in to change notification settings - Fork 0
fix(httpapi): restore handler package build #471
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -340,185 +340,6 @@ func (s *Server) registerRoutes() { | |
| s.registerStaticFileRoutes() | ||
| } | ||
|
|
||
| // getStatus handles GET /status | ||
| func (s *Server) getStatus(ctx context.Context, input *struct{}) (*StatusResponse, error) { | ||
| s.mu.RLock() | ||
| defer s.mu.RUnlock() | ||
|
|
||
| status := s.conversation.Status() | ||
| agentStatus := convertStatus(status) | ||
|
|
||
| resp := &StatusResponse{} | ||
| resp.Body.Status = agentStatus | ||
| resp.Body.AgentType = s.agentType | ||
| resp.Body.Transport = s.transport | ||
|
|
||
| return resp, nil | ||
| } | ||
|
|
||
| // getMessages handles GET /messages | ||
| func (s *Server) getMessages(ctx context.Context, input *struct{}) (*MessagesResponse, error) { | ||
| s.mu.RLock() | ||
| defer s.mu.RUnlock() | ||
|
|
||
| resp := &MessagesResponse{} | ||
| resp.Body.Messages = make([]Message, len(s.conversation.Messages())) | ||
| for i, msg := range s.conversation.Messages() { | ||
| resp.Body.Messages[i] = Message{ | ||
| Id: msg.Id, | ||
| Role: msg.Role, | ||
| Content: msg.Message, | ||
| Time: msg.Time, | ||
| } | ||
| } | ||
|
|
||
| return resp, nil | ||
| } | ||
|
|
||
| // createMessage handles POST /message | ||
| func (s *Server) createMessage(ctx context.Context, input *MessageRequest) (*MessageResponse, error) { | ||
| s.mu.Lock() | ||
| defer s.mu.Unlock() | ||
|
|
||
| switch input.Body.Type { | ||
| case MessageTypeUser: | ||
| if err := s.conversation.Send(FormatMessage(s.agentType, input.Body.Content)...); err != nil { | ||
| return nil, xerrors.Errorf("failed to send message: %w", err) | ||
| } | ||
| case MessageTypeRaw: | ||
| if _, err := s.agentio.Write([]byte(input.Body.Content)); err != nil { | ||
| return nil, xerrors.Errorf("failed to send message: %w", err) | ||
| } | ||
| } | ||
|
|
||
| resp := &MessageResponse{} | ||
| resp.Body.Ok = true | ||
|
|
||
| return resp, nil | ||
| } | ||
|
|
||
| // uploadFiles handles POST /upload | ||
| func (s *Server) uploadFiles(ctx context.Context, input *struct { | ||
| RawBody huma.MultipartFormFiles[UploadRequest] | ||
| }, | ||
| ) (*UploadResponse, error) { | ||
| formData := input.RawBody.Data() | ||
|
|
||
| file := formData.File.File | ||
|
|
||
| // Limit file size to 10MB | ||
| const maxFileSize = 10 << 20 // 10MB | ||
| buf, err := io.ReadAll(io.LimitReader(file, maxFileSize+1)) | ||
| if err != nil { | ||
| return nil, xerrors.Errorf("failed to upload file: %w", err) | ||
| } | ||
| if len(buf) > maxFileSize { | ||
| return nil, huma.Error400BadRequest("file size exceeds 10MB limit") | ||
| } | ||
|
|
||
| // Calculate checksum of the uploaded file to create unique subdirectory | ||
| hash := sha256.Sum256(buf) | ||
| checksum := hex.EncodeToString(hash[:8]) // Use first 8 bytes (16 hex chars) | ||
|
|
||
| // Create checksum-based subdirectory in tempDir | ||
| uploadDir := filepath.Join(s.tempDir, checksum) | ||
| err = os.MkdirAll(uploadDir, 0o755) | ||
| if err != nil { | ||
| return nil, xerrors.Errorf("failed to create upload directory: %w", err) | ||
| } | ||
|
|
||
| // Save individual file with original filename (extract just the base filename for security) | ||
| filename := filepath.Base(formData.File.Filename) | ||
|
|
||
| outPath := filepath.Join(uploadDir, filename) | ||
| err = os.WriteFile(outPath, buf, 0o644) | ||
| if err != nil { | ||
| return nil, xerrors.Errorf("failed to write file: %w", err) | ||
| } | ||
|
|
||
| resp := &UploadResponse{} | ||
| resp.Body.Ok = true | ||
| resp.Body.FilePath = outPath | ||
| return resp, nil | ||
| } | ||
|
|
||
| // subscribeEvents is an SSE endpoint that sends events to the client | ||
| func (s *Server) subscribeEvents(ctx context.Context, input *struct{}, send sse.Sender) { | ||
| subscriberId, ch, stateEvents := s.emitter.Subscribe() | ||
| defer s.emitter.Unsubscribe(subscriberId) | ||
|
|
||
| s.logger.Info("New subscriber", "subscriberId", subscriberId) | ||
| for _, event := range stateEvents { | ||
| if event.Type == EventTypeScreenUpdate { | ||
| continue | ||
| } | ||
| if err := send.Data(event.Payload); err != nil { | ||
| s.logger.Error("Failed to send event", "subscriberId", subscriberId, "error", err) | ||
| return | ||
| } | ||
| } | ||
|
|
||
| for { | ||
| select { | ||
| case event, ok := <-ch: | ||
| if !ok { | ||
| s.logger.Info("Channel closed", "subscriberId", subscriberId) | ||
| return | ||
| } | ||
| if event.Type == EventTypeScreenUpdate { | ||
| continue | ||
| } | ||
| if err := send.Data(event.Payload); err != nil { | ||
| s.logger.Error("Failed to send event", "subscriberId", subscriberId, "error", err) | ||
| return | ||
| } | ||
| case <-s.shutdownCtx.Done(): | ||
| s.logger.Info("Server stop initiated, unsubscribing.", "subscriberId", subscriberId) | ||
| return | ||
| case <-ctx.Done(): | ||
| s.logger.Info("Context done", "subscriberId", subscriberId) | ||
| return | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func (s *Server) subscribeScreen(ctx context.Context, input *struct{}, send sse.Sender) { | ||
| subscriberId, ch, stateEvents := s.emitter.Subscribe() | ||
| defer s.emitter.Unsubscribe(subscriberId) | ||
| s.logger.Info("New screen subscriber", "subscriberId", subscriberId) | ||
| for _, event := range stateEvents { | ||
| if event.Type != EventTypeScreenUpdate { | ||
| continue | ||
| } | ||
| if err := send.Data(event.Payload); err != nil { | ||
| s.logger.Error("Failed to send screen event", "subscriberId", subscriberId, "error", err) | ||
| return | ||
| } | ||
| } | ||
| for { | ||
| select { | ||
| case event, ok := <-ch: | ||
| if !ok { | ||
| s.logger.Info("Screen channel closed", "subscriberId", subscriberId) | ||
| return | ||
| } | ||
| if event.Type != EventTypeScreenUpdate { | ||
| continue | ||
| } | ||
| if err := send.Data(event.Payload); err != nil { | ||
| s.logger.Error("Failed to send screen event", "subscriberId", subscriberId, "error", err) | ||
| return | ||
| } | ||
| case <-s.shutdownCtx.Done(): | ||
| s.logger.Info("Server stop initiated, unsubscribing.", "subscriberId", subscriberId) | ||
| return | ||
| case <-ctx.Done(): | ||
| s.logger.Info("Screen context done", "subscriberId", subscriberId) | ||
| return | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Start starts the HTTP server | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 Architect Review — HIGH The active /status handler in handlers.go no longer sets StatusResponse.Body.Transport, so the /status JSON always has an empty transport field even when the server is running with ACP transport. The attach CLI relies on this field (via cmd/attach/checkACPMode) to detect ACP servers; with the field unset, ACP servers are misclassified as PTY and attach proceeds against an unsupported transport. Suggestion: Keep handlers.go as the single implementation surface, but update getStatus to populate StatusResponse.Body.Transport from the server's configured transport (e.g., s.transport). Add/restore a test that hits /status for both PTY and ACP configurations and asserts that the transport field is present and correct so clients like cmd/attach can reliably detect ACP mode. Fix in Cursor | Fix in VSCode Claude (Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is an **Architect / Logical Review** comment left during a code review. These reviews are first-class, important findings — not optional suggestions. Do NOT dismiss this as a 'big architectural change' just because the title says architect review; most of these can be resolved with a small, localized fix once the intent is understood.
**Path:** lib/httpapi/server.go
**Line:** 343:350
**Comment:**
*HIGH: The active /status handler in handlers.go no longer sets StatusResponse.Body.Transport, so the /status JSON always has an empty transport field even when the server is running with ACP transport. The attach CLI relies on this field (via cmd/attach/checkACPMode) to detect ACP servers; with the field unset, ACP servers are misclassified as PTY and attach proceeds against an unsupported transport.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
If a suggested approach is provided above, use it as the authoritative instruction. If no explicit code suggestion is given, you MUST still draft and apply your own minimal, localized fix — do not punt back with 'no suggestion provided, review manually'. Keep the change as small as possible: add a guard clause, gate on a loading state, reorder an await, wrap in a conditional, etc. Do not refactor surrounding code or expand scope beyond the finding.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fixThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Architect Review — CRITICAL Removing the in-file handler implementations in server.go left the active getStatus handler in handlers.go without setting StatusResponse.Body.Transport, so /status returns an empty transport and cmd/attach can no longer reliably detect ACP mode before attempting unsupported attach flows. Suggestion: In getStatus, populate StatusResponse.Body.Transport from the server's configured transport (s.transport) and add an HTTP API test that asserts /status includes a valid non-empty transport value (pty or acp). Fix in Cursor | Fix in VSCode Claude (Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is an **Architect / Logical Review** comment left during a code review. These reviews are first-class, important findings — not optional suggestions. Do NOT dismiss this as a 'big architectural change' just because the title says architect review; most of these can be resolved with a small, localized fix once the intent is understood.
**Path:** lib/httpapi/server.go
**Line:** 343:350
**Comment:**
*CRITICAL: Removing the in-file handler implementations in server.go left the active getStatus handler in handlers.go without setting StatusResponse.Body.Transport, so /status returns an empty transport and cmd/attach can no longer reliably detect ACP mode before attempting unsupported attach flows.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
If a suggested approach is provided above, use it as the authoritative instruction. If no explicit code suggestion is given, you MUST still draft and apply your own minimal, localized fix — do not punt back with 'no suggestion provided, review manually'. Keep the change as small as possible: add a guard clause, gate on a loading state, reorder an await, wrap in a conditional, etc. Do not refactor surrounding code or expand scope beyond the finding.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix |
||
| func (s *Server) Start() error { | ||
| addr := fmt.Sprintf(":%d", s.port) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,25 @@ | ||
| package httpapi | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "context" | ||
| "encoding/json" | ||
| "io" | ||
| "log/slog" | ||
| "mime/multipart" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "os" | ||
| "path/filepath" | ||
| "strings" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/coder/agentapi/lib/logctx" | ||
| "github.com/coder/agentapi/lib/msgfmt" | ||
| "github.com/go-chi/chi/v5" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| // Traces to: FR-HTTP-003, FR-HTTP-005 | ||
|
|
@@ -16,24 +29,28 @@ func TestOpenAPISchema(t *testing.T) { | |
| t.Parallel() | ||
|
|
||
| ctx := logctx.WithLogger(context.Background(), slog.New(slog.NewTextHandler(os.Stdout, nil))) | ||
| srv, err := httpapi.NewServer(ctx, httpapi.ServerConfig{ | ||
| srv, err := NewServer(ctx, ServerConfig{ | ||
| AgentType: msgfmt.AgentTypeClaude, | ||
| AgentIO: nil, | ||
| Port: 0, | ||
| ChatBasePath: "/chat", | ||
| AllowedHosts: []string{"*"}, | ||
| AllowedOrigins: []string{"*"}, | ||
| }) | ||
| require.NoError(t, err) | ||
|
Comment on lines
+32
to
+40
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: Severity Level: Major
|
||
|
|
||
| req := httptest.NewRequest("GET", "/", nil) | ||
| req := httptest.NewRequest("GET", "/openapi.json", nil) | ||
| req.Host = "evil.com" | ||
| w := httptest.NewRecorder() | ||
|
|
||
| router.ServeHTTP(w, req) | ||
| srv.Handler().ServeHTTP(w, req) | ||
|
|
||
| if w.Code != http.StatusOK { | ||
| t.Errorf("expected 200 for wildcard, got %d", w.Code) | ||
| } | ||
| if !json.Valid(w.Body.Bytes()) { | ||
| t.Fatalf("expected valid OpenAPI JSON, got %q", w.Body.String()) | ||
| } | ||
|
Comment on lines
+51
to
+53
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: This assertion only checks that Severity Level: Critical 🚨- ❌ API contract drift between `/openapi.json` endpoint and openapi.json file.
- ⚠️ Generated SDKs using openapi.json may misrepresent server behavior.Steps of Reproduction ✅1. Open `lib/httpapi/server_test.go:25-27` and note the comment on `TestOpenAPISchema`
stating it should "Ensure the OpenAPI schema on disk is up to date" and referencing
`openapi.json`.
2. Inspect `TestOpenAPISchema` implementation at `lib/httpapi/server_test.go:28-54`: lines
31-39 construct a server via `NewServer`, lines 42-47 issue a `GET /openapi.json` request
using `httptest`, and lines 48-53 only assert that `w.Code == http.StatusOK` and
`json.Valid(w.Body.Bytes())`, without reading or comparing against the checked-in
`openapi.json` file.
3. Confirm via repository search that no Go code compares the HTTP response to the on-disk
schema: `grep -R "openapi.json" -n` shows usages in comments and scripts such as
`version.sh:12` and `scripts/validate_openapi_agent_client_module.sh:29-36`, but no test
in `lib/httpapi` opens or parses `openapi.json`, so `TestOpenAPISchema` cannot detect
discrepancies by design.
4. In this state, a developer can change the server routes or regenerate `/openapi.json`
output without updating the committed `openapi.json` (or vice versa); as long as the
endpoint response remains syntactically valid JSON, `go test ./lib/httpapi -run
TestOpenAPISchema` will still pass, allowing drift between the runtime `/openapi.json`
(produced by `Server.GetOpenAPI` in `lib/httpapi/server.go:73-91`) and the stored
`openapi.json` contract used by tooling in `version.sh` and
`scripts/validate_openapi_agent_client_module.sh`.Fix in Cursor | Fix in VSCode Claude (Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** lib/httpapi/server_test.go
**Line:** 51:53
**Comment:**
*Logic Error: This assertion only checks that `/openapi.json` is syntactically valid JSON, but no longer verifies it matches the checked-in schema file, so OpenAPI drift will pass unnoticed. Restore a deterministic comparison against the on-disk `openapi.json` content (after normalization if needed).
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix |
||
| } | ||
|
|
||
| // Traces to: FR-SEC-001 | ||
|
|
@@ -154,12 +171,7 @@ func TestParseAllowedHosts_Valid(t *testing.T) { | |
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| var diskSchema any | ||
| if err := json.Unmarshal(diskSchemaBytes, &diskSchema); err != nil { | ||
| t.Fatalf("failed to unmarshal disk schema: %s", err) | ||
| } | ||
|
|
||
| require.Equal(t, currentSchema, diskSchema) | ||
| require.Equal(t, []string{"localhost", "example.com"}, hosts) | ||
| } | ||
|
|
||
| func TestServer_redirectToChat(t *testing.T) { | ||
|
|
@@ -176,7 +188,7 @@ func TestServer_redirectToChat(t *testing.T) { | |
| t.Run(tc.name, func(t *testing.T) { | ||
| t.Parallel() | ||
| tCtx := logctx.WithLogger(context.Background(), slog.New(slog.NewTextHandler(os.Stdout, nil))) | ||
| s, err := httpapi.NewServer(tCtx, httpapi.ServerConfig{ | ||
| s, err := NewServer(tCtx, ServerConfig{ | ||
| AgentType: msgfmt.AgentTypeClaude, | ||
| AgentIO: nil, | ||
| Port: 0, | ||
|
|
@@ -340,7 +352,7 @@ func TestServer_AllowedHosts(t *testing.T) { | |
| t.Run(tc.name, func(t *testing.T) { | ||
| t.Parallel() | ||
| ctx := logctx.WithLogger(context.Background(), slog.New(slog.NewTextHandler(os.Stdout, nil))) | ||
| s, err := httpapi.NewServer(ctx, httpapi.ServerConfig{ | ||
| s, err := NewServer(ctx, ServerConfig{ | ||
| AgentType: msgfmt.AgentTypeClaude, | ||
| AgentIO: nil, | ||
| Port: 0, | ||
|
|
@@ -423,7 +435,7 @@ func TestServer_CORSPreflightWithHosts(t *testing.T) { | |
| t.Run(tc.name, func(t *testing.T) { | ||
| t.Parallel() | ||
| ctx := logctx.WithLogger(context.Background(), slog.New(slog.NewTextHandler(os.Stdout, nil))) | ||
| s, err := httpapi.NewServer(ctx, httpapi.ServerConfig{ | ||
| s, err := NewServer(ctx, ServerConfig{ | ||
| AgentType: msgfmt.AgentTypeClaude, | ||
| AgentIO: nil, | ||
| Port: 0, | ||
|
|
@@ -582,7 +594,7 @@ func TestServer_CORSOrigins(t *testing.T) { | |
| t.Run(tc.name, func(t *testing.T) { | ||
| t.Parallel() | ||
| ctx := logctx.WithLogger(context.Background(), slog.New(slog.NewTextHandler(os.Stdout, nil))) | ||
| s, err := httpapi.NewServer(ctx, httpapi.ServerConfig{ | ||
| s, err := NewServer(ctx, ServerConfig{ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: In Severity Level: Major
|
||
| AgentType: msgfmt.AgentTypeClaude, | ||
| AgentIO: nil, | ||
| Port: 0, | ||
|
|
@@ -662,7 +674,7 @@ func TestServer_CORSPreflightOrigins(t *testing.T) { | |
| t.Run(tc.name, func(t *testing.T) { | ||
| t.Parallel() | ||
| ctx := logctx.WithLogger(context.Background(), slog.New(slog.NewTextHandler(os.Stdout, nil))) | ||
| s, err := httpapi.NewServer(ctx, httpapi.ServerConfig{ | ||
| s, err := NewServer(ctx, ServerConfig{ | ||
| AgentType: msgfmt.AgentTypeClaude, | ||
| AgentIO: nil, | ||
| Port: 0, | ||
|
|
@@ -713,7 +725,7 @@ func TestServer_CORSPreflightOrigins(t *testing.T) { | |
| func TestServer_SSEMiddleware_Events(t *testing.T) { | ||
| t.Parallel() | ||
| ctx := logctx.WithLogger(context.Background(), slog.New(slog.NewTextHandler(os.Stdout, nil))) | ||
| srv, err := httpapi.NewServer(ctx, httpapi.ServerConfig{ | ||
| srv, err := NewServer(ctx, ServerConfig{ | ||
| AgentType: msgfmt.AgentTypeClaude, | ||
| AgentIO: nil, | ||
| Port: 0, | ||
|
|
@@ -760,7 +772,7 @@ func assertSSEHeaders(t testing.TB, resp *http.Response) { | |
| func TestServer_UploadFiles(t *testing.T) { | ||
| t.Parallel() | ||
| ctx := logctx.WithLogger(context.Background(), slog.New(slog.NewTextHandler(os.Stdout, nil))) | ||
| srv, err := httpapi.NewServer(ctx, httpapi.ServerConfig{ | ||
| srv, err := NewServer(ctx, ServerConfig{ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: Severity Level: Major
|
||
| AgentType: msgfmt.AgentTypeClaude, | ||
| AgentIO: nil, | ||
| Port: 0, | ||
|
|
@@ -915,7 +927,7 @@ func TestServer_UploadFiles(t *testing.T) { | |
| func TestServer_UploadFiles_Errors(t *testing.T) { | ||
| t.Parallel() | ||
| ctx := logctx.WithLogger(context.Background(), slog.New(slog.NewTextHandler(os.Stdout, nil))) | ||
| srv, err := httpapi.NewServer(ctx, httpapi.ServerConfig{ | ||
| srv, err := NewServer(ctx, ServerConfig{ | ||
| AgentType: msgfmt.AgentTypeClaude, | ||
| AgentIO: nil, | ||
| Port: 0, | ||
|
|
@@ -1061,7 +1073,7 @@ func TestServer_Stop_Idempotency(t *testing.T) { | |
| t.Parallel() | ||
| ctx := logctx.WithLogger(context.Background(), slog.New(slog.NewTextHandler(os.Stdout, nil))) | ||
|
|
||
| srv, err := httpapi.NewServer(ctx, httpapi.ServerConfig{ | ||
| srv, err := NewServer(ctx, ServerConfig{ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: Severity Level: Major
|
||
| AgentType: msgfmt.AgentTypeClaude, | ||
| AgentIO: nil, | ||
| Port: 0, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Transportfield never set in status handlerHigh Severity
The removed
getStatusinserver.gosetresp.Body.Transport = s.transport, but the surviving implementation inhandlers.goomits this assignment. SinceStatusResponsestill declares aTransportfield, the/statusendpoint now always returns an empty string fortransportinstead of the actual value ("pty"or"acp"). Clients relying on this field to determine the backend transport will break.Reviewed by Cursor Bugbot for commit 7652f7e. Configure here.