diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..1bd27c3 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,85 @@ +{ + "name": "AgentAPI Development", + "image": "mcr.microsoft.com/devcontainers/go:1.23-bookworm", + + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "20" + }, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/shyim/devcontainers-features/bun:0": {} + }, + + "customizations": { + "vscode": { + "settings": { + "go.toolsManagement.checkForUpdates": "local", + "go.useLanguageServer": true, + "go.gopath": "/go", + "go.lintTool": "golangci-lint", + "go.lintFlags": ["--fast"], + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + }, + "[go]": { + "editor.defaultFormatter": "golang.go" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } + }, + "extensions": [ + "golang.go", + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "bradlc.vscode-tailwindcss", + "GitHub.vscode-github-actions", + "ms-vscode.makefile-tools", + "redhat.vscode-yaml" + ] + } + }, + + "forwardPorts": [3284, 3000, 6006], + "portsAttributes": { + "3284": { + "label": "AgentAPI Server", + "onAutoForward": "notify" + }, + "3000": { + "label": "Next.js Dev Server", + "onAutoForward": "notify" + }, + "6006": { + "label": "Storybook", + "onAutoForward": "notify" + } + }, + + "postCreateCommand": "sudo chown -R vscode:vscode ${containerWorkspaceFolder}/chat/node_modules && go mod download && cd chat && bun install", + + "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", + + "remoteUser": "vscode", + + "mounts": [ + "source=${localWorkspaceFolderBasename}-node_modules,target=${containerWorkspaceFolder}/chat/node_modules,type=volume" + ], + + "runArgs": ["--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined"], + + "remoteEnv": { + "GOPATH": "/go", + "PATH": "${containerEnv:PATH}:/go/bin" + } +} diff --git a/cmd/server/server.go b/cmd/server/server.go index 84ad594..0d0fbb9 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -23,6 +23,7 @@ var ( agentTypeVar string port int printOpenAPI bool + basePath string chatBasePath string termWidth uint16 termHeight uint16 @@ -94,7 +95,7 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er return xerrors.Errorf("failed to setup process: %w", err) } } - srv := httpapi.NewServer(ctx, agentType, process, port, chatBasePath) + srv := httpapi.NewServer(ctx, agentType, process, port, chatBasePath, basePath) if printOpenAPI { fmt.Println(srv.GetOpenAPI()) return nil @@ -155,6 +156,7 @@ func init() { ServerCmd.Flags().IntVarP(&port, "port", "p", 3284, "Port to run the server on") ServerCmd.Flags().BoolVarP(&printOpenAPI, "print-openapi", "P", false, "Print the OpenAPI schema to stdout and exit") ServerCmd.Flags().StringVarP(&chatBasePath, "chat-base-path", "c", "/chat", "Base path for assets and routes used in the static files of the chat interface") + ServerCmd.Flags().StringVarP(&basePath, "base-path", "b", "", "Base path for the entire server, e.g. /api/v1. This is used when running the sever behind a reverse proxy under a path prefix.") ServerCmd.Flags().Uint16VarP(&termWidth, "term-width", "W", 80, "Width of the emulated terminal") ServerCmd.Flags().Uint16VarP(&termHeight, "term-height", "H", 1000, "Height of the emulated terminal") } diff --git a/lib/httpapi/middleware.go b/lib/httpapi/middleware.go new file mode 100644 index 0000000..03e4b80 --- /dev/null +++ b/lib/httpapi/middleware.go @@ -0,0 +1,57 @@ +package httpapi + +import ( + "net/http" + "strings" +) + +// responseWriter wraps http.ResponseWriter to intercept redirects +type basePathResponseWriter struct { + http.ResponseWriter + basePath string +} + +func (w *basePathResponseWriter) WriteHeader(statusCode int) { + // Intercept redirects and prepend base path to Location header + if statusCode >= 300 && statusCode < 400 { + if location := w.Header().Get("Location"); location != "" { + // Only modify relative redirects + if !strings.HasPrefix(location, "http://") && !strings.HasPrefix(location, "https://") { + if !strings.HasPrefix(location, w.basePath) { + w.Header().Set("Location", w.basePath+location) + } + } + } + } + w.ResponseWriter.WriteHeader(statusCode) +} + +// StripBasePath creates a middleware that strips the base path from incoming requests +func StripBasePath(basePath string) func(http.Handler) http.Handler { + // Normalize base path: ensure it starts with / and doesn't end with / + if basePath != "" { + if !strings.HasPrefix(basePath, "/") { + basePath = "/" + basePath + } + basePath = strings.TrimSuffix(basePath, "/") + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if basePath != "" && strings.HasPrefix(r.URL.Path, basePath) { + // Strip the base path + r.URL.Path = strings.TrimPrefix(r.URL.Path, basePath) + if r.URL.Path == "" { + r.URL.Path = "/" + } + + // Wrap response writer to handle redirects + w = &basePathResponseWriter{ + ResponseWriter: w, + basePath: basePath, + } + } + next.ServeHTTP(w, r) + }) + } +} \ No newline at end of file diff --git a/lib/httpapi/middleware_test.go b/lib/httpapi/middleware_test.go new file mode 100644 index 0000000..b524785 --- /dev/null +++ b/lib/httpapi/middleware_test.go @@ -0,0 +1,97 @@ +package httpapi + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStripBasePath(t *testing.T) { + tests := []struct { + name string + basePath string + requestPath string + expectedPath string + redirectPath string + expectedRedirect string + }{ + { + name: "no base path", + basePath: "", + requestPath: "/status", + expectedPath: "/status", + }, + { + name: "with base path - match", + basePath: "/api/v1", + requestPath: "/api/v1/status", + expectedPath: "/status", + }, + { + name: "with base path - no match", + basePath: "/api/v1", + requestPath: "/other/status", + expectedPath: "/other/status", + }, + { + name: "with base path - root", + basePath: "/api/v1", + requestPath: "/api/v1", + expectedPath: "/", + }, + { + name: "base path with trailing slash", + basePath: "/api/v1/", + requestPath: "/api/v1/status", + expectedPath: "/status", + }, + { + name: "base path without leading slash", + basePath: "api/v1", + requestPath: "/api/v1/status", + expectedPath: "/status", + }, + { + name: "redirect with base path", + basePath: "/api/v1", + requestPath: "/api/v1/old", + expectedPath: "/old", + redirectPath: "/new", + expectedRedirect: "/api/v1/new", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check that the path was modified correctly + assert.Equal(t, tt.expectedPath, r.URL.Path) + + // If test specifies a redirect, do it + if tt.redirectPath != "" { + http.Redirect(w, r, tt.redirectPath, http.StatusFound) + } else { + w.WriteHeader(http.StatusOK) + } + }) + + middleware := StripBasePath(tt.basePath) + wrappedHandler := middleware(handler) + + req := httptest.NewRequest("GET", tt.requestPath, nil) + rec := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(rec, req) + + // Check redirect if expected + if tt.redirectPath != "" { + assert.Equal(t, http.StatusFound, rec.Code) + assert.Equal(t, tt.expectedRedirect, rec.Header().Get("Location")) + } else { + assert.Equal(t, http.StatusOK, rec.Code) + } + }) + } +} \ No newline at end of file diff --git a/lib/httpapi/server.go b/lib/httpapi/server.go index a343baa..badf19b 100644 --- a/lib/httpapi/server.go +++ b/lib/httpapi/server.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "net/http" + "path/filepath" "sync" "time" @@ -33,6 +34,7 @@ type Server struct { agentio *termexec.Process agentType mf.AgentType emitter *EventEmitter + basePath string } func (s *Server) GetOpenAPI() string { @@ -57,7 +59,7 @@ func (s *Server) GetOpenAPI() string { const snapshotInterval = 25 * time.Millisecond // NewServer creates a new server instance -func NewServer(ctx context.Context, agentType mf.AgentType, process *termexec.Process, port int, chatBasePath string) *Server { +func NewServer(ctx context.Context, agentType mf.AgentType, process *termexec.Process, port int, chatBasePath, basePath string) *Server { router := chi.NewMux() corsMiddleware := cors.New(cors.Options{ @@ -95,6 +97,7 @@ func NewServer(ctx context.Context, agentType mf.AgentType, process *termexec.Pr agentio: process, agentType: agentType, emitter: emitter, + basePath: basePath, } // Register API routes @@ -118,17 +121,17 @@ func (s *Server) StartSnapshotLoop(ctx context.Context) { // registerRoutes sets up all API endpoints func (s *Server) registerRoutes(chatBasePath string) { // GET /status endpoint - huma.Get(s.api, "/status", s.getStatus, func(o *huma.Operation) { + huma.Get(s.api, filepath.Join(s.basePath, "/status"), s.getStatus, func(o *huma.Operation) { o.Description = "Returns the current status of the agent." }) // GET /messages endpoint - huma.Get(s.api, "/messages", s.getMessages, func(o *huma.Operation) { + huma.Get(s.api, filepath.Join(s.basePath, "/messages"), s.getMessages, func(o *huma.Operation) { o.Description = "Returns a list of messages representing the conversation history with the agent." }) // POST /message endpoint - huma.Post(s.api, "/message", s.createMessage, func(o *huma.Operation) { + huma.Post(s.api, filepath.Join(s.basePath, "/message"), s.createMessage, func(o *huma.Operation) { o.Description = "Send a message to the agent. For messages of type 'user', the agent's status must be 'stable' for the operation to complete successfully. Otherwise, this endpoint will return an error." }) @@ -136,7 +139,7 @@ func (s *Server) registerRoutes(chatBasePath string) { sse.Register(s.api, huma.Operation{ OperationID: "subscribeEvents", Method: http.MethodGet, - Path: "/events", + Path: filepath.Join(s.basePath, "/events"), Summary: "Subscribe to events", Description: "The events are sent as Server-Sent Events (SSE). Initially, the endpoint returns a list of events needed to reconstruct the current state of the conversation and the agent's status. After that, it only returns events that have occurred since the last event was sent.\n\nNote: When an agent is running, the last message in the conversation history is updated frequently, and the endpoint sends a new message update event each time.", }, map[string]any{ @@ -148,13 +151,14 @@ func (s *Server) registerRoutes(chatBasePath string) { sse.Register(s.api, huma.Operation{ OperationID: "subscribeScreen", Method: http.MethodGet, - Path: "/internal/screen", + Path: filepath.Join(s.basePath, "/internal/screen"), Summary: "Subscribe to screen", Hidden: true, }, map[string]any{ "screen": ScreenUpdateBody{}, }, s.subscribeScreen) + s.router.Handle(filepath.Join(s.basePath, "/"), http.HandlerFunc(s.redirectToChat)) s.router.Handle("/", http.HandlerFunc(s.redirectToChat)) // Serve static files for the chat interface under /chat @@ -296,6 +300,10 @@ func (s *Server) Start() error { return s.srv.ListenAndServe() } +func (s *Server) Handler() http.Handler { + return s.router +} + // Stop gracefully stops the HTTP server func (s *Server) Stop(ctx context.Context) error { if s.srv != nil { @@ -309,10 +317,10 @@ func (s *Server) registerStaticFileRoutes(chatBasePath string) { chatHandler := FileServerWithIndexFallback(chatBasePath) // Mount the file server at /chat - s.router.Handle("/chat", http.StripPrefix("/chat", chatHandler)) - s.router.Handle("/chat/*", http.StripPrefix("/chat", chatHandler)) + s.router.Handle(filepath.Join(s.basePath, "/chat"), http.StripPrefix("/chat", chatHandler)) + s.router.Handle(filepath.Join(s.basePath, "/chat/*"), http.StripPrefix("/chat", chatHandler)) } func (s *Server) redirectToChat(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/chat/embed", http.StatusTemporaryRedirect) + http.Redirect(w, r, filepath.Join(s.basePath, "/chat/embed"), http.StatusTemporaryRedirect) } diff --git a/lib/httpapi/server_test.go b/lib/httpapi/server_test.go index 456235a..0fd77d8 100644 --- a/lib/httpapi/server_test.go +++ b/lib/httpapi/server_test.go @@ -6,6 +6,8 @@ import ( "fmt" "io" "log/slog" + "net/http" + "net/http/httptest" "os" "sort" "testing" @@ -13,6 +15,8 @@ import ( "github.com/coder/agentapi/lib/httpapi" "github.com/coder/agentapi/lib/logctx" "github.com/coder/agentapi/lib/msgfmt" + "github.com/coder/agentapi/lib/termexec" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -44,7 +48,7 @@ func TestOpenAPISchema(t *testing.T) { t.Parallel() ctx := logctx.WithLogger(context.Background(), slog.New(slog.NewTextHandler(os.Stdout, nil))) - srv := httpapi.NewServer(ctx, msgfmt.AgentTypeClaude, nil, 0, "/chat") + srv := httpapi.NewServer(ctx, msgfmt.AgentTypeClaude, nil, 0, "/chat", "") currentSchemaStr := srv.GetOpenAPI() var currentSchema any if err := json.Unmarshal([]byte(currentSchemaStr), ¤tSchema); err != nil { @@ -73,3 +77,29 @@ func TestOpenAPISchema(t *testing.T) { require.Equal(t, currentSchema, diskSchema) } + +func TestBasePath(t *testing.T) { + t.Parallel() + + ctx := logctx.WithLogger(context.Background(), slog.New(slog.NewTextHandler(os.Stdout, nil))) + proc, err := termexec.StartProcess(ctx, termexec.StartProcessConfig{ + Program: "sleep", + Args: []string{"inf"}, + }) + require.NoError(t, err) + + // Given: a server with a non-empty base path + require.NoError(t, err) + hndlr := httpapi.NewServer(ctx, msgfmt.AgentTypeCustom, proc, 0, "/chat", "/subpath").Handler() + srv := httptest.NewServer(hndlr) + t.Cleanup(srv.Close) + + // When: we make a request to "/" + resp, err := srv.Client().Get(srv.URL) + require.NoError(t, err) + + // Then: we get redirected to /belongs/to/me/embed/chat + assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + location := resp.Header.Get("Location") + assert.Equal(t, "/subpath/chat/embed", location) +}