Skip to content

Commit 5729323

Browse files
committed
wip
1 parent a8c8cdb commit 5729323

File tree

6 files changed

+290
-11
lines changed

6 files changed

+290
-11
lines changed

.devcontainer/devcontainer.json

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
{
2+
"name": "AgentAPI Development",
3+
"image": "mcr.microsoft.com/devcontainers/go:1.23-bookworm",
4+
5+
"features": {
6+
"ghcr.io/devcontainers/features/node:1": {
7+
"version": "20"
8+
},
9+
"ghcr.io/devcontainers/features/github-cli:1": {},
10+
"ghcr.io/shyim/devcontainers-features/bun:0": {}
11+
},
12+
13+
"customizations": {
14+
"vscode": {
15+
"settings": {
16+
"go.toolsManagement.checkForUpdates": "local",
17+
"go.useLanguageServer": true,
18+
"go.gopath": "/go",
19+
"go.lintTool": "golangci-lint",
20+
"go.lintFlags": ["--fast"],
21+
"editor.formatOnSave": true,
22+
"editor.codeActionsOnSave": {
23+
"source.organizeImports": "explicit"
24+
},
25+
"[go]": {
26+
"editor.defaultFormatter": "golang.go"
27+
},
28+
"[typescript]": {
29+
"editor.defaultFormatter": "esbenp.prettier-vscode"
30+
},
31+
"[typescriptreact]": {
32+
"editor.defaultFormatter": "esbenp.prettier-vscode"
33+
},
34+
"[javascript]": {
35+
"editor.defaultFormatter": "esbenp.prettier-vscode"
36+
},
37+
"[json]": {
38+
"editor.defaultFormatter": "esbenp.prettier-vscode"
39+
}
40+
},
41+
"extensions": [
42+
"golang.go",
43+
"esbenp.prettier-vscode",
44+
"dbaeumer.vscode-eslint",
45+
"bradlc.vscode-tailwindcss",
46+
"GitHub.vscode-github-actions",
47+
"ms-vscode.makefile-tools",
48+
"redhat.vscode-yaml"
49+
]
50+
}
51+
},
52+
53+
"forwardPorts": [3284, 3000, 6006],
54+
"portsAttributes": {
55+
"3284": {
56+
"label": "AgentAPI Server",
57+
"onAutoForward": "notify"
58+
},
59+
"3000": {
60+
"label": "Next.js Dev Server",
61+
"onAutoForward": "notify"
62+
},
63+
"6006": {
64+
"label": "Storybook",
65+
"onAutoForward": "notify"
66+
}
67+
},
68+
69+
"postCreateCommand": "sudo chown -R vscode:vscode ${containerWorkspaceFolder}/chat/node_modules && go mod download && cd chat && bun install",
70+
71+
"postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}",
72+
73+
"remoteUser": "vscode",
74+
75+
"mounts": [
76+
"source=${localWorkspaceFolderBasename}-node_modules,target=${containerWorkspaceFolder}/chat/node_modules,type=volume"
77+
],
78+
79+
"runArgs": ["--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined"],
80+
81+
"remoteEnv": {
82+
"GOPATH": "/go",
83+
"PATH": "${containerEnv:PATH}:/go/bin"
84+
}
85+
}

cmd/server/server.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ var (
2323
agentTypeVar string
2424
port int
2525
printOpenAPI bool
26+
basePath string
2627
chatBasePath string
2728
termWidth uint16
2829
termHeight uint16
@@ -94,7 +95,7 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er
9495
return xerrors.Errorf("failed to setup process: %w", err)
9596
}
9697
}
97-
srv := httpapi.NewServer(ctx, agentType, process, port, chatBasePath)
98+
srv := httpapi.NewServer(ctx, agentType, process, port, chatBasePath, basePath)
9899
if printOpenAPI {
99100
fmt.Println(srv.GetOpenAPI())
100101
return nil
@@ -155,6 +156,7 @@ func init() {
155156
ServerCmd.Flags().IntVarP(&port, "port", "p", 3284, "Port to run the server on")
156157
ServerCmd.Flags().BoolVarP(&printOpenAPI, "print-openapi", "P", false, "Print the OpenAPI schema to stdout and exit")
157158
ServerCmd.Flags().StringVarP(&chatBasePath, "chat-base-path", "c", "/chat", "Base path for assets and routes used in the static files of the chat interface")
159+
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.")
158160
ServerCmd.Flags().Uint16VarP(&termWidth, "term-width", "W", 80, "Width of the emulated terminal")
159161
ServerCmd.Flags().Uint16VarP(&termHeight, "term-height", "H", 1000, "Height of the emulated terminal")
160162
}

lib/httpapi/middleware.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package httpapi
2+
3+
import (
4+
"net/http"
5+
"strings"
6+
)
7+
8+
// responseWriter wraps http.ResponseWriter to intercept redirects
9+
type basePathResponseWriter struct {
10+
http.ResponseWriter
11+
basePath string
12+
}
13+
14+
func (w *basePathResponseWriter) WriteHeader(statusCode int) {
15+
// Intercept redirects and prepend base path to Location header
16+
if statusCode >= 300 && statusCode < 400 {
17+
if location := w.Header().Get("Location"); location != "" {
18+
// Only modify relative redirects
19+
if !strings.HasPrefix(location, "http://") && !strings.HasPrefix(location, "https://") {
20+
if !strings.HasPrefix(location, w.basePath) {
21+
w.Header().Set("Location", w.basePath+location)
22+
}
23+
}
24+
}
25+
}
26+
w.ResponseWriter.WriteHeader(statusCode)
27+
}
28+
29+
// StripBasePath creates a middleware that strips the base path from incoming requests
30+
func StripBasePath(basePath string) func(http.Handler) http.Handler {
31+
// Normalize base path: ensure it starts with / and doesn't end with /
32+
if basePath != "" {
33+
if !strings.HasPrefix(basePath, "/") {
34+
basePath = "/" + basePath
35+
}
36+
basePath = strings.TrimSuffix(basePath, "/")
37+
}
38+
39+
return func(next http.Handler) http.Handler {
40+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
41+
if basePath != "" && strings.HasPrefix(r.URL.Path, basePath) {
42+
// Strip the base path
43+
r.URL.Path = strings.TrimPrefix(r.URL.Path, basePath)
44+
if r.URL.Path == "" {
45+
r.URL.Path = "/"
46+
}
47+
48+
// Wrap response writer to handle redirects
49+
w = &basePathResponseWriter{
50+
ResponseWriter: w,
51+
basePath: basePath,
52+
}
53+
}
54+
next.ServeHTTP(w, r)
55+
})
56+
}
57+
}

lib/httpapi/middleware_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package httpapi
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestStripBasePath(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
basePath string
15+
requestPath string
16+
expectedPath string
17+
redirectPath string
18+
expectedRedirect string
19+
}{
20+
{
21+
name: "no base path",
22+
basePath: "",
23+
requestPath: "/status",
24+
expectedPath: "/status",
25+
},
26+
{
27+
name: "with base path - match",
28+
basePath: "/api/v1",
29+
requestPath: "/api/v1/status",
30+
expectedPath: "/status",
31+
},
32+
{
33+
name: "with base path - no match",
34+
basePath: "/api/v1",
35+
requestPath: "/other/status",
36+
expectedPath: "/other/status",
37+
},
38+
{
39+
name: "with base path - root",
40+
basePath: "/api/v1",
41+
requestPath: "/api/v1",
42+
expectedPath: "/",
43+
},
44+
{
45+
name: "base path with trailing slash",
46+
basePath: "/api/v1/",
47+
requestPath: "/api/v1/status",
48+
expectedPath: "/status",
49+
},
50+
{
51+
name: "base path without leading slash",
52+
basePath: "api/v1",
53+
requestPath: "/api/v1/status",
54+
expectedPath: "/status",
55+
},
56+
{
57+
name: "redirect with base path",
58+
basePath: "/api/v1",
59+
requestPath: "/api/v1/old",
60+
expectedPath: "/old",
61+
redirectPath: "/new",
62+
expectedRedirect: "/api/v1/new",
63+
},
64+
}
65+
66+
for _, tt := range tests {
67+
t.Run(tt.name, func(t *testing.T) {
68+
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
69+
// Check that the path was modified correctly
70+
assert.Equal(t, tt.expectedPath, r.URL.Path)
71+
72+
// If test specifies a redirect, do it
73+
if tt.redirectPath != "" {
74+
http.Redirect(w, r, tt.redirectPath, http.StatusFound)
75+
} else {
76+
w.WriteHeader(http.StatusOK)
77+
}
78+
})
79+
80+
middleware := StripBasePath(tt.basePath)
81+
wrappedHandler := middleware(handler)
82+
83+
req := httptest.NewRequest("GET", tt.requestPath, nil)
84+
rec := httptest.NewRecorder()
85+
86+
wrappedHandler.ServeHTTP(rec, req)
87+
88+
// Check redirect if expected
89+
if tt.redirectPath != "" {
90+
assert.Equal(t, http.StatusFound, rec.Code)
91+
assert.Equal(t, tt.expectedRedirect, rec.Header().Get("Location"))
92+
} else {
93+
assert.Equal(t, http.StatusOK, rec.Code)
94+
}
95+
})
96+
}
97+
}

lib/httpapi/server.go

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"log/slog"
88
"net/http"
9+
"path/filepath"
910
"sync"
1011
"time"
1112

@@ -33,6 +34,7 @@ type Server struct {
3334
agentio *termexec.Process
3435
agentType mf.AgentType
3536
emitter *EventEmitter
37+
basePath string
3638
}
3739

3840
func (s *Server) GetOpenAPI() string {
@@ -57,7 +59,7 @@ func (s *Server) GetOpenAPI() string {
5759
const snapshotInterval = 25 * time.Millisecond
5860

5961
// NewServer creates a new server instance
60-
func NewServer(ctx context.Context, agentType mf.AgentType, process *termexec.Process, port int, chatBasePath string) *Server {
62+
func NewServer(ctx context.Context, agentType mf.AgentType, process *termexec.Process, port int, chatBasePath, basePath string) *Server {
6163
router := chi.NewMux()
6264

6365
corsMiddleware := cors.New(cors.Options{
@@ -95,6 +97,7 @@ func NewServer(ctx context.Context, agentType mf.AgentType, process *termexec.Pr
9597
agentio: process,
9698
agentType: agentType,
9799
emitter: emitter,
100+
basePath: basePath,
98101
}
99102

100103
// Register API routes
@@ -118,25 +121,25 @@ func (s *Server) StartSnapshotLoop(ctx context.Context) {
118121
// registerRoutes sets up all API endpoints
119122
func (s *Server) registerRoutes(chatBasePath string) {
120123
// GET /status endpoint
121-
huma.Get(s.api, "/status", s.getStatus, func(o *huma.Operation) {
124+
huma.Get(s.api, filepath.Join(s.basePath, "/status"), s.getStatus, func(o *huma.Operation) {
122125
o.Description = "Returns the current status of the agent."
123126
})
124127

125128
// GET /messages endpoint
126-
huma.Get(s.api, "/messages", s.getMessages, func(o *huma.Operation) {
129+
huma.Get(s.api, filepath.Join(s.basePath, "/messages"), s.getMessages, func(o *huma.Operation) {
127130
o.Description = "Returns a list of messages representing the conversation history with the agent."
128131
})
129132

130133
// POST /message endpoint
131-
huma.Post(s.api, "/message", s.createMessage, func(o *huma.Operation) {
134+
huma.Post(s.api, filepath.Join(s.basePath, "/message"), s.createMessage, func(o *huma.Operation) {
132135
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."
133136
})
134137

135138
// GET /events endpoint
136139
sse.Register(s.api, huma.Operation{
137140
OperationID: "subscribeEvents",
138141
Method: http.MethodGet,
139-
Path: "/events",
142+
Path: filepath.Join(s.basePath, "/events"),
140143
Summary: "Subscribe to events",
141144
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.",
142145
}, map[string]any{
@@ -148,13 +151,14 @@ func (s *Server) registerRoutes(chatBasePath string) {
148151
sse.Register(s.api, huma.Operation{
149152
OperationID: "subscribeScreen",
150153
Method: http.MethodGet,
151-
Path: "/internal/screen",
154+
Path: filepath.Join(s.basePath, "/internal/screen"),
152155
Summary: "Subscribe to screen",
153156
Hidden: true,
154157
}, map[string]any{
155158
"screen": ScreenUpdateBody{},
156159
}, s.subscribeScreen)
157160

161+
s.router.Handle(filepath.Join(s.basePath, "/"), http.HandlerFunc(s.redirectToChat))
158162
s.router.Handle("/", http.HandlerFunc(s.redirectToChat))
159163

160164
// Serve static files for the chat interface under /chat
@@ -296,6 +300,10 @@ func (s *Server) Start() error {
296300
return s.srv.ListenAndServe()
297301
}
298302

303+
func (s *Server) Handler() http.Handler {
304+
return s.router
305+
}
306+
299307
// Stop gracefully stops the HTTP server
300308
func (s *Server) Stop(ctx context.Context) error {
301309
if s.srv != nil {
@@ -309,10 +317,10 @@ func (s *Server) registerStaticFileRoutes(chatBasePath string) {
309317
chatHandler := FileServerWithIndexFallback(chatBasePath)
310318

311319
// Mount the file server at /chat
312-
s.router.Handle("/chat", http.StripPrefix("/chat", chatHandler))
313-
s.router.Handle("/chat/*", http.StripPrefix("/chat", chatHandler))
320+
s.router.Handle(filepath.Join(s.basePath, "/chat"), http.StripPrefix("/chat", chatHandler))
321+
s.router.Handle(filepath.Join(s.basePath, "/chat/*"), http.StripPrefix("/chat", chatHandler))
314322
}
315323

316324
func (s *Server) redirectToChat(w http.ResponseWriter, r *http.Request) {
317-
http.Redirect(w, r, "/chat/embed", http.StatusTemporaryRedirect)
325+
http.Redirect(w, r, filepath.Join(s.basePath, "/chat/embed"), http.StatusTemporaryRedirect)
318326
}

0 commit comments

Comments
 (0)