diff --git a/Makefile b/Makefile index 95c69fe..745d9b9 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ BASE_PATH ?= /magic-base-path-placeholder $(CHAT_SOURCES_STAMP): $(CHAT_SOURCES) @echo "Chat sources changed. Running build steps..." - cd chat && BASE_PATH=${BASE_PATH} bun run build + cd chat && NEXT_PUBLIC_BASE_PATH="${BASE_PATH}" bun run build rm -rf lib/httpapi/chat && mkdir -p lib/httpapi/chat && touch lib/httpapi/chat/marker cp -r chat/out/. lib/httpapi/chat/ touch $@ diff --git a/chat/next.config.ts b/chat/next.config.ts index baf9e51..358d829 100644 --- a/chat/next.config.ts +++ b/chat/next.config.ts @@ -1,5 +1,8 @@ import type { NextConfig } from "next"; -const basePath = process.env.BASE_PATH ?? "/chat"; +let basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "/chat"; +if (basePath.endsWith("/")) { + basePath = basePath.slice(0, -1); +} const nextConfig: NextConfig = { // Enable static exports diff --git a/chat/src/components/chat-provider.tsx b/chat/src/components/chat-provider.tsx index 2d5e1e7..d57c3c8 100644 --- a/chat/src/components/chat-provider.tsx +++ b/chat/src/components/chat-provider.tsx @@ -51,13 +51,47 @@ interface ChatContextValue { const ChatContext = createContext(undefined); +const useAgentAPIUrl = (): string => { + const searchParams = useSearchParams(); + const paramsUrl = searchParams.get("url"); + if (paramsUrl) { + return paramsUrl; + } + const basePath = process.env.NEXT_PUBLIC_BASE_PATH; + if (!basePath) { + throw new Error( + "agentAPIUrl is not set. Please set the url query parameter to the URL of the AgentAPI or the NEXT_PUBLIC_BASE_PATH environment variable." + ); + } + // NOTE(cian): We use '../' here to construct the agent API URL relative + // to the chat's location. Let's say the app is hosted on a subpath + // `/@admin/workspace.agent/apps/ccw/`. When you visit this URL you get + // redirected to `/@admin/workspace.agent/apps/ccw/chat/embed`. This serves + // this React application, but it needs to know where the agent API is hosted. + // This will be at the root of where the application is mounted e.g. + // `/@admin/workspace.agent/apps/ccw/`. Previously we used + // `window.location.origin` but this assumes that the application owns the + // entire origin. + // See: https://github.com/coder/coder/issues/18779#issuecomment-3133290494 for more context. + let chatURL: string = new URL(basePath, window.location.origin).toString(); + // NOTE: trailing slashes and relative URLs are tricky. + // https://developer.mozilla.org/en-US/docs/Web/API/URL_API/Resolving_relative_references#current_directory_relative + if (!chatURL.endsWith("/")) { + chatURL += "/"; + } + const agentAPIURL = new URL("..", chatURL).toString(); + if (agentAPIURL.endsWith("/")) { + return agentAPIURL.slice(0, -1); + } + return agentAPIURL; +}; + export function ChatProvider({ children }: PropsWithChildren) { const [messages, setMessages] = useState<(Message | DraftMessage)[]>([]); const [loading, setLoading] = useState(false); const [serverStatus, setServerStatus] = useState("unknown"); const eventSourceRef = useRef(null); - const searchParams = useSearchParams(); - const agentAPIUrl = searchParams.get("url") || window.location.origin; + const agentAPIUrl = useAgentAPIUrl(); // Set up SSE connection to the events endpoint useEffect(() => { diff --git a/lib/httpapi/server.go b/lib/httpapi/server.go index a343baa..3d8c710 100644 --- a/lib/httpapi/server.go +++ b/lib/httpapi/server.go @@ -6,6 +6,8 @@ import ( "fmt" "log/slog" "net/http" + "net/url" + "strings" "sync" "time" @@ -33,6 +35,7 @@ type Server struct { agentio *termexec.Process agentType mf.AgentType emitter *EventEmitter + chatBasePath string } func (s *Server) GetOpenAPI() string { @@ -95,14 +98,20 @@ func NewServer(ctx context.Context, agentType mf.AgentType, process *termexec.Pr agentio: process, agentType: agentType, emitter: emitter, + chatBasePath: strings.TrimSuffix(chatBasePath, "/"), } // Register API routes - s.registerRoutes(chatBasePath) + s.registerRoutes() return s } +// Handler returns the underlying chi.Router for testing purposes. +func (s *Server) Handler() http.Handler { + return s.router +} + func (s *Server) StartSnapshotLoop(ctx context.Context) { s.conversation.StartSnapshotLoop(ctx) go func() { @@ -116,7 +125,7 @@ func (s *Server) StartSnapshotLoop(ctx context.Context) { } // registerRoutes sets up all API endpoints -func (s *Server) registerRoutes(chatBasePath string) { +func (s *Server) registerRoutes() { // GET /status endpoint huma.Get(s.api, "/status", s.getStatus, func(o *huma.Operation) { o.Description = "Returns the current status of the agent." @@ -158,7 +167,7 @@ func (s *Server) registerRoutes(chatBasePath string) { s.router.Handle("/", http.HandlerFunc(s.redirectToChat)) // Serve static files for the chat interface under /chat - s.registerStaticFileRoutes(chatBasePath) + s.registerStaticFileRoutes() } // getStatus handles GET /status @@ -305,8 +314,8 @@ func (s *Server) Stop(ctx context.Context) error { } // registerStaticFileRoutes sets up routes for serving static files -func (s *Server) registerStaticFileRoutes(chatBasePath string) { - chatHandler := FileServerWithIndexFallback(chatBasePath) +func (s *Server) registerStaticFileRoutes() { + chatHandler := FileServerWithIndexFallback(s.chatBasePath) // Mount the file server at /chat s.router.Handle("/chat", http.StripPrefix("/chat", chatHandler)) @@ -314,5 +323,11 @@ func (s *Server) registerStaticFileRoutes(chatBasePath string) { } func (s *Server) redirectToChat(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/chat/embed", http.StatusTemporaryRedirect) + rdir, err := url.JoinPath(s.chatBasePath, "embed") + if err != nil { + s.logger.Error("Failed to construct redirect URL", "error", err) + http.Error(w, "Failed to redirect", http.StatusInternalServerError) + return + } + http.Redirect(w, r, rdir, http.StatusTemporaryRedirect) } diff --git a/lib/httpapi/server_test.go b/lib/httpapi/server_test.go index 456235a..5abc5f5 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" @@ -73,3 +75,38 @@ func TestOpenAPISchema(t *testing.T) { require.Equal(t, currentSchema, diskSchema) } + +func TestServer_redirectToChat(t *testing.T) { + cases := []struct { + name string + chatBasePath string + expectedResponseCode int + expectedLocation string + }{ + {"default base path", "/chat", http.StatusTemporaryRedirect, "/chat/embed"}, + {"custom base path", "/custom", http.StatusTemporaryRedirect, "/custom/embed"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + tCtx := logctx.WithLogger(context.Background(), slog.New(slog.NewTextHandler(os.Stdout, nil))) + s := httpapi.NewServer(tCtx, msgfmt.AgentTypeClaude, nil, 0, tc.chatBasePath) + tsServer := httptest.NewServer(s.Handler()) + t.Cleanup(tsServer.Close) + + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := client.Get(tsServer.URL + "/") + require.NoError(t, err, "unexpected error making GET request") + t.Cleanup(func() { + _ = resp.Body.Close() + }) + require.Equal(t, tc.expectedResponseCode, resp.StatusCode, "expected %d status code", tc.expectedResponseCode) + loc := resp.Header.Get("Location") + require.Equal(t, tc.expectedLocation, loc, "expected Location %q, got %q", tc.expectedLocation, loc) + }) + } +}