diff --git a/go/adk/cmd/main.go b/go/adk/cmd/main.go index ad0d28d804..5f4d7a7d29 100644 --- a/go/adk/cmd/main.go +++ b/go/adk/cmd/main.go @@ -173,7 +173,7 @@ func main() { logger.Info("Memory service enabled", "appName", appName) } - runnerConfig, subagentSessionIDs, err := runnerpkg.CreateRunnerConfig(ctx, agentConfig, sessionService, appName, memoryService) + runnerConfig, subagentSessionIDs, err := runnerpkg.CreateRunnerConfig(ctx, agentConfig, sessionService, appName, memoryService, kagentURL, httpClient) if err != nil { logger.Error(err, "Failed to create Google ADK Runner config") os.Exit(1) diff --git a/go/adk/pkg/runner/adapter.go b/go/adk/pkg/runner/adapter.go index 0441f778c0..b769d3fe04 100644 --- a/go/adk/pkg/runner/adapter.go +++ b/go/adk/pkg/runner/adapter.go @@ -3,6 +3,7 @@ package runner import ( "context" "fmt" + "net/http" "os" "strings" @@ -11,6 +12,7 @@ import ( kagentmemory "github.com/kagent-dev/kagent/go/adk/pkg/memory" "github.com/kagent-dev/kagent/go/adk/pkg/session" "github.com/kagent-dev/kagent/go/adk/pkg/sts" + "github.com/kagent-dev/kagent/go/adk/pkg/tools" "github.com/kagent-dev/kagent/go/api/adk" adkmemory "google.golang.org/adk/memory" adkplugin "google.golang.org/adk/plugin" @@ -34,6 +36,8 @@ func CreateRunnerConfig( sessionService *session.KAgentSessionService, appName string, memoryService *kagentmemory.KagentMemoryService, + kagentURL string, + httpClient *http.Client, ) (runner.Config, map[string]string, error) { log := logr.FromContextOrDiscard(ctx) @@ -46,6 +50,23 @@ func CreateRunnerConfig( extraTools = append(extraTools, saveTool) } + if agentConfig.ShareTools != nil && *agentConfig.ShareTools && kagentURL != "" && httpClient != nil { + createTool, err := tools.NewCreateShareLinkTool(httpClient, kagentURL, appName) + if err != nil { + return runner.Config{}, nil, fmt.Errorf("failed to create create_share_link tool: %w", err) + } + listTool, err := tools.NewListShareLinksTool(httpClient, kagentURL, appName) + if err != nil { + return runner.Config{}, nil, fmt.Errorf("failed to create list_share_links tool: %w", err) + } + deleteTool, err := tools.NewDeleteShareLinkTool(httpClient, kagentURL, appName) + if err != nil { + return runner.Config{}, nil, fmt.Errorf("failed to create delete_share_link tool: %w", err) + } + extraTools = append(extraTools, createTool, listTool, deleteTool) + log.Info("Share link tools enabled") + } + stsPlugin, err := buildTokenPropagationPlugin(ctx, log) if err != nil { return runner.Config{}, nil, err diff --git a/go/adk/pkg/tools/share_tools.go b/go/adk/pkg/tools/share_tools.go new file mode 100644 index 0000000000..c4bfd107e8 --- /dev/null +++ b/go/adk/pkg/tools/share_tools.go @@ -0,0 +1,205 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + + "google.golang.org/adk/tool" + "google.golang.org/adk/tool/functiontool" +) + +// shareClient holds the dependencies for share link tools, captured at construction time. +type shareClient struct { + baseURL string + uiURL string // KAGENT_UI_URL, used to build full share URLs + appName string + httpClient *http.Client +} + +// parseAppName converts a Python-identifier app_name back to (namespace, name). +// Format: "namespace__NS__agent_name" with hyphens encoded as underscores. +func parseAppName(appName string) (namespace, name string) { + parts := strings.SplitN(appName, "__NS__", 2) + if len(parts) != 2 { + return "", strings.ReplaceAll(appName, "_", "-") + } + return strings.ReplaceAll(parts[0], "_", "-"), strings.ReplaceAll(parts[1], "_", "-") +} + +// shareURL returns the share URL for a session token. +// With uiURL set it returns a full absolute URL; otherwise a relative path. +func (c *shareClient) shareURL(token, sessionID string) string { + ns, name := parseAppName(c.appName) + path := fmt.Sprintf("/agents/%s/%s/chat/%s?share=%s", ns, name, sessionID, token) + if c.uiURL != "" { + return c.uiURL + path + } + return path +} + +func (c *shareClient) do(ctx context.Context, method, path string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, nil) + if err != nil { + return nil, fmt.Errorf("building request %s %s: %w", method, c.baseURL+path, err) + } + req.Header.Set("X-Agent-Name", c.appName) + return c.httpClient.Do(req) +} + +func (c *shareClient) doWithJSON(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) + if err != nil { + return nil, fmt.Errorf("building request %s %s: %w", method, c.baseURL+path, err) + } + req.Header.Set("X-Agent-Name", c.appName) + req.Header.Set("Content-Type", "application/json") + return c.httpClient.Do(req) +} + +func (c *shareClient) readBody(resp *http.Response) (map[string]any, error) { + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + var out map[string]any + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + return out, nil +} + +type createShareInput struct { + // ReadOnly controls whether the shared link allows visitors to send messages. + // When nil (not provided by the model), the server defaults to true (read-only). + ReadOnly *bool `json:"read_only,omitempty"` +} + +// NewCreateShareLinkTool creates a tool that generates a share token for the current session. +func NewCreateShareLinkTool(httpClient *http.Client, baseURL, appName string) (tool.Tool, error) { + c := &shareClient{ + baseURL: baseURL, + uiURL: strings.TrimRight(os.Getenv("KAGENT_UI_URL"), "/"), + appName: appName, + httpClient: httpClient, + } + return functiontool.New(functiontool.Config{ + Name: "create_share_link", + Description: "Creates a share link for the current chat session. " + + "Returns a URL any authenticated user can open to view this session. " + + "The link is read-only by default (visitors cannot send messages). " + + "Set read_only=false to allow visitors to interact. " + + "Each call creates a new token; existing tokens remain valid.", + }, func(ctx tool.Context, in createShareInput) (map[string]any, error) { + sessionID := ctx.SessionID() + if sessionID == "" { + return nil, fmt.Errorf("create_share_link: no session ID in context") + } + reqBody, err := json.Marshal(in) + if err != nil { + return nil, fmt.Errorf("create_share_link: encoding request: %w", err) + } + resp, err := c.doWithJSON(ctx, http.MethodPost, "/api/sessions/"+url.PathEscape(sessionID)+"/shares", strings.NewReader(string(reqBody))) + if err != nil { + return nil, fmt.Errorf("create_share_link: request failed: %w", err) + } + if resp.StatusCode != http.StatusCreated { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + return nil, fmt.Errorf("create_share_link: unexpected status %d", resp.StatusCode) + } + body, err := c.readBody(resp) + if err != nil { + return nil, fmt.Errorf("create_share_link: %w", err) + } + data, _ := body["data"].(map[string]any) + token, _ := data["token"].(string) + readOnly, _ := data["read_only"].(bool) + return map[string]any{ + "url": c.shareURL(token, sessionID), + "read_only": readOnly, + }, nil + }) +} + +// NewListShareLinksTool creates a tool that lists active share tokens for the current session. +func NewListShareLinksTool(httpClient *http.Client, baseURL, appName string) (tool.Tool, error) { + c := &shareClient{ + baseURL: baseURL, + uiURL: strings.TrimRight(os.Getenv("KAGENT_UI_URL"), "/"), + appName: appName, + httpClient: httpClient, + } + return functiontool.New(functiontool.Config{ + Name: "list_share_links", + Description: "Lists all active share links for the current session. " + + "Returns each share token and creation time. " + + "Use this to find a token before calling delete_share_link.", + }, func(ctx tool.Context, _ struct{}) (map[string]any, error) { + sessionID := ctx.SessionID() + if sessionID == "" { + return nil, fmt.Errorf("list_share_links: no session ID in context") + } + resp, err := c.do(ctx, http.MethodGet, "/api/sessions/"+url.PathEscape(sessionID)+"/shares") + if err != nil { + return nil, fmt.Errorf("list_share_links: request failed: %w", err) + } + if resp.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + return nil, fmt.Errorf("list_share_links: unexpected status %d", resp.StatusCode) + } + body, err := c.readBody(resp) + if err != nil { + return nil, fmt.Errorf("list_share_links: %w", err) + } + shares := body["data"] + if shares == nil { + shares = []any{} + } + return map[string]any{"shares": shares}, nil + }) +} + +type deleteShareInput struct { + Token string `json:"token"` +} + +// NewDeleteShareLinkTool creates a tool that revokes a specific share token for the current session. +func NewDeleteShareLinkTool(httpClient *http.Client, baseURL, appName string) (tool.Tool, error) { + c := &shareClient{ + baseURL: baseURL, + uiURL: strings.TrimRight(os.Getenv("KAGENT_UI_URL"), "/"), + appName: appName, + httpClient: httpClient, + } + return functiontool.New(functiontool.Config{ + Name: "delete_share_link", + Description: "Deletes a share link by token, immediately revoking access for anyone using it. " + + "Use list_share_links first to find the token you want to revoke.", + }, func(ctx tool.Context, in deleteShareInput) (map[string]any, error) { + if in.Token == "" { + return nil, fmt.Errorf("delete_share_link: token is required") + } + sessionID := ctx.SessionID() + if sessionID == "" { + return nil, fmt.Errorf("delete_share_link: no session ID in context") + } + path := "/api/sessions/" + url.PathEscape(sessionID) + "/shares/" + url.PathEscape(in.Token) + resp, err := c.do(ctx, http.MethodDelete, path) + if err != nil { + return nil, fmt.Errorf("delete_share_link: request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("delete_share_link: unexpected status %d", resp.StatusCode) + } + return map[string]any{"status": "revoked", "token": in.Token}, nil + }) +} diff --git a/go/adk/pkg/tools/share_tools_test.go b/go/adk/pkg/tools/share_tools_test.go new file mode 100644 index 0000000000..0dd4de0216 --- /dev/null +++ b/go/adk/pkg/tools/share_tools_test.go @@ -0,0 +1,156 @@ +package tools + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestParseAppName(t *testing.T) { + tests := []struct { + name string + input string + wantNamespace string + wantName string + }{ + { + name: "standard format with underscores", + input: "kagent__NS__my_agent", + wantNamespace: "kagent", + wantName: "my-agent", + }, + { + name: "custom namespace and agent name", + input: "default__NS__test_agent", + wantNamespace: "default", + wantName: "test-agent", + }, + { + name: "no separator returns empty namespace", + input: "noseperator", + wantNamespace: "", + wantName: "noseperator", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotNamespace, gotName := parseAppName(tt.input) + if gotNamespace != tt.wantNamespace { + t.Errorf("parseAppName(%q) namespace = %q, want %q", tt.input, gotNamespace, tt.wantNamespace) + } + if gotName != tt.wantName { + t.Errorf("parseAppName(%q) name = %q, want %q", tt.input, gotName, tt.wantName) + } + }) + } +} + +func TestShareClient_ShareURL_WithUIURL(t *testing.T) { + c := &shareClient{ + baseURL: "http://localhost", + uiURL: "https://example.com", + appName: "kagent__NS__myagent", + } + + got := c.shareURL("abc123", "sess-1") + want := "https://example.com/agents/kagent/myagent/chat/sess-1?share=abc123" + if got != want { + t.Errorf("shareURL() = %q, want %q", got, want) + } +} + +func TestShareClient_ShareURL_WithoutUIURL(t *testing.T) { + c := &shareClient{ + baseURL: "http://localhost", + uiURL: "", + appName: "kagent__NS__myagent", + } + + got := c.shareURL("abc123", "sess-1") + want := "/agents/kagent/myagent/chat/sess-1?share=abc123" + if got != want { + t.Errorf("shareURL() = %q, want %q", got, want) + } +} + +func TestNewShareTools_HaveCorrectNames(t *testing.T) { + tests := []struct { + toolName string + constructor func(*http.Client, string, string) (interface{ Name() string }, error) + }{ + { + toolName: "create_share_link", + constructor: func(c *http.Client, base, app string) (interface{ Name() string }, error) { + return NewCreateShareLinkTool(c, base, app) + }, + }, + { + toolName: "list_share_links", + constructor: func(c *http.Client, base, app string) (interface{ Name() string }, error) { + return NewListShareLinksTool(c, base, app) + }, + }, + { + toolName: "delete_share_link", + constructor: func(c *http.Client, base, app string) (interface{ Name() string }, error) { + return NewDeleteShareLinkTool(c, base, app) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.toolName, func(t *testing.T) { + tool, err := tt.constructor(http.DefaultClient, "http://localhost", "test__NS__app") + if err != nil { + t.Fatalf("constructor for %q returned error: %v", tt.toolName, err) + } + if tool.Name() != tt.toolName { + t.Errorf("tool.Name() = %q, want %q", tool.Name(), tt.toolName) + } + }) + } +} + +func TestShareClient_DoWithJSON_SendsCorrectHeaders(t *testing.T) { + var capturedReq *http.Request + var capturedBody []byte + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedReq = r + var err error + capturedBody, err = io.ReadAll(r.Body) + if err != nil { + t.Errorf("reading request body: %v", err) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + c := &shareClient{ + baseURL: server.URL, + appName: "test-app", + httpClient: server.Client(), + } + + resp, err := c.doWithJSON(context.Background(), "POST", "/test", strings.NewReader(`{}`)) + if err != nil { + t.Fatalf("doWithJSON() error = %v", err) + } + defer resp.Body.Close() + + if got := capturedReq.Header.Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type header = %q, want %q", got, "application/json") + } + + if got := capturedReq.Header.Get("X-Agent-Name"); got != "test-app" { + t.Errorf("X-Agent-Name header = %q, want %q", got, "test-app") + } + + if string(capturedBody) != `{}` { + t.Errorf("request body = %q, want %q", string(capturedBody), `{}`) + } +} diff --git a/go/api/adk/types.go b/go/api/adk/types.go index 602a457980..d0662c2082 100644 --- a/go/api/adk/types.go +++ b/go/api/adk/types.go @@ -497,6 +497,7 @@ type AgentConfig struct { Memory *MemoryConfig `json:"memory,omitempty"` Network *NetworkConfig `json:"network,omitempty"` ContextConfig *AgentContextConfig `json:"context_config,omitempty"` + ShareTools *bool `json:"share_tools,omitempty"` } // GetStream returns the stream value or default if not set @@ -528,6 +529,7 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error { Memory json.RawMessage `json:"memory"` Network *NetworkConfig `json:"network,omitempty"` ContextConfig *AgentContextConfig `json:"context_config,omitempty"` + ShareTools *bool `json:"share_tools,omitempty"` } if err := json.Unmarshal(data, &tmp); err != nil { return err @@ -557,6 +559,7 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error { a.Memory = memory a.Network = tmp.Network a.ContextConfig = tmp.ContextConfig + a.ShareTools = tmp.ShareTools return nil } diff --git a/go/api/config/crd/bases/kagent.dev_agents.yaml b/go/api/config/crd/bases/kagent.dev_agents.yaml index f54a5879d1..9b82b9d7b2 100644 --- a/go/api/config/crd/bases/kagent.dev_agents.yaml +++ b/go/api/config/crd/bases/kagent.dev_agents.yaml @@ -13055,6 +13055,12 @@ spec: - python - go type: string + shareTools: + description: |- + ShareTools enables the built-in share link tools for this agent. + When true, the agent gains create_share_link, list_share_links, and delete_share_link tools + that allow it to manage share tokens for the current session. + type: boolean stream: description: |- Whether to stream the response from the model. diff --git a/go/api/config/crd/bases/kagent.dev_sandboxagents.yaml b/go/api/config/crd/bases/kagent.dev_sandboxagents.yaml index b8bc8dce7a..6442847603 100644 --- a/go/api/config/crd/bases/kagent.dev_sandboxagents.yaml +++ b/go/api/config/crd/bases/kagent.dev_sandboxagents.yaml @@ -10713,6 +10713,12 @@ spec: - python - go type: string + shareTools: + description: |- + ShareTools enables the built-in share link tools for this agent. + When true, the agent gains create_share_link, list_share_links, and delete_share_link tools + that allow it to manage share tokens for the current session. + type: boolean stream: description: |- Whether to stream the response from the model. diff --git a/go/api/database/client.go b/go/api/database/client.go index 43943da678..fd214d12b9 100644 --- a/go/api/database/client.go +++ b/go/api/database/client.go @@ -50,7 +50,7 @@ type Client interface { ListFeedback(ctx context.Context, userID string) ([]Feedback, error) ListTasksForSession(ctx context.Context, sessionID string) ([]*protocol.Task, error) ListSessions(ctx context.Context, userID string) ([]Session, error) - ListSessionsForAgent(ctx context.Context, agentID string, userID string) ([]Session, error) + ListSessionsForAgent(ctx context.Context, agentID string, userID string) ([]SessionWithShareToken, error) ListSessionsForAgentAllUsers(ctx context.Context, agentID string) ([]Session, error) ListAgents(ctx context.Context) ([]Agent, error) ListToolServers(ctx context.Context) ([]ToolServer, error) @@ -74,6 +74,13 @@ type Client interface { StoreCrewAIFlowState(ctx context.Context, state *CrewAIFlowState) error GetCrewAIFlowState(ctx context.Context, userID, threadID string) (*CrewAIFlowState, error) + // Session share methods + CreateSessionShare(ctx context.Context, share *SessionShare) (*SessionShare, error) + GetSessionShareByToken(ctx context.Context, token string) (*SessionShare, error) + ListSessionSharesBySession(ctx context.Context, sessionID string) ([]SessionShare, error) + DeleteSessionShare(ctx context.Context, token, sessionID, userID string) error + RecordShareAccess(ctx context.Context, userID string, shareID int64) error + // Agent memory (vector search) methods StoreAgentMemory(ctx context.Context, memory *Memory) error StoreAgentMemories(ctx context.Context, memories []*Memory) error diff --git a/go/api/database/models.go b/go/api/database/models.go index 7bb7be9daa..67311f5958 100644 --- a/go/api/database/models.go +++ b/go/api/database/models.go @@ -76,6 +76,15 @@ type Session struct { Source *SessionSource `json:"source,omitempty"` } +// SessionWithShareToken extends Session with optional share fields. +// ShareToken and ShareReadOnly are nil for sessions owned by the requesting user; +// non-nil for sessions shared by another user that the caller accesses via X-Share-Token. +type SessionWithShareToken struct { + Session + ShareToken *string `json:"share_token,omitempty"` + ShareReadOnly *bool `json:"share_read_only,omitempty"` +} + type Task struct { ID string `json:"id"` CreatedAt time.Time `json:"created_at"` @@ -222,3 +231,12 @@ type AgentMemorySearchResult struct { Memory Score float64 `json:"score"` } + +type SessionShare struct { + ID int64 `json:"id"` + Token string `json:"token"` + SessionID string `json:"session_id"` + UserID string `json:"user_id"` + ReadOnly bool `json:"read_only"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/go/api/v1alpha2/agent_types.go b/go/api/v1alpha2/agent_types.go index 661643f54a..52cacb2a77 100644 --- a/go/api/v1alpha2/agent_types.go +++ b/go/api/v1alpha2/agent_types.go @@ -217,6 +217,12 @@ type DeclarativeAgentSpec struct { // +optional Memory *MemorySpec `json:"memory,omitempty"` + // ShareTools enables the built-in share link tools for this agent. + // When true, the agent gains create_share_link, list_share_links, and delete_share_link tools + // that allow it to manage share tokens for the current session. + // +optional + ShareTools *bool `json:"shareTools,omitempty"` + // Context configures context management for this agent. // This includes event compaction (compression) and context caching. // +optional diff --git a/go/api/v1alpha2/zz_generated.deepcopy.go b/go/api/v1alpha2/zz_generated.deepcopy.go index 52d10ed714..283420e066 100644 --- a/go/api/v1alpha2/zz_generated.deepcopy.go +++ b/go/api/v1alpha2/zz_generated.deepcopy.go @@ -819,6 +819,11 @@ func (in *DeclarativeAgentSpec) DeepCopyInto(out *DeclarativeAgentSpec) { *out = new(MemorySpec) **out = **in } + if in.ShareTools != nil { + in, out := &in.ShareTools, &out.ShareTools + *out = new(bool) + **out = **in + } if in.Context != nil { in, out := &in.Context, &out.Context *out = new(ContextConfig) diff --git a/go/core/internal/a2a/manager.go b/go/core/internal/a2a/manager.go index 97141e4a0e..f935846f63 100644 --- a/go/core/internal/a2a/manager.go +++ b/go/core/internal/a2a/manager.go @@ -3,6 +3,7 @@ package a2a import ( "context" + pkgauth "github.com/kagent-dev/kagent/go/core/pkg/auth" "trpc.group/trpc-go/trpc-a2a-go/client" "trpc.group/trpc-go/trpc-a2a-go/protocol" "trpc.group/trpc-go/trpc-a2a-go/taskmanager" @@ -18,6 +19,24 @@ func NewPassthroughManager(client *client.A2AClient) taskmanager.TaskManager { } } +func injectInitiatedBy(ctx context.Context, msg *protocol.Message) { + if _, ok := pkgauth.ShareContextFrom(ctx); !ok { + return + } + session, ok := pkgauth.AuthSessionFrom(ctx) + if !ok { + return + } + userID := session.Principal().User.ID + if userID == "" { + return + } + if msg.Metadata == nil { + msg.Metadata = make(map[string]any) + } + msg.Metadata["initiated_by"] = userID +} + func (m *PassthroughManager) OnSendMessage(ctx context.Context, request protocol.SendMessageParams) (*protocol.MessageResult, error) { if request.Message.MessageID == "" { request.Message.MessageID = protocol.GenerateMessageID() @@ -25,6 +44,7 @@ func (m *PassthroughManager) OnSendMessage(ctx context.Context, request protocol if request.Message.Kind == "" { request.Message.Kind = protocol.KindMessage } + injectInitiatedBy(ctx, &request.Message) return m.client.SendMessage(ctx, request) } @@ -35,6 +55,7 @@ func (m *PassthroughManager) OnSendMessageStream(ctx context.Context, request pr if request.Message.Kind == "" { request.Message.Kind = protocol.KindMessage } + injectInitiatedBy(ctx, &request.Message) return m.client.StreamMessage(ctx, request) } diff --git a/go/core/internal/a2a/manager_test.go b/go/core/internal/a2a/manager_test.go new file mode 100644 index 0000000000..95f320314e --- /dev/null +++ b/go/core/internal/a2a/manager_test.go @@ -0,0 +1,79 @@ +package a2a + +import ( + "context" + "testing" + + authimpl "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" + pkgauth "github.com/kagent-dev/kagent/go/core/pkg/auth" + "trpc.group/trpc-go/trpc-a2a-go/protocol" +) + +func ctxWithUser(userID string) context.Context { + return pkgauth.AuthSessionTo(context.Background(), &authimpl.SimpleSession{ + P: pkgauth.Principal{User: pkgauth.User{ID: userID}}, + }) +} + +func ctxWithUserAndShare(userID string) context.Context { + ctx := ctxWithUser(userID) + return pkgauth.ShareContextTo(ctx, &pkgauth.ShareContext{ + Token: "tok", + SessionID: "sess-1", + UserID: "owner-id", + ReadOnly: false, + }) +} + +func TestInjectInitiatedBy(t *testing.T) { + tests := []struct { + name string + ctx context.Context + wantMetaKey bool + wantInitiator string + }{ + { + name: "no share context — metadata not set", + ctx: ctxWithUser("caller-id"), + wantMetaKey: false, + }, + { + name: "share context present — sets initiated_by to caller user ID", + ctx: ctxWithUserAndShare("caller-id"), + wantMetaKey: true, + wantInitiator: "caller-id", + }, + { + name: "no auth session — metadata not set", + ctx: pkgauth.ShareContextTo(context.Background(), &pkgauth.ShareContext{Token: "tok"}), + wantMetaKey: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msg := &protocol.Message{} + injectInitiatedBy(tt.ctx, msg) + + if !tt.wantMetaKey { + if msg.Metadata != nil { + if _, ok := msg.Metadata["initiated_by"]; ok { + t.Errorf("expected initiated_by not set, but it was: %v", msg.Metadata["initiated_by"]) + } + } + return + } + + if msg.Metadata == nil { + t.Fatal("expected Metadata to be set, got nil") + } + got, ok := msg.Metadata["initiated_by"] + if !ok { + t.Fatal("expected initiated_by key in Metadata") + } + if got != tt.wantInitiator { + t.Errorf("initiated_by = %q, want %q", got, tt.wantInitiator) + } + }) + } +} diff --git a/go/core/internal/controller/translator/agent/compiler.go b/go/core/internal/controller/translator/agent/compiler.go index 0232a859e6..2f4520293f 100644 --- a/go/core/internal/controller/translator/agent/compiler.go +++ b/go/core/internal/controller/translator/agent/compiler.go @@ -226,6 +226,12 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent v1alp cfg.ContextConfig = contextCfg } + // ShareTools: pass the flag through to AgentConfig; the Python runtime injects the tools. + if spec.Declarative.ShareTools != nil && *spec.Declarative.ShareTools { + t := true + cfg.ShareTools = &t + } + // Handle Memory Configuration: presence of Memory field enables it. if spec.Declarative.Memory != nil { embCfg, embMdd, embHash, err := a.translateEmbeddingConfig(ctx, agent.GetNamespace(), spec.Declarative.Memory.ModelConfig) diff --git a/go/core/internal/controller/translator/agent/manifest_builder.go b/go/core/internal/controller/translator/agent/manifest_builder.go index 3b059bbef5..2503d1d635 100644 --- a/go/core/internal/controller/translator/agent/manifest_builder.go +++ b/go/core/internal/controller/translator/agent/manifest_builder.go @@ -331,6 +331,12 @@ func collectSharedEnv(agent v1alpha2.AgentObject) []corev1.EnvVar { Value: fmt.Sprintf("http://%s.%s:8083", utils.GetControllerName(), utils.GetResourceNamespace()), }, ) + if uiURL := env.KagentUIURL.Get(); uiURL != "" { + sharedEnv = append(sharedEnv, corev1.EnvVar{ + Name: env.KagentUIURL.Name(), + Value: uiURL, + }) + } return sharedEnv } diff --git a/go/core/internal/database/client_postgres.go b/go/core/internal/database/client_postgres.go index 15a2dbacd7..a139b3cf41 100644 --- a/go/core/internal/database/client_postgres.go +++ b/go/core/internal/database/client_postgres.go @@ -9,6 +9,7 @@ import ( "time" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" dbpkg "github.com/kagent-dev/kagent/go/api/database" "github.com/kagent-dev/kagent/go/api/v1alpha2" @@ -114,7 +115,7 @@ func (c *postgresClient) ListSessions(ctx context.Context, userID string) ([]dbp return sessions, nil } -func (c *postgresClient) ListSessionsForAgent(ctx context.Context, agentID, userID string) ([]dbpkg.Session, error) { +func (c *postgresClient) ListSessionsForAgent(ctx context.Context, agentID, userID string) ([]dbpkg.SessionWithShareToken, error) { rows, err := c.q.ListSessionsForAgent(ctx, dbgen.ListSessionsForAgentParams{ AgentID: &agentID, UserID: userID, @@ -122,9 +123,9 @@ func (c *postgresClient) ListSessionsForAgent(ctx context.Context, agentID, user if err != nil { return nil, fmt.Errorf("failed to list sessions for agent: %w", err) } - sessions := make([]dbpkg.Session, len(rows)) + sessions := make([]dbpkg.SessionWithShareToken, len(rows)) for i, r := range rows { - sessions[i] = *toSession(r) + sessions[i] = toSessionWithShareToken(r) } return sessions, nil } @@ -145,6 +146,75 @@ func (c *postgresClient) DeleteSession(ctx context.Context, sessionID, userID st return c.q.SoftDeleteSession(ctx, dbgen.SoftDeleteSessionParams{ID: sessionID, UserID: userID}) } +// ── Session Shares ───────────────────────────────────────────────────────────── + +func toSessionShare(row dbgen.SessionShare) dbpkg.SessionShare { + return dbpkg.SessionShare{ + ID: row.ID, + Token: row.Token, + SessionID: row.SessionID, + UserID: row.UserID, + ReadOnly: row.ReadOnly, + CreatedAt: row.CreatedAt.Time, + } +} + +func (c *postgresClient) CreateSessionShare(ctx context.Context, share *dbpkg.SessionShare) (*dbpkg.SessionShare, error) { + row, err := c.q.CreateSessionShare(ctx, dbgen.CreateSessionShareParams{ + Token: share.Token, + SessionID: share.SessionID, + UserID: share.UserID, + ReadOnly: share.ReadOnly, + }) + if err != nil { + return nil, fmt.Errorf("create session share: %w", err) + } + result := toSessionShare(row) + return &result, nil +} + +func (c *postgresClient) GetSessionShareByToken(ctx context.Context, token string) (*dbpkg.SessionShare, error) { + row, err := c.q.GetSessionShareByToken(ctx, token) + if err != nil { + return nil, fmt.Errorf("get session share by token: %w", err) + } + result := toSessionShare(row) + return &result, nil +} + +func (c *postgresClient) ListSessionSharesBySession(ctx context.Context, sessionID string) ([]dbpkg.SessionShare, error) { + rows, err := c.q.ListSessionSharesBySession(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("list session shares by session: %w", err) + } + shares := make([]dbpkg.SessionShare, 0, len(rows)) + for _, row := range rows { + shares = append(shares, toSessionShare(row)) + } + return shares, nil +} + +func (c *postgresClient) DeleteSessionShare(ctx context.Context, token, sessionID, userID string) error { + if err := c.q.DeleteSessionShare(ctx, dbgen.DeleteSessionShareParams{ + Token: token, + SessionID: sessionID, + UserID: userID, + }); err != nil { + return fmt.Errorf("delete session share: %w", err) + } + return nil +} + +func (c *postgresClient) RecordShareAccess(ctx context.Context, userID string, shareID int64) error { + if err := c.q.UpsertShareAccess(ctx, dbgen.UpsertShareAccessParams{ + UserID: userID, + ShareID: shareID, + }); err != nil { + return fmt.Errorf("record share access: %w", err) + } + return nil +} + // ── Events ──────────────────────────────────────────────────────────────────── func (c *postgresClient) StoreEvents(ctx context.Context, events ...*dbpkg.Event) error { @@ -725,6 +795,38 @@ func toSession(r dbgen.Session) *dbpkg.Session { return s } +func toSessionWithShareToken(r dbgen.ListSessionsForAgentRow) dbpkg.SessionWithShareToken { + s := dbpkg.SessionWithShareToken{ + Session: *toSession(dbgen.Session{ + ID: r.ID, + UserID: r.UserID, + Name: r.Name, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + DeletedAt: r.DeletedAt, + AgentID: r.AgentID, + Source: r.Source, + }), + } + switch v := r.ShareToken.(type) { + case string: + s.ShareToken = &v + case pgtype.Text: + if v.Valid { + s.ShareToken = &v.String + } + } + switch v := r.ShareReadOnly.(type) { + case bool: + s.ShareReadOnly = &v + case pgtype.Bool: + if v.Valid { + s.ShareReadOnly = &v.Bool + } + } + return s +} + func toEvent(r dbgen.Event) *dbpkg.Event { return &dbpkg.Event{ ID: r.ID, diff --git a/go/core/internal/database/gen/models.go b/go/core/internal/database/gen/models.go index e05efa2ae8..c9f5802b19 100644 --- a/go/core/internal/database/gen/models.go +++ b/go/core/internal/database/gen/models.go @@ -7,6 +7,7 @@ package dbgen import ( "time" + "github.com/jackc/pgx/v5/pgtype" "github.com/kagent-dev/kagent/go/api/adk" "github.com/kagent-dev/kagent/go/api/database" pgvector_go "github.com/pgvector/pgvector-go" @@ -125,6 +126,21 @@ type Session struct { Source *string } +type SessionShare struct { + ID int64 + Token string + SessionID string + UserID string + ReadOnly bool + CreatedAt pgtype.Timestamp +} + +type SessionShareAccess struct { + UserID string + ShareID int64 + AccessedAt pgtype.Timestamp +} + type Task struct { ID string CreatedAt *time.Time diff --git a/go/core/internal/database/gen/querier.go b/go/core/internal/database/gen/querier.go index e58850e004..7ef2b8a8c3 100644 --- a/go/core/internal/database/gen/querier.go +++ b/go/core/internal/database/gen/querier.go @@ -9,8 +9,10 @@ import ( ) type Querier interface { + CreateSessionShare(ctx context.Context, arg CreateSessionShareParams) (SessionShare, error) DeleteAgentMemory(ctx context.Context, arg DeleteAgentMemoryParams) error DeleteExpiredMemories(ctx context.Context) error + DeleteSessionShare(ctx context.Context, arg DeleteSessionShareParams) error ExtendMemoryTTL(ctx context.Context) error GetAgent(ctx context.Context, id string) (Agent, error) GetCheckpoint(ctx context.Context, arg GetCheckpointParams) (LgCheckpoint, error) @@ -18,6 +20,7 @@ type Querier interface { GetLatestCrewAIFlowState(ctx context.Context, arg GetLatestCrewAIFlowStateParams) (CrewaiFlowState, error) GetPushNotification(ctx context.Context, arg GetPushNotificationParams) (PushNotification, error) GetSession(ctx context.Context, arg GetSessionParams) (Session, error) + GetSessionShareByToken(ctx context.Context, token string) (SessionShare, error) GetTask(ctx context.Context, id string) (Task, error) GetTool(ctx context.Context, id string) (Tool, error) GetToolServer(ctx context.Context, name string) (Toolserver, error) @@ -39,8 +42,9 @@ type Querier interface { ListEventsForSessionDescLimit(ctx context.Context, arg ListEventsForSessionDescLimitParams) ([]Event, error) ListFeedback(ctx context.Context, userID string) ([]Feedback, error) ListPushNotifications(ctx context.Context, taskID string) ([]PushNotification, error) + ListSessionSharesBySession(ctx context.Context, sessionID string) ([]SessionShare, error) ListSessions(ctx context.Context, userID string) ([]Session, error) - ListSessionsForAgent(ctx context.Context, arg ListSessionsForAgentParams) ([]Session, error) + ListSessionsForAgent(ctx context.Context, arg ListSessionsForAgentParams) ([]ListSessionsForAgentRow, error) ListSessionsForAgentAllUsers(ctx context.Context, agentID *string) ([]Session, error) ListTasksForSession(ctx context.Context, sessionID *string) ([]Task, error) ListToolServers(ctx context.Context) ([]Toolserver, error) @@ -68,6 +72,7 @@ type Querier interface { UpsertCrewAIMemory(ctx context.Context, arg UpsertCrewAIMemoryParams) error UpsertPushNotification(ctx context.Context, arg UpsertPushNotificationParams) error UpsertSession(ctx context.Context, arg UpsertSessionParams) error + UpsertShareAccess(ctx context.Context, arg UpsertShareAccessParams) error UpsertTask(ctx context.Context, arg UpsertTaskParams) error UpsertTool(ctx context.Context, arg UpsertToolParams) error UpsertToolServer(ctx context.Context, arg UpsertToolServerParams) (Toolserver, error) diff --git a/go/core/internal/database/gen/session_shares.sql.go b/go/core/internal/database/gen/session_shares.sql.go new file mode 100644 index 0000000000..9e84e5363f --- /dev/null +++ b/go/core/internal/database/gen/session_shares.sql.go @@ -0,0 +1,127 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: session_shares.sql + +package dbgen + +import ( + "context" +) + +const createSessionShare = `-- name: CreateSessionShare :one +INSERT INTO session_share (token, session_id, user_id, read_only) +VALUES ($1, $2, $3, $4) +RETURNING id, token, session_id, user_id, read_only, created_at +` + +type CreateSessionShareParams struct { + Token string + SessionID string + UserID string + ReadOnly bool +} + +func (q *Queries) CreateSessionShare(ctx context.Context, arg CreateSessionShareParams) (SessionShare, error) { + row := q.db.QueryRow(ctx, createSessionShare, + arg.Token, + arg.SessionID, + arg.UserID, + arg.ReadOnly, + ) + var i SessionShare + err := row.Scan( + &i.ID, + &i.Token, + &i.SessionID, + &i.UserID, + &i.ReadOnly, + &i.CreatedAt, + ) + return i, err +} + +const deleteSessionShare = `-- name: DeleteSessionShare :exec +DELETE FROM session_share +WHERE token = $1 AND session_id = $2 AND user_id = $3 +` + +type DeleteSessionShareParams struct { + Token string + SessionID string + UserID string +} + +func (q *Queries) DeleteSessionShare(ctx context.Context, arg DeleteSessionShareParams) error { + _, err := q.db.Exec(ctx, deleteSessionShare, arg.Token, arg.SessionID, arg.UserID) + return err +} + +const getSessionShareByToken = `-- name: GetSessionShareByToken :one +SELECT id, token, session_id, user_id, read_only, created_at FROM session_share +WHERE token = $1 +LIMIT 1 +` + +func (q *Queries) GetSessionShareByToken(ctx context.Context, token string) (SessionShare, error) { + row := q.db.QueryRow(ctx, getSessionShareByToken, token) + var i SessionShare + err := row.Scan( + &i.ID, + &i.Token, + &i.SessionID, + &i.UserID, + &i.ReadOnly, + &i.CreatedAt, + ) + return i, err +} + +const listSessionSharesBySession = `-- name: ListSessionSharesBySession :many +SELECT id, token, session_id, user_id, read_only, created_at FROM session_share +WHERE session_id = $1 +ORDER BY created_at DESC +` + +func (q *Queries) ListSessionSharesBySession(ctx context.Context, sessionID string) ([]SessionShare, error) { + rows, err := q.db.Query(ctx, listSessionSharesBySession, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SessionShare + for rows.Next() { + var i SessionShare + if err := rows.Scan( + &i.ID, + &i.Token, + &i.SessionID, + &i.UserID, + &i.ReadOnly, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertShareAccess = `-- name: UpsertShareAccess :exec +INSERT INTO session_share_access (user_id, share_id, accessed_at) +VALUES ($1, $2, NOW()) +ON CONFLICT (user_id, share_id) DO UPDATE SET accessed_at = NOW() +` + +type UpsertShareAccessParams struct { + UserID string + ShareID int64 +} + +func (q *Queries) UpsertShareAccess(ctx context.Context, arg UpsertShareAccessParams) error { + _, err := q.db.Exec(ctx, upsertShareAccess, arg.UserID, arg.ShareID) + return err +} diff --git a/go/core/internal/database/gen/sessions.sql.go b/go/core/internal/database/gen/sessions.sql.go index 24490bc627..d93ce3e2ca 100644 --- a/go/core/internal/database/gen/sessions.sql.go +++ b/go/core/internal/database/gen/sessions.sql.go @@ -7,6 +7,7 @@ package dbgen import ( "context" + "time" ) const getSession = `-- name: GetSession :one @@ -72,10 +73,28 @@ func (q *Queries) ListSessions(ctx context.Context, userID string) ([]Session, e } const listSessionsForAgent = `-- name: ListSessionsForAgent :many -SELECT id, user_id, name, created_at, updated_at, deleted_at, agent_id, source FROM session -WHERE agent_id = $1 AND user_id = $2 AND deleted_at IS NULL - AND (source IS NULL OR source != 'agent') -ORDER BY updated_at DESC, created_at DESC +SELECT s.id, s.user_id, s.name, s.created_at, s.updated_at, s.deleted_at, s.agent_id, s.source, + (CASE WHEN s.user_id = $2 THEN NULL::text + ELSE (SELECT ss.token FROM session_share ss + JOIN session_share_access sa ON sa.share_id = ss.id + WHERE ss.session_id = s.id AND sa.user_id = $2 + ORDER BY ss.read_only ASC, ss.created_at DESC LIMIT 1) + END) AS share_token, + (CASE WHEN s.user_id = $2 THEN NULL::boolean + ELSE (SELECT ss.read_only FROM session_share ss + JOIN session_share_access sa ON sa.share_id = ss.id + WHERE ss.session_id = s.id AND sa.user_id = $2 + ORDER BY ss.read_only ASC, ss.created_at DESC LIMIT 1) + END) AS share_read_only +FROM session s +WHERE s.agent_id = $1 AND s.deleted_at IS NULL + AND (s.source IS NULL OR s.source != 'agent') + AND (s.user_id = $2 OR EXISTS ( + SELECT 1 FROM session_share ss + JOIN session_share_access sa ON sa.share_id = ss.id + WHERE ss.session_id = s.id AND sa.user_id = $2 + )) +ORDER BY s.updated_at DESC, s.created_at DESC ` type ListSessionsForAgentParams struct { @@ -83,15 +102,28 @@ type ListSessionsForAgentParams struct { UserID string } -func (q *Queries) ListSessionsForAgent(ctx context.Context, arg ListSessionsForAgentParams) ([]Session, error) { +type ListSessionsForAgentRow struct { + ID string + UserID string + Name *string + CreatedAt *time.Time + UpdatedAt *time.Time + DeletedAt *time.Time + AgentID *string + Source *string + ShareToken interface{} + ShareReadOnly interface{} +} + +func (q *Queries) ListSessionsForAgent(ctx context.Context, arg ListSessionsForAgentParams) ([]ListSessionsForAgentRow, error) { rows, err := q.db.Query(ctx, listSessionsForAgent, arg.AgentID, arg.UserID) if err != nil { return nil, err } defer rows.Close() - var items []Session + var items []ListSessionsForAgentRow for rows.Next() { - var i Session + var i ListSessionsForAgentRow if err := rows.Scan( &i.ID, &i.UserID, @@ -101,6 +133,8 @@ func (q *Queries) ListSessionsForAgent(ctx context.Context, arg ListSessionsForA &i.DeletedAt, &i.AgentID, &i.Source, + &i.ShareToken, + &i.ShareReadOnly, ); err != nil { return nil, err } diff --git a/go/core/internal/database/queries/session_shares.sql b/go/core/internal/database/queries/session_shares.sql new file mode 100644 index 0000000000..48f2153525 --- /dev/null +++ b/go/core/internal/database/queries/session_shares.sql @@ -0,0 +1,23 @@ +-- name: CreateSessionShare :one +INSERT INTO session_share (token, session_id, user_id, read_only) +VALUES ($1, $2, $3, $4) +RETURNING id, token, session_id, user_id, read_only, created_at; + +-- name: GetSessionShareByToken :one +SELECT id, token, session_id, user_id, read_only, created_at FROM session_share +WHERE token = $1 +LIMIT 1; + +-- name: ListSessionSharesBySession :many +SELECT id, token, session_id, user_id, read_only, created_at FROM session_share +WHERE session_id = $1 +ORDER BY created_at DESC; + +-- name: DeleteSessionShare :exec +DELETE FROM session_share +WHERE token = $1 AND session_id = $2 AND user_id = $3; + +-- name: UpsertShareAccess :exec +INSERT INTO session_share_access (user_id, share_id, accessed_at) +VALUES ($1, $2, NOW()) +ON CONFLICT (user_id, share_id) DO UPDATE SET accessed_at = NOW(); diff --git a/go/core/internal/database/queries/sessions.sql b/go/core/internal/database/queries/sessions.sql index 98fdda5726..edba76ee19 100644 --- a/go/core/internal/database/queries/sessions.sql +++ b/go/core/internal/database/queries/sessions.sql @@ -9,10 +9,28 @@ WHERE user_id = $1 AND deleted_at IS NULL ORDER BY updated_at DESC, created_at DESC; -- name: ListSessionsForAgent :many -SELECT * FROM session -WHERE agent_id = $1 AND user_id = $2 AND deleted_at IS NULL - AND (source IS NULL OR source != 'agent') -ORDER BY updated_at DESC, created_at DESC; +SELECT s.id, s.user_id, s.name, s.created_at, s.updated_at, s.deleted_at, s.agent_id, s.source, + (CASE WHEN s.user_id = $2 THEN NULL::text + ELSE (SELECT ss.token FROM session_share ss + JOIN session_share_access sa ON sa.share_id = ss.id + WHERE ss.session_id = s.id AND sa.user_id = $2 + ORDER BY ss.read_only ASC, ss.created_at DESC LIMIT 1) + END) AS share_token, + (CASE WHEN s.user_id = $2 THEN NULL::boolean + ELSE (SELECT ss.read_only FROM session_share ss + JOIN session_share_access sa ON sa.share_id = ss.id + WHERE ss.session_id = s.id AND sa.user_id = $2 + ORDER BY ss.read_only ASC, ss.created_at DESC LIMIT 1) + END) AS share_read_only +FROM session s +WHERE s.agent_id = $1 AND s.deleted_at IS NULL + AND (s.source IS NULL OR s.source != 'agent') + AND (s.user_id = $2 OR EXISTS ( + SELECT 1 FROM session_share ss + JOIN session_share_access sa ON sa.share_id = ss.id + WHERE ss.session_id = s.id AND sa.user_id = $2 + )) +ORDER BY s.updated_at DESC, s.created_at DESC; -- name: ListSessionsForAgentAllUsers :many SELECT * FROM session diff --git a/go/core/internal/httpserver/auth/authn.go b/go/core/internal/httpserver/auth/authn.go index ef5292f588..3452bb7a20 100644 --- a/go/core/internal/httpserver/auth/authn.go +++ b/go/core/internal/httpserver/auth/authn.go @@ -105,6 +105,12 @@ func A2ARequestHandler(authProvider auth.AuthProvider, agentNns types.Namespaced if err := authProvider.UpstreamAuth(req, session, upstreamPrincipal); err != nil { return nil, fmt.Errorf("a2aClient.httpRequestHandler: upstream auth failed: %w", err) } + // When the request carries a share token, the agent pod must use the + // session owner's identity — not the caller's — when calling back to + // the controller, so that session lookups resolve to the owner's row. + if sc, ok := auth.ShareContextFrom(ctx); ok { + req.Header.Set("X-User-Id", sc.UserID) + } } resp, err = client.Do(req) diff --git a/go/core/internal/httpserver/handlers/handlers.go b/go/core/internal/httpserver/handlers/handlers.go index 13a66adeb9..20c5b498b4 100644 --- a/go/core/internal/httpserver/handlers/handlers.go +++ b/go/core/internal/httpserver/handlers/handlers.go @@ -17,6 +17,7 @@ type Handlers struct { Model *ModelHandler ModelProviderConfig *ModelProviderConfigHandler Sessions *SessionsHandler + SessionShares *SessionSharesHandler Agents *AgentsHandler Tools *ToolsHandler ToolServers *ToolServersHandler @@ -60,6 +61,7 @@ func NewHandlers(kubeClient client.Client, defaultModelConfig types.NamespacedNa Model: NewModelHandler(base), ModelProviderConfig: NewModelProviderConfigHandler(base, rcnclr), Sessions: NewSessionsHandler(base), + SessionShares: NewSessionSharesHandler(base), Agents: NewAgentsHandler(base), Tools: NewToolsHandler(base), ToolServers: NewToolServersHandler(base), diff --git a/go/core/internal/httpserver/handlers/session_shares.go b/go/core/internal/httpserver/handlers/session_shares.go new file mode 100644 index 0000000000..38c8663a5a --- /dev/null +++ b/go/core/internal/httpserver/handlers/session_shares.go @@ -0,0 +1,160 @@ +package handlers + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + + dbpkg "github.com/kagent-dev/kagent/go/api/database" + api "github.com/kagent-dev/kagent/go/api/httpapi" + "github.com/kagent-dev/kagent/go/core/internal/httpserver/errors" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" +) + +// SessionSharesHandler handles session share CRUD operations. +type SessionSharesHandler struct { + *Base +} + +func NewSessionSharesHandler(base *Base) *SessionSharesHandler { + return &SessionSharesHandler{Base: base} +} + +func generateShareToken() (string, error) { + b := make([]byte, 24) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("reading random bytes: %w", err) + } + return hex.EncodeToString(b), nil +} + +// createSessionShareRequest is the optional POST body for creating a share. +// ReadOnly defaults to true when omitted. +type createSessionShareRequest struct { + ReadOnly *bool `json:"read_only"` +} + +// HandleCreateSessionShare handles POST /api/sessions/{session_id}/shares. +// Only the session owner may create share links. +func (h *SessionSharesHandler) HandleCreateSessionShare(w ErrorResponseWriter, r *http.Request) { + log := ctrllog.FromContext(r.Context()).WithName("session-shares").WithValues("op", "create") + + sessionID, err := GetPathParam(r, "session_id") + if err != nil { + w.RespondWithError(errors.NewBadRequestError("missing session_id", err)) + return + } + + userID, err := GetUserID(r) + if err != nil { + w.RespondWithError(errors.NewBadRequestError("failed to get user ID", err)) + return + } + + // Default read_only to true; explicit false opt-in to read-write. + readOnly := true + if r.Body != nil && r.ContentLength != 0 { + var body createSessionShareRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + w.RespondWithError(errors.NewBadRequestError("invalid request body", err)) + return + } + if body.ReadOnly != nil { + readOnly = *body.ReadOnly + } + } + + // Verify the session belongs to the caller. + if _, err := h.DatabaseService.GetSession(r.Context(), sessionID, userID); err != nil { + w.RespondWithError(errors.NewNotFoundError("session not found", err)) + return + } + + token, err := generateShareToken() + if err != nil { + w.RespondWithError(errors.NewInternalServerError("failed to generate token", err)) + return + } + + share := &dbpkg.SessionShare{ + Token: token, + SessionID: sessionID, + UserID: userID, + ReadOnly: readOnly, + } + created, err := h.DatabaseService.CreateSessionShare(r.Context(), share) + if err != nil { + w.RespondWithError(errors.NewInternalServerError("failed to create share", err)) + return + } + + log.Info("created session share", "sessionID", sessionID) + RespondWithJSON(w, http.StatusCreated, api.NewResponse(created, "share created", false)) +} + +// HandleListSessionShares handles GET /api/sessions/{session_id}/shares. +// Only the session owner may list share links. +func (h *SessionSharesHandler) HandleListSessionShares(w ErrorResponseWriter, r *http.Request) { + log := ctrllog.FromContext(r.Context()).WithName("session-shares").WithValues("op", "list") + + sessionID, err := GetPathParam(r, "session_id") + if err != nil { + w.RespondWithError(errors.NewBadRequestError("missing session_id", err)) + return + } + + userID, err := GetUserID(r) + if err != nil { + w.RespondWithError(errors.NewBadRequestError("failed to get user ID", err)) + return + } + + // Verify the session belongs to the caller. + if _, err := h.DatabaseService.GetSession(r.Context(), sessionID, userID); err != nil { + w.RespondWithError(errors.NewNotFoundError("session not found", err)) + return + } + + shares, err := h.DatabaseService.ListSessionSharesBySession(r.Context(), sessionID) + if err != nil { + w.RespondWithError(errors.NewInternalServerError("failed to list shares", err)) + return + } + + log.V(1).Info("listed session shares", "sessionID", sessionID, "count", len(shares)) + RespondWithJSON(w, http.StatusOK, api.NewResponse(shares, "shares listed", false)) +} + +// HandleDeleteSessionShare handles DELETE /api/sessions/{session_id}/shares/{token}. +// Only the session owner may delete share links. +func (h *SessionSharesHandler) HandleDeleteSessionShare(w ErrorResponseWriter, r *http.Request) { + log := ctrllog.FromContext(r.Context()).WithName("session-shares").WithValues("op", "delete") + + sessionID, err := GetPathParam(r, "session_id") + if err != nil { + w.RespondWithError(errors.NewBadRequestError("missing session_id", err)) + return + } + + token, err := GetPathParam(r, "token") + if err != nil { + w.RespondWithError(errors.NewBadRequestError("missing token", err)) + return + } + + userID, err := GetUserID(r) + if err != nil { + w.RespondWithError(errors.NewBadRequestError("failed to get user ID", err)) + return + } + + if err := h.DatabaseService.DeleteSessionShare(r.Context(), token, sessionID, userID); err != nil { + w.RespondWithError(errors.NewInternalServerError("failed to delete share", err)) + return + } + + log.Info("deleted session share", "sessionID", sessionID) + RespondWithJSON(w, http.StatusOK, api.NewResponse(struct{}{}, "share deleted", false)) +} diff --git a/go/core/internal/httpserver/handlers/session_shares_test.go b/go/core/internal/httpserver/handlers/session_shares_test.go new file mode 100644 index 0000000000..9b124ed785 --- /dev/null +++ b/go/core/internal/httpserver/handlers/session_shares_test.go @@ -0,0 +1,320 @@ +package handlers_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dbpkg "github.com/kagent-dev/kagent/go/api/database" + api "github.com/kagent-dev/kagent/go/api/httpapi" + "github.com/kagent-dev/kagent/go/core/internal/httpserver/handlers" +) + +func TestSessionSharesHandler(t *testing.T) { + setupHandler := func(t *testing.T) (*handlers.SessionSharesHandler, dbpkg.Client, *mockErrorResponseWriter) { + t.Helper() + dbClient := setupTestDBClient(t) + base := &handlers.Base{ + DatabaseService: dbClient, + } + handler := handlers.NewSessionSharesHandler(base) + responseRecorder := newMockErrorResponseWriter() + return handler, dbClient, responseRecorder + } + + createTestSession := func(t *testing.T, dbClient dbpkg.Client, sessionID, userID string) { + t.Helper() + agentID := "agent-1" + session := &dbpkg.Session{ + ID: sessionID, + Name: new(sessionID), + UserID: userID, + AgentID: &agentID, + } + require.NoError(t, dbClient.StoreSession(context.Background(), session)) + } + + createTestShare := func(t *testing.T, dbClient dbpkg.Client, token, sessionID, userID string, readOnly bool) { + t.Helper() + share := &dbpkg.SessionShare{ + Token: token, + SessionID: sessionID, + UserID: userID, + ReadOnly: readOnly, + } + _, err := dbClient.CreateSessionShare(context.Background(), share) + require.NoError(t, err) + } + + t.Run("HandleCreateSessionShare", func(t *testing.T) { + t.Run("DefaultsToReadOnly", func(t *testing.T) { + handler, dbClient, responseRecorder := setupHandler(t) + userID := "user-a" + sessionID := "test-session-1" + + createTestSession(t, dbClient, sessionID, userID) + + req := httptest.NewRequest("POST", "/api/sessions/"+sessionID+"/shares", nil) + req = mux.SetURLVars(req, map[string]string{"session_id": sessionID}) + req = setUser(req, userID) + + handler.HandleCreateSessionShare(responseRecorder, req) + + assert.Equal(t, http.StatusCreated, responseRecorder.Code) + + var response api.StandardResponse[*dbpkg.SessionShare] + err := json.Unmarshal(responseRecorder.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, "share created", response.Message) + assert.True(t, response.Data.ReadOnly) + assert.Equal(t, sessionID, response.Data.SessionID) + assert.NotEmpty(t, response.Data.Token) + }) + + t.Run("ExplicitReadWrite", func(t *testing.T) { + handler, dbClient, responseRecorder := setupHandler(t) + userID := "user-a" + sessionID := "test-session-1" + + createTestSession(t, dbClient, sessionID, userID) + + readOnly := false + body, _ := json.Marshal(map[string]bool{"read_only": readOnly}) + req := httptest.NewRequest("POST", "/api/sessions/"+sessionID+"/shares", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req = mux.SetURLVars(req, map[string]string{"session_id": sessionID}) + req = setUser(req, userID) + + handler.HandleCreateSessionShare(responseRecorder, req) + + assert.Equal(t, http.StatusCreated, responseRecorder.Code) + + var response api.StandardResponse[*dbpkg.SessionShare] + err := json.Unmarshal(responseRecorder.Body.Bytes(), &response) + require.NoError(t, err) + assert.False(t, response.Data.ReadOnly) + }) + + t.Run("ExplicitReadOnly", func(t *testing.T) { + handler, dbClient, responseRecorder := setupHandler(t) + userID := "user-a" + sessionID := "test-session-1" + + createTestSession(t, dbClient, sessionID, userID) + + body, _ := json.Marshal(map[string]bool{"read_only": true}) + req := httptest.NewRequest("POST", "/api/sessions/"+sessionID+"/shares", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req = mux.SetURLVars(req, map[string]string{"session_id": sessionID}) + req = setUser(req, userID) + + handler.HandleCreateSessionShare(responseRecorder, req) + + assert.Equal(t, http.StatusCreated, responseRecorder.Code) + + var response api.StandardResponse[*dbpkg.SessionShare] + err := json.Unmarshal(responseRecorder.Body.Bytes(), &response) + require.NoError(t, err) + assert.True(t, response.Data.ReadOnly) + }) + + t.Run("SessionNotFound", func(t *testing.T) { + handler, _, responseRecorder := setupHandler(t) + userID := "user-a" + sessionID := "non-existent-session" + + req := httptest.NewRequest("POST", "/api/sessions/"+sessionID+"/shares", nil) + req = mux.SetURLVars(req, map[string]string{"session_id": sessionID}) + req = setUser(req, userID) + + handler.HandleCreateSessionShare(responseRecorder, req) + + assert.Equal(t, http.StatusNotFound, responseRecorder.Code) + assert.NotNil(t, responseRecorder.errorReceived) + }) + + t.Run("MissingUserID", func(t *testing.T) { + handler, _, responseRecorder := setupHandler(t) + sessionID := "test-session-1" + + req := httptest.NewRequest("POST", "/api/sessions/"+sessionID+"/shares", nil) + req = mux.SetURLVars(req, map[string]string{"session_id": sessionID}) + + handler.HandleCreateSessionShare(responseRecorder, req) + + assert.Equal(t, http.StatusBadRequest, responseRecorder.Code) + assert.NotNil(t, responseRecorder.errorReceived) + }) + + t.Run("WrongOwner", func(t *testing.T) { + handler, dbClient, responseRecorder := setupHandler(t) + ownerID := "other-user" + callerID := "user-a" + sessionID := "test-session-1" + + createTestSession(t, dbClient, sessionID, ownerID) + + req := httptest.NewRequest("POST", "/api/sessions/"+sessionID+"/shares", nil) + req = mux.SetURLVars(req, map[string]string{"session_id": sessionID}) + req = setUser(req, callerID) + + handler.HandleCreateSessionShare(responseRecorder, req) + + assert.Equal(t, http.StatusNotFound, responseRecorder.Code) + assert.NotNil(t, responseRecorder.errorReceived) + }) + }) + + t.Run("HandleListSessionShares", func(t *testing.T) { + t.Run("EmptyList", func(t *testing.T) { + handler, dbClient, responseRecorder := setupHandler(t) + userID := "user-a" + sessionID := "test-session-1" + + createTestSession(t, dbClient, sessionID, userID) + + req := httptest.NewRequest("GET", "/api/sessions/"+sessionID+"/shares", nil) + req = mux.SetURLVars(req, map[string]string{"session_id": sessionID}) + req = setUser(req, userID) + + handler.HandleListSessionShares(responseRecorder, req) + + assert.Equal(t, http.StatusOK, responseRecorder.Code) + + var response api.StandardResponse[[]dbpkg.SessionShare] + err := json.Unmarshal(responseRecorder.Body.Bytes(), &response) + require.NoError(t, err) + assert.Empty(t, response.Data) + }) + + t.Run("WithShares", func(t *testing.T) { + handler, dbClient, responseRecorder := setupHandler(t) + userID := "user-a" + sessionID := "test-session-1" + + createTestSession(t, dbClient, sessionID, userID) + createTestShare(t, dbClient, "token-ro", sessionID, userID, true) + createTestShare(t, dbClient, "token-rw", sessionID, userID, false) + + req := httptest.NewRequest("GET", "/api/sessions/"+sessionID+"/shares", nil) + req = mux.SetURLVars(req, map[string]string{"session_id": sessionID}) + req = setUser(req, userID) + + handler.HandleListSessionShares(responseRecorder, req) + + assert.Equal(t, http.StatusOK, responseRecorder.Code) + + var response api.StandardResponse[[]dbpkg.SessionShare] + err := json.Unmarshal(responseRecorder.Body.Bytes(), &response) + require.NoError(t, err) + assert.Len(t, response.Data, 2) + + byToken := make(map[string]dbpkg.SessionShare, len(response.Data)) + for _, s := range response.Data { + byToken[s.Token] = s + } + roShare, ok := byToken["token-ro"] + require.True(t, ok) + assert.True(t, roShare.ReadOnly) + + rwShare, ok := byToken["token-rw"] + require.True(t, ok) + assert.False(t, rwShare.ReadOnly) + }) + + t.Run("SessionNotFound", func(t *testing.T) { + handler, _, responseRecorder := setupHandler(t) + userID := "user-a" + sessionID := "non-existent-session" + + req := httptest.NewRequest("GET", "/api/sessions/"+sessionID+"/shares", nil) + req = mux.SetURLVars(req, map[string]string{"session_id": sessionID}) + req = setUser(req, userID) + + handler.HandleListSessionShares(responseRecorder, req) + + assert.Equal(t, http.StatusNotFound, responseRecorder.Code) + assert.NotNil(t, responseRecorder.errorReceived) + }) + + t.Run("MissingUserID", func(t *testing.T) { + handler, _, responseRecorder := setupHandler(t) + sessionID := "test-session-1" + + req := httptest.NewRequest("GET", "/api/sessions/"+sessionID+"/shares", nil) + req = mux.SetURLVars(req, map[string]string{"session_id": sessionID}) + + handler.HandleListSessionShares(responseRecorder, req) + + assert.Equal(t, http.StatusBadRequest, responseRecorder.Code) + assert.NotNil(t, responseRecorder.errorReceived) + }) + }) + + t.Run("HandleDeleteSessionShare", func(t *testing.T) { + t.Run("Success", func(t *testing.T) { + handler, dbClient, responseRecorder := setupHandler(t) + userID := "user-a" + sessionID := "test-session-1" + token := "test-token-123" + + createTestSession(t, dbClient, sessionID, userID) + createTestShare(t, dbClient, token, sessionID, userID, true) + + req := httptest.NewRequest("DELETE", "/api/sessions/"+sessionID+"/shares/"+token, nil) + req = mux.SetURLVars(req, map[string]string{"session_id": sessionID, "token": token}) + req = setUser(req, userID) + + handler.HandleDeleteSessionShare(responseRecorder, req) + + assert.Equal(t, http.StatusOK, responseRecorder.Code) + + var response api.StandardResponse[struct{}] + err := json.Unmarshal(responseRecorder.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, "share deleted", response.Message) + }) + + t.Run("MissingUserID", func(t *testing.T) { + handler, _, responseRecorder := setupHandler(t) + sessionID := "test-session-1" + token := "test-token-123" + + req := httptest.NewRequest("DELETE", "/api/sessions/"+sessionID+"/shares/"+token, nil) + req = mux.SetURLVars(req, map[string]string{"session_id": sessionID, "token": token}) + + handler.HandleDeleteSessionShare(responseRecorder, req) + + assert.Equal(t, http.StatusBadRequest, responseRecorder.Code) + assert.NotNil(t, responseRecorder.errorReceived) + }) + + t.Run("WrongOwner", func(t *testing.T) { + handler, dbClient, responseRecorder := setupHandler(t) + ownerID := "owner-user" + callerID := "attacker-user" + sessionID := "test-session-1" + token := "test-token-123" + + createTestSession(t, dbClient, sessionID, ownerID) + createTestShare(t, dbClient, token, sessionID, ownerID, true) + + req := httptest.NewRequest("DELETE", "/api/sessions/"+sessionID+"/shares/"+token, nil) + req = mux.SetURLVars(req, map[string]string{"session_id": sessionID, "token": token}) + req = setUser(req, callerID) + + handler.HandleDeleteSessionShare(responseRecorder, req) + + shares, err := dbClient.ListSessionSharesBySession(context.Background(), sessionID) + require.NoError(t, err) + assert.Len(t, shares, 1, "share must not be deleted by a non-owner") + }) + }) +} diff --git a/go/core/internal/httpserver/handlers/sessions.go b/go/core/internal/httpserver/handlers/sessions.go index 1ff768077d..e96f95321b 100644 --- a/go/core/internal/httpserver/handlers/sessions.go +++ b/go/core/internal/httpserver/handlers/sessions.go @@ -11,6 +11,7 @@ import ( "github.com/kagent-dev/kagent/go/api/v1alpha2" "github.com/kagent-dev/kagent/go/core/internal/httpserver/errors" "github.com/kagent-dev/kagent/go/core/internal/utils" + "github.com/kagent-dev/kagent/go/core/pkg/auth" ctrllog "sigs.k8s.io/controller-runtime/pkg/log" "trpc.group/trpc-go/trpc-a2a-go/protocol" ) @@ -167,8 +168,19 @@ func (h *SessionsHandler) HandleCreateSession(w ErrorResponseWriter, r *http.Req } type SessionResponse struct { - Session *database.Session `json:"session"` - Events []*database.Event `json:"events"` + Session *database.Session `json:"session"` + Events []*database.Event `json:"events"` + ReadOnly *bool `json:"read_only,omitempty"` +} + +// getEffectiveUserIDForSession returns the user ID to use for DB lookups on a specific session. +// When the request carries a valid X-Share-Token scoped to sessionID, the share owner's user ID +// is returned so that shared access works transparently. +func getEffectiveUserIDForSession(r *http.Request, sessionID string) (string, error) { + if sc, ok := auth.ShareContextFrom(r.Context()); ok && sc.SessionID == sessionID { + return sc.UserID, nil + } + return getUserIDOrAgentUser(r) } // HandleGetSession handles GET /api/sessions/{session_id} requests using database @@ -182,7 +194,7 @@ func (h *SessionsHandler) HandleGetSession(w ErrorResponseWriter, r *http.Reques } log = log.WithValues("session_id", sessionID) - userID, err := getUserIDOrAgentUser(r) + userID, err := getEffectiveUserIDForSession(r, sessionID) if err != nil { w.RespondWithError(errors.NewBadRequestError("Failed to get user ID", err)) return @@ -228,10 +240,15 @@ func (h *SessionsHandler) HandleGetSession(w ErrorResponseWriter, r *http.Reques } log.Info("Successfully retrieved session") - data := api.NewResponse(SessionResponse{ + resp := SessionResponse{ Session: session, Events: events, - }, "Successfully retrieved session", false) + } + if sc, ok := auth.ShareContextFrom(r.Context()); ok && sc.SessionID == sessionID && sc.ReadOnly { + t := true + resp.ReadOnly = &t + } + data := api.NewResponse(resp, "Successfully retrieved session", false) RespondWithJSON(w, http.StatusOK, data) } @@ -326,7 +343,7 @@ func (h *SessionsHandler) HandleListTasksForSession(w ErrorResponseWriter, r *ht } log = log.WithValues("session_id", sessionID) - userID, err := GetUserID(r) + userID, err := getEffectiveUserIDForSession(r, sessionID) if err != nil { w.RespondWithError(errors.NewBadRequestError("Failed to get user ID", err)) return @@ -366,7 +383,7 @@ func (h *SessionsHandler) HandleAddEventToSession(w ErrorResponseWriter, r *http w.RespondWithError(errors.NewBadRequestError("Failed to get user ID", err)) return } - userID, err := getUserID(r) + userID, err := getEffectiveUserIDForSession(r, sessionID) if err != nil { w.RespondWithError(errors.NewBadRequestError("Failed to get user ID", err)) return diff --git a/go/core/internal/httpserver/handlers/sessions_share_context_test.go b/go/core/internal/httpserver/handlers/sessions_share_context_test.go new file mode 100644 index 0000000000..164862f1bd --- /dev/null +++ b/go/core/internal/httpserver/handlers/sessions_share_context_test.go @@ -0,0 +1,77 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "testing" + + authimpl "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" + "github.com/kagent-dev/kagent/go/core/pkg/auth" +) + +func makeReqWithUser(userID string) *http.Request { + req := httptest.NewRequest("GET", "/", nil) + ctx := auth.AuthSessionTo(req.Context(), &authimpl.SimpleSession{ + P: auth.Principal{User: auth.User{ID: userID}}, + }) + return req.WithContext(ctx) +} + +func makeReqWithShareContext(userID, shareOwnerID, shareSessionID string) *http.Request { + req := makeReqWithUser(userID) + sc := &auth.ShareContext{ + Token: "tok", + SessionID: shareSessionID, + UserID: shareOwnerID, + ReadOnly: true, + } + ctx := auth.ShareContextTo(req.Context(), sc) + return req.WithContext(ctx) +} + +func TestGetEffectiveUserIDForSession(t *testing.T) { + tests := []struct { + name string + req *http.Request + sessionID string + wantID string + wantErr bool + }{ + { + name: "no share context returns caller user ID", + req: makeReqWithUser("caller-id"), + sessionID: "sess-1", + wantID: "caller-id", + }, + { + name: "share context matching session returns owner ID", + req: makeReqWithShareContext("visitor-id", "owner-id", "sess-1"), + sessionID: "sess-1", + wantID: "owner-id", + }, + { + name: "share context non-matching session falls back to caller user ID", + req: makeReqWithShareContext("visitor-id", "owner-id", "sess-other"), + sessionID: "sess-1", + wantID: "visitor-id", + }, + { + name: "no user and no share context returns error", + req: httptest.NewRequest("GET", "/", nil), + sessionID: "sess-1", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getEffectiveUserIDForSession(tt.req, tt.sessionID) + if (err != nil) != tt.wantErr { + t.Fatalf("getEffectiveUserIDForSession() error = %v, wantErr %v", err, tt.wantErr) + } + if !tt.wantErr && got != tt.wantID { + t.Errorf("getEffectiveUserIDForSession() = %q, want %q", got, tt.wantID) + } + }) + } +} diff --git a/go/core/internal/httpserver/handlers/sessions_test.go b/go/core/internal/httpserver/handlers/sessions_test.go index 02ee3243d2..1ce536cf50 100644 --- a/go/core/internal/httpserver/handlers/sessions_test.go +++ b/go/core/internal/httpserver/handlers/sessions_test.go @@ -349,6 +349,53 @@ func TestSessionsHandler(t *testing.T) { assert.Equal(t, event1.ID, response.Data.Events[0].ID) assert.Equal(t, event2.ID, response.Data.Events[1].ID) }) + + t.Run("OwnerSeesNilReadOnly", func(t *testing.T) { + handler, dbClient, responseRecorder := setupHandler(t) + ownerID := "owner-user" + sessionID := "owned-session" + agentID := "1" + createTestSession(t, dbClient, sessionID, ownerID, agentID) + + req := httptest.NewRequest("GET", "/api/sessions/"+sessionID, nil) + req = mux.SetURLVars(req, map[string]string{"session_id": sessionID}) + req = setUser(req, ownerID) + + handler.HandleGetSession(responseRecorder, req) + + assert.Equal(t, http.StatusOK, responseRecorder.Code) + var response api.StandardResponse[handlers.SessionResponse] + require.NoError(t, json.Unmarshal(responseRecorder.Body.Bytes(), &response)) + assert.Nil(t, response.Data.ReadOnly) + }) + + t.Run("ShareVisitorSeesReadOnlyTrue", func(t *testing.T) { + handler, dbClient, responseRecorder := setupHandler(t) + ownerID := "owner-user" + visitorID := "visitor-user" + sessionID := "shared-session" + agentID := "1" + createTestSession(t, dbClient, sessionID, ownerID, agentID) + + req := httptest.NewRequest("GET", "/api/sessions/"+sessionID, nil) + req = mux.SetURLVars(req, map[string]string{"session_id": sessionID}) + req = setUser(req, visitorID) + ctx := auth.ShareContextTo(req.Context(), &auth.ShareContext{ + Token: "tok", + SessionID: sessionID, + UserID: ownerID, + ReadOnly: true, + }) + req = req.WithContext(ctx) + + handler.HandleGetSession(responseRecorder, req) + + assert.Equal(t, http.StatusOK, responseRecorder.Code) + var response api.StandardResponse[handlers.SessionResponse] + require.NoError(t, json.Unmarshal(responseRecorder.Body.Bytes(), &response)) + require.NotNil(t, response.Data.ReadOnly) + assert.True(t, *response.Data.ReadOnly) + }) }) t.Run("HandleUpdateSession", func(t *testing.T) { @@ -497,7 +544,7 @@ func TestSessionsHandler(t *testing.T) { assert.Equal(t, http.StatusOK, responseRecorder.Code) - var response api.StandardResponse[[]*database.Session] + var response api.StandardResponse[[]database.SessionWithShareToken] err := json.Unmarshal(responseRecorder.Body.Bytes(), &response) require.NoError(t, err) assert.Len(t, response.Data, 2) diff --git a/go/core/internal/httpserver/middleware.go b/go/core/internal/httpserver/middleware.go index 2f3a329378..3cd6feecb6 100644 --- a/go/core/internal/httpserver/middleware.go +++ b/go/core/internal/httpserver/middleware.go @@ -5,9 +5,11 @@ import ( "fmt" "net" "net/http" + "strings" "time" "github.com/kagent-dev/kagent/go/core/internal/httpserver/handlers" + "github.com/kagent-dev/kagent/go/core/pkg/auth" ctrllog "sigs.k8s.io/controller-runtime/pkg/log" ) @@ -85,3 +87,59 @@ func contentTypeMiddleware(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } + +// shareTokenMiddleware validates X-Share-Token headers. +// It runs after the auth middleware, so the caller is already authenticated. +// When the header is present and resolves to a valid share record, a ShareContext +// is stored on the request context so that session handlers can use the owner's +// user ID for DB lookups while retaining the caller's identity for initiated_by tracking. +func (s *HTTPServer) shareTokenMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get("X-Share-Token") + if token == "" { + next.ServeHTTP(w, r) + return + } + + _, ok := auth.AuthSessionFrom(r.Context()) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + share, err := s.config.DbClient.GetSessionShareByToken(r.Context(), token) + if err != nil { + http.Error(w, "Invalid or expired share token", http.StatusForbidden) + return + } + + // Enforce read-only on session and A2A paths only. Visitors retain full + // authenticated access to all other endpoints (creating their own sessions, + // submitting feedback, etc.) — the share token should not restrict unrelated operations. + if share.ReadOnly && r.Method != http.MethodGet && r.Method != http.MethodHead { + path := r.URL.Path + if strings.HasPrefix(path, APIPathSessions+"/") || + strings.HasPrefix(path, APIPathA2A+"/") || + strings.HasPrefix(path, APIPathA2ASandboxes+"/") { + http.Error(w, "This share link is read-only", http.StatusForbidden) + return + } + } + + callerSession, _ := auth.AuthSessionFrom(r.Context()) + callerID := callerSession.Principal().User.ID + if err := s.config.DbClient.RecordShareAccess(r.Context(), callerID, share.ID); err != nil { + log := ctrllog.FromContext(r.Context()) + log.Error(err, "failed to record share access", "shareID", share.ID) + } + + sc := &auth.ShareContext{ + Token: token, + SessionID: share.SessionID, + UserID: share.UserID, + ReadOnly: share.ReadOnly, + } + r = r.WithContext(auth.ShareContextTo(r.Context(), sc)) + next.ServeHTTP(w, r) + }) +} diff --git a/go/core/internal/httpserver/server.go b/go/core/internal/httpserver/server.go index aac7e831ab..ddff8f1a1f 100644 --- a/go/core/internal/httpserver/server.go +++ b/go/core/internal/httpserver/server.go @@ -226,6 +226,9 @@ func (s *HTTPServer) setupRoutes() { s.router.HandleFunc(APIPathSessions+"/{session_id}", adaptHandler(s.handlers.Sessions.HandleDeleteSession)).Methods(http.MethodDelete) s.router.HandleFunc(APIPathSessions+"/{session_id}", adaptHandler(s.handlers.Sessions.HandleUpdateSession)).Methods(http.MethodPut) s.router.HandleFunc(APIPathSessions+"/{session_id}/events", adaptHandler(s.handlers.Sessions.HandleAddEventToSession)).Methods(http.MethodPost) + s.router.HandleFunc(APIPathSessions+"/{session_id}/shares", adaptHandler(s.handlers.SessionShares.HandleCreateSessionShare)).Methods(http.MethodPost) + s.router.HandleFunc(APIPathSessions+"/{session_id}/shares", adaptHandler(s.handlers.SessionShares.HandleListSessionShares)).Methods(http.MethodGet) + s.router.HandleFunc(APIPathSessions+"/{session_id}/shares/{token}", adaptHandler(s.handlers.SessionShares.HandleDeleteSessionShare)).Methods(http.MethodDelete) // Tasks s.router.HandleFunc(APIPathTasks+"/{task_id}", adaptHandler(s.handlers.Tasks.HandleGetTask)).Methods(http.MethodGet) @@ -315,6 +318,7 @@ func (s *HTTPServer) setupRoutes() { // Use middleware for common functionality (first registered runs outermost on incoming requests). s.router.Use(wsSandboxSSHAuthQueryMiddleware) s.router.Use(auth.AuthnMiddleware(s.authenticator)) + s.router.Use(s.shareTokenMiddleware) s.router.Use(contentTypeMiddleware) s.router.Use(loggingMiddleware) s.router.Use(errorHandlerMiddleware) diff --git a/go/core/internal/httpserver/server_share_middleware_test.go b/go/core/internal/httpserver/server_share_middleware_test.go new file mode 100644 index 0000000000..77e86c2414 --- /dev/null +++ b/go/core/internal/httpserver/server_share_middleware_test.go @@ -0,0 +1,233 @@ +package httpserver + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + dbpkg "github.com/kagent-dev/kagent/go/api/database" + authimpl "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" + "github.com/kagent-dev/kagent/go/core/pkg/auth" +) + +// stubShareDB only implements GetSessionShareByToken and RecordShareAccess; all other methods panic on call. +type stubShareDB struct { + dbpkg.Client + getShare func(ctx context.Context, token string) (*dbpkg.SessionShare, error) +} + +func (s *stubShareDB) GetSessionShareByToken(ctx context.Context, token string) (*dbpkg.SessionShare, error) { + return s.getShare(ctx, token) +} + +func (s *stubShareDB) RecordShareAccess(_ context.Context, _ string, _ int64) error { + return nil +} + +func newMiddlewareServer(getShare func(ctx context.Context, token string) (*dbpkg.SessionShare, error)) *HTTPServer { + return &HTTPServer{ + config: ServerConfig{ + DbClient: &stubShareDB{getShare: getShare}, + }, + } +} + +func withUser(r *http.Request, userID string) *http.Request { + ctx := auth.AuthSessionTo(r.Context(), &authimpl.SimpleSession{ + P: auth.Principal{User: auth.User{ID: userID}}, + }) + return r.WithContext(ctx) +} + +func TestShareTokenMiddleware(t *testing.T) { + okShare := &dbpkg.SessionShare{ + Token: "valid-token", + SessionID: "sess-1", + UserID: "owner-id", + ReadOnly: true, + } + rwShare := &dbpkg.SessionShare{ + Token: "rw-token", + SessionID: "sess-1", + UserID: "owner-id", + ReadOnly: false, + } + + tests := []struct { + name string + getShare func(ctx context.Context, token string) (*dbpkg.SessionShare, error) + buildReq func() *http.Request + wantStatus int + wantShareCtx bool + wantReadOnly bool + }{ + { + name: "no token passes through without ShareContext", + getShare: nil, // never called + buildReq: func() *http.Request { + r := httptest.NewRequest(http.MethodGet, "/api/sessions/sess-1", nil) + return withUser(r, "caller-id") + }, + wantStatus: http.StatusOK, + wantShareCtx: false, + }, + { + name: "token without auth session returns 401", + getShare: nil, // never called + buildReq: func() *http.Request { + r := httptest.NewRequest(http.MethodGet, "/api/sessions/sess-1", nil) + r.Header.Set("X-Share-Token", "some-token") + return r // no auth session + }, + wantStatus: http.StatusUnauthorized, + wantShareCtx: false, + }, + { + name: "invalid token returns 403", + getShare: func(_ context.Context, _ string) (*dbpkg.SessionShare, error) { + return nil, errors.New("not found") + }, + buildReq: func() *http.Request { + r := httptest.NewRequest(http.MethodGet, "/api/sessions/sess-1", nil) + r.Header.Set("X-Share-Token", "bad-token") + return withUser(r, "caller-id") + }, + wantStatus: http.StatusForbidden, + wantShareCtx: false, + }, + { + name: "valid read-only token with GET passes through with ShareContext", + getShare: func(_ context.Context, _ string) (*dbpkg.SessionShare, error) { + return okShare, nil + }, + buildReq: func() *http.Request { + r := httptest.NewRequest(http.MethodGet, "/api/sessions/sess-1", nil) + r.Header.Set("X-Share-Token", "valid-token") + return withUser(r, "visitor-id") + }, + wantStatus: http.StatusOK, + wantShareCtx: true, + wantReadOnly: true, + }, + { + name: "valid read-only token with POST to session path returns 403", + getShare: func(_ context.Context, _ string) (*dbpkg.SessionShare, error) { + return okShare, nil + }, + buildReq: func() *http.Request { + r := httptest.NewRequest(http.MethodPost, "/api/sessions/sess-1/events", nil) + r.Header.Set("X-Share-Token", "valid-token") + return withUser(r, "visitor-id") + }, + wantStatus: http.StatusForbidden, + wantShareCtx: false, + }, + { + name: "valid read-only token with POST to unrelated path passes through", + getShare: func(_ context.Context, _ string) (*dbpkg.SessionShare, error) { + return okShare, nil + }, + buildReq: func() *http.Request { + r := httptest.NewRequest(http.MethodPost, "/api/feedback", nil) + r.Header.Set("X-Share-Token", "valid-token") + return withUser(r, "visitor-id") + }, + wantStatus: http.StatusOK, + wantShareCtx: true, + wantReadOnly: true, + }, + { + name: "valid read-write token with POST passes through with ShareContext", + getShare: func(_ context.Context, _ string) (*dbpkg.SessionShare, error) { + return rwShare, nil + }, + buildReq: func() *http.Request { + r := httptest.NewRequest(http.MethodPost, "/api/sessions/sess-1/events", nil) + r.Header.Set("X-Share-Token", "rw-token") + return withUser(r, "visitor-id") + }, + wantStatus: http.StatusOK, + wantShareCtx: true, + wantReadOnly: false, + }, + { + name: "valid read-only token with POST to A2A path returns 403", + getShare: func(_ context.Context, _ string) (*dbpkg.SessionShare, error) { + return okShare, nil + }, + buildReq: func() *http.Request { + r := httptest.NewRequest(http.MethodPost, APIPathA2A+"/default/my-agent", nil) + r.Header.Set("X-Share-Token", "valid-token") + return withUser(r, "visitor-id") + }, + wantStatus: http.StatusForbidden, + wantShareCtx: false, + }, + { + name: "valid read-write token with POST to A2A path passes through", + getShare: func(_ context.Context, _ string) (*dbpkg.SessionShare, error) { + return rwShare, nil + }, + buildReq: func() *http.Request { + r := httptest.NewRequest(http.MethodPost, APIPathA2A+"/default/my-agent", nil) + r.Header.Set("X-Share-Token", "rw-token") + return withUser(r, "visitor-id") + }, + wantStatus: http.StatusOK, + wantShareCtx: true, + wantReadOnly: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + getShare := tt.getShare + if getShare == nil { + getShare = func(_ context.Context, _ string) (*dbpkg.SessionShare, error) { + t.Fatal("GetSessionShareByToken should not have been called") + return nil, nil + } + } + + srv := newMiddlewareServer(getShare) + + var capturedCtx context.Context + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedCtx = r.Context() + w.WriteHeader(http.StatusOK) + }) + + w := httptest.NewRecorder() + srv.shareTokenMiddleware(inner).ServeHTTP(w, tt.buildReq()) + + if w.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", w.Code, tt.wantStatus) + } + + if !tt.wantShareCtx { + if capturedCtx != nil { + if sc, ok := auth.ShareContextFrom(capturedCtx); ok { + t.Errorf("expected no ShareContext in context, got %+v", sc) + } + } + return + } + + if capturedCtx == nil { + t.Fatal("inner handler was not called") + } + sc, ok := auth.ShareContextFrom(capturedCtx) + if !ok { + t.Fatal("expected ShareContext in context, got none") + } + if sc.ReadOnly != tt.wantReadOnly { + t.Errorf("ReadOnly = %v, want %v", sc.ReadOnly, tt.wantReadOnly) + } + if sc.UserID != "owner-id" { + t.Errorf("UserID = %q, want %q", sc.UserID, "owner-id") + } + }) + } +} diff --git a/go/core/pkg/auth/share.go b/go/core/pkg/auth/share.go new file mode 100644 index 0000000000..391491e774 --- /dev/null +++ b/go/core/pkg/auth/share.go @@ -0,0 +1,26 @@ +package auth + +import "context" + +// ShareContext holds the context derived from a validated X-Share-Token header. +type ShareContext struct { + Token string // the raw share token + SessionID string // session this token grants access to + UserID string // owner's user ID — used for DB lookups + ReadOnly bool // when true, only read operations are allowed +} + +type shareContextKeyType struct{} + +var shareContextKey = shareContextKeyType{} + +// ShareContextFrom returns the ShareContext stored in ctx, if any. +func ShareContextFrom(ctx context.Context) (*ShareContext, bool) { + v, ok := ctx.Value(shareContextKey).(*ShareContext) + return v, ok && v != nil +} + +// ShareContextTo returns a copy of ctx with sc stored as the share context. +func ShareContextTo(ctx context.Context, sc *ShareContext) context.Context { + return context.WithValue(ctx, shareContextKey, sc) +} diff --git a/go/core/pkg/auth/share_test.go b/go/core/pkg/auth/share_test.go new file mode 100644 index 0000000000..f79d11ceb4 --- /dev/null +++ b/go/core/pkg/auth/share_test.go @@ -0,0 +1,83 @@ +package auth + +import ( + "context" + "testing" +) + +func TestShareContext(t *testing.T) { + tests := []struct { + name string + stored *ShareContext + wantOK bool + }{ + { + name: "empty context returns nil and false", + stored: nil, // not stored at all — use background context directly + wantOK: false, + }, + { + name: "store and retrieve read-only context", + stored: &ShareContext{ + Token: "tok-abc", + SessionID: "sess-123", + UserID: "user-456", + ReadOnly: true, + }, + wantOK: true, + }, + { + name: "store and retrieve read-write context", + stored: &ShareContext{ + Token: "tok-xyz", + SessionID: "sess-789", + UserID: "user-001", + ReadOnly: false, + }, + wantOK: true, + }, + { + name: "nil value stored returns false", + stored: (*ShareContext)(nil), // explicitly store nil via ShareContextTo + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ctx context.Context + if tt.name == "empty context returns nil and false" { + ctx = context.Background() + } else { + ctx = ShareContextTo(context.Background(), tt.stored) + } + + got, ok := ShareContextFrom(ctx) + + if ok != tt.wantOK { + t.Errorf("ok = %v, want %v", ok, tt.wantOK) + } + if !tt.wantOK { + if got != nil { + t.Errorf("expected nil ShareContext, got %+v", got) + } + return + } + if got == nil { + t.Fatalf("expected non-nil ShareContext, got nil") + } + if got.Token != tt.stored.Token { + t.Errorf("Token = %q, want %q", got.Token, tt.stored.Token) + } + if got.SessionID != tt.stored.SessionID { + t.Errorf("SessionID = %q, want %q", got.SessionID, tt.stored.SessionID) + } + if got.UserID != tt.stored.UserID { + t.Errorf("UserID = %q, want %q", got.UserID, tt.stored.UserID) + } + if got.ReadOnly != tt.stored.ReadOnly { + t.Errorf("ReadOnly = %v, want %v", got.ReadOnly, tt.stored.ReadOnly) + } + }) + } +} diff --git a/go/core/pkg/env/kagent.go b/go/core/pkg/env/kagent.go index 5d158b2060..184985e370 100644 --- a/go/core/pkg/env/kagent.go +++ b/go/core/pkg/env/kagent.go @@ -49,6 +49,14 @@ var ( ComponentAgentRuntime, ) + KagentUIURL = RegisterStringVar( + "KAGENT_UI_URL", + "", + "Public base URL of the kagent UI (e.g. https://kagent.example.com). "+ + "When set, share link tools return full clickable URLs instead of paths.", + ComponentAgentRuntime, + ) + KagentSkillsFolder = RegisterStringVar( "KAGENT_SKILLS_FOLDER", "/skills", diff --git a/go/core/pkg/migrations/core/000005_session_shares.down.sql b/go/core/pkg/migrations/core/000005_session_shares.down.sql new file mode 100644 index 0000000000..190d54c1ab --- /dev/null +++ b/go/core/pkg/migrations/core/000005_session_shares.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS session_share; diff --git a/go/core/pkg/migrations/core/000005_session_shares.up.sql b/go/core/pkg/migrations/core/000005_session_shares.up.sql new file mode 100644 index 0000000000..75b24159e2 --- /dev/null +++ b/go/core/pkg/migrations/core/000005_session_shares.up.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS session_share ( + id BIGSERIAL PRIMARY KEY, + token TEXT UNIQUE NOT NULL, + session_id TEXT NOT NULL, + user_id TEXT NOT NULL, + read_only BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_session_share_session_id ON session_share (session_id); + +CREATE TABLE IF NOT EXISTS session_share_access ( + user_id TEXT NOT NULL, + share_id BIGINT NOT NULL REFERENCES session_share(id) ON DELETE CASCADE, + accessed_at TIMESTAMP NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, share_id) +); diff --git a/helm/kagent-crds/templates/kagent.dev_agents.yaml b/helm/kagent-crds/templates/kagent.dev_agents.yaml index f54a5879d1..9b82b9d7b2 100644 --- a/helm/kagent-crds/templates/kagent.dev_agents.yaml +++ b/helm/kagent-crds/templates/kagent.dev_agents.yaml @@ -13055,6 +13055,12 @@ spec: - python - go type: string + shareTools: + description: |- + ShareTools enables the built-in share link tools for this agent. + When true, the agent gains create_share_link, list_share_links, and delete_share_link tools + that allow it to manage share tokens for the current session. + type: boolean stream: description: |- Whether to stream the response from the model. diff --git a/helm/kagent-crds/templates/kagent.dev_sandboxagents.yaml b/helm/kagent-crds/templates/kagent.dev_sandboxagents.yaml index b8bc8dce7a..6442847603 100644 --- a/helm/kagent-crds/templates/kagent.dev_sandboxagents.yaml +++ b/helm/kagent-crds/templates/kagent.dev_sandboxagents.yaml @@ -10713,6 +10713,12 @@ spec: - python - go type: string + shareTools: + description: |- + ShareTools enables the built-in share link tools for this agent. + When true, the agent gains create_share_link, list_share_links, and delete_share_link tools + that allow it to manage share tokens for the current session. + type: boolean stream: description: |- Whether to stream the response from the model. diff --git a/helm/kagent/templates/controller-deployment.yaml b/helm/kagent/templates/controller-deployment.yaml index ee7119b8ea..8f546ab2e3 100644 --- a/helm/kagent/templates/controller-deployment.yaml +++ b/helm/kagent/templates/controller-deployment.yaml @@ -84,6 +84,10 @@ spec: {{- else }} {{ fail "No database connection configured. Set database.postgres.url, database.postgres.urlFile, or enable database.postgres.bundled." }} {{- end }} + {{- if .Values.controller.externalUrl }} + - name: KAGENT_UI_URL + value: {{ .Values.controller.externalUrl | quote }} + {{- end }} {{- with .Values.controller.env }} {{- toYaml . | nindent 12 }} {{- end }} diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index d6e5dff723..074c3a2de7 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -176,6 +176,11 @@ controller: # -- The base URL of the A2A Server endpoint, as advertised to clients. # @default -- `http://-controller..svc.cluster.local:` a2aBaseUrl: "" + # -- Public-facing base URL of this kagent deployment (e.g. https://kagent.example.com). + # When set, the controller injects KAGENT_UI_URL into agent pods so that + # share link tools return full clickable URLs instead of relative paths. + # @default -- "" (share tools return paths only) + externalUrl: "" agentImage: registry: "" repository: kagent-dev/kagent/app diff --git a/python/packages/kagent-adk/src/kagent/adk/tools/__init__.py b/python/packages/kagent-adk/src/kagent/adk/tools/__init__.py index 062f17e8e6..716b38b0fd 100644 --- a/python/packages/kagent-adk/src/kagent/adk/tools/__init__.py +++ b/python/packages/kagent-adk/src/kagent/adk/tools/__init__.py @@ -1,5 +1,6 @@ from .bash_tool import BashTool from .file_tools import EditFileTool, ReadFileTool, WriteFileTool +from .share_tools import CreateShareLinkTool, DeleteShareLinkTool, ListShareLinksTool from .skill_tool import SkillsTool from .skills_plugin import add_skills_tool_to_agent from .skills_toolset import SkillsToolset @@ -12,4 +13,7 @@ "ReadFileTool", "WriteFileTool", "add_skills_tool_to_agent", + "CreateShareLinkTool", + "ListShareLinksTool", + "DeleteShareLinkTool", ] diff --git a/python/packages/kagent-adk/src/kagent/adk/tools/share_tools.py b/python/packages/kagent-adk/src/kagent/adk/tools/share_tools.py new file mode 100644 index 0000000000..9b2edaa9e2 --- /dev/null +++ b/python/packages/kagent-adk/src/kagent/adk/tools/share_tools.py @@ -0,0 +1,221 @@ +"""Share link tools for agents to manage session sharing.""" + +from __future__ import annotations + +import asyncio +import logging +import os +from typing import Any, Dict + +import httpx +from google.adk.tools import BaseTool, ToolContext +from google.genai import types +from kagent.core.a2a import get_request_user_id + +from .._token import read_token + +logger = logging.getLogger("kagent_adk." + __name__) + +_KAGENT_URL = os.getenv("KAGENT_URL", "") +_KAGENT_UI_URL = os.getenv("KAGENT_UI_URL", "").rstrip("/") + + +def _parse_app_name(app_name: str) -> tuple[str, str]: + """Parse a Python-identifier app_name back to (namespace, name).""" + parts = app_name.split("__NS__", 1) + if len(parts) != 2: + return ("", app_name.replace("_", "-")) + return (parts[0].replace("_", "-"), parts[1].replace("_", "-")) + + +def _share_url(token: str, session_id: str, app_name: str) -> str: + """Return the share URL for the current session. + + Requires KAGENT_UI_URL to be set; without it only the raw token is returned. + """ + namespace, name = _parse_app_name(app_name) + path = f"/agents/{namespace}/{name}/chat/{session_id}?share={token}" + return f"{_KAGENT_UI_URL}{path}" if _KAGENT_UI_URL else path + + +async def _kagent_request( + method: str, + path: str, + app_name: str, + json_body: Dict[str, Any] | None = None, +) -> httpx.Response: + """Make an authenticated request to the kagent API.""" + if not _KAGENT_URL: + raise RuntimeError("KAGENT_URL environment variable is not set") + headers: Dict[str, str] = {"X-Agent-Name": app_name} + token = await asyncio.to_thread(read_token) + if token: + headers["Authorization"] = f"Bearer {token}" + user_id = get_request_user_id() + if user_id: + headers["X-User-Id"] = user_id + async with httpx.AsyncClient(base_url=_KAGENT_URL) as client: + return await client.request(method, path, headers=headers, json=json_body) + + +class CreateShareLinkTool(BaseTool): + """Create a share link for the current session. + + The link allows any authenticated user to view (and optionally interact with) the session. + """ + + def __init__(self) -> None: + super().__init__( + name="create_share_link", + description=( + "Creates a share link for the current chat session. " + "Returns a URL any authenticated user can open to view this session. " + "The link is read-only by default (visitors cannot send messages). " + "Set read_only=false to allow visitors to interact. " + "Each call creates a new token; existing tokens remain valid." + ), + ) + + def _get_declaration(self) -> types.FunctionDeclaration: + return types.FunctionDeclaration( + name=self.name, + description=self.description, + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "read_only": types.Schema( + type=types.Type.BOOLEAN, + description="When true, the shared link will be read-only (visitors cannot send messages). Defaults to true.", + ), + }, + ), + ) + + async def run_async(self, *, args: Dict[str, Any], tool_context: ToolContext) -> str: + session_id = tool_context.session.id + if not session_id or not session_id.strip(): + return "Error: session ID is empty — cannot create share link." + app_name = tool_context.session.app_name + read_only = bool(args.get("read_only", True)) + try: + response = await _kagent_request( + "POST", + f"/api/sessions/{session_id}/shares", + app_name, + json_body={"read_only": read_only}, + ) + if response.status_code == 201: + data = response.json().get("data", {}) + token = data.get("token", "") + suffix = " (read-only)" if read_only else "" + return f"Share link created{suffix}: {_share_url(token, session_id, app_name)}" + return f"Failed to create share link: HTTP {response.status_code}: {response.text}" + except httpx.TimeoutException as e: + logger.error("Timeout creating share link: %s", e) + return "Error creating share link: request timed out" + except httpx.RequestError as e: + logger.error("Request error creating share link: %s", e) + return f"Error creating share link: {e}" + except Exception as e: + logger.error("Error creating share link: %s", e) + return f"Error creating share link: {e}" + + +class ListShareLinksTool(BaseTool): + """List existing share links for the current session.""" + + def __init__(self) -> None: + super().__init__( + name="list_share_links", + description=( + "Lists all active share links for the current session. " + "Returns each share token and creation time. " + "Use this to find a token before calling delete_share_link." + ), + ) + + def _get_declaration(self) -> types.FunctionDeclaration: + return types.FunctionDeclaration( + name=self.name, + description=self.description, + parameters=types.Schema(type=types.Type.OBJECT, properties={}), + ) + + async def run_async(self, *, args: Dict[str, Any], tool_context: ToolContext) -> str: + session_id = tool_context.session.id + if not session_id or not session_id.strip(): + return "Error: session ID is empty — cannot list share links." + app_name = tool_context.session.app_name + try: + response = await _kagent_request("GET", f"/api/sessions/{session_id}/shares", app_name) + if response.status_code == 200: + shares = response.json().get("data", []) + if not shares: + return "No active share links for this session." + lines = [ + f"- token: {s.get('token', '')}, created_at: {s.get('created_at', 'unknown')}" + for s in shares + ] + return "Active share links:\n" + "\n".join(lines) + return f"Failed to list share links: HTTP {response.status_code}: {response.text}" + except httpx.TimeoutException as e: + logger.error("Timeout listing share links: %s", e) + return "Error listing share links: request timed out" + except httpx.RequestError as e: + logger.error("Request error listing share links: %s", e) + return f"Error listing share links: {e}" + except Exception as e: + logger.error("Error listing share links: %s", e) + return f"Error listing share links: {e}" + + +class DeleteShareLinkTool(BaseTool): + """Delete a share link for the current session, revoking visitor access.""" + + def __init__(self) -> None: + super().__init__( + name="delete_share_link", + description=( + "Deletes a share link by token, immediately revoking access for anyone using it. " + "Use list_share_links first to find the token you want to revoke." + ), + ) + + def _get_declaration(self) -> types.FunctionDeclaration: + return types.FunctionDeclaration( + name=self.name, + description=self.description, + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "token": types.Schema( + type=types.Type.STRING, + description="The share token to revoke.", + ), + }, + required=["token"], + ), + ) + + async def run_async(self, *, args: Dict[str, Any], tool_context: ToolContext) -> str: + session_id = tool_context.session.id + if not session_id or not session_id.strip(): + return "Error: session ID is empty — cannot delete share link." + app_name = tool_context.session.app_name + token = args.get("token", "").strip() + if not token: + return "Error: token is required." + try: + response = await _kagent_request("DELETE", f"/api/sessions/{session_id}/shares/{token}", app_name) + if response.status_code == 200: + return f"Share link {token!r} revoked successfully." + return f"Failed to delete share link: HTTP {response.status_code}: {response.text}" + except httpx.TimeoutException as e: + logger.error("Timeout deleting share link: %s", e) + return "Error deleting share link: request timed out" + except httpx.RequestError as e: + logger.error("Request error deleting share link: %s", e) + return f"Error deleting share link: {e}" + except Exception as e: + logger.error("Error deleting share link: %s", e) + return f"Error deleting share link: {e}" diff --git a/python/packages/kagent-adk/src/kagent/adk/types.py b/python/packages/kagent-adk/src/kagent/adk/types.py index 5e2f4a97af..5a13888878 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -297,6 +297,7 @@ class AgentConfig(BaseModel): memory: MemoryConfig | None = None # Memory configuration network: NetworkConfig | None = None context_config: ContextConfig | None = None + share_tools: bool | None = None # Enable built-in share link tools def to_agent( self, name: str, sts_integration: Optional[ADKTokenPropagationPlugin] = None, propagate_token: bool = False @@ -441,6 +442,12 @@ async def rewrite_url_to_proxy(request: httpx.Request) -> None: if self.memory is not None: self._configure_memory(agent) + # Inject share link tools if enabled + if self.share_tools: + from kagent.adk.tools.share_tools import CreateShareLinkTool, DeleteShareLinkTool, ListShareLinksTool + + agent.tools.extend([CreateShareLinkTool(), ListShareLinksTool(), DeleteShareLinkTool()]) + return agent def _configure_memory(self, agent: Agent) -> None: diff --git a/python/packages/kagent-adk/tests/unittests/test_share_tools.py b/python/packages/kagent-adk/tests/unittests/test_share_tools.py new file mode 100644 index 0000000000..4e9fbe5849 --- /dev/null +++ b/python/packages/kagent-adk/tests/unittests/test_share_tools.py @@ -0,0 +1,293 @@ +"""Tests for share link tools.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from kagent.adk.tools.share_tools import ( + CreateShareLinkTool, + DeleteShareLinkTool, + ListShareLinksTool, + _parse_app_name, + _share_url, +) + + +class MockSession: + """Mock Session for testing.""" + + def __init__(self, session_id: str = "test-session-123", app_name: str = "kagent__NS__myagent"): + self.id = session_id + self.app_name = app_name + + +class MockToolContext: + """Mock ToolContext for testing.""" + + def __init__(self, session_id: str = "test-session-123", app_name: str = "kagent__NS__myagent"): + self.session = MockSession(session_id, app_name) + + +def _mock_response(status_code: int, json_data: object): + """Build a mock httpx.Response.""" + r = MagicMock() + r.status_code = status_code + r.json.return_value = json_data + r.text = str(json_data) + return r + + +# --------------------------------------------------------------------------- +# _parse_app_name +# --------------------------------------------------------------------------- + + +class TestParseAppName: + """Tests for _parse_app_name.""" + + def test_standard_format(self): + """kagent__NS__my_agent → ('kagent', 'my-agent').""" + ns, name = _parse_app_name("kagent__NS__my_agent") + assert ns == "kagent" + assert name == "my-agent" + + def test_no_separator(self): + """app_name with no __NS__ separator returns empty namespace.""" + ns, name = _parse_app_name("noformat") + assert ns == "" + assert name == "noformat" + + +# --------------------------------------------------------------------------- +# _share_url +# --------------------------------------------------------------------------- + + +class TestShareUrl: + """Tests for _share_url.""" + + def test_with_ui_url(self): + """With KAGENT_UI_URL set, returns an absolute URL.""" + with patch("kagent.adk.tools.share_tools._KAGENT_UI_URL", "https://example.com"): + url = _share_url("abc123", "sess-1", "kagent__NS__myagent") + assert url.startswith("https://example.com") + assert "abc123" in url + assert "sess-1" in url + + def test_without_ui_url(self): + """Without KAGENT_UI_URL, returns a relative path.""" + with patch("kagent.adk.tools.share_tools._KAGENT_UI_URL", ""): + url = _share_url("abc123", "sess-1", "kagent__NS__myagent") + assert url.startswith("/") + assert "abc123" in url + + +# --------------------------------------------------------------------------- +# CreateShareLinkTool +# --------------------------------------------------------------------------- + + +class TestCreateShareLinkTool: + """Tests for CreateShareLinkTool.run_async.""" + + async def test_creates_link_read_only_by_default(self): + """Default args produce a read-only share link.""" + tool = CreateShareLinkTool() + ctx = MockToolContext() + mock_resp = _mock_response(201, {"data": {"token": "tok-ro"}}) + + with patch( + "kagent.adk.tools.share_tools._kagent_request", + new=AsyncMock(return_value=mock_resp), + ): + result = await tool.run_async(args={}, tool_context=ctx) + + assert "tok-ro" in result + assert "(read-only)" in result + + async def test_creates_link_read_write(self): + """args={'read_only': False} produces a read-write share link.""" + tool = CreateShareLinkTool() + ctx = MockToolContext() + mock_resp = _mock_response(201, {"data": {"token": "tok-rw"}}) + + with patch( + "kagent.adk.tools.share_tools._kagent_request", + new=AsyncMock(return_value=mock_resp), + ): + result = await tool.run_async(args={"read_only": False}, tool_context=ctx) + + assert "tok-rw" in result + assert "(read-only)" not in result + + async def test_api_error(self): + """A non-201 status code returns a failure message.""" + tool = CreateShareLinkTool() + ctx = MockToolContext() + mock_resp = _mock_response(500, {"error": "internal server error"}) + + with patch( + "kagent.adk.tools.share_tools._kagent_request", + new=AsyncMock(return_value=mock_resp), + ): + result = await tool.run_async(args={}, tool_context=ctx) + + assert result.startswith("Failed to create share link") + + async def test_sends_correct_read_only_in_body(self): + """Default args send read_only=True in the request body.""" + tool = CreateShareLinkTool() + ctx = MockToolContext() + mock_resp = _mock_response(201, {"data": {"token": "t"}}) + + with patch( + "kagent.adk.tools.share_tools._kagent_request", + new=AsyncMock(return_value=mock_resp), + ) as mock_req: + await tool.run_async(args={}, tool_context=ctx) + + mock_req.assert_called_once() + _, _, _, kwargs_or_positional = _unpack_call(mock_req) + assert kwargs_or_positional == {"read_only": True} + + async def test_sends_read_write_in_body(self): + """args={'read_only': False} sends read_only=False in the request body.""" + tool = CreateShareLinkTool() + ctx = MockToolContext() + mock_resp = _mock_response(201, {"data": {"token": "t"}}) + + with patch( + "kagent.adk.tools.share_tools._kagent_request", + new=AsyncMock(return_value=mock_resp), + ) as mock_req: + await tool.run_async(args={"read_only": False}, tool_context=ctx) + + mock_req.assert_called_once() + _, _, _, kwargs_or_positional = _unpack_call(mock_req) + assert kwargs_or_positional == {"read_only": False} + + +# --------------------------------------------------------------------------- +# ListShareLinksTool +# --------------------------------------------------------------------------- + + +class TestListShareLinksTool: + """Tests for ListShareLinksTool.run_async.""" + + async def test_returns_formatted_list(self): + """A non-empty share list is returned with each token shown.""" + tool = ListShareLinksTool() + ctx = MockToolContext() + shares = [ + {"token": "tok-1", "created_at": "2024-01-01T00:00:00Z"}, + {"token": "tok-2", "created_at": "2024-01-02T00:00:00Z"}, + ] + mock_resp = _mock_response(200, {"data": shares}) + + with patch( + "kagent.adk.tools.share_tools._kagent_request", + new=AsyncMock(return_value=mock_resp), + ): + result = await tool.run_async(args={}, tool_context=ctx) + + assert "tok-1" in result + assert "tok-2" in result + + async def test_empty_list(self): + """An empty data list returns the 'no active share links' message.""" + tool = ListShareLinksTool() + ctx = MockToolContext() + mock_resp = _mock_response(200, {"data": []}) + + with patch( + "kagent.adk.tools.share_tools._kagent_request", + new=AsyncMock(return_value=mock_resp), + ): + result = await tool.run_async(args={}, tool_context=ctx) + + assert result == "No active share links for this session." + + async def test_api_error(self): + """A non-200 status code returns a failure message.""" + tool = ListShareLinksTool() + ctx = MockToolContext() + mock_resp = _mock_response(404, {"error": "not found"}) + + with patch( + "kagent.adk.tools.share_tools._kagent_request", + new=AsyncMock(return_value=mock_resp), + ): + result = await tool.run_async(args={}, tool_context=ctx) + + assert result.startswith("Failed") + + +# --------------------------------------------------------------------------- +# DeleteShareLinkTool +# --------------------------------------------------------------------------- + + +class TestDeleteShareLinkTool: + """Tests for DeleteShareLinkTool.run_async.""" + + async def test_revokes_token(self): + """A successful DELETE returns a message containing 'revoked'.""" + tool = DeleteShareLinkTool() + ctx = MockToolContext() + mock_resp = _mock_response(200, {"data": {}}) + + with patch( + "kagent.adk.tools.share_tools._kagent_request", + new=AsyncMock(return_value=mock_resp), + ): + result = await tool.run_async(args={"token": "abc123"}, tool_context=ctx) + + assert "revoked" in result + + async def test_empty_token(self): + """An empty token returns the 'token is required' error without an API call.""" + tool = DeleteShareLinkTool() + ctx = MockToolContext() + + with patch( + "kagent.adk.tools.share_tools._kagent_request", + new=AsyncMock(), + ) as mock_req: + result = await tool.run_async(args={"token": ""}, tool_context=ctx) + + assert result == "Error: token is required." + mock_req.assert_not_called() + + async def test_api_error(self): + """A non-200 status code returns a failure message.""" + tool = DeleteShareLinkTool() + ctx = MockToolContext() + mock_resp = _mock_response(403, {"error": "forbidden"}) + + with patch( + "kagent.adk.tools.share_tools._kagent_request", + new=AsyncMock(return_value=mock_resp), + ): + result = await tool.run_async(args={"token": "abc123"}, tool_context=ctx) + + assert result.startswith("Failed") + + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + + +def _unpack_call(mock_req: AsyncMock): + """Return (method, path, app_name, json_body) from the first call to mock_req. + + _kagent_request is called as: + await _kagent_request(method, path, app_name, json_body=...) + """ + call_args = mock_req.call_args + # positional args + pos = list(call_args.args) + method = pos[0] if len(pos) > 0 else call_args.kwargs.get("method") + path = pos[1] if len(pos) > 1 else call_args.kwargs.get("path") + app_name = pos[2] if len(pos) > 2 else call_args.kwargs.get("app_name") + json_body = call_args.kwargs.get("json_body", pos[3] if len(pos) > 3 else None) + return method, path, app_name, json_body diff --git a/ui/src/app/actions/sessionShares.ts b/ui/src/app/actions/sessionShares.ts new file mode 100644 index 0000000000..af278b75ba --- /dev/null +++ b/ui/src/app/actions/sessionShares.ts @@ -0,0 +1,45 @@ +"use server"; + +import { BaseResponse } from "@/types"; +import { fetchApi, createErrorResponse } from "./utils"; + +export interface SessionShare { + token: string; + session_id: string; + read_only: boolean; + created_at: string; +} + +/** Creates a share link for the given session (caller must own the session). */ +export async function createSessionShare(sessionId: string, readOnly: boolean = true): Promise> { + try { + const data = await fetchApi>(`/sessions/${sessionId}/shares`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ read_only: readOnly }), + }); + return data; + } catch (error) { + return createErrorResponse(error, "Error creating session share"); + } +} + +/** Lists all share links for the given session (caller must own the session). */ +export async function listSessionShares(sessionId: string): Promise> { + try { + const data = await fetchApi>(`/sessions/${sessionId}/shares`); + return data; + } catch (error) { + return createErrorResponse(error, "Error listing session shares"); + } +} + +/** Deletes a share link (caller must own the session). */ +export async function deleteSessionShare(sessionId: string, token: string): Promise> { + try { + await fetchApi(`/sessions/${sessionId}/shares/${token}`, { method: "DELETE" }); + return { message: "Share deleted" }; + } catch (error) { + return createErrorResponse(error, "Error deleting session share"); + } +} diff --git a/ui/src/app/actions/sessions.ts b/ui/src/app/actions/sessions.ts index 3f987076f8..2a7a02403d 100644 --- a/ui/src/app/actions/sessions.ts +++ b/ui/src/app/actions/sessions.ts @@ -6,6 +6,12 @@ import { revalidatePath } from "next/cache"; import { fetchApi, createErrorResponse } from "./utils"; import { Task } from "@a2a-js/sdk"; +export interface SessionWithEvents { + session: Session; + events: unknown[]; + read_only?: boolean | null; +} + /** * Deletes a session * @param sessionId The session ID @@ -27,11 +33,14 @@ export async function deleteSession(sessionId: string): Promise> { +export async function getSession(sessionId: string, shareToken?: string): Promise> { try { - const data = await fetchApi(`/sessions/${sessionId}`); + const data = await fetchApi(`/sessions/${sessionId}`, { + headers: shareToken ? { "X-Share-Token": shareToken } : undefined, + }); return { message: "Session fetched successfully", data }; } catch (error) { return createErrorResponse(error, "Error getting session"); @@ -79,11 +88,14 @@ export async function createSession(session: CreateSessionRequest): Promise> { +export async function getSessionTasks(sessionId: string, shareToken?: string): Promise> { try { - const data = await fetchApi>(`/sessions/${sessionId}/tasks`); + const data = await fetchApi>(`/sessions/${sessionId}/tasks`, { + headers: shareToken ? { "X-Share-Token": shareToken } : undefined, + }); return data; } catch (error) { return createErrorResponse(error, "Error getting session tasks"); @@ -118,6 +130,23 @@ export async function getSubagentSessionWithEvents( } } +/** + * Gets a session with its events, optionally using a share token. + * @param sessionId The session ID + * @param shareToken Optional X-Share-Token for accessing a shared session + */ +export async function getSessionWithEvents(sessionId: string, shareToken?: string): Promise> { + try { + const opts = { + headers: shareToken ? { "X-Share-Token": shareToken } : undefined, + }; + const data = await fetchApi>(`/sessions/${sessionId}`, opts); + return data; + } catch (error) { + return createErrorResponse(error, "Error getting session"); + } +} + /** * Check if a session exists * @param sessionId The session ID to check @@ -127,10 +156,9 @@ export async function checkSessionExists(sessionId: string): Promise>(`/sessions/${sessionId}`); return { message: "Session exists successfully", data: !!response.data }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error: any) { + } catch (error: unknown) { // If we get a 404, return success: true but data: false - if (error?.status === 404) { + if (typeof error === "object" && error !== null && "status" in error && (error as { status: unknown }).status === 404) { return { message: "Session does not exist", data: false }; } return createErrorResponse(error, "Error checking session"); diff --git a/ui/src/app/agents/[namespace]/[name]/chat/[chatId]/page.tsx b/ui/src/app/agents/[namespace]/[name]/chat/[chatId]/page.tsx index 42ba1f5a18..1f249af944 100644 --- a/ui/src/app/agents/[namespace]/[name]/chat/[chatId]/page.tsx +++ b/ui/src/app/agents/[namespace]/[name]/chat/[chatId]/page.tsx @@ -1,13 +1,25 @@ "use client"; -import { use } from "react"; +import { use, Suspense } from "react"; +import { useSearchParams } from "next/navigation"; import ChatInterface from "@/components/chat/ChatInterface"; -export default function ChatPageView({ params }: { params: Promise<{ name: string; namespace: string; chatId: string }> }) { +function ChatPageViewInner({ params }: { params: Promise<{ name: string; namespace: string; chatId: string }> }) { const { name, namespace, chatId } = use(params); + const searchParams = useSearchParams(); + const shareToken = searchParams.get("share") ?? undefined; return ; } + +export default function ChatPageView({ params }: { params: Promise<{ name: string; namespace: string; chatId: string }> }) { + return ( + + + + ); +} diff --git a/ui/src/components/ToolDisplay.tsx b/ui/src/components/ToolDisplay.tsx index 1fd6ef81a3..76b436079d 100644 --- a/ui/src/components/ToolDisplay.tsx +++ b/ui/src/components/ToolDisplay.tsx @@ -191,7 +191,7 @@ const ToolDisplay = ({ call, result, status = "requested", isError = false, isDe )} - {/* Approval buttons — hidden when decided (batch) or submitting */} + {/* Approval buttons — hidden when decided (batch) or submitting; disabled when no handler (read-only) */} {status === "pending_approval" && !isSubmitting && !isDecided && !showRejectForm && (
@@ -199,6 +199,7 @@ const ToolDisplay = ({ call, result, status = "requested", isError = false, isDe size="sm" variant="default" onClick={handleApprove} + disabled={!onApprove} > Approve @@ -206,6 +207,7 @@ const ToolDisplay = ({ call, result, status = "requested", isError = false, isDe size="sm" variant="destructive" onClick={handleRejectClick} + disabled={!onReject} > Reject diff --git a/ui/src/components/chat/AskUserDisplay.tsx b/ui/src/components/chat/AskUserDisplay.tsx index 1810246552..d08df624eb 100644 --- a/ui/src/components/chat/AskUserDisplay.tsx +++ b/ui/src/components/chat/AskUserDisplay.tsx @@ -15,7 +15,7 @@ export interface AskUserQuestion { interface AskUserDisplayProps { questions: AskUserQuestion[]; - onSubmit: (answers: Array<{ answer: string[] }>) => void; + onSubmit?: (answers: Array<{ answer: string[] }>) => void; isResolved?: boolean; /** Resolved answers — one entry per question. */ resolvedAnswers?: Array<{ answer: string[] }> | null; @@ -51,7 +51,7 @@ export default function AskUserDisplay({ const [isSubmitting, setIsSubmitting] = useState(false); const toggleChoice = (qIdx: number, choice: string) => { - if (isResolved || isSubmitting) return; + if (isResolved || isSubmitting || !onSubmit) return; setSelectedChoices(prev => { const next = prev.map(s => [...s]); const q = questions[qIdx]; @@ -82,7 +82,7 @@ export default function AskUserDisplay({ }); const handleSubmit = () => { - if (!isReadyToSubmit || isSubmitting) return; + if (!onSubmit || !isReadyToSubmit || isSubmitting) return; setIsSubmitting(true); const answers = questions.map((_, i) => { const chips = selectedChoices[i]; @@ -137,14 +137,14 @@ export default function AskUserDisplay({
-
- - {sessionStats.total > 0 && } -
- -
-