From 9a2d4114acd8183d462c0e6c80c3eafcfaa7d6c5 Mon Sep 17 00:00:00 2001 From: Maaz Ghani Date: Mon, 18 May 2026 14:54:56 -0700 Subject: [PATCH 1/2] Implement namespace filtering for /api/agents Signed-off-by: Maaz Ghani --- go/api/client/agent.go | 23 ++++- .../internal/httpserver/handlers/agents.go | 73 ++++++++++++---- .../httpserver/handlers/agents_test.go | 86 +++++++++++++++++++ ui/src/app/actions/agents.ts | 10 ++- 4 files changed, 167 insertions(+), 25 deletions(-) diff --git a/go/api/client/agent.go b/go/api/client/agent.go index 1a08c445a..d6bd0dd1a 100644 --- a/go/api/client/agent.go +++ b/go/api/client/agent.go @@ -3,6 +3,7 @@ package client import ( "context" "fmt" + "net/url" api "github.com/kagent-dev/kagent/go/api/httpapi" "github.com/kagent-dev/kagent/go/api/v1alpha2" @@ -10,13 +11,18 @@ import ( // Agent defines the agent operations type Agent interface { - ListAgents(ctx context.Context) (*api.StandardResponse[[]api.AgentResponse], error) + ListAgents(ctx context.Context, opts ...ListAgentsOptions) (*api.StandardResponse[[]api.AgentResponse], error) CreateAgent(ctx context.Context, request *v1alpha2.Agent) (*api.StandardResponse[*v1alpha2.Agent], error) GetAgent(ctx context.Context, agentRef string) (*api.StandardResponse[*api.AgentResponse], error) UpdateAgent(ctx context.Context, request *v1alpha2.Agent) (*api.StandardResponse[*v1alpha2.Agent], error) DeleteAgent(ctx context.Context, agentRef string) error } +// ListAgentsOptions configures ListAgents requests. +type ListAgentsOptions struct { + Namespace string +} + // agentClient handles agent-related requests type agentClient struct { client *BaseClient @@ -27,14 +33,23 @@ func NewAgentClient(client *BaseClient) Agent { return &agentClient{client: client} } -// ListAgents lists all agents for a user -func (c *agentClient) ListAgents(ctx context.Context) (*api.StandardResponse[[]api.AgentResponse], error) { +// ListAgents lists all agents for a user. When Namespace is set, only agents in that namespace are returned. +func (c *agentClient) ListAgents(ctx context.Context, opts ...ListAgentsOptions) (*api.StandardResponse[[]api.AgentResponse], error) { + if len(opts) > 1 { + return nil, fmt.Errorf("ListAgents accepts at most one options argument") + } + userID := c.client.GetUserIDOrDefault("") if userID == "" { return nil, fmt.Errorf("userID is required") } - resp, err := c.client.Get(ctx, "/api/agents", userID) + path := "/api/agents" + if len(opts) > 0 && opts[0].Namespace != "" { + path += "?namespace=" + url.QueryEscape(opts[0].Namespace) + } + + resp, err := c.client.Get(ctx, path, userID) if err != nil { return nil, err } diff --git a/go/core/internal/httpserver/handlers/agents.go b/go/core/internal/httpserver/handlers/agents.go index c324aa5ed..3233204ba 100644 --- a/go/core/internal/httpserver/handlers/agents.go +++ b/go/core/internal/httpserver/handlers/agents.go @@ -18,6 +18,7 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + utilvalidation "k8s.io/apimachinery/pkg/util/validation" "sigs.k8s.io/controller-runtime/pkg/client" ctrllog "sigs.k8s.io/controller-runtime/pkg/log" ) @@ -32,35 +33,46 @@ func NewAgentsHandler(base *Base) *AgentsHandler { return &AgentsHandler{Base: base} } -// HandleListAgents handles GET /api/agents requests using database +// HandleListAgents handles GET /api/agents requests using database. +// Optional query param: namespace=. func (h *AgentsHandler) HandleListAgents(w ErrorResponseWriter, r *http.Request) { log := ctrllog.FromContext(r.Context()).WithName("agents-handler").WithValues("operation", "list-db") - if err := Check(h.Authorizer, r, auth.Resource{Type: "Agent"}); err != nil { - w.RespondWithError(err) + namespace := r.URL.Query().Get("namespace") + if namespace == "" { + h.handleListAgents(w, r, log) return } - agentList := &v1alpha2.AgentList{} - if err := h.KubeClient.List(r.Context(), agentList); err != nil { - w.RespondWithError(errors.NewInternalServerError("Failed to list Agents from Kubernetes", err)) + if strings.TrimSpace(namespace) != namespace { + w.RespondWithError(errors.NewBadRequestError( + fmt.Sprintf("invalid namespace %q: must not contain leading or trailing whitespace", namespace), + nil, + )) return } - agentsWithID := make([]api.AgentResponse, 0) - h.appendAgentResponses(r.Context(), log, agentObjects(agentList.Items), &agentsWithID) + if errs := utilvalidation.IsDNS1123Label(namespace); len(errs) > 0 { + w.RespondWithError(errors.NewBadRequestError( + fmt.Sprintf("invalid namespace %q: %s", namespace, strings.Join(errs, "; ")), + nil, + )) + return + } - harnessList := &v1alpha2.AgentHarnessList{} - if err := h.KubeClient.List(r.Context(), harnessList); err != nil { - w.RespondWithError(errors.NewInternalServerError("Failed to list AgentHarness resources from Kubernetes", err)) + h.handleListAgents(w, r, log.WithValues("namespace", namespace), client.InNamespace(namespace)) +} + +func (h *AgentsHandler) handleListAgents(w ErrorResponseWriter, r *http.Request, log logr.Logger, opts ...client.ListOption) { + if err := Check(h.Authorizer, r, auth.Resource{Type: "Agent"}); err != nil { + w.RespondWithError(err) return } - for i := range harnessList.Items { - sb := &harnessList.Items[i] - if sb.Spec.Backend != v1alpha2.AgentHarnessBackendOpenClaw && sb.Spec.Backend != v1alpha2.AgentHarnessBackendNemoClaw { - continue - } - agentsWithID = append(agentsWithID, h.openshellAgentHarnessAgentResponse(r.Context(), log, sb)) + + agentsWithID, err := h.listAgentResponses(r.Context(), log, opts...) + if err != nil { + w.RespondWithError(err) + return } log.Info("Successfully listed agents", "count", len(agentsWithID)) @@ -91,6 +103,33 @@ func (h *AgentsHandler) HandleListSandboxAgents(w ErrorResponseWriter, r *http.R RespondWithJSON(w, http.StatusOK, data) } +// listAgentResponses fetches Agent and AgentHarness resources, applies the +// provided list options (e.g. client.InNamespace), and returns the merged +// slice of AgentResponse values. +func (h *AgentsHandler) listAgentResponses(ctx context.Context, log logr.Logger, opts ...client.ListOption) ([]api.AgentResponse, error) { + agentList := &v1alpha2.AgentList{} + if err := h.KubeClient.List(ctx, agentList, opts...); err != nil { + return nil, errors.NewInternalServerError("Failed to list Agents from Kubernetes", err) + } + + harnessList := &v1alpha2.AgentHarnessList{} + if err := h.KubeClient.List(ctx, harnessList, opts...); err != nil { + return nil, errors.NewInternalServerError("Failed to list AgentHarness resources from Kubernetes", err) + } + + result := make([]api.AgentResponse, 0, len(agentList.Items)+len(harnessList.Items)) + h.appendAgentResponses(ctx, log, agentObjects(agentList.Items), &result) + for i := range harnessList.Items { + sb := &harnessList.Items[i] + if sb.Spec.Backend != v1alpha2.AgentHarnessBackendOpenClaw && sb.Spec.Backend != v1alpha2.AgentHarnessBackendNemoClaw { + continue + } + result = append(result, h.openshellAgentHarnessAgentResponse(ctx, log, sb)) + } + + return result, nil +} + func (h *AgentsHandler) appendAgentResponses( ctx context.Context, log logr.Logger, diff --git a/go/core/internal/httpserver/handlers/agents_test.go b/go/core/internal/httpserver/handlers/agents_test.go index 0c1a15b02..23a08aae9 100644 --- a/go/core/internal/httpserver/handlers/agents_test.go +++ b/go/core/internal/httpserver/handlers/agents_test.go @@ -500,6 +500,92 @@ func TestHandleListAgents(t *testing.T) { } require.True(t, found) }) + + t.Run("filters Agent and AgentHarness rows by namespace query parameter", func(t *testing.T) { + modelConfig := createTestModelConfig() + agentDefault := createTestAgent("agent-in-default", modelConfig) + agentOther := &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{Name: "agent-in-other", Namespace: "other"}, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + ModelConfig: modelConfig.Name, + }, + }, + } + harnessDefault := &v1alpha2.AgentHarness{ + ObjectMeta: metav1.ObjectMeta{Name: "harness-default", Namespace: "default"}, + Spec: v1alpha2.AgentHarnessSpec{ + Backend: v1alpha2.AgentHarnessBackendOpenClaw, + ModelConfigRef: "test-model-config", + }, + } + harnessOther := &v1alpha2.AgentHarness{ + ObjectMeta: metav1.ObjectMeta{Name: "harness-other", Namespace: "other"}, + Spec: v1alpha2.AgentHarnessSpec{ + Backend: v1alpha2.AgentHarnessBackendOpenClaw, + ModelConfigRef: "test-model-config", + }, + } + unsupportedHarnessDefault := &v1alpha2.AgentHarness{ + ObjectMeta: metav1.ObjectMeta{Name: "unsupported-harness", Namespace: "default"}, + Spec: v1alpha2.AgentHarnessSpec{ + Backend: v1alpha2.AgentHarnessBackendType("unsupported"), + ModelConfigRef: "test-model-config", + }, + } + handler, _ := setupTestHandler(t, agentDefault, agentOther, harnessDefault, harnessOther, unsupportedHarnessDefault, modelConfig) + + req := httptest.NewRequest("GET", "/api/agents?namespace=default", nil) + req = setUser(req, "test-user") + w := httptest.NewRecorder() + + handler.HandleListAgents(&testErrorResponseWriter{w}, req) + + require.Equal(t, http.StatusOK, w.Code) + var response api.StandardResponse[[]api.AgentResponse] + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Len(t, response.Data, 2) + + byName := make(map[string]api.AgentResponse, len(response.Data)) + for _, row := range response.Data { + byName[row.Agent.Metadata.Name] = row + require.Equal(t, "default", row.Agent.Metadata.Namespace) + } + require.Contains(t, byName, "agent-in-default") + require.Contains(t, byName, "harness-default") + require.NotContains(t, byName, "agent-in-other") + require.NotContains(t, byName, "harness-other") + require.NotContains(t, byName, "unsupported-harness") + }) + + // Kubernetes namespace names must be DNS-1123 labels. Rejecting invalid input + // before calling the Kubernetes client keeps the list path consistent with + // other resource handlers and avoids surprising cross-namespace behavior. + t.Run("returns 400 for invalid namespace query value", func(t *testing.T) { + handler, _ := setupTestHandler(t) + + req := httptest.NewRequest("GET", "/api/agents?namespace=INVALID_NS!", nil) + req = setUser(req, "test-user") + w := httptest.NewRecorder() + + handler.HandleListAgents(&testErrorResponseWriter{w}, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("returns 400 for namespace query value with leading or trailing whitespace", func(t *testing.T) { + handler, _ := setupTestHandler(t) + + req := httptest.NewRequest("GET", "/api/agents?namespace=%20default", nil) + req = setUser(req, "test-user") + w := httptest.NewRecorder() + + handler.HandleListAgents(&testErrorResponseWriter{w}, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + require.Contains(t, w.Body.String(), "must not contain leading or trailing whitespace") + }) } func TestHandleListSandboxAgents(t *testing.T) { diff --git a/ui/src/app/actions/agents.ts b/ui/src/app/actions/agents.ts index f3b453f5a..bdf6209d9 100644 --- a/ui/src/app/actions/agents.ts +++ b/ui/src/app/actions/agents.ts @@ -562,12 +562,14 @@ export async function createAgent(agentConfig: AgentFormData, update: boolean = } /** - * Gets all agents - * @returns A promise with all agents + * Gets all agents, optionally filtered by namespace. + * @param opts.namespace When set, calls `/agents?namespace=`; otherwise calls `/agents`. + * @returns A promise with the matching agents */ -export async function getAgents(): Promise> { +export async function getAgents(opts: { namespace?: string } = {}): Promise> { try { - const { data } = await fetchApi>(`/agents`); + const path = opts.namespace ? `/agents?namespace=${encodeURIComponent(opts.namespace)}` : `/agents`; + const { data } = await fetchApi>(path); const sortedData = data?.sort((a, b) => { const aRef = k8sRefUtils.toRef(a.agent.metadata.namespace || "", a.agent.metadata.name); From c1e2fa40251dc2b7dae00f7fab9977c3342cf34f Mon Sep 17 00:00:00 2001 From: Maaz Ghani Date: Mon, 18 May 2026 14:54:56 -0700 Subject: [PATCH 2/2] Update devcontainer Go version Signed-off-by: Maaz Ghani --- .devcontainer/devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 2b83e12f6..b333cf84b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -3,7 +3,7 @@ "build": { "dockerfile": "Dockerfile", "args": { - "TOOLS_GO_VERSION": "1.26.1", + "TOOLS_GO_VERSION": "1.26.2", "TOOLS_NODE_VERSION": "24.13.0", "TOOLS_UV_VERSION": "0.10.4", "TOOLS_K9S_VERSION": "0.50.4",