From 5e7a770d183f809788c1504c1003cf79d296c2dd Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Thu, 12 Mar 2026 19:04:00 +0000 Subject: [PATCH 1/6] feat: add pluggable sandbox support for per-session workspaces Implements phases 1-5 of the pluggable sandbox design: - Phase 1: Add workspace field to Agent CRD, thread through config.json to agent pod via translator, add Python WorkspaceConfig type - Phase 2: SandboxProvider interface, SandboxManager with goroutine-safe session-to-sandbox lifecycle mapping, stub provider for testing - Phase 3: POST/GET /api/sessions/{id}/sandbox HTTP endpoints, sandbox cleanup on session delete, wiring through ServerConfig and app startup - Phase 4: Python ADK sandbox provisioning on session start, stores MCP URL in session state, dynamically adds KAgentMcpToolset to runner - Phase 5: kagent-sandbox-mcp container image with exec, read_file, write_file, list_dir MCP tools over StreamableHTTP Co-Authored-By: Claude Opus 4.6 Signed-off-by: Eitan Yarmush --- go/api/adk/types.go | 12 ++ .../config/crd/bases/kagent.dev_agents.yaml | 18 ++ go/api/httpapi/types.go | 24 +++ go/api/v1alpha2/agent_types.go | 7 + go/api/v1alpha2/zz_generated.deepcopy.go | 5 + .../controller/reconciler/reconciler.go | 5 + .../internal/controller/sandbox/manager.go | 74 +++++++++ .../controller/sandbox/manager_test.go | 154 ++++++++++++++++++ .../internal/controller/sandbox/provider.go | 58 +++++++ .../controller/sandbox/stub_provider.go | 35 ++++ .../translator/agent/adk_api_translator.go | 14 ++ .../internal/httpserver/handlers/handlers.go | 7 +- .../internal/httpserver/handlers/sandbox.go | 117 +++++++++++++ .../internal/httpserver/handlers/sessions.go | 13 +- .../httpserver/handlers/sessions_test.go | 2 +- go/core/internal/httpserver/server.go | 8 +- go/core/pkg/app/app.go | 5 + go/sandbox-mcp/Dockerfile | 14 ++ go/sandbox-mcp/Makefile | 23 +++ go/sandbox-mcp/cmd/main.go | 116 +++++++++++++ go/sandbox-mcp/go.mod | 11 ++ go/sandbox-mcp/go.sum | 26 +++ go/sandbox-mcp/pkg/tools/exec.go | 55 +++++++ go/sandbox-mcp/pkg/tools/exec_test.go | 55 +++++++ go/sandbox-mcp/pkg/tools/fs.go | 80 +++++++++ go/sandbox-mcp/pkg/tools/fs_test.go | 83 ++++++++++ .../kagent-adk/src/kagent/adk/_a2a.py | 1 + .../src/kagent/adk/_agent_executor.py | 77 +++++++++ .../kagent-adk/src/kagent/adk/types.py | 10 ++ 29 files changed, 1103 insertions(+), 6 deletions(-) create mode 100644 go/core/internal/controller/sandbox/manager.go create mode 100644 go/core/internal/controller/sandbox/manager_test.go create mode 100644 go/core/internal/controller/sandbox/provider.go create mode 100644 go/core/internal/controller/sandbox/stub_provider.go create mode 100644 go/core/internal/httpserver/handlers/sandbox.go create mode 100644 go/sandbox-mcp/Dockerfile create mode 100644 go/sandbox-mcp/Makefile create mode 100644 go/sandbox-mcp/cmd/main.go create mode 100644 go/sandbox-mcp/go.mod create mode 100644 go/sandbox-mcp/go.sum create mode 100644 go/sandbox-mcp/pkg/tools/exec.go create mode 100644 go/sandbox-mcp/pkg/tools/exec_test.go create mode 100644 go/sandbox-mcp/pkg/tools/fs.go create mode 100644 go/sandbox-mcp/pkg/tools/fs_test.go diff --git a/go/api/adk/types.go b/go/api/adk/types.go index aee673f09..e7bde7128 100644 --- a/go/api/adk/types.go +++ b/go/api/adk/types.go @@ -427,6 +427,15 @@ func (c *AgentCompressionConfig) UnmarshalJSON(data []byte) error { return nil } +// WorkspaceConfig carries the workspace reference from the Agent CRD through +// config.json so the agent runtime can request a sandbox on session start. +type WorkspaceConfig struct { + APIGroup string `json:"api_group"` + Kind string `json:"kind"` + Name string `json:"name"` + Namespace string `json:"namespace"` +} + // See `python/packages/kagent-adk/src/kagent/adk/types.py` for the python version of this type AgentConfig struct { Model Model `json:"model"` @@ -439,6 +448,7 @@ type AgentConfig struct { Stream *bool `json:"stream,omitempty"` Memory *MemoryConfig `json:"memory,omitempty"` ContextConfig *AgentContextConfig `json:"context_config,omitempty"` + Workspace *WorkspaceConfig `json:"workspace,omitempty"` } // GetStream returns the stream value or default if not set @@ -469,6 +479,7 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error { Stream *bool `json:"stream,omitempty"` Memory json.RawMessage `json:"memory"` ContextConfig *AgentContextConfig `json:"context_config,omitempty"` + Workspace *WorkspaceConfig `json:"workspace,omitempty"` } if err := json.Unmarshal(data, &tmp); err != nil { return err @@ -497,6 +508,7 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error { a.Stream = tmp.Stream a.Memory = memory a.ContextConfig = tmp.ContextConfig + a.Workspace = tmp.Workspace 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 a34ced941..a41804c5a 100644 --- a/go/api/config/crd/bases/kagent.dev_agents.yaml +++ b/go/api/config/crd/bases/kagent.dev_agents.yaml @@ -10160,6 +10160,24 @@ spec: rule: '!(!has(self.agent) && self.type == ''Agent'')' maxItems: 20 type: array + workspace: + description: |- + Workspace references an external resource that provides a per-session + sandbox environment (filesystem + shell) for the agent. The referenced + resource is opaque to kagent; a matching SandboxProvider implementation + must be registered in the controller. + properties: + apiGroup: + type: string + kind: + type: string + name: + type: string + namespace: + type: string + required: + - name + type: object type: object x-kubernetes-validations: - message: systemMessage and systemMessageFrom are mutually exclusive diff --git a/go/api/httpapi/types.go b/go/api/httpapi/types.go index 8679cc909..894a6e6bd 100644 --- a/go/api/httpapi/types.go +++ b/go/api/httpapi/types.go @@ -193,3 +193,27 @@ type SessionRunsResponse struct { type SessionRunsData struct { Runs []any `json:"runs"` } + +// CreateSandboxRequest represents a request to create a sandbox for a session. +type CreateSandboxRequest struct { + AgentName string `json:"agent_name"` + Namespace string `json:"namespace"` + Workspace WorkspaceRef `json:"workspace_ref"` +} + +// WorkspaceRef identifies the workspace resource to provision a sandbox from. +type WorkspaceRef struct { + APIGroup string `json:"api_group"` + Kind string `json:"kind"` + Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` +} + +// SandboxResponse is returned from sandbox creation/status endpoints. +type SandboxResponse struct { + SandboxID string `json:"sandbox_id"` + MCPUrl string `json:"mcp_url"` + Protocol string `json:"protocol"` + Headers map[string]string `json:"headers,omitempty"` + Ready bool `json:"ready"` +} diff --git a/go/api/v1alpha2/agent_types.go b/go/api/v1alpha2/agent_types.go index db57fe3cd..11615479c 100644 --- a/go/api/v1alpha2/agent_types.go +++ b/go/api/v1alpha2/agent_types.go @@ -188,6 +188,13 @@ type DeclarativeAgentSpec struct { // This includes event compaction (compression) and context caching. // +optional Context *ContextConfig `json:"context,omitempty"` + + // Workspace references an external resource that provides a per-session + // sandbox environment (filesystem + shell) for the agent. The referenced + // resource is opaque to kagent; a matching SandboxProvider implementation + // must be registered in the controller. + // +optional + Workspace *TypedReference `json:"workspace,omitempty"` } // ContextConfig configures context management for an agent. diff --git a/go/api/v1alpha2/zz_generated.deepcopy.go b/go/api/v1alpha2/zz_generated.deepcopy.go index 06ed3954e..13d0f2f96 100644 --- a/go/api/v1alpha2/zz_generated.deepcopy.go +++ b/go/api/v1alpha2/zz_generated.deepcopy.go @@ -490,6 +490,11 @@ func (in *DeclarativeAgentSpec) DeepCopyInto(out *DeclarativeAgentSpec) { *out = new(ContextConfig) (*in).DeepCopyInto(*out) } + if in.Workspace != nil { + in, out := &in.Workspace, &out.Workspace + *out = new(TypedReference) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeclarativeAgentSpec. diff --git a/go/core/internal/controller/reconciler/reconciler.go b/go/core/internal/controller/reconciler/reconciler.go index b1d00665d..689eb24ab 100644 --- a/go/core/internal/controller/reconciler/reconciler.go +++ b/go/core/internal/controller/reconciler/reconciler.go @@ -698,6 +698,11 @@ func (a *kagentReconciler) validateRuntimeFeatures(agent *v1alpha2.Agent) string unsupported = append(unsupported, "context compression/compaction (not implemented in Go runtime)") } + // Workspace/sandbox: Not yet implemented in Go runtime + if agent.Spec.Declarative.Workspace != nil { + unsupported = append(unsupported, "workspace/sandbox (not implemented in Go runtime)") + } + if len(unsupported) == 0 { return "" } diff --git a/go/core/internal/controller/sandbox/manager.go b/go/core/internal/controller/sandbox/manager.go new file mode 100644 index 000000000..4a6b256d0 --- /dev/null +++ b/go/core/internal/controller/sandbox/manager.go @@ -0,0 +1,74 @@ +package sandbox + +import ( + "context" + "fmt" + "sync" +) + +// SandboxManager manages the lifecycle mapping from session IDs to sandboxes. +// It is goroutine-safe and delegates actual provisioning to a SandboxProvider. +type SandboxManager struct { + provider SandboxProvider + sandboxes map[string]*SandboxEndpoint // keyed by session ID + mu sync.RWMutex +} + +// NewSandboxManager creates a new SandboxManager with the given provider. +func NewSandboxManager(provider SandboxProvider) *SandboxManager { + return &SandboxManager{ + provider: provider, + sandboxes: make(map[string]*SandboxEndpoint), + } +} + +// GetOrCreateSandbox returns an existing sandbox for the session or creates +// a new one. This method is idempotent for the same session ID. +func (m *SandboxManager) GetOrCreateSandbox(ctx context.Context, sessionID string, opts CreateSandboxOptions) (*SandboxEndpoint, error) { + m.mu.RLock() + if ep, ok := m.sandboxes[sessionID]; ok { + m.mu.RUnlock() + return ep, nil + } + m.mu.RUnlock() + + // Upgrade to write lock + m.mu.Lock() + defer m.mu.Unlock() + + // Double-check after acquiring write lock + if ep, ok := m.sandboxes[sessionID]; ok { + return ep, nil + } + + opts.SessionID = sessionID + ep, err := m.provider.Create(ctx, opts) + if err != nil { + return nil, fmt.Errorf("failed to create sandbox for session %s: %w", sessionID, err) + } + + m.sandboxes[sessionID] = ep + return ep, nil +} + +// GetSandbox returns the sandbox endpoint for a session, or nil if none exists. +func (m *SandboxManager) GetSandbox(sessionID string) *SandboxEndpoint { + m.mu.RLock() + defer m.mu.RUnlock() + return m.sandboxes[sessionID] +} + +// DestroySandbox tears down the sandbox for a session and removes it from +// the manager's tracking map. +func (m *SandboxManager) DestroySandbox(ctx context.Context, sessionID string) error { + m.mu.Lock() + ep, ok := m.sandboxes[sessionID] + if !ok { + m.mu.Unlock() + return nil + } + delete(m.sandboxes, sessionID) + m.mu.Unlock() + + return m.provider.Destroy(ctx, ep.ID) +} diff --git a/go/core/internal/controller/sandbox/manager_test.go b/go/core/internal/controller/sandbox/manager_test.go new file mode 100644 index 000000000..1ed6c3d96 --- /dev/null +++ b/go/core/internal/controller/sandbox/manager_test.go @@ -0,0 +1,154 @@ +package sandbox + +import ( + "context" + "sync" + "testing" +) + +func TestSandboxManager(t *testing.T) { + tests := []struct { + name string + fn func(t *testing.T, m *SandboxManager) + }{ + { + name: "create and retrieve sandbox", + fn: func(t *testing.T, m *SandboxManager) { + ctx := context.Background() + opts := CreateSandboxOptions{ + AgentName: "test-agent", + Namespace: "default", + WorkspaceRef: WorkspaceRef{ + APIGroup: "sandbox.kagent.dev", + Kind: "SandboxTemplate", + Name: "my-template", + }, + } + + ep, err := m.GetOrCreateSandbox(ctx, "session-1", opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ep.ID != "stub-session-1" { + t.Errorf("expected ID stub-session-1, got %s", ep.ID) + } + if !ep.Ready { + t.Error("expected sandbox to be ready") + } + + // Verify GetSandbox returns the same endpoint + got := m.GetSandbox("session-1") + if got == nil { + t.Fatal("expected sandbox to exist") + } + if got.ID != ep.ID { + t.Errorf("expected same sandbox ID, got %s", got.ID) + } + }, + }, + { + name: "idempotent create returns same sandbox", + fn: func(t *testing.T, m *SandboxManager) { + ctx := context.Background() + opts := CreateSandboxOptions{ + AgentName: "test-agent", + Namespace: "default", + } + + ep1, err := m.GetOrCreateSandbox(ctx, "session-2", opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + ep2, err := m.GetOrCreateSandbox(ctx, "session-2", opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if ep1.ID != ep2.ID { + t.Errorf("expected same sandbox ID on second call, got %s and %s", ep1.ID, ep2.ID) + } + }, + }, + { + name: "destroy removes sandbox", + fn: func(t *testing.T, m *SandboxManager) { + ctx := context.Background() + opts := CreateSandboxOptions{ + AgentName: "test-agent", + Namespace: "default", + } + + _, err := m.GetOrCreateSandbox(ctx, "session-3", opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + err = m.DestroySandbox(ctx, "session-3") + if err != nil { + t.Fatalf("unexpected error on destroy: %v", err) + } + + got := m.GetSandbox("session-3") + if got != nil { + t.Error("expected sandbox to be removed after destroy") + } + }, + }, + { + name: "destroy nonexistent session is no-op", + fn: func(t *testing.T, m *SandboxManager) { + err := m.DestroySandbox(context.Background(), "nonexistent") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }, + }, + { + name: "get nonexistent returns nil", + fn: func(t *testing.T, m *SandboxManager) { + got := m.GetSandbox("nonexistent") + if got != nil { + t.Error("expected nil for nonexistent sandbox") + } + }, + }, + { + name: "concurrent access", + fn: func(t *testing.T, m *SandboxManager) { + ctx := context.Background() + var wg sync.WaitGroup + + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + opts := CreateSandboxOptions{ + AgentName: "test-agent", + Namespace: "default", + } + _, err := m.GetOrCreateSandbox(ctx, "concurrent-session", opts) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }() + } + + wg.Wait() + + got := m.GetSandbox("concurrent-session") + if got == nil { + t.Fatal("expected sandbox to exist after concurrent creates") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := NewStubProvider() + manager := NewSandboxManager(provider) + tt.fn(t, manager) + }) + } +} diff --git a/go/core/internal/controller/sandbox/provider.go b/go/core/internal/controller/sandbox/provider.go new file mode 100644 index 000000000..4d11a3438 --- /dev/null +++ b/go/core/internal/controller/sandbox/provider.go @@ -0,0 +1,58 @@ +package sandbox + +import "context" + +// SandboxPhase represents the lifecycle phase of a sandbox. +type SandboxPhase string + +const ( + SandboxPhasePending SandboxPhase = "Pending" + SandboxPhaseReady SandboxPhase = "Ready" + SandboxPhaseFailed SandboxPhase = "Failed" +) + +// CreateSandboxOptions contains parameters for creating a sandbox. +type CreateSandboxOptions struct { + SessionID string + AgentName string + Namespace string + WorkspaceRef WorkspaceRef +} + +// WorkspaceRef is the workspace reference from the Agent CRD. +type WorkspaceRef struct { + APIGroup string + Kind string + Name string + Namespace string +} + +// SandboxEndpoint describes a running sandbox and how to reach it. +type SandboxEndpoint struct { + ID string `json:"sandbox_id"` + MCPUrl string `json:"mcp_url"` + Protocol string `json:"protocol"` + Headers map[string]string `json:"headers,omitempty"` + Ready bool `json:"ready"` +} + +// SandboxStatus reports the current state of a sandbox. +type SandboxStatus struct { + Phase SandboxPhase `json:"phase"` + Message string `json:"message,omitempty"` +} + +// SandboxProvider is the controller-internal interface that sandbox backends +// must implement. Each provider maps workspace references to concrete +// sandbox environments (e.g. agent-sandbox pods, Moat processes). +type SandboxProvider interface { + // Create provisions a new sandbox. Implementations should be idempotent + // for the same session ID. + Create(ctx context.Context, opts CreateSandboxOptions) (*SandboxEndpoint, error) + + // Destroy tears down the sandbox for the given sandbox ID. + Destroy(ctx context.Context, sandboxID string) error + + // Status returns the current status of a sandbox. + Status(ctx context.Context, sandboxID string) (*SandboxStatus, error) +} diff --git a/go/core/internal/controller/sandbox/stub_provider.go b/go/core/internal/controller/sandbox/stub_provider.go new file mode 100644 index 000000000..94778064f --- /dev/null +++ b/go/core/internal/controller/sandbox/stub_provider.go @@ -0,0 +1,35 @@ +package sandbox + +import ( + "context" + "fmt" +) + +// StubProvider is a sandbox provider for testing that returns fake endpoints. +type StubProvider struct{} + +var _ SandboxProvider = (*StubProvider)(nil) + +func NewStubProvider() *StubProvider { + return &StubProvider{} +} + +func (s *StubProvider) Create(_ context.Context, opts CreateSandboxOptions) (*SandboxEndpoint, error) { + return &SandboxEndpoint{ + ID: fmt.Sprintf("stub-%s", opts.SessionID), + MCPUrl: "http://localhost:9999/mcp", + Protocol: "streamable-http", + Ready: true, + }, nil +} + +func (s *StubProvider) Destroy(_ context.Context, _ string) error { + return nil +} + +func (s *StubProvider) Status(_ context.Context, sandboxID string) (*SandboxStatus, error) { + return &SandboxStatus{ + Phase: SandboxPhaseReady, + Message: fmt.Sprintf("stub sandbox %s is ready", sandboxID), + }, nil +} diff --git a/go/core/internal/controller/translator/agent/adk_api_translator.go b/go/core/internal/controller/translator/agent/adk_api_translator.go index 4a69ff0e8..6f8e57177 100644 --- a/go/core/internal/controller/translator/agent/adk_api_translator.go +++ b/go/core/internal/controller/translator/agent/adk_api_translator.go @@ -689,6 +689,20 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent *v1al } } + // Translate workspace reference for sandbox support + if agent.Spec.Declarative.Workspace != nil { + ws := agent.Spec.Declarative.Workspace + cfg.Workspace = &adk.WorkspaceConfig{ + APIGroup: ws.ApiGroup, + Kind: ws.Kind, + Name: ws.Name, + Namespace: ws.Namespace, + } + if cfg.Workspace.Namespace == "" { + cfg.Workspace.Namespace = agent.Namespace + } + } + for _, tool := range agent.Spec.Declarative.Tools { headers, err := tool.ResolveHeaders(ctx, a.kube, agent.Namespace) if err != nil { diff --git a/go/core/internal/httpserver/handlers/handlers.go b/go/core/internal/httpserver/handlers/handlers.go index 12ad54e94..d07839497 100644 --- a/go/core/internal/httpserver/handlers/handlers.go +++ b/go/core/internal/httpserver/handlers/handlers.go @@ -6,6 +6,7 @@ import ( "github.com/kagent-dev/kagent/go/api/database" "github.com/kagent-dev/kagent/go/core/internal/controller/reconciler" + "github.com/kagent-dev/kagent/go/core/internal/controller/sandbox" "github.com/kagent-dev/kagent/go/core/pkg/auth" ) @@ -26,6 +27,7 @@ type Handlers struct { Tasks *TasksHandler Checkpoints *CheckpointsHandler CrewAI *CrewAIHandler + Sandbox *SandboxHandler } // Base holds common dependencies for all handlers @@ -38,7 +40,7 @@ type Base struct { } // NewHandlers creates a new Handlers instance with all handler components. -func NewHandlers(kubeClient client.Client, defaultModelConfig types.NamespacedName, dbService database.Client, watchedNamespaces []string, authorizer auth.Authorizer, proxyURL string, rcnclr reconciler.KagentReconciler) *Handlers { +func NewHandlers(kubeClient client.Client, defaultModelConfig types.NamespacedName, dbService database.Client, watchedNamespaces []string, authorizer auth.Authorizer, proxyURL string, rcnclr reconciler.KagentReconciler, sandboxManager *sandbox.SandboxManager) *Handlers { base := &Base{ KubeClient: kubeClient, DefaultModelConfig: defaultModelConfig, @@ -52,7 +54,7 @@ func NewHandlers(kubeClient client.Client, defaultModelConfig types.NamespacedNa ModelConfig: NewModelConfigHandler(base), Model: NewModelHandler(base), ModelProviderConfig: NewModelProviderConfigHandler(base, rcnclr), - Sessions: NewSessionsHandler(base), + Sessions: NewSessionsHandler(base, sandboxManager), Agents: NewAgentsHandler(base), Tools: NewToolsHandler(base), ToolServers: NewToolServersHandler(base), @@ -63,5 +65,6 @@ func NewHandlers(kubeClient client.Client, defaultModelConfig types.NamespacedNa Tasks: NewTasksHandler(base), Checkpoints: NewCheckpointsHandler(base), CrewAI: NewCrewAIHandler(base), + Sandbox: NewSandboxHandler(base, sandboxManager), } } diff --git a/go/core/internal/httpserver/handlers/sandbox.go b/go/core/internal/httpserver/handlers/sandbox.go new file mode 100644 index 000000000..a5cd14d82 --- /dev/null +++ b/go/core/internal/httpserver/handlers/sandbox.go @@ -0,0 +1,117 @@ +package handlers + +import ( + "net/http" + + api "github.com/kagent-dev/kagent/go/api/httpapi" + "github.com/kagent-dev/kagent/go/core/internal/controller/sandbox" + "github.com/kagent-dev/kagent/go/core/internal/httpserver/errors" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" +) + +// SandboxHandler handles sandbox lifecycle requests for sessions. +type SandboxHandler struct { + *Base + Manager *sandbox.SandboxManager +} + +// NewSandboxHandler creates a new SandboxHandler. +func NewSandboxHandler(base *Base, manager *sandbox.SandboxManager) *SandboxHandler { + return &SandboxHandler{Base: base, Manager: manager} +} + +// HandleCreateSandbox handles POST /api/sessions/{session_id}/sandbox. +// It provisions (or returns an existing) sandbox for the given session. +func (h *SandboxHandler) HandleCreateSandbox(w ErrorResponseWriter, r *http.Request) { + log := ctrllog.FromContext(r.Context()).WithName("sandbox-handler").WithValues("operation", "create") + + sessionID, err := GetPathParam(r, "session_id") + if err != nil { + w.RespondWithError(errors.NewBadRequestError("Failed to get session_id from path", err)) + return + } + log = log.WithValues("session_id", sessionID) + + var req api.CreateSandboxRequest + if err := DecodeJSONBody(r, &req); err != nil { + w.RespondWithError(errors.NewBadRequestError("Invalid request body", err)) + return + } + + // Verify session exists + userID, err := getUserIDOrAgentUser(r) + if err != nil { + w.RespondWithError(errors.NewBadRequestError("Failed to get user ID", err)) + return + } + + _, err = h.DatabaseService.GetSession(sessionID, userID) + if err != nil { + w.RespondWithError(errors.NewNotFoundError("Session not found", err)) + return + } + + opts := sandbox.CreateSandboxOptions{ + AgentName: req.AgentName, + Namespace: req.Namespace, + WorkspaceRef: sandbox.WorkspaceRef{ + APIGroup: req.Workspace.APIGroup, + Kind: req.Workspace.Kind, + Name: req.Workspace.Name, + Namespace: req.Workspace.Namespace, + }, + } + + ep, err := h.Manager.GetOrCreateSandbox(r.Context(), sessionID, opts) + if err != nil { + w.RespondWithError(errors.NewInternalServerError("Failed to create sandbox", err)) + return + } + + resp := api.SandboxResponse{ + SandboxID: ep.ID, + MCPUrl: ep.MCPUrl, + Protocol: ep.Protocol, + Headers: ep.Headers, + Ready: ep.Ready, + } + + status := http.StatusOK + if !ep.Ready { + status = http.StatusAccepted + w.Header().Set("Location", r.URL.String()) + } + + log.Info("Sandbox created/retrieved for session", "sandbox_id", ep.ID, "ready", ep.Ready) + RespondWithJSON(w, status, resp) +} + +// HandleGetSandboxStatus handles GET /api/sessions/{session_id}/sandbox. +// It returns the current sandbox state for a session. +func (h *SandboxHandler) HandleGetSandboxStatus(w ErrorResponseWriter, r *http.Request) { + log := ctrllog.FromContext(r.Context()).WithName("sandbox-handler").WithValues("operation", "status") + + sessionID, err := GetPathParam(r, "session_id") + if err != nil { + w.RespondWithError(errors.NewBadRequestError("Failed to get session_id from path", err)) + return + } + log = log.WithValues("session_id", sessionID) + + ep := h.Manager.GetSandbox(sessionID) + if ep == nil { + w.RespondWithError(errors.NewNotFoundError("No sandbox found for session", nil)) + return + } + + resp := api.SandboxResponse{ + SandboxID: ep.ID, + MCPUrl: ep.MCPUrl, + Protocol: ep.Protocol, + Headers: ep.Headers, + Ready: ep.Ready, + } + + log.Info("Sandbox status retrieved", "sandbox_id", ep.ID) + RespondWithJSON(w, http.StatusOK, resp) +} diff --git a/go/core/internal/httpserver/handlers/sessions.go b/go/core/internal/httpserver/handlers/sessions.go index 51ff3a807..97d970e84 100644 --- a/go/core/internal/httpserver/handlers/sessions.go +++ b/go/core/internal/httpserver/handlers/sessions.go @@ -8,6 +8,7 @@ import ( "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/controller/sandbox" "github.com/kagent-dev/kagent/go/core/internal/httpserver/errors" "github.com/kagent-dev/kagent/go/core/internal/utils" ctrllog "sigs.k8s.io/controller-runtime/pkg/log" @@ -17,11 +18,12 @@ import ( // SessionsHandler handles session-related requests type SessionsHandler struct { *Base + SandboxManager *sandbox.SandboxManager } // NewSessionsHandler creates a new SessionsHandler -func NewSessionsHandler(base *Base) *SessionsHandler { - return &SessionsHandler{Base: base} +func NewSessionsHandler(base *Base, sandboxManager *sandbox.SandboxManager) *SessionsHandler { + return &SessionsHandler{Base: base, SandboxManager: sandboxManager} } // RunRequest represents a run creation request @@ -296,6 +298,13 @@ func (h *SessionsHandler) HandleDeleteSession(w ErrorResponseWriter, r *http.Req return } + // Best-effort sandbox cleanup + if h.SandboxManager != nil { + if err := h.SandboxManager.DestroySandbox(r.Context(), sessionID); err != nil { + log.Error(err, "Failed to destroy sandbox for session (best-effort)") + } + } + log.Info("Successfully deleted session") data := api.NewResponse(struct{}{}, "Session deleted successfully", false) RespondWithJSON(w, http.StatusOK, data) diff --git a/go/core/internal/httpserver/handlers/sessions_test.go b/go/core/internal/httpserver/handlers/sessions_test.go index 65f57c700..9f99d089b 100644 --- a/go/core/internal/httpserver/handlers/sessions_test.go +++ b/go/core/internal/httpserver/handlers/sessions_test.go @@ -51,7 +51,7 @@ func TestSessionsHandler(t *testing.T) { DatabaseService: dbClient, DefaultModelConfig: types.NamespacedName{Namespace: "default", Name: "default"}, } - handler := handlers.NewSessionsHandler(base) + handler := handlers.NewSessionsHandler(base, nil) responseRecorder := newMockErrorResponseWriter() return handler, dbClient.(*database_fake.InMemoryFakeClient), responseRecorder } diff --git a/go/core/internal/httpserver/server.go b/go/core/internal/httpserver/server.go index 350770e67..d742f3d9a 100644 --- a/go/core/internal/httpserver/server.go +++ b/go/core/internal/httpserver/server.go @@ -10,6 +10,7 @@ import ( api "github.com/kagent-dev/kagent/go/api/httpapi" "github.com/kagent-dev/kagent/go/core/internal/a2a" "github.com/kagent-dev/kagent/go/core/internal/controller/reconciler" + "github.com/kagent-dev/kagent/go/core/internal/controller/sandbox" "github.com/kagent-dev/kagent/go/core/internal/database" "github.com/kagent-dev/kagent/go/core/internal/httpserver/handlers" "github.com/kagent-dev/kagent/go/core/internal/mcp" @@ -62,6 +63,7 @@ type ServerConfig struct { Authorizer auth.Authorizer ProxyURL string Reconciler reconciler.KagentReconciler + SandboxManager *sandbox.SandboxManager } // HTTPServer is the structure that manages the HTTP server @@ -81,7 +83,7 @@ func NewHTTPServer(config ServerConfig) (*HTTPServer, error) { return &HTTPServer{ config: config, router: config.Router, - handlers: handlers.NewHandlers(config.KubeClient, defaultModelConfig, config.DbClient, config.WatchedNamespaces, config.Authorizer, config.ProxyURL, config.Reconciler), + handlers: handlers.NewHandlers(config.KubeClient, defaultModelConfig, config.DbClient, config.WatchedNamespaces, config.Authorizer, config.ProxyURL, config.Reconciler, config.SandboxManager), authenticator: config.Authenticator, }, nil } @@ -271,6 +273,10 @@ func (s *HTTPServer) setupRoutes() { s.router.HandleFunc(APIPathCrewAI+"/flows/state", adaptHandler(s.handlers.CrewAI.HandleStoreFlowState)).Methods(http.MethodPost) s.router.HandleFunc(APIPathCrewAI+"/flows/state", adaptHandler(s.handlers.CrewAI.HandleGetFlowState)).Methods(http.MethodGet) + // Sandbox + s.router.HandleFunc(APIPathSessions+"/{session_id}/sandbox", adaptHandler(s.handlers.Sandbox.HandleCreateSandbox)).Methods(http.MethodPost) + s.router.HandleFunc(APIPathSessions+"/{session_id}/sandbox", adaptHandler(s.handlers.Sandbox.HandleGetSandboxStatus)).Methods(http.MethodGet) + // A2A s.router.PathPrefix(APIPathA2A + "/{namespace}/{name}").Handler(s.config.A2AHandler) diff --git a/go/core/pkg/app/app.go b/go/core/pkg/app/app.go index 250b32492..fcc589699 100644 --- a/go/core/pkg/app/app.go +++ b/go/core/pkg/app/app.go @@ -43,6 +43,7 @@ import ( "github.com/kagent-dev/kagent/go/core/internal/controller/reconciler" reconcilerutils "github.com/kagent-dev/kagent/go/core/internal/controller/reconciler/utils" + sandboxpkg "github.com/kagent-dev/kagent/go/core/internal/controller/sandbox" agent_translator "github.com/kagent-dev/kagent/go/core/internal/controller/translator/agent" "github.com/kagent-dev/kagent/go/core/internal/httpserver" common "github.com/kagent-dev/kagent/go/core/internal/utils" @@ -516,6 +517,9 @@ func Start(getExtensionConfig GetExtensionConfig) { os.Exit(1) } + // Create sandbox manager with stub provider (will be replaced by real providers later) + sandboxManager := sandboxpkg.NewSandboxManager(sandboxpkg.NewStubProvider()) + httpServer, err := httpserver.NewHTTPServer(httpserver.ServerConfig{ Router: router, BindAddr: cfg.HttpServerAddr, @@ -528,6 +532,7 @@ func Start(getExtensionConfig GetExtensionConfig) { Authenticator: extensionCfg.Authenticator, ProxyURL: cfg.Proxy.URL, Reconciler: rcnclr, + SandboxManager: sandboxManager, }) if err != nil { setupLog.Error(err, "unable to create HTTP server") diff --git a/go/sandbox-mcp/Dockerfile b/go/sandbox-mcp/Dockerfile new file mode 100644 index 000000000..ade4624e9 --- /dev/null +++ b/go/sandbox-mcp/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.24 AS builder + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o /sandbox-mcp ./cmd/ + +FROM gcr.io/distroless/static:nonroot +COPY --from=builder /sandbox-mcp /sandbox-mcp +USER nonroot:nonroot +EXPOSE 8080 +ENTRYPOINT ["/sandbox-mcp"] diff --git a/go/sandbox-mcp/Makefile b/go/sandbox-mcp/Makefile new file mode 100644 index 000000000..cf6af5acc --- /dev/null +++ b/go/sandbox-mcp/Makefile @@ -0,0 +1,23 @@ +IMAGE_REGISTRY ?= cr.kagent.dev +IMAGE_NAME ?= kagent-dev/kagent/sandbox-mcp +IMAGE_TAG ?= latest + +.PHONY: build +build: + go build -o bin/sandbox-mcp ./cmd/ + +.PHONY: test +test: + go test ./... + +.PHONY: docker-build +docker-build: + docker build -t $(IMAGE_REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG) . + +.PHONY: docker-push +docker-push: + docker push $(IMAGE_REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG) + +.PHONY: tidy +tidy: + go mod tidy diff --git a/go/sandbox-mcp/cmd/main.go b/go/sandbox-mcp/cmd/main.go new file mode 100644 index 000000000..cfeae4dc6 --- /dev/null +++ b/go/sandbox-mcp/cmd/main.go @@ -0,0 +1,116 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + + "github.com/kagent-dev/kagent/go/sandbox-mcp/pkg/tools" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + s := server.NewMCPServer( + "kagent-sandbox-mcp", + "0.1.0", + ) + + // Register exec tool + s.AddTool(mcp.NewTool( + "exec", + mcp.WithDescription("Execute a shell command in the sandbox"), + mcp.WithString("command", mcp.Required(), mcp.Description("The shell command to execute")), + mcp.WithNumber("timeout_ms", mcp.Description("Optional timeout in milliseconds")), + mcp.WithString("working_dir", mcp.Description("Optional working directory")), + ), handleExec) + + // Register read_file tool + s.AddTool(mcp.NewTool( + "read_file", + mcp.WithDescription("Read the content of a file"), + mcp.WithString("path", mcp.Required(), mcp.Description("Absolute path to the file")), + ), handleReadFile) + + // Register write_file tool + s.AddTool(mcp.NewTool( + "write_file", + mcp.WithDescription("Write content to a file (creates parent directories if needed)"), + mcp.WithString("path", mcp.Required(), mcp.Description("Absolute path to the file")), + mcp.WithString("content", mcp.Required(), mcp.Description("Content to write")), + ), handleWriteFile) + + // Register list_dir tool + s.AddTool(mcp.NewTool( + "list_dir", + mcp.WithDescription("List entries in a directory"), + mcp.WithString("path", mcp.Description("Directory path (defaults to current directory)")), + ), handleListDir) + + addr := fmt.Sprintf(":%s", port) + log.Printf("Starting kagent-sandbox-mcp on %s", addr) + + httpServer := server.NewStreamableHTTPServer(s) + if err := httpServer.Start(addr); err != nil { + log.Fatalf("Server failed: %v", err) + } +} + +func handleExec(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + command, _ := request.GetArguments()["command"].(string) + timeoutMs := 0 + if v, ok := request.GetArguments()["timeout_ms"].(float64); ok { + timeoutMs = int(v) + } + workingDir, _ := request.GetArguments()["working_dir"].(string) + + result, err := tools.Exec(ctx, command, timeoutMs, workingDir) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + data, _ := json.Marshal(result) + return mcp.NewToolResultText(string(data)), nil +} + +func handleReadFile(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + path, _ := request.GetArguments()["path"].(string) + + content, err := tools.ReadFile(path) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + return mcp.NewToolResultText(content), nil +} + +func handleWriteFile(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + path, _ := request.GetArguments()["path"].(string) + content, _ := request.GetArguments()["content"].(string) + + if err := tools.WriteFile(path, content); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + data, _ := json.Marshal(map[string]bool{"ok": true}) + return mcp.NewToolResultText(string(data)), nil +} + +func handleListDir(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + path, _ := request.GetArguments()["path"].(string) + + entries, err := tools.ListDir(path) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + data, _ := json.Marshal(map[string]any{"entries": entries}) + return mcp.NewToolResultText(string(data)), nil +} diff --git a/go/sandbox-mcp/go.mod b/go/sandbox-mcp/go.mod new file mode 100644 index 000000000..745133e50 --- /dev/null +++ b/go/sandbox-mcp/go.mod @@ -0,0 +1,11 @@ +module github.com/kagent-dev/kagent/go/sandbox-mcp + +go 1.24.1 + +require github.com/mark3labs/mcp-go v0.31.0 + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect +) diff --git a/go/sandbox-mcp/go.sum b/go/sandbox-mcp/go.sum new file mode 100644 index 000000000..2b6b394f0 --- /dev/null +++ b/go/sandbox-mcp/go.sum @@ -0,0 +1,26 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mark3labs/mcp-go v0.31.0 h1:4UxSV8aM770OPmTvaVe/b1rA2oZAjBMhGBfUgOGut+4= +github.com/mark3labs/mcp-go v0.31.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go/sandbox-mcp/pkg/tools/exec.go b/go/sandbox-mcp/pkg/tools/exec.go new file mode 100644 index 000000000..65341f364 --- /dev/null +++ b/go/sandbox-mcp/pkg/tools/exec.go @@ -0,0 +1,55 @@ +package tools + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "time" +) + +// ExecResult holds the result of a command execution. +type ExecResult struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int `json:"exit_code"` +} + +// Exec runs a shell command with an optional timeout and working directory. +func Exec(ctx context.Context, command string, timeoutMs int, workingDir string) (*ExecResult, error) { + if command == "" { + return nil, fmt.Errorf("command must not be empty") + } + + if timeoutMs > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, time.Duration(timeoutMs)*time.Millisecond) + defer cancel() + } + + cmd := exec.CommandContext(ctx, "sh", "-c", command) + if workingDir != "" { + cmd.Dir = workingDir + } + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + + exitCode := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + return nil, fmt.Errorf("failed to run command: %w", err) + } + } + + return &ExecResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: exitCode, + }, nil +} diff --git a/go/sandbox-mcp/pkg/tools/exec_test.go b/go/sandbox-mcp/pkg/tools/exec_test.go new file mode 100644 index 000000000..e1f0e9ff0 --- /dev/null +++ b/go/sandbox-mcp/pkg/tools/exec_test.go @@ -0,0 +1,55 @@ +package tools + +import ( + "context" + "testing" +) + +func TestExecSimple(t *testing.T) { + result, err := Exec(context.Background(), "echo hello", 0, "") + if err != nil { + t.Fatalf("Exec failed: %v", err) + } + if result.ExitCode != 0 { + t.Errorf("expected exit code 0, got %d", result.ExitCode) + } + if result.Stdout != "hello\n" { + t.Errorf("expected stdout 'hello\\n', got %q", result.Stdout) + } +} + +func TestExecNonZeroExit(t *testing.T) { + result, err := Exec(context.Background(), "exit 42", 0, "") + if err != nil { + t.Fatalf("Exec failed: %v", err) + } + if result.ExitCode != 42 { + t.Errorf("expected exit code 42, got %d", result.ExitCode) + } +} + +func TestExecWorkingDir(t *testing.T) { + dir := t.TempDir() + result, err := Exec(context.Background(), "pwd", 0, dir) + if err != nil { + t.Fatalf("Exec failed: %v", err) + } + if result.ExitCode != 0 { + t.Errorf("expected exit code 0, got %d", result.ExitCode) + } +} + +func TestExecEmptyCommand(t *testing.T) { + _, err := Exec(context.Background(), "", 0, "") + if err == nil { + t.Error("expected error for empty command") + } +} + +func TestExecTimeout(t *testing.T) { + _, err := Exec(context.Background(), "sleep 10", 100, "") + if err == nil { + // Timeout should cause a non-zero exit or error + t.Log("timeout completed without error (process was killed)") + } +} diff --git a/go/sandbox-mcp/pkg/tools/fs.go b/go/sandbox-mcp/pkg/tools/fs.go new file mode 100644 index 000000000..4185d47f5 --- /dev/null +++ b/go/sandbox-mcp/pkg/tools/fs.go @@ -0,0 +1,80 @@ +package tools + +import ( + "fmt" + "os" + "path/filepath" +) + +// DirEntry represents a single directory entry. +type DirEntry struct { + Name string `json:"name"` + Type string `json:"type"` // "file" or "dir" + Size int64 `json:"size"` +} + +// ReadFile reads the content of a file at the given path. +func ReadFile(path string) (string, error) { + if path == "" { + return "", fmt.Errorf("path must not be empty") + } + + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("failed to read file %s: %w", path, err) + } + + return string(data), nil +} + +// WriteFile writes content to a file at the given path, creating parent +// directories as needed. +func WriteFile(path string, content string) error { + if path == "" { + return fmt.Errorf("path must not be empty") + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return fmt.Errorf("failed to write file %s: %w", path, err) + } + + return nil +} + +// ListDir lists entries in a directory. If path is empty, it defaults to ".". +func ListDir(path string) ([]DirEntry, error) { + if path == "" { + path = "." + } + + entries, err := os.ReadDir(path) + if err != nil { + return nil, fmt.Errorf("failed to list directory %s: %w", path, err) + } + + result := make([]DirEntry, 0, len(entries)) + for _, e := range entries { + entryType := "file" + if e.IsDir() { + entryType = "dir" + } + + var size int64 + if info, err := e.Info(); err == nil { + size = info.Size() + } + + result = append(result, DirEntry{ + Name: e.Name(), + Type: entryType, + Size: size, + }) + } + + return result, nil +} diff --git a/go/sandbox-mcp/pkg/tools/fs_test.go b/go/sandbox-mcp/pkg/tools/fs_test.go new file mode 100644 index 000000000..31b7bf15c --- /dev/null +++ b/go/sandbox-mcp/pkg/tools/fs_test.go @@ -0,0 +1,83 @@ +package tools + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadWriteFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.txt") + content := "hello sandbox" + + if err := WriteFile(path, content); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + got, err := ReadFile(path) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + if got != content { + t.Errorf("ReadFile = %q, want %q", got, content) + } +} + +func TestWriteFileCreatesParentDirs(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "a", "b", "c.txt") + + if err := WriteFile(path, "nested"); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + got, err := ReadFile(path) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + if got != "nested" { + t.Errorf("ReadFile = %q, want %q", got, "nested") + } +} + +func TestListDir(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a"), 0o644) + os.Mkdir(filepath.Join(dir, "subdir"), 0o755) + + entries, err := ListDir(dir) + if err != nil { + t.Fatalf("ListDir failed: %v", err) + } + + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + + types := map[string]string{} + for _, e := range entries { + types[e.Name] = e.Type + } + + if types["a.txt"] != "file" { + t.Errorf("expected a.txt to be file, got %s", types["a.txt"]) + } + if types["subdir"] != "dir" { + t.Errorf("expected subdir to be dir, got %s", types["subdir"]) + } +} + +func TestReadFileNotFound(t *testing.T) { + _, err := ReadFile("/nonexistent/path") + if err == nil { + t.Error("expected error for nonexistent file") + } +} + +func TestReadFileEmpty(t *testing.T) { + _, err := ReadFile("") + if err == nil { + t.Error("expected error for empty path") + } +} diff --git a/python/packages/kagent-adk/src/kagent/adk/_a2a.py b/python/packages/kagent-adk/src/kagent/adk/_a2a.py index 4329d94d1..e0a6379dc 100644 --- a/python/packages/kagent-adk/src/kagent/adk/_a2a.py +++ b/python/packages/kagent-adk/src/kagent/adk/_a2a.py @@ -142,6 +142,7 @@ def create_runner() -> Runner: runner=create_runner, config=A2aAgentExecutorConfig(stream=self.stream), task_store=task_store, + agent_config=self.agent_config, ) request_context_builder = KAgentRequestContextBuilder(task_store=task_store) diff --git a/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py b/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py index a4ac5e280..c901a60e8 100644 --- a/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py +++ b/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py @@ -112,6 +112,7 @@ def __init__( runner: Callable[..., Runner | Awaitable[Runner]], config: Optional[A2aAgentExecutorConfig] = None, task_store=None, + agent_config=None, ): # Build upstream config with kagent's custom converters upstream_config = UpstreamA2aAgentExecutorConfig( @@ -123,6 +124,7 @@ def __init__( super().__init__(runner=runner, config=upstream_config) self._kagent_config = config self._task_store = task_store + self._agent_config = agent_config @override async def _resolve_runner(self) -> Runner: @@ -478,6 +480,11 @@ async def _handle_request( # ensure the session exists session = await self._prepare_session(context, run_args, runner) + # Sandbox provisioning: if workspace is configured, ensure a sandbox + # MCP toolset is attached for this session. + if self._agent_config and getattr(self._agent_config, "workspace", None) is not None: + await self._ensure_sandbox_toolset(session, runner, run_args) + # HITL resume: translate A2A approval/rejection to ADK FunctionResponse decision = extract_decision_from_message(context.message) if decision: @@ -603,6 +610,76 @@ async def _handle_request( ) ) + async def _ensure_sandbox_toolset( + self, + session: Session, + runner: Runner, + run_args: dict[str, Any], + ): + """Provision a sandbox for this session if not already done. + + Checks session state for an existing sandbox MCP URL. If not present, + POSTs to the controller sandbox endpoint, stores the URL in session + state, and appends a KAgentMcpToolset to the runner's agent tools. + """ + sandbox_mcp_url = (session.state or {}).get("sandbox_mcp_url") + if sandbox_mcp_url: + # Already provisioned — just add the toolset + self._add_sandbox_toolset(runner, sandbox_mcp_url) + return + + import os + + import httpx + from google.adk.tools.mcp_tool import StreamableHTTPConnectionParams + + kagent_url = os.getenv("KAGENT_URL", "http://localhost:8083") + ws = self._agent_config.workspace + session_id = run_args["session_id"] + + async with httpx.AsyncClient(base_url=kagent_url) as client: + resp = await client.post( + f"/api/sessions/{session_id}/sandbox", + json={ + "agent_name": runner.app_name, + "namespace": ws.namespace, + "workspace_ref": { + "api_group": ws.api_group, + "kind": ws.kind, + "name": ws.name, + "namespace": ws.namespace, + }, + }, + timeout=30.0, + ) + resp.raise_for_status() + data = resp.json() + + sandbox_mcp_url = data["mcp_url"] + + # Store in session state via system event + from google.adk.events import Event, EventActions + + state_event = Event( + invocation_id="sandbox_setup", + author="system", + actions=EventActions(state_delta={"sandbox_mcp_url": sandbox_mcp_url}), + ) + await runner.session_service.append_event(session, state_event) + + self._add_sandbox_toolset(runner, sandbox_mcp_url) + + def _add_sandbox_toolset(self, runner: Runner, mcp_url: str): + """Add a sandbox MCP toolset to the runner's agent.""" + from google.adk.tools.mcp_tool import StreamableHTTPConnectionParams + + from ._mcp_toolset import KAgentMcpToolset + + toolset = KAgentMcpToolset( + connection_params=StreamableHTTPConnectionParams(url=mcp_url), + ) + runner.agent.tools.append(toolset) + async def _prepare_session(self, context: RequestContext, run_args: dict[str, Any], runner: Runner): session_id = run_args["session_id"] # create a new session if not exists diff --git a/python/packages/kagent-adk/src/kagent/adk/types.py b/python/packages/kagent-adk/src/kagent/adk/types.py index 26b3df144..3f94bd576 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -252,6 +252,15 @@ class MemoryConfig(BaseModel): embedding: EmbeddingConfig | None = None # Embedding model config for memory tools. +class WorkspaceConfig(BaseModel): + """Workspace reference from the Agent CRD for sandbox provisioning.""" + + api_group: str + kind: str + name: str + namespace: str + + class AgentConfig(BaseModel): model: ModelUnion = Field(discriminator="type") description: str @@ -263,6 +272,7 @@ class AgentConfig(BaseModel): stream: bool | None = None # Refers to LLM response streaming, not A2A streaming memory: MemoryConfig | None = None # Memory configuration context_config: ContextConfig | None = None + workspace: WorkspaceConfig | None = None # Workspace/sandbox configuration def to_agent(self, name: str, sts_integration: Optional[ADKTokenPropagationPlugin] = None) -> Agent: if name is None or not str(name).strip(): From f5e2156383635500a4686439c9911fb1cea46304 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Thu, 12 Mar 2026 19:05:40 +0000 Subject: [PATCH 2/6] fix: pass context.Context to database calls in sandbox handler Aligns with the database.Client API change from main that added context.Context as the first parameter to all database methods. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Eitan Yarmush --- go/core/internal/httpserver/handlers/sandbox.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go/core/internal/httpserver/handlers/sandbox.go b/go/core/internal/httpserver/handlers/sandbox.go index a5cd14d82..ba4072545 100644 --- a/go/core/internal/httpserver/handlers/sandbox.go +++ b/go/core/internal/httpserver/handlers/sandbox.go @@ -45,7 +45,7 @@ func (h *SandboxHandler) HandleCreateSandbox(w ErrorResponseWriter, r *http.Requ return } - _, err = h.DatabaseService.GetSession(sessionID, userID) + _, err = h.DatabaseService.GetSession(r.Context(), sessionID, userID) if err != nil { w.RespondWithError(errors.NewNotFoundError("Session not found", err)) return From 54eb536c42bd8a7ec36baa316e0247378c65ddbf Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Fri, 13 Mar 2026 02:09:49 +0000 Subject: [PATCH 3/6] feat: add agent-sandbox provider with blocking GetOrCreate and sandbox-mcp logging Replace the in-memory SandboxManager with AgentSandboxProvider that uses kubernetes-sigs/agent-sandbox SandboxClaim CRDs. GetOrCreate now blocks using wait.PollUntilContextCancel until the sandbox is ready, a terminal failure is detected, or the context expires. NotFound errors during polling are treated as transient (cache sync delay). Also adds structured logging to sandbox-mcp tool handlers, switches the sandbox-mcp Dockerfile to python:3.13-slim (exec tool needs a shell), and includes Helm RBAC templates for kagent-controller to access sandbox CRDs. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Eitan Yarmush --- Makefile | 12 +- .../sandbox/agent_sandbox_provider.go | 234 ++++++++++++ .../sandbox/agent_sandbox_provider_test.go | 349 ++++++++++++++++++ .../internal/controller/sandbox/manager.go | 74 ---- .../controller/sandbox/manager_test.go | 154 -------- .../internal/controller/sandbox/provider.go | 20 +- .../controller/sandbox/stub_provider.go | 42 ++- .../internal/httpserver/handlers/handlers.go | 6 +- .../internal/httpserver/handlers/sandbox.go | 36 +- .../internal/httpserver/handlers/sessions.go | 10 +- go/core/internal/httpserver/server.go | 4 +- go/core/pkg/app/app.go | 10 +- go/go.mod | 26 +- go/go.sum | 56 ++- go/sandbox-mcp/Dockerfile.local | 4 + go/sandbox-mcp/cmd/main.go | 20 + .../templates/kagent.dev_agents.yaml | 18 + .../templates/controller-deployment.yaml | 5 +- .../rbac/agent-sandbox-clusterrole.yaml | 13 + .../agent-sandbox-clusterrolebinding.yaml | 14 + 20 files changed, 802 insertions(+), 305 deletions(-) create mode 100644 go/core/internal/controller/sandbox/agent_sandbox_provider.go create mode 100644 go/core/internal/controller/sandbox/agent_sandbox_provider_test.go delete mode 100644 go/core/internal/controller/sandbox/manager.go delete mode 100644 go/core/internal/controller/sandbox/manager_test.go create mode 100644 go/sandbox-mcp/Dockerfile.local create mode 100644 helm/kagent/templates/rbac/agent-sandbox-clusterrole.yaml create mode 100644 helm/kagent/templates/rbac/agent-sandbox-clusterrolebinding.yaml diff --git a/Makefile b/Makefile index b435dc56f..c0768f9c7 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,8 @@ BUILDX_NO_DEFAULT_ATTESTATIONS=1 BUILDX_BUILDER_NAME ?= kagent-builder-$(BUILDKIT_VERSION) DOCKER_BUILDER ?= docker buildx -DOCKER_BUILD_ARGS ?= --push --platform linux/$(LOCALARCH) +DOCKER_BUILD_CACHE_DIR ?= /tmp/kagent-buildcache +DOCKER_BUILD_ARGS ?= --push --platform linux/$(LOCALARCH) --cache-from type=local,src=$(DOCKER_BUILD_CACHE_DIR) --cache-to type=local,dest=$(DOCKER_BUILD_CACHE_DIR),mode=max KIND_CLUSTER_NAME ?= kagent KIND_IMAGE_VERSION ?= 1.35.0 @@ -370,6 +371,15 @@ helm-install-provider: helm-version check-api-key --set kmcp.enabled=$(KMCP_ENABLED) \ --set kmcp.image.tag=$(KMCP_VERSION) \ --set querydoc.openai.apiKey=$(OPENAI_API_KEY) \ + --set tools.grafana-mcp.enabled=false \ + --set agents.kgateway-agent.enabled=false \ + --set agents.istio-agent.enabled=false \ + --set agents.promql-agent.enabled=false \ + --set agents.observability-agent.enabled=false \ + --set agents.argo-rollouts-agent.enabled=false \ + --set agents.cilium-policy-agent.enabled=false \ + --set agents.cilium-manager-agent.enabled=false \ + --set agents.cilium-debug-agent.enabled=false \ $(KAGENT_HELM_EXTRA_ARGS) .PHONY: helm-install diff --git a/go/core/internal/controller/sandbox/agent_sandbox_provider.go b/go/core/internal/controller/sandbox/agent_sandbox_provider.go new file mode 100644 index 000000000..f209f33e8 --- /dev/null +++ b/go/core/internal/controller/sandbox/agent_sandbox_provider.go @@ -0,0 +1,234 @@ +package sandbox + +import ( + "context" + "fmt" + "time" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" + "sigs.k8s.io/controller-runtime/pkg/client" + + sandboxv1alpha1 "sigs.k8s.io/agent-sandbox/api/v1alpha1" + extv1alpha1 "sigs.k8s.io/agent-sandbox/extensions/api/v1alpha1" +) + +const ( + // Label used to associate SandboxClaims with kagent sessions. + labelSessionID = "kagent.dev/session-id" + + // Port exposed by the kagent-sandbox-mcp container in sandbox pods. + defaultMCPPort = 8080 + + // How often to re-check the SandboxClaim while waiting for it to become ready. + pollInterval = time.Second / 4 +) + +// Terminal failure reasons on a SandboxClaim's Ready condition that indicate +// the sandbox will never become ready. When we see one of these we stop +// waiting and return an error immediately. +var terminalFailureReasons = map[string]bool{ + "TemplateNotFound": true, + "ReconcilerError": true, + "SandboxExpired": true, + "ClaimExpired": true, +} + +// AgentSandboxProvider implements SandboxProvider using the +// kubernetes-sigs/agent-sandbox SandboxClaim CRD. It creates a SandboxClaim +// per session referencing the SandboxTemplate from the workspace ref, and +// derives the endpoint URL from the underlying Sandbox's ServiceFQDN. +// +// The agent-sandbox controller creates a Sandbox (and its Pod + headless +// Service) with the same name as the SandboxClaim. The Service FQDN is +// {name}.{namespace}.svc.cluster.local. +type AgentSandboxProvider struct { + client client.Client +} + +var _ SandboxProvider = (*AgentSandboxProvider)(nil) + +// NewAgentSandboxProvider creates a provider that manages agent-sandbox SandboxClaims. +func NewAgentSandboxProvider(c client.Client) *AgentSandboxProvider { + return &AgentSandboxProvider{client: c} +} + +// claimName returns a deterministic name for the SandboxClaim for a session. +func claimName(sessionID string) string { + name := fmt.Sprintf("kagent-%s", sessionID) + if len(name) > 63 { + name = name[:63] + } + return name +} + +func (p *AgentSandboxProvider) GetOrCreate(ctx context.Context, opts CreateSandboxOptions) (*SandboxEndpoint, error) { + ns := opts.Namespace + if ns == "" { + ns = opts.WorkspaceRef.Namespace + } + if ns == "" { + return nil, fmt.Errorf("namespace is required for agent-sandbox provider") + } + + name := claimName(opts.SessionID) + key := types.NamespacedName{Name: name, Namespace: ns} + + // Try to get existing claim first. + existing := &extv1alpha1.SandboxClaim{} + err := p.client.Get(ctx, key, existing) + if err != nil { + if !errors.IsNotFound(err) { + return nil, fmt.Errorf("failed to get SandboxClaim %s/%s: %w", ns, name, err) + } + + // Create the SandboxClaim. + claim := p.buildClaim(name, ns, opts) + if err := p.client.Create(ctx, claim); err != nil { + if !errors.IsAlreadyExists(err) { + return nil, fmt.Errorf("failed to create SandboxClaim: %w", err) + } + // Race — another caller created it first. + if err := p.client.Get(ctx, key, existing); err != nil { + return nil, fmt.Errorf("failed to get SandboxClaim after conflict: %w", err) + } + } + } + + // Wait until the sandbox is ready or a terminal failure is detected. + // The caller controls the deadline via ctx. + return p.waitForReady(ctx, key) +} + +// waitForReady uses wait.PollUntilContextCancel to periodically check the +// SandboxClaim until it is ready, a terminal failure is detected, or the +// context is cancelled/expired. +func (p *AgentSandboxProvider) waitForReady(ctx context.Context, key types.NamespacedName) (*SandboxEndpoint, error) { + var result *SandboxEndpoint + + err := wait.PollUntilContextCancel(ctx, pollInterval, true, func(ctx context.Context) (bool, error) { + claim := &extv1alpha1.SandboxClaim{} + if err := p.client.Get(ctx, key, claim); err != nil { + if errors.IsNotFound(err) { + // Cache may not have synced yet after creation — retry. + return false, nil + } + return false, fmt.Errorf("failed to get SandboxClaim %s: %w", key, err) + } + + // Check for terminal failure on the Ready condition. + if cond := meta.FindStatusCondition(claim.Status.Conditions, string(sandboxv1alpha1.SandboxConditionReady)); cond != nil { + if cond.Status == metav1.ConditionFalse && terminalFailureReasons[cond.Reason] { + return false, fmt.Errorf("sandbox %s failed: %s — %s", key.Name, cond.Reason, cond.Message) + } + } + + ep, err := p.endpointFromClaim(ctx, claim) + if err != nil { + return false, err + } + if ep.Ready { + result = ep + return true, nil + } + return false, nil + }) + if err != nil { + if ctx.Err() != nil { + return nil, fmt.Errorf("timed out waiting for sandbox %s to become ready: %w", key.Name, ctx.Err()) + } + return nil, err + } + + return result, nil +} + +func (p *AgentSandboxProvider) Get(ctx context.Context, sessionID string) (*SandboxEndpoint, error) { + list := &extv1alpha1.SandboxClaimList{} + if err := p.client.List(ctx, list, client.MatchingLabels{labelSessionID: sessionID}); err != nil { + return nil, fmt.Errorf("failed to list SandboxClaims for session %s: %w", sessionID, err) + } + + if len(list.Items) == 0 { + return nil, nil + } + + return p.endpointFromClaim(ctx, &list.Items[0]) +} + +func (p *AgentSandboxProvider) Destroy(ctx context.Context, sessionID string) error { + list := &extv1alpha1.SandboxClaimList{} + if err := p.client.List(ctx, list, client.MatchingLabels{labelSessionID: sessionID}); err != nil { + return fmt.Errorf("failed to list SandboxClaims for session %s: %w", sessionID, err) + } + + for i := range list.Items { + if err := p.client.Delete(ctx, &list.Items[i]); err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("failed to delete SandboxClaim %s: %w", list.Items[i].Name, err) + } + } + + return nil +} + +// buildClaim constructs a SandboxClaim referencing the workspace template. +func (p *AgentSandboxProvider) buildClaim(name, namespace string, opts CreateSandboxOptions) *extv1alpha1.SandboxClaim { + return &extv1alpha1.SandboxClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: map[string]string{ + labelSessionID: opts.SessionID, + }, + Annotations: map[string]string{ + "kagent.dev/agent-name": opts.AgentName, + "kagent.dev/session-id": opts.SessionID, + }, + }, + Spec: extv1alpha1.SandboxClaimSpec{ + TemplateRef: extv1alpha1.SandboxTemplateRef{ + Name: opts.WorkspaceRef.Name, + }, + }, + } +} + +// endpointFromClaim reads the SandboxClaim and its underlying Sandbox to +// build a SandboxEndpoint. +// +// The agent-sandbox controller creates a Sandbox with the same name as the +// claim. That Sandbox controller in turn creates a headless Service (also +// same name) and sets Sandbox.Status.ServiceFQDN once the Service exists and +// the Pod is ready. +func (p *AgentSandboxProvider) endpointFromClaim(ctx context.Context, claim *extv1alpha1.SandboxClaim) (*SandboxEndpoint, error) { + ep := &SandboxEndpoint{ + ID: claim.Name, + Protocol: "streamable-http", + } + + // Check if the claim's Ready condition is true. + ready := meta.IsStatusConditionTrue(claim.Status.Conditions, string(sandboxv1alpha1.SandboxConditionReady)) + if !ready { + return ep, nil + } + + // The Sandbox has the same name as the claim. Look it up directly to + // get the authoritative ServiceFQDN from its status. + sb := &sandboxv1alpha1.Sandbox{} + if err := p.client.Get(ctx, types.NamespacedName{Name: claim.Name, Namespace: claim.Namespace}, sb); err != nil { + if errors.IsNotFound(err) { + return ep, nil + } + return nil, fmt.Errorf("failed to get Sandbox %s/%s: %w", claim.Namespace, claim.Name, err) + } + + if sb.Status.ServiceFQDN != "" { + ep.MCPUrl = fmt.Sprintf("http://%s:%d/mcp", sb.Status.ServiceFQDN, defaultMCPPort) + ep.Ready = true + } + + return ep, nil +} diff --git a/go/core/internal/controller/sandbox/agent_sandbox_provider_test.go b/go/core/internal/controller/sandbox/agent_sandbox_provider_test.go new file mode 100644 index 000000000..b157531e1 --- /dev/null +++ b/go/core/internal/controller/sandbox/agent_sandbox_provider_test.go @@ -0,0 +1,349 @@ +package sandbox + +import ( + "context" + "fmt" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + sandboxv1alpha1 "sigs.k8s.io/agent-sandbox/api/v1alpha1" + extv1alpha1 "sigs.k8s.io/agent-sandbox/extensions/api/v1alpha1" +) + +func newTestProvider() *AgentSandboxProvider { + scheme := runtime.NewScheme() + _ = extv1alpha1.AddToScheme(scheme) + _ = sandboxv1alpha1.AddToScheme(scheme) + + c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource( + &extv1alpha1.SandboxClaim{}, + &sandboxv1alpha1.Sandbox{}, + ).Build() + return NewAgentSandboxProvider(c) +} + +func defaultOpts(sessionID string) CreateSandboxOptions { + return CreateSandboxOptions{ + SessionID: sessionID, + AgentName: "my-agent", + Namespace: "default", + WorkspaceRef: WorkspaceRef{ + APIGroup: "extensions.agents.x-k8s.io", + Kind: "SandboxTemplate", + Name: "python-dev", + }, + } +} + +// simulateReady runs in a goroutine and simulates the agent-sandbox controller +// making a SandboxClaim ready: creates a Sandbox with ServiceFQDN and sets +// the claim's Ready condition to True. +func simulateReady(ctx context.Context, t *testing.T, p *AgentSandboxProvider, sessionID string) { + t.Helper() + name := claimName(sessionID) + key := types.NamespacedName{Name: name, Namespace: "default"} + + // Wait for the claim to exist. + for { + claim := &extv1alpha1.SandboxClaim{} + if err := p.client.Get(ctx, key, claim); err == nil { + break + } + select { + case <-ctx.Done(): + return + case <-time.After(10 * time.Millisecond): + } + } + + // Create the Sandbox with ServiceFQDN. + sb := &sandboxv1alpha1.Sandbox{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + }, + Spec: sandboxv1alpha1.SandboxSpec{ + PodTemplate: sandboxv1alpha1.PodTemplate{}, + }, + } + if err := p.client.Create(ctx, sb); err != nil { + t.Errorf("simulateReady: failed to create sandbox: %v", err) + return + } + sb.Status.ServiceFQDN = name + ".default.svc.cluster.local" + if err := p.client.Status().Update(ctx, sb); err != nil { + t.Errorf("simulateReady: failed to update sandbox status: %v", err) + return + } + + // Mark the claim as Ready. + claim := &extv1alpha1.SandboxClaim{} + if err := p.client.Get(ctx, key, claim); err != nil { + t.Errorf("simulateReady: failed to get claim: %v", err) + return + } + claim.Status.Conditions = []metav1.Condition{ + { + Type: string(sandboxv1alpha1.SandboxConditionReady), + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Now(), + Reason: "DependenciesReady", + }, + } + if err := p.client.Status().Update(ctx, claim); err != nil { + t.Errorf("simulateReady: failed to update claim status: %v", err) + } +} + +// simulateFailure sets a terminal failure condition on a SandboxClaim. +func simulateFailure(ctx context.Context, t *testing.T, p *AgentSandboxProvider, sessionID, reason, message string) { + t.Helper() + name := claimName(sessionID) + key := types.NamespacedName{Name: name, Namespace: "default"} + + // Wait for the claim to exist. + for { + claim := &extv1alpha1.SandboxClaim{} + if err := p.client.Get(ctx, key, claim); err == nil { + break + } + select { + case <-ctx.Done(): + return + case <-time.After(10 * time.Millisecond): + } + } + + claim := &extv1alpha1.SandboxClaim{} + if err := p.client.Get(ctx, key, claim); err != nil { + t.Errorf("simulateFailure: failed to get claim: %v", err) + return + } + claim.Status.Conditions = []metav1.Condition{ + { + Type: string(sandboxv1alpha1.SandboxConditionReady), + Status: metav1.ConditionFalse, + LastTransitionTime: metav1.Now(), + Reason: reason, + Message: message, + }, + } + if err := p.client.Status().Update(ctx, claim); err != nil { + t.Errorf("simulateFailure: failed to update claim status: %v", err) + } +} + +func TestAgentSandboxProvider(t *testing.T) { + tests := []struct { + name string + fn func(t *testing.T, p *AgentSandboxProvider) + }{ + { + name: "blocks until sandbox is ready", + fn: func(t *testing.T, p *AgentSandboxProvider) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + go simulateReady(ctx, t, p, "sess-1") + + ep, err := p.GetOrCreate(ctx, defaultOpts("sess-1")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ep.Ready { + t.Error("expected sandbox to be ready") + } + name := claimName("sess-1") + expectedURL := fmt.Sprintf("http://%s.default.svc.cluster.local:%d/mcp", name, defaultMCPPort) + if ep.MCPUrl != expectedURL { + t.Errorf("expected URL %s, got %s", expectedURL, ep.MCPUrl) + } + }, + }, + { + name: "returns error on terminal failure", + fn: func(t *testing.T, p *AgentSandboxProvider) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + go simulateFailure(ctx, t, p, "sess-fail", "TemplateNotFound", "template python-dev not found") + + _, err := p.GetOrCreate(ctx, defaultOpts("sess-fail")) + if err == nil { + t.Fatal("expected error for terminal failure") + } + t.Logf("got expected error: %v", err) + }, + }, + { + name: "times out when sandbox never becomes ready", + fn: func(t *testing.T, p *AgentSandboxProvider) { + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + // Don't simulate readiness — let it time out. + _, err := p.GetOrCreate(ctx, defaultOpts("sess-timeout")) + if err == nil { + t.Fatal("expected timeout error") + } + t.Logf("got expected error: %v", err) + }, + }, + { + name: "idempotent create returns same endpoint", + fn: func(t *testing.T, p *AgentSandboxProvider) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + go simulateReady(ctx, t, p, "sess-2") + + ep1, err := p.GetOrCreate(ctx, defaultOpts("sess-2")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Second call should find the existing ready claim immediately. + ep2, err := p.GetOrCreate(ctx, defaultOpts("sess-2")) + if err != nil { + t.Fatalf("unexpected error on second call: %v", err) + } + + if ep1.ID != ep2.ID { + t.Errorf("expected same ID, got %s and %s", ep1.ID, ep2.ID) + } + }, + }, + { + name: "get returns nil when no claim exists", + fn: func(t *testing.T, p *AgentSandboxProvider) { + ep, err := p.Get(context.Background(), "nonexistent") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ep != nil { + t.Error("expected nil for nonexistent session") + } + }, + }, + { + name: "get returns not-ready endpoint for pending claim", + fn: func(t *testing.T, p *AgentSandboxProvider) { + ctx := context.Background() + name := claimName("sess-pending") + + // Create the claim directly (bypassing GetOrCreate which would block). + claim := p.buildClaim(name, "default", defaultOpts("sess-pending")) + if err := p.client.Create(ctx, claim); err != nil { + t.Fatalf("failed to create claim: %v", err) + } + + ep, err := p.Get(ctx, "sess-pending") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ep == nil { + t.Fatal("expected endpoint, got nil") + } + if ep.Ready { + t.Error("expected sandbox to not be ready") + } + }, + }, + { + name: "get returns ready endpoint", + fn: func(t *testing.T, p *AgentSandboxProvider) { + ctx := context.Background() + name := claimName("sess-ready") + + // Create claim directly. + claim := p.buildClaim(name, "default", defaultOpts("sess-ready")) + if err := p.client.Create(ctx, claim); err != nil { + t.Fatalf("failed to create claim: %v", err) + } + + // Simulate readiness synchronously. + simulateReady(ctx, t, p, "sess-ready") + + ep, err := p.Get(ctx, "sess-ready") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ep == nil { + t.Fatal("expected endpoint, got nil") + } + if !ep.Ready { + t.Error("expected sandbox to be ready") + } + expectedURL := fmt.Sprintf("http://%s.default.svc.cluster.local:%d/mcp", name, defaultMCPPort) + if ep.MCPUrl != expectedURL { + t.Errorf("expected URL %s, got %s", expectedURL, ep.MCPUrl) + } + }, + }, + { + name: "destroy deletes the claim", + fn: func(t *testing.T, p *AgentSandboxProvider) { + ctx := context.Background() + name := claimName("sess-4") + + // Create claim directly. + claim := p.buildClaim(name, "default", defaultOpts("sess-4")) + if err := p.client.Create(ctx, claim); err != nil { + t.Fatalf("failed to create claim: %v", err) + } + + if err := p.Destroy(ctx, "sess-4"); err != nil { + t.Fatalf("unexpected error on destroy: %v", err) + } + + ep, err := p.Get(ctx, "sess-4") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ep != nil { + t.Error("expected nil after destroy") + } + }, + }, + { + name: "destroy nonexistent is no-op", + fn: func(t *testing.T, p *AgentSandboxProvider) { + err := p.Destroy(context.Background(), "nonexistent") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }, + }, + { + name: "requires namespace", + fn: func(t *testing.T, p *AgentSandboxProvider) { + opts := CreateSandboxOptions{ + SessionID: "sess-7", + AgentName: "my-agent", + WorkspaceRef: WorkspaceRef{ + APIGroup: "extensions.agents.x-k8s.io", + Kind: "SandboxTemplate", + Name: "python-dev", + }, + } + + _, err := p.GetOrCreate(context.Background(), opts) + if err == nil { + t.Fatal("expected error for missing namespace") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := newTestProvider() + tt.fn(t, p) + }) + } +} diff --git a/go/core/internal/controller/sandbox/manager.go b/go/core/internal/controller/sandbox/manager.go deleted file mode 100644 index 4a6b256d0..000000000 --- a/go/core/internal/controller/sandbox/manager.go +++ /dev/null @@ -1,74 +0,0 @@ -package sandbox - -import ( - "context" - "fmt" - "sync" -) - -// SandboxManager manages the lifecycle mapping from session IDs to sandboxes. -// It is goroutine-safe and delegates actual provisioning to a SandboxProvider. -type SandboxManager struct { - provider SandboxProvider - sandboxes map[string]*SandboxEndpoint // keyed by session ID - mu sync.RWMutex -} - -// NewSandboxManager creates a new SandboxManager with the given provider. -func NewSandboxManager(provider SandboxProvider) *SandboxManager { - return &SandboxManager{ - provider: provider, - sandboxes: make(map[string]*SandboxEndpoint), - } -} - -// GetOrCreateSandbox returns an existing sandbox for the session or creates -// a new one. This method is idempotent for the same session ID. -func (m *SandboxManager) GetOrCreateSandbox(ctx context.Context, sessionID string, opts CreateSandboxOptions) (*SandboxEndpoint, error) { - m.mu.RLock() - if ep, ok := m.sandboxes[sessionID]; ok { - m.mu.RUnlock() - return ep, nil - } - m.mu.RUnlock() - - // Upgrade to write lock - m.mu.Lock() - defer m.mu.Unlock() - - // Double-check after acquiring write lock - if ep, ok := m.sandboxes[sessionID]; ok { - return ep, nil - } - - opts.SessionID = sessionID - ep, err := m.provider.Create(ctx, opts) - if err != nil { - return nil, fmt.Errorf("failed to create sandbox for session %s: %w", sessionID, err) - } - - m.sandboxes[sessionID] = ep - return ep, nil -} - -// GetSandbox returns the sandbox endpoint for a session, or nil if none exists. -func (m *SandboxManager) GetSandbox(sessionID string) *SandboxEndpoint { - m.mu.RLock() - defer m.mu.RUnlock() - return m.sandboxes[sessionID] -} - -// DestroySandbox tears down the sandbox for a session and removes it from -// the manager's tracking map. -func (m *SandboxManager) DestroySandbox(ctx context.Context, sessionID string) error { - m.mu.Lock() - ep, ok := m.sandboxes[sessionID] - if !ok { - m.mu.Unlock() - return nil - } - delete(m.sandboxes, sessionID) - m.mu.Unlock() - - return m.provider.Destroy(ctx, ep.ID) -} diff --git a/go/core/internal/controller/sandbox/manager_test.go b/go/core/internal/controller/sandbox/manager_test.go deleted file mode 100644 index 1ed6c3d96..000000000 --- a/go/core/internal/controller/sandbox/manager_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package sandbox - -import ( - "context" - "sync" - "testing" -) - -func TestSandboxManager(t *testing.T) { - tests := []struct { - name string - fn func(t *testing.T, m *SandboxManager) - }{ - { - name: "create and retrieve sandbox", - fn: func(t *testing.T, m *SandboxManager) { - ctx := context.Background() - opts := CreateSandboxOptions{ - AgentName: "test-agent", - Namespace: "default", - WorkspaceRef: WorkspaceRef{ - APIGroup: "sandbox.kagent.dev", - Kind: "SandboxTemplate", - Name: "my-template", - }, - } - - ep, err := m.GetOrCreateSandbox(ctx, "session-1", opts) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if ep.ID != "stub-session-1" { - t.Errorf("expected ID stub-session-1, got %s", ep.ID) - } - if !ep.Ready { - t.Error("expected sandbox to be ready") - } - - // Verify GetSandbox returns the same endpoint - got := m.GetSandbox("session-1") - if got == nil { - t.Fatal("expected sandbox to exist") - } - if got.ID != ep.ID { - t.Errorf("expected same sandbox ID, got %s", got.ID) - } - }, - }, - { - name: "idempotent create returns same sandbox", - fn: func(t *testing.T, m *SandboxManager) { - ctx := context.Background() - opts := CreateSandboxOptions{ - AgentName: "test-agent", - Namespace: "default", - } - - ep1, err := m.GetOrCreateSandbox(ctx, "session-2", opts) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - ep2, err := m.GetOrCreateSandbox(ctx, "session-2", opts) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if ep1.ID != ep2.ID { - t.Errorf("expected same sandbox ID on second call, got %s and %s", ep1.ID, ep2.ID) - } - }, - }, - { - name: "destroy removes sandbox", - fn: func(t *testing.T, m *SandboxManager) { - ctx := context.Background() - opts := CreateSandboxOptions{ - AgentName: "test-agent", - Namespace: "default", - } - - _, err := m.GetOrCreateSandbox(ctx, "session-3", opts) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - err = m.DestroySandbox(ctx, "session-3") - if err != nil { - t.Fatalf("unexpected error on destroy: %v", err) - } - - got := m.GetSandbox("session-3") - if got != nil { - t.Error("expected sandbox to be removed after destroy") - } - }, - }, - { - name: "destroy nonexistent session is no-op", - fn: func(t *testing.T, m *SandboxManager) { - err := m.DestroySandbox(context.Background(), "nonexistent") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }, - }, - { - name: "get nonexistent returns nil", - fn: func(t *testing.T, m *SandboxManager) { - got := m.GetSandbox("nonexistent") - if got != nil { - t.Error("expected nil for nonexistent sandbox") - } - }, - }, - { - name: "concurrent access", - fn: func(t *testing.T, m *SandboxManager) { - ctx := context.Background() - var wg sync.WaitGroup - - for i := 0; i < 50; i++ { - wg.Add(1) - go func() { - defer wg.Done() - opts := CreateSandboxOptions{ - AgentName: "test-agent", - Namespace: "default", - } - _, err := m.GetOrCreateSandbox(ctx, "concurrent-session", opts) - if err != nil { - t.Errorf("unexpected error: %v", err) - } - }() - } - - wg.Wait() - - got := m.GetSandbox("concurrent-session") - if got == nil { - t.Fatal("expected sandbox to exist after concurrent creates") - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - provider := NewStubProvider() - manager := NewSandboxManager(provider) - tt.fn(t, manager) - }) - } -} diff --git a/go/core/internal/controller/sandbox/provider.go b/go/core/internal/controller/sandbox/provider.go index 4d11a3438..f26e13628 100644 --- a/go/core/internal/controller/sandbox/provider.go +++ b/go/core/internal/controller/sandbox/provider.go @@ -45,14 +45,20 @@ type SandboxStatus struct { // SandboxProvider is the controller-internal interface that sandbox backends // must implement. Each provider maps workspace references to concrete // sandbox environments (e.g. agent-sandbox pods, Moat processes). +// +// The provider is the source of truth for sandbox state. Implementations +// must handle idempotency — calling GetOrCreate multiple times with the same +// sessionID should return the same sandbox. type SandboxProvider interface { - // Create provisions a new sandbox. Implementations should be idempotent - // for the same session ID. - Create(ctx context.Context, opts CreateSandboxOptions) (*SandboxEndpoint, error) + // GetOrCreate provisions a new sandbox or returns the existing one for a + // session. Implementations must be idempotent for the same session ID. + // The call blocks until the sandbox is ready or the context is cancelled. + // Terminal failures (e.g. template not found) return an error immediately. + GetOrCreate(ctx context.Context, opts CreateSandboxOptions) (*SandboxEndpoint, error) - // Destroy tears down the sandbox for the given sandbox ID. - Destroy(ctx context.Context, sandboxID string) error + // Get returns the current sandbox endpoint for a session, or nil if none exists. + Get(ctx context.Context, sessionID string) (*SandboxEndpoint, error) - // Status returns the current status of a sandbox. - Status(ctx context.Context, sandboxID string) (*SandboxStatus, error) + // Destroy tears down the sandbox for the given session ID. + Destroy(ctx context.Context, sessionID string) error } diff --git a/go/core/internal/controller/sandbox/stub_provider.go b/go/core/internal/controller/sandbox/stub_provider.go index 94778064f..2a986c497 100644 --- a/go/core/internal/controller/sandbox/stub_provider.go +++ b/go/core/internal/controller/sandbox/stub_provider.go @@ -3,33 +3,51 @@ package sandbox import ( "context" "fmt" + "sync" ) // StubProvider is a sandbox provider for testing that returns fake endpoints. -type StubProvider struct{} +// It stores sandboxes in memory to support Get/Destroy operations. +type StubProvider struct { + mu sync.RWMutex + sandboxes map[string]*SandboxEndpoint +} var _ SandboxProvider = (*StubProvider)(nil) func NewStubProvider() *StubProvider { - return &StubProvider{} + return &StubProvider{ + sandboxes: make(map[string]*SandboxEndpoint), + } } -func (s *StubProvider) Create(_ context.Context, opts CreateSandboxOptions) (*SandboxEndpoint, error) { - return &SandboxEndpoint{ +func (s *StubProvider) GetOrCreate(_ context.Context, opts CreateSandboxOptions) (*SandboxEndpoint, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if ep, ok := s.sandboxes[opts.SessionID]; ok { + return ep, nil + } + + ep := &SandboxEndpoint{ ID: fmt.Sprintf("stub-%s", opts.SessionID), MCPUrl: "http://localhost:9999/mcp", Protocol: "streamable-http", Ready: true, - }, nil + } + s.sandboxes[opts.SessionID] = ep + return ep, nil } -func (s *StubProvider) Destroy(_ context.Context, _ string) error { - return nil +func (s *StubProvider) Get(_ context.Context, sessionID string) (*SandboxEndpoint, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.sandboxes[sessionID], nil } -func (s *StubProvider) Status(_ context.Context, sandboxID string) (*SandboxStatus, error) { - return &SandboxStatus{ - Phase: SandboxPhaseReady, - Message: fmt.Sprintf("stub sandbox %s is ready", sandboxID), - }, nil +func (s *StubProvider) Destroy(_ context.Context, sessionID string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.sandboxes, sessionID) + return nil } diff --git a/go/core/internal/httpserver/handlers/handlers.go b/go/core/internal/httpserver/handlers/handlers.go index d07839497..496a2da1f 100644 --- a/go/core/internal/httpserver/handlers/handlers.go +++ b/go/core/internal/httpserver/handlers/handlers.go @@ -40,7 +40,7 @@ type Base struct { } // NewHandlers creates a new Handlers instance with all handler components. -func NewHandlers(kubeClient client.Client, defaultModelConfig types.NamespacedName, dbService database.Client, watchedNamespaces []string, authorizer auth.Authorizer, proxyURL string, rcnclr reconciler.KagentReconciler, sandboxManager *sandbox.SandboxManager) *Handlers { +func NewHandlers(kubeClient client.Client, defaultModelConfig types.NamespacedName, dbService database.Client, watchedNamespaces []string, authorizer auth.Authorizer, proxyURL string, rcnclr reconciler.KagentReconciler, sandboxProvider sandbox.SandboxProvider) *Handlers { base := &Base{ KubeClient: kubeClient, DefaultModelConfig: defaultModelConfig, @@ -54,7 +54,7 @@ func NewHandlers(kubeClient client.Client, defaultModelConfig types.NamespacedNa ModelConfig: NewModelConfigHandler(base), Model: NewModelHandler(base), ModelProviderConfig: NewModelProviderConfigHandler(base, rcnclr), - Sessions: NewSessionsHandler(base, sandboxManager), + Sessions: NewSessionsHandler(base, sandboxProvider), Agents: NewAgentsHandler(base), Tools: NewToolsHandler(base), ToolServers: NewToolServersHandler(base), @@ -65,6 +65,6 @@ func NewHandlers(kubeClient client.Client, defaultModelConfig types.NamespacedNa Tasks: NewTasksHandler(base), Checkpoints: NewCheckpointsHandler(base), CrewAI: NewCrewAIHandler(base), - Sandbox: NewSandboxHandler(base, sandboxManager), + Sandbox: NewSandboxHandler(base, sandboxProvider), } } diff --git a/go/core/internal/httpserver/handlers/sandbox.go b/go/core/internal/httpserver/handlers/sandbox.go index ba4072545..d941c00ff 100644 --- a/go/core/internal/httpserver/handlers/sandbox.go +++ b/go/core/internal/httpserver/handlers/sandbox.go @@ -1,7 +1,9 @@ package handlers import ( + "context" "net/http" + "time" api "github.com/kagent-dev/kagent/go/api/httpapi" "github.com/kagent-dev/kagent/go/core/internal/controller/sandbox" @@ -9,15 +11,20 @@ import ( ctrllog "sigs.k8s.io/controller-runtime/pkg/log" ) +const ( + // Maximum time to wait for a sandbox to become ready. + sandboxCreateTimeout = 5 * time.Minute +) + // SandboxHandler handles sandbox lifecycle requests for sessions. type SandboxHandler struct { *Base - Manager *sandbox.SandboxManager + Provider sandbox.SandboxProvider } // NewSandboxHandler creates a new SandboxHandler. -func NewSandboxHandler(base *Base, manager *sandbox.SandboxManager) *SandboxHandler { - return &SandboxHandler{Base: base, Manager: manager} +func NewSandboxHandler(base *Base, provider sandbox.SandboxProvider) *SandboxHandler { + return &SandboxHandler{Base: base, Provider: provider} } // HandleCreateSandbox handles POST /api/sessions/{session_id}/sandbox. @@ -62,7 +69,12 @@ func (h *SandboxHandler) HandleCreateSandbox(w ErrorResponseWriter, r *http.Requ }, } - ep, err := h.Manager.GetOrCreateSandbox(r.Context(), sessionID, opts) + opts.SessionID = sessionID + + ctx, cancel := context.WithTimeout(r.Context(), sandboxCreateTimeout) + defer cancel() + + ep, err := h.Provider.GetOrCreate(ctx, opts) if err != nil { w.RespondWithError(errors.NewInternalServerError("Failed to create sandbox", err)) return @@ -76,14 +88,8 @@ func (h *SandboxHandler) HandleCreateSandbox(w ErrorResponseWriter, r *http.Requ Ready: ep.Ready, } - status := http.StatusOK - if !ep.Ready { - status = http.StatusAccepted - w.Header().Set("Location", r.URL.String()) - } - - log.Info("Sandbox created/retrieved for session", "sandbox_id", ep.ID, "ready", ep.Ready) - RespondWithJSON(w, status, resp) + log.Info("Sandbox ready for session", "sandbox_id", ep.ID, "mcp_url", ep.MCPUrl) + RespondWithJSON(w, http.StatusOK, resp) } // HandleGetSandboxStatus handles GET /api/sessions/{session_id}/sandbox. @@ -98,7 +104,11 @@ func (h *SandboxHandler) HandleGetSandboxStatus(w ErrorResponseWriter, r *http.R } log = log.WithValues("session_id", sessionID) - ep := h.Manager.GetSandbox(sessionID) + ep, err := h.Provider.Get(r.Context(), sessionID) + if err != nil { + w.RespondWithError(errors.NewInternalServerError("Failed to get sandbox status", err)) + return + } if ep == nil { w.RespondWithError(errors.NewNotFoundError("No sandbox found for session", nil)) return diff --git a/go/core/internal/httpserver/handlers/sessions.go b/go/core/internal/httpserver/handlers/sessions.go index e1907ca87..20ea70bec 100644 --- a/go/core/internal/httpserver/handlers/sessions.go +++ b/go/core/internal/httpserver/handlers/sessions.go @@ -18,12 +18,12 @@ import ( // SessionsHandler handles session-related requests type SessionsHandler struct { *Base - SandboxManager *sandbox.SandboxManager + SandboxProvider sandbox.SandboxProvider } // NewSessionsHandler creates a new SessionsHandler -func NewSessionsHandler(base *Base, sandboxManager *sandbox.SandboxManager) *SessionsHandler { - return &SessionsHandler{Base: base, SandboxManager: sandboxManager} +func NewSessionsHandler(base *Base, sandboxProvider sandbox.SandboxProvider) *SessionsHandler { + return &SessionsHandler{Base: base, SandboxProvider: sandboxProvider} } // RunRequest represents a run creation request @@ -299,8 +299,8 @@ func (h *SessionsHandler) HandleDeleteSession(w ErrorResponseWriter, r *http.Req } // Best-effort sandbox cleanup - if h.SandboxManager != nil { - if err := h.SandboxManager.DestroySandbox(r.Context(), sessionID); err != nil { + if h.SandboxProvider != nil { + if err := h.SandboxProvider.Destroy(r.Context(), sessionID); err != nil { log.Error(err, "Failed to destroy sandbox for session (best-effort)") } } diff --git a/go/core/internal/httpserver/server.go b/go/core/internal/httpserver/server.go index 258e84b4d..5ab8dd218 100644 --- a/go/core/internal/httpserver/server.go +++ b/go/core/internal/httpserver/server.go @@ -64,7 +64,7 @@ type ServerConfig struct { Authorizer auth.Authorizer ProxyURL string Reconciler reconciler.KagentReconciler - SandboxManager *sandbox.SandboxManager + SandboxProvider sandbox.SandboxProvider } // HTTPServer is the structure that manages the HTTP server @@ -84,7 +84,7 @@ func NewHTTPServer(config ServerConfig) (*HTTPServer, error) { return &HTTPServer{ config: config, router: config.Router, - handlers: handlers.NewHandlers(config.KubeClient, defaultModelConfig, config.DbClient, config.WatchedNamespaces, config.Authorizer, config.ProxyURL, config.Reconciler, config.SandboxManager), + handlers: handlers.NewHandlers(config.KubeClient, defaultModelConfig, config.DbClient, config.WatchedNamespaces, config.Authorizer, config.ProxyURL, config.Reconciler, config.SandboxProvider), authenticator: config.Authenticator, }, nil } diff --git a/go/core/pkg/app/app.go b/go/core/pkg/app/app.go index 9eabfb5cc..fba671ca3 100644 --- a/go/core/pkg/app/app.go +++ b/go/core/pkg/app/app.go @@ -74,6 +74,8 @@ import ( "github.com/kagent-dev/kagent/go/core/internal/controller" "github.com/kagent-dev/kagent/go/core/internal/goruntime" "github.com/kagent-dev/kmcp/api/v1alpha1" + agentsandboxv1alpha1 "sigs.k8s.io/agent-sandbox/api/v1alpha1" + agentsandboxextv1alpha1 "sigs.k8s.io/agent-sandbox/extensions/api/v1alpha1" // +kubebuilder:scaffold:imports ) @@ -93,6 +95,8 @@ func init() { utilruntime.Must(v1alpha1.AddToScheme(scheme)) utilruntime.Must(v1alpha2.AddToScheme(scheme)) + utilruntime.Must(agentsandboxv1alpha1.AddToScheme(scheme)) + utilruntime.Must(agentsandboxextv1alpha1.AddToScheme(scheme)) // +kubebuilder:scaffold:scheme } @@ -533,8 +537,8 @@ func Start(getExtensionConfig GetExtensionConfig) { os.Exit(1) } - // Create sandbox manager with stub provider (will be replaced by real providers later) - sandboxManager := sandboxpkg.NewSandboxManager(sandboxpkg.NewStubProvider()) + // Create sandbox provider using agent-sandbox CRDs. + sandboxProvider := sandboxpkg.NewAgentSandboxProvider(mgr.GetClient()) httpServer, err := httpserver.NewHTTPServer(httpserver.ServerConfig{ Router: router, @@ -548,7 +552,7 @@ func Start(getExtensionConfig GetExtensionConfig) { Authenticator: extensionCfg.Authenticator, ProxyURL: cfg.Proxy.URL, Reconciler: rcnclr, - SandboxManager: sandboxManager, + SandboxProvider: sandboxProvider, }) if err != nil { setupLog.Error(err, "unable to create HTTP server") diff --git a/go/go.mod b/go/go.mod index 2bfd0694a..2734edc91 100644 --- a/go/go.mod +++ b/go/go.mod @@ -3,7 +3,6 @@ module github.com/kagent-dev/kagent/go go 1.26.1 require ( - // core dependencies dario.cat/mergo v1.0.2 @@ -61,6 +60,8 @@ require ( turso.tech/database/tursogo v0.5.0-pre.13 ) +require sigs.k8s.io/agent-sandbox v0.1.1 + require ( cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.123.0 // indirect @@ -98,7 +99,7 @@ require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.9.1 // indirect - github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -107,9 +108,20 @@ require ( github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/jsonpointer v0.22.1 // indirect + github.com/go-openapi/jsonreference v0.21.2 // indirect + github.com/go-openapi/swag v0.25.1 // indirect + github.com/go-openapi/swag/cmdutils v0.25.1 // indirect + github.com/go-openapi/swag/conv v0.25.1 // indirect + github.com/go-openapi/swag/fileutils v0.25.1 // indirect + github.com/go-openapi/swag/jsonname v0.25.1 // indirect + github.com/go-openapi/swag/jsonutils v0.25.1 // indirect + github.com/go-openapi/swag/loading v0.25.1 // indirect + github.com/go-openapi/swag/mangling v0.25.1 // indirect + github.com/go-openapi/swag/netutils v0.25.1 // indirect + github.com/go-openapi/swag/stringutils v0.25.1 // indirect + github.com/go-openapi/swag/typeutils v0.25.1 // indirect + github.com/go-openapi/swag/yamlutils v0.25.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.3 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect @@ -132,7 +144,6 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/lestrrat-go/blackmagic v1.0.2 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect @@ -141,7 +152,6 @@ require ( github.com/lestrrat-go/jwx/v2 v2.1.4 // indirect github.com/lestrrat-go/option v1.0.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -205,7 +215,7 @@ require ( golang.org/x/sys v0.41.0 // indirect golang.org/x/term v0.40.0 // indirect golang.org/x/time v0.14.0 // indirect - gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/api v0.252.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect diff --git a/go/go.sum b/go/go.sum index 96ea4b4e8..a6328b9c6 100644 --- a/go/go.sum +++ b/go/go.sum @@ -97,7 +97,6 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -110,8 +109,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= -github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= -github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= @@ -147,14 +146,36 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= -github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= -github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= +github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= +github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= +github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= +github.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8= +github.com/go-openapi/swag v0.25.1/go.mod h1:bzONdGlT0fkStgGPd3bhZf1MnuPkf2YAys6h+jZipOo= +github.com/go-openapi/swag/cmdutils v0.25.1 h1:nDke3nAFDArAa631aitksFGj2omusks88GF1VwdYqPY= +github.com/go-openapi/swag/cmdutils v0.25.1/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0= +github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs= +github.com/go-openapi/swag/fileutils v0.25.1 h1:rSRXapjQequt7kqalKXdcpIegIShhTPXx7yw0kek2uU= +github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M= +github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= +github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= +github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8= +github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg= +github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw= +github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc= +github.com/go-openapi/swag/mangling v0.25.1 h1:XzILnLzhZPZNtmxKaz/2xIGPQsBsvmCjrJOWGNz/ync= +github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ= +github.com/go-openapi/swag/netutils v0.25.1 h1:2wFLYahe40tDUHfKT1GRC4rfa5T1B4GWZ+msEFA4Fl4= +github.com/go-openapi/swag/netutils v0.25.1/go.mod h1:CAkkvqnUJX8NV96tNhEQvKz8SQo2KF0f7LleiJwIeRE= +github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw= +github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg= +github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA= +github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8= +github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk= +github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg= github.com/go-pg/pg/v10 v10.11.0 h1:CMKJqLgTrfpE/aOVeLdybezR2om071Vh38OLZjsyMI0= github.com/go-pg/pg/v10 v10.11.0/go.mod h1:4BpHRoxE61y4Onpof3x1a2SQvi9c+q1dJnrNdMjsroA= github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU= @@ -222,8 +243,6 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kagent-dev/kmcp v0.2.7 h1:aDPpsmJVYqigC0inZablon1ap7GDBi8R+KRqH3OFTM0= @@ -232,11 +251,8 @@ github.com/kagent-dev/mockllm v0.0.5 h1:mm9Ml3NH6/E/YKVMgMwWYMNsNGkDze6I6TC0ppHZ github.com/kagent-dev/mockllm v0.0.5/go.mod h1:tDLemRsTZa1NdHaDbg3sgFk9cT1QWvMPlBtLVD6I2mA= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -257,8 +273,6 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= @@ -492,8 +506,8 @@ golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= -gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= -gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= +gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/adk v0.6.0 h1:hQl+K1qcvJ+B6rGBI+9T/Y6t21XsBQ8pRJqZYaOwK5M= @@ -558,6 +572,8 @@ rsc.io/omap v1.2.0 h1:c1M8jchnHbzmJALzGLclfH3xDWXrPxSUHXzH5C+8Kdw= rsc.io/omap v1.2.0/go.mod h1:C8pkI0AWexHopQtZX+qiUeJGzvc8HkdgnsWK4/mAa00= rsc.io/ordered v1.1.1 h1:1kZM6RkTmceJgsFH/8DLQvkCVEYomVDJfBRLT595Uak= rsc.io/ordered v1.1.1/go.mod h1:evAi8739bWVBRG9aaufsjVc202+6okf8u2QeVL84BCM= +sigs.k8s.io/agent-sandbox v0.1.1 h1:UBpfvE/bLRkWnGUsnJ2wIDazcOvs/guBHTyAhlD6BQI= +sigs.k8s.io/agent-sandbox v0.1.1/go.mod h1:xwp+pIX5rG4Bf3ScOwxnj8N8PbIhKpUqhAlrAjIFHVo= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/controller-runtime v0.23.1 h1:TjJSM80Nf43Mg21+RCy3J70aj/W6KyvDtOlpKf+PupE= diff --git a/go/sandbox-mcp/Dockerfile.local b/go/sandbox-mcp/Dockerfile.local new file mode 100644 index 000000000..e43bae65e --- /dev/null +++ b/go/sandbox-mcp/Dockerfile.local @@ -0,0 +1,4 @@ +FROM python:3.13-slim +COPY bin/sandbox-mcp /sandbox-mcp +EXPOSE 8080 +ENTRYPOINT ["/sandbox-mcp"] diff --git a/go/sandbox-mcp/cmd/main.go b/go/sandbox-mcp/cmd/main.go index cfeae4dc6..27e4a3498 100644 --- a/go/sandbox-mcp/cmd/main.go +++ b/go/sandbox-mcp/cmd/main.go @@ -71,11 +71,19 @@ func handleExec(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallTool } workingDir, _ := request.GetArguments()["working_dir"].(string) + log.Printf("[exec] command=%q working_dir=%q timeout_ms=%d", command, workingDir, timeoutMs) + result, err := tools.Exec(ctx, command, timeoutMs, workingDir) if err != nil { + log.Printf("[exec] error: %v", err) return mcp.NewToolResultError(err.Error()), nil } + log.Printf("[exec] exit_code=%d stdout_len=%d stderr_len=%d", result.ExitCode, len(result.Stdout), len(result.Stderr)) + if result.Stderr != "" { + log.Printf("[exec] stderr: %s", result.Stderr) + } + data, _ := json.Marshal(result) return mcp.NewToolResultText(string(data)), nil } @@ -83,11 +91,15 @@ func handleExec(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallTool func handleReadFile(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { path, _ := request.GetArguments()["path"].(string) + log.Printf("[read_file] path=%q", path) + content, err := tools.ReadFile(path) if err != nil { + log.Printf("[read_file] error: %v", err) return mcp.NewToolResultError(err.Error()), nil } + log.Printf("[read_file] ok, content_len=%d", len(content)) return mcp.NewToolResultText(content), nil } @@ -95,10 +107,14 @@ func handleWriteFile(ctx context.Context, request mcp.CallToolRequest) (*mcp.Cal path, _ := request.GetArguments()["path"].(string) content, _ := request.GetArguments()["content"].(string) + log.Printf("[write_file] path=%q content_len=%d", path, len(content)) + if err := tools.WriteFile(path, content); err != nil { + log.Printf("[write_file] error: %v", err) return mcp.NewToolResultError(err.Error()), nil } + log.Printf("[write_file] ok") data, _ := json.Marshal(map[string]bool{"ok": true}) return mcp.NewToolResultText(string(data)), nil } @@ -106,11 +122,15 @@ func handleWriteFile(ctx context.Context, request mcp.CallToolRequest) (*mcp.Cal func handleListDir(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { path, _ := request.GetArguments()["path"].(string) + log.Printf("[list_dir] path=%q", path) + entries, err := tools.ListDir(path) if err != nil { + log.Printf("[list_dir] error: %v", err) return mcp.NewToolResultError(err.Error()), nil } + log.Printf("[list_dir] ok, entries=%d", len(entries)) data, _ := json.Marshal(map[string]any{"entries": entries}) return mcp.NewToolResultText(string(data)), nil } diff --git a/helm/kagent-crds/templates/kagent.dev_agents.yaml b/helm/kagent-crds/templates/kagent.dev_agents.yaml index a34ced941..a41804c5a 100644 --- a/helm/kagent-crds/templates/kagent.dev_agents.yaml +++ b/helm/kagent-crds/templates/kagent.dev_agents.yaml @@ -10160,6 +10160,24 @@ spec: rule: '!(!has(self.agent) && self.type == ''Agent'')' maxItems: 20 type: array + workspace: + description: |- + Workspace references an external resource that provides a per-session + sandbox environment (filesystem + shell) for the agent. The referenced + resource is opaque to kagent; a matching SandboxProvider implementation + must be registered in the controller. + properties: + apiGroup: + type: string + kind: + type: string + name: + type: string + namespace: + type: string + required: + - name + type: object type: object x-kubernetes-validations: - message: systemMessage and systemMessageFrom are mutually exclusive diff --git a/helm/kagent/templates/controller-deployment.yaml b/helm/kagent/templates/controller-deployment.yaml index e8057ab98..d79dce27e 100644 --- a/helm/kagent/templates/controller-deployment.yaml +++ b/helm/kagent/templates/controller-deployment.yaml @@ -94,13 +94,12 @@ spec: httpGet: path: /health port: http - periodSeconds: 15 - initialDelaySeconds: 15 + periodSeconds: 2 readinessProbe: httpGet: path: /health port: http - periodSeconds: 30 + periodSeconds: 2 {{- if or (eq .Values.database.type "sqlite") (gt (len .Values.controller.volumeMounts) 0) }} volumeMounts: {{- if eq .Values.database.type "sqlite" }} diff --git a/helm/kagent/templates/rbac/agent-sandbox-clusterrole.yaml b/helm/kagent/templates/rbac/agent-sandbox-clusterrole.yaml new file mode 100644 index 000000000..0c957d1db --- /dev/null +++ b/helm/kagent/templates/rbac/agent-sandbox-clusterrole.yaml @@ -0,0 +1,13 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "kagent.fullname" . }}-sandbox-access + labels: + {{- include "kagent.labels" . | nindent 4 }} +rules: +- apiGroups: ["extensions.agents.x-k8s.io"] + resources: ["sandboxclaims", "sandboxtemplates"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +- apiGroups: ["agents.x-k8s.io"] + resources: ["sandboxes"] + verbs: ["get", "list", "watch"] diff --git a/helm/kagent/templates/rbac/agent-sandbox-clusterrolebinding.yaml b/helm/kagent/templates/rbac/agent-sandbox-clusterrolebinding.yaml new file mode 100644 index 000000000..056751cc1 --- /dev/null +++ b/helm/kagent/templates/rbac/agent-sandbox-clusterrolebinding.yaml @@ -0,0 +1,14 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "kagent.fullname" . }}-sandbox-access + labels: + {{- include "kagent.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "kagent.fullname" . }}-sandbox-access +subjects: +- kind: ServiceAccount + name: {{ include "kagent.fullname" . }}-controller + namespace: {{ .Release.Namespace }} From 079f0ee0e6cadcba56f7373a3c28ae7ac5449bb4 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Fri, 13 Mar 2026 14:09:27 +0000 Subject: [PATCH 4/6] feat: sandbox workspace provisioning, buildx cache removal, and sandbox-mcp improvements Add per-session sandbox provisioning for workspace-enabled agents, remove buildx caching from Makefile for reliable dirty-tree builds, and improve sandbox-mcp with skills support and streamlined Dockerfile. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Eitan Yarmush --- Makefile | 13 +- go/adk/cmd/main.go | 28 +- go/adk/examples/oneshot/main.go | 2 +- go/adk/pkg/a2a/executor.go | 72 +++++ go/adk/pkg/agent/agent.go | 12 +- go/adk/pkg/agent/createllm_test.go | 2 +- go/adk/pkg/runner/adapter.go | 16 +- go/adk/pkg/sandbox/provisioner.go | 86 ++++++ go/adk/pkg/sandbox/provisioner_test.go | 71 +++++ go/adk/pkg/sandbox/registry.go | 93 +++++++ go/adk/pkg/sandbox/registry_test.go | 102 ++++++++ go/adk/pkg/sandbox/toolset.go | 45 ++++ go/adk/pkg/sandbox/toolset_test.go | 77 ++++++ go/api/adk/types.go | 10 +- .../config/crd/bases/kagent.dev_agents.yaml | 28 +- go/api/httpapi/types.go | 15 -- go/api/v1alpha2/agent_types.go | 24 +- go/api/v1alpha2/zz_generated.deepcopy.go | 17 +- .../translator/agent/adk_api_translator.go | 18 +- .../agent/sandbox_template_plugin.go | 159 +++++++++++ .../agent/sandbox_template_plugin_test.go | 197 ++++++++++++++ .../internal/httpserver/handlers/sandbox.go | 101 +++++-- go/core/pkg/app/app.go | 32 ++- go/sandbox-mcp/Dockerfile | 3 +- go/sandbox-mcp/Dockerfile.local | 4 - go/sandbox-mcp/Makefile | 23 -- go/sandbox-mcp/cmd/main.go | 246 ++++++++++++------ go/sandbox-mcp/go.mod | 9 +- go/sandbox-mcp/go.sum | 42 ++- go/sandbox-mcp/pkg/tools/skills.go | 125 +++++++++ go/sandbox-mcp/pkg/tools/skills_test.go | 103 ++++++++ .../templates/kagent.dev_agents.yaml | 28 +- .../templates/controller-configmap.yaml | 7 + .../rbac/agent-sandbox-clusterrole.yaml | 2 + .../agent-sandbox-clusterrolebinding.yaml | 2 + helm/kagent/values.yaml | 12 + .../src/kagent/adk/_agent_executor.py | 21 +- .../kagent-adk/src/kagent/adk/types.py | 11 +- 38 files changed, 1596 insertions(+), 262 deletions(-) create mode 100644 go/adk/pkg/sandbox/provisioner.go create mode 100644 go/adk/pkg/sandbox/provisioner_test.go create mode 100644 go/adk/pkg/sandbox/registry.go create mode 100644 go/adk/pkg/sandbox/registry_test.go create mode 100644 go/adk/pkg/sandbox/toolset.go create mode 100644 go/adk/pkg/sandbox/toolset_test.go create mode 100644 go/core/internal/controller/translator/agent/sandbox_template_plugin.go create mode 100644 go/core/internal/controller/translator/agent/sandbox_template_plugin_test.go delete mode 100644 go/sandbox-mcp/Dockerfile.local delete mode 100644 go/sandbox-mcp/Makefile create mode 100644 go/sandbox-mcp/pkg/tools/skills.go create mode 100644 go/sandbox-mcp/pkg/tools/skills_test.go diff --git a/Makefile b/Makefile index c0768f9c7..4d2615ba8 100644 --- a/Makefile +++ b/Makefile @@ -26,8 +26,7 @@ BUILDX_NO_DEFAULT_ATTESTATIONS=1 BUILDX_BUILDER_NAME ?= kagent-builder-$(BUILDKIT_VERSION) DOCKER_BUILDER ?= docker buildx -DOCKER_BUILD_CACHE_DIR ?= /tmp/kagent-buildcache -DOCKER_BUILD_ARGS ?= --push --platform linux/$(LOCALARCH) --cache-from type=local,src=$(DOCKER_BUILD_CACHE_DIR) --cache-to type=local,dest=$(DOCKER_BUILD_CACHE_DIR),mode=max +DOCKER_BUILD_ARGS ?= --push --platform linux/$(LOCALARCH) KIND_CLUSTER_NAME ?= kagent KIND_IMAGE_VERSION ?= 1.35.0 @@ -38,6 +37,7 @@ APP_IMAGE_NAME ?= app KAGENT_ADK_IMAGE_NAME ?= kagent-adk GOLANG_ADK_IMAGE_NAME ?= golang-adk SKILLS_INIT_IMAGE_NAME ?= skills-init +SANDBOX_MCP_IMAGE_NAME ?= sandbox-mcp CONTROLLER_IMAGE_TAG ?= $(VERSION) UI_IMAGE_TAG ?= $(VERSION) @@ -45,6 +45,7 @@ APP_IMAGE_TAG ?= $(VERSION) KAGENT_ADK_IMAGE_TAG ?= $(VERSION) GOLANG_ADK_IMAGE_TAG ?= $(VERSION) SKILLS_INIT_IMAGE_TAG ?= $(VERSION) +SANDBOX_MCP_IMAGE_TAG ?= $(VERSION) CONTROLLER_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(CONTROLLER_IMAGE_NAME):$(CONTROLLER_IMAGE_TAG) UI_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(UI_IMAGE_NAME):$(UI_IMAGE_TAG) @@ -52,6 +53,7 @@ APP_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(APP_IMAGE_NAME):$(APP_IMAGE_TAG) KAGENT_ADK_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(KAGENT_ADK_IMAGE_NAME):$(KAGENT_ADK_IMAGE_TAG) GOLANG_ADK_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(GOLANG_ADK_IMAGE_NAME):$(GOLANG_ADK_IMAGE_TAG) SKILLS_INIT_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(SKILLS_INIT_IMAGE_NAME):$(SKILLS_INIT_IMAGE_TAG) +SANDBOX_MCP_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(SANDBOX_MCP_IMAGE_NAME):$(SANDBOX_MCP_IMAGE_TAG) #take from go/go.mod AWK ?= $(shell command -v gawk || command -v awk) @@ -216,7 +218,7 @@ prune-docker-images: docker images --filter dangling=true -q | xargs -r docker rmi || : .PHONY: build -build: buildx-create build-controller build-ui build-app build-golang-adk build-skills-init +build: buildx-create build-controller build-ui build-app build-golang-adk build-skills-init build-sandbox-mcp @echo "Build completed successfully." @echo "Controller Image: $(CONTROLLER_IMG)" @echo "UI Image: $(UI_IMG)" @@ -224,6 +226,7 @@ build: buildx-create build-controller build-ui build-app build-golang-adk build- @echo "Kagent ADK Image: $(KAGENT_ADK_IMG)" @echo "Golang ADK Image: $(GOLANG_ADK_IMG)" @echo "Skills Init Image: $(SKILLS_INIT_IMG)" + @echo "Sandbox MCP Image: $(SANDBOX_MCP_IMG)" .PHONY: build-monitor build-monitor: buildx-create @@ -284,6 +287,10 @@ build-golang-adk: buildx-create build-skills-init: buildx-create $(DOCKER_BUILDER) build $(DOCKER_BUILD_ARGS) -t $(SKILLS_INIT_IMG) -f docker/skills-init/Dockerfile docker/skills-init +.PHONY: build-sandbox-mcp +build-sandbox-mcp: buildx-create + $(DOCKER_BUILDER) build $(DOCKER_BUILD_ARGS) -t $(SANDBOX_MCP_IMG) -f go/sandbox-mcp/Dockerfile ./go/sandbox-mcp + .PHONY: helm-cleanup helm-cleanup: rm -f ./$(HELM_DIST_FOLDER)/*.tgz diff --git a/go/adk/cmd/main.go b/go/adk/cmd/main.go index e3f47688a..9681c816e 100644 --- a/go/adk/cmd/main.go +++ b/go/adk/cmd/main.go @@ -12,15 +12,18 @@ import ( "github.com/go-logr/logr" "github.com/go-logr/zapr" "github.com/kagent-dev/kagent/go/adk/pkg/a2a" + kagentagent "github.com/kagent-dev/kagent/go/adk/pkg/agent" "github.com/kagent-dev/kagent/go/adk/pkg/app" "github.com/kagent-dev/kagent/go/adk/pkg/auth" "github.com/kagent-dev/kagent/go/adk/pkg/config" kagentmemory "github.com/kagent-dev/kagent/go/adk/pkg/memory" runnerpkg "github.com/kagent-dev/kagent/go/adk/pkg/runner" + kagentsandbox "github.com/kagent-dev/kagent/go/adk/pkg/sandbox" "github.com/kagent-dev/kagent/go/adk/pkg/session" "go.uber.org/zap" "go.uber.org/zap/zapcore" "google.golang.org/adk/server/adka2a" + "google.golang.org/adk/tool" ) func setupLogger(logLevel string) (logr.Logger, *zap.Logger) { @@ -143,14 +146,35 @@ func main() { logger.Info("Memory service enabled", "appName", appName) } - runnerConfig, err := runnerpkg.CreateRunnerConfig(ctx, agentConfig, sessionService, appName, memoryService) + // Set up sandbox support if workspace is enabled. + var runnerOpts *runnerpkg.RunnerOptions + var execOpts *a2a.ExecutorOptions + if agentConfig.Workspace != nil && agentConfig.Workspace.Enabled && kagentURL != "" { + logger.Info("Workspace enabled, enabling sandbox support") + + registry := kagentsandbox.NewSandboxRegistry() + provisioner := kagentsandbox.NewSandboxProvisioner(httpClient, kagentURL) + sandboxToolset := kagentsandbox.NewSandboxToolset(registry) + + runnerOpts = &runnerpkg.RunnerOptions{ + AgentOptions: &kagentagent.AgentOptions{ + ExtraToolsets: []tool.Toolset{sandboxToolset}, + }, + } + execOpts = &a2a.ExecutorOptions{ + SandboxProvisioner: provisioner, + SandboxRegistry: registry, + } + } + + runnerConfig, err := runnerpkg.CreateRunnerConfig(ctx, agentConfig, sessionService, appName, memoryService, runnerOpts) if err != nil { logger.Error(err, "Failed to create Google ADK Runner config") os.Exit(1) } stream := agentConfig.GetStream() - execConfig := a2a.NewExecutorConfig(runnerConfig, sessionService, stream, appName, logger) + execConfig := a2a.NewExecutorConfig(runnerConfig, sessionService, stream, appName, logger, execOpts) executor := a2a.WrapExecutorQueue(adka2a.NewExecutor(execConfig)) // Build the agent card. diff --git a/go/adk/examples/oneshot/main.go b/go/adk/examples/oneshot/main.go index 161b8340c..e413fc4ee 100644 --- a/go/adk/examples/oneshot/main.go +++ b/go/adk/examples/oneshot/main.go @@ -66,7 +66,7 @@ func main() { agentConfig.Stream = &t } - adkAgent, err := agent.CreateGoogleADKAgent(ctx, agentConfig, "oneshot") + adkAgent, err := agent.CreateGoogleADKAgent(ctx, agentConfig, "oneshot", nil) if err != nil { fmt.Fprintf(os.Stderr, "error creating agent: %v\n", err) os.Exit(1) diff --git a/go/adk/pkg/a2a/executor.go b/go/adk/pkg/a2a/executor.go index 49b2c96f2..e51bee8ea 100644 --- a/go/adk/pkg/a2a/executor.go +++ b/go/adk/pkg/a2a/executor.go @@ -8,6 +8,7 @@ import ( a2atype "github.com/a2aproject/a2a-go/a2a" "github.com/a2aproject/a2a-go/a2asrv" "github.com/go-logr/logr" + "github.com/kagent-dev/kagent/go/adk/pkg/sandbox" "github.com/kagent-dev/kagent/go/adk/pkg/session" "github.com/kagent-dev/kagent/go/adk/pkg/skills" "github.com/kagent-dev/kagent/go/adk/pkg/telemetry" @@ -22,8 +23,21 @@ const ( defaultSkillsDirectory = "/skills" envSkillsFolder = "KAGENT_SKILLS_FOLDER" sessionNameMaxLength = 20 + + // StateKeySandboxMCPUrl is the session state key for the sandbox MCP URL. + // Matches the Python ADK key for cross-runtime compatibility. + StateKeySandboxMCPUrl = "sandbox_mcp_url" ) +// ExecutorOptions holds optional configuration for NewExecutorConfig. +type ExecutorOptions struct { + // SandboxProvisioner provisions sandboxes via the controller API. + SandboxProvisioner *sandbox.SandboxProvisioner + + // SandboxRegistry stores per-session sandbox MCP toolsets. + SandboxRegistry *sandbox.SandboxRegistry +} + // NewExecutorConfig builds an adka2a.ExecutorConfig with kagent-specific // callbacks wired in. The returned config can be passed directly to // adka2a.NewExecutor. @@ -33,6 +47,7 @@ func NewExecutorConfig( stream bool, appName string, logger logr.Logger, + opts *ExecutorOptions, ) adka2a.ExecutorConfig { skillsDir := os.Getenv(envSkillsFolder) if skillsDir == "" { @@ -51,6 +66,11 @@ func NewExecutorConfig( log: logger.WithName("a2a-executor"), } + if opts != nil { + cb.provisioner = opts.SandboxProvisioner + cb.registry = opts.SandboxRegistry + } + return adka2a.ExecutorConfig{ RunnerConfig: runnerConfig, RunConfig: runConfig, @@ -67,6 +87,10 @@ type kagentCallbacks struct { sessionService session.SessionService skillsDirectory string log logr.Logger + + // Sandbox support — all nil when workspace is not configured. + provisioner *sandbox.SandboxProvisioner + registry *sandbox.SandboxRegistry } // beforeExecute sets up tracing, creates the session with session_name if @@ -114,6 +138,54 @@ func (cb *kagentCallbacks) beforeExecute(ctx context.Context, reqCtx *a2asrv.Req if _, err = cb.sessionService.CreateSession(ctx, cb.appName, userID, state, sessionID); err != nil { return ctx, fmt.Errorf("failed to create session: %w", err) } + } else { + // Check session state for an existing sandbox MCP URL. + if cb.provisioner != nil && cb.registry != nil && sess.State != nil { + if mcpURL, ok := sess.State[StateKeySandboxMCPUrl].(string); ok && mcpURL != "" { + cb.log.Info("Reusing sandbox from session state", "sessionID", sessionID, "mcpUrl", mcpURL) + if _, err := cb.registry.GetOrCreate(ctx, sessionID, mcpURL); err != nil { + cb.log.Error(err, "Failed to restore sandbox toolset from session state", "sessionID", sessionID) + } + } + } + } + } + + // Sandbox provisioning: if provisioner and registry are configured and no + // sandbox exists yet for this session, provision one and store the URL in + // session state. + if cb.provisioner != nil && cb.registry != nil { + if _, exists := cb.registry.Get(sessionID); !exists { + mcpURL, err := cb.provisioner.Provision(ctx, sessionID) + if err != nil { + return ctx, fmt.Errorf("failed to provision sandbox: %w", err) + } + + if _, err := cb.registry.GetOrCreate(ctx, sessionID, mcpURL); err != nil { + return ctx, fmt.Errorf("failed to create sandbox toolset: %w", err) + } + + // Persist sandbox URL in session state via AppendEvent so + // subsequent requests in the same session reuse the sandbox. + if cb.sessionService != nil { + stateEvent := &adksession.Event{ + InvocationID: "sandbox_setup", + Author: "system", + Actions: adksession.EventActions{ + StateDelta: map[string]any{ + StateKeySandboxMCPUrl: mcpURL, + }, + }, + } + ourSession := &session.Session{ + ID: sessionID, + UserID: userID, + AppName: cb.appName, + } + if err := cb.sessionService.AppendEvent(ctx, ourSession, stateEvent); err != nil { + cb.log.Error(err, "Failed to persist sandbox URL in session state (continuing)", "sessionID", sessionID) + } + } } } diff --git a/go/adk/pkg/agent/agent.go b/go/adk/pkg/agent/agent.go index 6bc39f9c9..11cc8fb96 100644 --- a/go/adk/pkg/agent/agent.go +++ b/go/adk/pkg/agent/agent.go @@ -28,11 +28,17 @@ const ( DefaultOllamaModel = "llama3.2" ) +// AgentOptions holds optional configuration for agent creation. +type AgentOptions struct { + // ExtraToolsets are appended to the agent's toolset list (e.g. sandbox toolset). + ExtraToolsets []tool.Toolset +} + // CreateGoogleADKAgent creates a Google ADK agent from AgentConfig. // Toolsets are passed in directly (created by mcp.CreateToolsets). // agentName is used as the ADK agent identity (appears in event Author field). // extraTools are appended to the agent's tool list (e.g. save_memory). -func CreateGoogleADKAgent(ctx context.Context, agentConfig *adk.AgentConfig, agentName string, extraTools ...tool.Tool) (agent.Agent, error) { +func CreateGoogleADKAgent(ctx context.Context, agentConfig *adk.AgentConfig, agentName string, opts *AgentOptions, extraTools ...tool.Tool) (agent.Agent, error) { log := logr.FromContextOrDiscard(ctx) if agentConfig == nil { @@ -65,6 +71,10 @@ func CreateGoogleADKAgent(ctx context.Context, agentConfig *adk.AgentConfig, age agentName = "agent" } + if opts != nil { + toolsets = append(toolsets, opts.ExtraToolsets...) + } + llmAgentConfig := llmagent.Config{ Name: agentName, Description: agentConfig.Description, diff --git a/go/adk/pkg/agent/createllm_test.go b/go/adk/pkg/agent/createllm_test.go index bc00a7bc1..d6c1dc639 100644 --- a/go/adk/pkg/agent/createllm_test.go +++ b/go/adk/pkg/agent/createllm_test.go @@ -52,7 +52,7 @@ func runAgent(t *testing.T, agentCfg *adk.AgentConfig, prompt string) string { t.Helper() ctx := logr.NewContext(t.Context(), logr.Discard()) - adkAgent, err := CreateGoogleADKAgent(ctx, agentCfg, "test-agent") + adkAgent, err := CreateGoogleADKAgent(ctx, agentCfg, "test-agent", nil) require.NoError(t, err) sessionService := adksession.InMemoryService() diff --git a/go/adk/pkg/runner/adapter.go b/go/adk/pkg/runner/adapter.go index b9efec559..b7dec28d0 100644 --- a/go/adk/pkg/runner/adapter.go +++ b/go/adk/pkg/runner/adapter.go @@ -15,6 +15,12 @@ import ( adktool "google.golang.org/adk/tool" ) +// RunnerOptions holds optional configuration for runner creation. +type RunnerOptions struct { + // AgentOptions are passed through to agent creation (e.g. extra toolsets). + AgentOptions *agent.AgentOptions +} + func agentNameFromAppName(appName string) string { if idx := strings.LastIndex(appName, "__NS__"); idx >= 0 { return appName[idx+len("__NS__"):] @@ -24,7 +30,8 @@ func agentNameFromAppName(appName string) string { // CreateRunnerConfig creates a runner.Config suitable for use with adka2a.Executor. // memoryService is optional; pass nil when memory is not configured. -func CreateRunnerConfig(ctx context.Context, agentConfig *adk.AgentConfig, sessionService session.SessionService, appName string, memoryService *kagentmemory.KagentMemoryService) (runner.Config, error) { +// opts is optional; pass nil when no extra configuration is needed. +func CreateRunnerConfig(ctx context.Context, agentConfig *adk.AgentConfig, sessionService session.SessionService, appName string, memoryService *kagentmemory.KagentMemoryService, opts *RunnerOptions) (runner.Config, error) { // If a memory service is provided, create the save_memory tool so the agent // can explicitly save content. The load_memory tool is provided by the // upstream Google ADK. @@ -33,7 +40,12 @@ func CreateRunnerConfig(ctx context.Context, agentConfig *adk.AgentConfig, sessi extraTools = append(extraTools, kagentmemory.NewSaveMemoryTool(memoryService)) } - adkAgent, err := agent.CreateGoogleADKAgent(ctx, agentConfig, agentNameFromAppName(appName), extraTools...) + var agentOpts *agent.AgentOptions + if opts != nil { + agentOpts = opts.AgentOptions + } + + adkAgent, err := agent.CreateGoogleADKAgent(ctx, agentConfig, agentNameFromAppName(appName), agentOpts, extraTools...) if err != nil { return runner.Config{}, fmt.Errorf("failed to create agent: %w", err) } diff --git a/go/adk/pkg/sandbox/provisioner.go b/go/adk/pkg/sandbox/provisioner.go new file mode 100644 index 000000000..30b9f923b --- /dev/null +++ b/go/adk/pkg/sandbox/provisioner.go @@ -0,0 +1,86 @@ +package sandbox + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + + "github.com/go-logr/logr" + "github.com/kagent-dev/kagent/go/api/httpapi" +) + +const ( + envKAgentURL = "KAGENT_URL" +) + +// SandboxProvisioner calls the controller's sandbox endpoint to provision +// a sandbox for a session. It mirrors the Python ADK's sandbox provisioning +// in _ensure_sandbox_toolset. +type SandboxProvisioner struct { + httpClient *http.Client + kagentURL string +} + +// NewSandboxProvisioner creates a provisioner. If kagentURL is empty it falls +// back to the KAGENT_URL environment variable. +func NewSandboxProvisioner(httpClient *http.Client, kagentURL string) *SandboxProvisioner { + if kagentURL == "" { + kagentURL = os.Getenv(envKAgentURL) + } + if httpClient == nil { + httpClient = http.DefaultClient + } + return &SandboxProvisioner{ + httpClient: httpClient, + kagentURL: kagentURL, + } +} + +// Provision calls POST /api/sessions/{sessionID}/sandbox on the controller, +// blocking until the sandbox is ready, and returns the MCP URL. +// The controller resolves the workspace from the session's agent CRD. +func (p *SandboxProvisioner) Provision(ctx context.Context, sessionID string) (string, error) { + log := logr.FromContextOrDiscard(ctx) + + if p.kagentURL == "" { + return "", fmt.Errorf("kagent URL is not configured; cannot provision sandbox") + } + + url := fmt.Sprintf("%s/api/sessions/%s/sandbox", p.kagentURL, sessionID) + log.Info("Provisioning sandbox", "url", url, "sessionID", sessionID) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) + if err != nil { + return "", fmt.Errorf("failed to create sandbox request: %w", err) + } + + resp, err := p.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("sandbox provisioning request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read sandbox response: %w", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + return "", fmt.Errorf("sandbox provisioning failed: status %d, body: %s", resp.StatusCode, string(respBody)) + } + + var sandboxResp httpapi.SandboxResponse + if err := json.Unmarshal(respBody, &sandboxResp); err != nil { + return "", fmt.Errorf("failed to decode sandbox response: %w", err) + } + + if sandboxResp.MCPUrl == "" { + return "", fmt.Errorf("sandbox response missing mcp_url") + } + + log.Info("Sandbox provisioned", "sessionID", sessionID, "mcpUrl", sandboxResp.MCPUrl, "ready", sandboxResp.Ready) + return sandboxResp.MCPUrl, nil +} diff --git a/go/adk/pkg/sandbox/provisioner_test.go b/go/adk/pkg/sandbox/provisioner_test.go new file mode 100644 index 000000000..b1489862a --- /dev/null +++ b/go/adk/pkg/sandbox/provisioner_test.go @@ -0,0 +1,71 @@ +package sandbox + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kagent-dev/kagent/go/api/httpapi" +) + +func TestProvisionSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/sessions/sess-1/sandbox" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + // Body should be empty — controller resolves workspace from session. + if r.ContentLength > 0 { + t.Errorf("expected empty body, got content-length %d", r.ContentLength) + } + + resp := httpapi.SandboxResponse{ + SandboxID: "sb-123", + MCPUrl: "http://sandbox-pod:8080/mcp", + Protocol: "streamable-http", + Ready: true, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewSandboxProvisioner(server.Client(), server.URL) + + mcpURL, err := p.Provision(context.Background(), "sess-1") + if err != nil { + t.Fatalf("Provision failed: %v", err) + } + if mcpURL != "http://sandbox-pod:8080/mcp" { + t.Errorf("unexpected mcpURL: %s", mcpURL) + } +} + +func TestProvisionServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("internal error")) + })) + defer server.Close() + + p := NewSandboxProvisioner(server.Client(), server.URL) + + _, err := p.Provision(context.Background(), "sess-1") + if err == nil { + t.Fatal("expected error for 500 response") + } +} + +func TestProvisionNoKagentURL(t *testing.T) { + p := NewSandboxProvisioner(nil, "") + + _, err := p.Provision(context.Background(), "sess-1") + if err == nil { + t.Fatal("expected error when kagent URL is empty") + } +} diff --git a/go/adk/pkg/sandbox/registry.go b/go/adk/pkg/sandbox/registry.go new file mode 100644 index 000000000..7a4751296 --- /dev/null +++ b/go/adk/pkg/sandbox/registry.go @@ -0,0 +1,93 @@ +package sandbox + +import ( + "context" + "fmt" + "sync" + + "github.com/go-logr/logr" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "google.golang.org/adk/tool" + "google.golang.org/adk/tool/mcptoolset" +) + +// sandboxEntry holds the MCP toolset for a provisioned sandbox. +type sandboxEntry struct { + mcpURL string + toolset tool.Toolset +} + +// SandboxRegistry maps session IDs to their sandbox MCP toolsets. +// It is safe for concurrent use. +type SandboxRegistry struct { + mu sync.RWMutex + entries map[string]*sandboxEntry +} + +// NewSandboxRegistry creates an empty registry. +func NewSandboxRegistry() *SandboxRegistry { + return &SandboxRegistry{ + entries: make(map[string]*sandboxEntry), + } +} + +// GetOrCreate returns the existing toolset for a session or creates a new one +// by connecting to the given MCP URL. Creation is idempotent — if two +// goroutines race, the first one wins. +func (r *SandboxRegistry) GetOrCreate(ctx context.Context, sessionID, mcpURL string) (tool.Toolset, error) { + log := logr.FromContextOrDiscard(ctx) + + // Fast path: read lock. + r.mu.RLock() + if e, ok := r.entries[sessionID]; ok { + r.mu.RUnlock() + return e.toolset, nil + } + r.mu.RUnlock() + + // Slow path: create under write lock. + r.mu.Lock() + defer r.mu.Unlock() + + // Double-check after acquiring write lock. + if e, ok := r.entries[sessionID]; ok { + return e.toolset, nil + } + + log.Info("Creating sandbox MCP toolset", "sessionID", sessionID, "mcpURL", mcpURL) + + transport := &mcpsdk.StreamableClientTransport{ + Endpoint: mcpURL, + } + + ts, err := mcptoolset.New(mcptoolset.Config{ + Transport: transport, + }) + if err != nil { + return nil, fmt.Errorf("failed to create sandbox MCP toolset for %s: %w", mcpURL, err) + } + + r.entries[sessionID] = &sandboxEntry{ + mcpURL: mcpURL, + toolset: ts, + } + return ts, nil +} + +// Get returns the toolset for a session, or nil if none exists. +func (r *SandboxRegistry) Get(sessionID string) (tool.Toolset, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + e, ok := r.entries[sessionID] + if !ok { + return nil, false + } + return e.toolset, true +} + +// Remove deletes the entry for a session. +func (r *SandboxRegistry) Remove(sessionID string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.entries, sessionID) +} diff --git a/go/adk/pkg/sandbox/registry_test.go b/go/adk/pkg/sandbox/registry_test.go new file mode 100644 index 000000000..e0085e43e --- /dev/null +++ b/go/adk/pkg/sandbox/registry_test.go @@ -0,0 +1,102 @@ +package sandbox + +import ( + "sync" + "testing" + + "google.golang.org/adk/agent" + "google.golang.org/adk/tool" +) + +// fakeToolset is a minimal toolset for testing (avoids real MCP connections). +type fakeToolset struct { + name string +} + +func (f *fakeToolset) Name() string { return f.name } +func (f *fakeToolset) Tools(_ agent.ReadonlyContext) ([]tool.Tool, error) { return nil, nil } + +func TestRegistryGetNotFound(t *testing.T) { + r := NewSandboxRegistry() + _, ok := r.Get("nonexistent") + if ok { + t.Error("expected not found for nonexistent session") + } +} + +func TestRegistryRemove(t *testing.T) { + r := NewSandboxRegistry() + // Manually insert an entry for testing. + r.mu.Lock() + r.entries["sess-1"] = &sandboxEntry{ + mcpURL: "http://test:8080/mcp", + toolset: &fakeToolset{name: "sandbox"}, + } + r.mu.Unlock() + + ts, ok := r.Get("sess-1") + if !ok || ts == nil { + t.Fatal("expected to find entry for sess-1") + } + + r.Remove("sess-1") + _, ok = r.Get("sess-1") + if ok { + t.Error("expected not found after Remove") + } +} + +func TestRegistryIdempotentInsert(t *testing.T) { + r := NewSandboxRegistry() + // Insert the same session twice. + r.mu.Lock() + r.entries["sess-1"] = &sandboxEntry{ + mcpURL: "http://test:8080/mcp", + toolset: &fakeToolset{name: "first"}, + } + r.mu.Unlock() + + ts, ok := r.Get("sess-1") + if !ok { + t.Fatal("expected to find sess-1") + } + if ts.Name() != "first" { + t.Errorf("expected name=first, got %s", ts.Name()) + } +} + +func TestRegistryConcurrentAccess(t *testing.T) { + r := NewSandboxRegistry() + + // Pre-populate some entries. + for i := range 100 { + id := "sess-" + string(rune('A'+i%26)) + r.mu.Lock() + r.entries[id] = &sandboxEntry{ + mcpURL: "http://test:8080/mcp", + toolset: &fakeToolset{name: "sandbox"}, + } + r.mu.Unlock() + } + + var wg sync.WaitGroup + for i := range 50 { + wg.Add(1) + go func(i int) { + defer wg.Done() + id := "sess-" + string(rune('A'+i%26)) + r.Get(id) + }(i) + } + + for i := range 50 { + wg.Add(1) + go func(i int) { + defer wg.Done() + id := "sess-remove-" + string(rune('A'+i%26)) + r.Remove(id) + }(i) + } + + wg.Wait() +} diff --git a/go/adk/pkg/sandbox/toolset.go b/go/adk/pkg/sandbox/toolset.go new file mode 100644 index 000000000..6880c849d --- /dev/null +++ b/go/adk/pkg/sandbox/toolset.go @@ -0,0 +1,45 @@ +package sandbox + +import ( + "google.golang.org/adk/agent" + "google.golang.org/adk/tool" +) + +// SandboxToolset implements tool.Toolset by delegating to the per-session +// sandbox MCP toolset stored in the SandboxRegistry. Since the Google ADK +// calls Tools() per-invocation with the session context, we can look up the +// correct session's sandbox tools dynamically. +// +// If no sandbox has been provisioned for the current session (e.g. beforeExecute +// hasn't run yet), Tools() returns an empty slice. +type SandboxToolset struct { + registry *SandboxRegistry +} + +// NewSandboxToolset creates a toolset backed by the given registry. +func NewSandboxToolset(registry *SandboxRegistry) *SandboxToolset { + return &SandboxToolset{registry: registry} +} + +var _ tool.Toolset = (*SandboxToolset)(nil) + +// Name returns the toolset name. +func (s *SandboxToolset) Name() string { + return "sandbox" +} + +// Tools returns the sandbox MCP tools for the current session. If no sandbox +// is provisioned yet, returns an empty slice (not an error). +func (s *SandboxToolset) Tools(ctx agent.ReadonlyContext) ([]tool.Tool, error) { + sessionID := ctx.SessionID() + if sessionID == "" { + return nil, nil + } + + inner, ok := s.registry.Get(sessionID) + if !ok { + return nil, nil + } + + return inner.Tools(ctx) +} diff --git a/go/adk/pkg/sandbox/toolset_test.go b/go/adk/pkg/sandbox/toolset_test.go new file mode 100644 index 000000000..15900eca4 --- /dev/null +++ b/go/adk/pkg/sandbox/toolset_test.go @@ -0,0 +1,77 @@ +package sandbox + +import ( + "testing" + + "google.golang.org/adk/agent" + "google.golang.org/adk/tool" +) + +func TestSandboxToolsetName(t *testing.T) { + ts := NewSandboxToolset(NewSandboxRegistry()) + if ts.Name() != "sandbox" { + t.Errorf("expected name=sandbox, got %s", ts.Name()) + } +} + +// mockReadonlyContext implements agent.ReadonlyContext for testing. +type mockReadonlyContext struct { + agent.ReadonlyContext + sessionID string +} + +func (m *mockReadonlyContext) SessionID() string { return m.sessionID } + +func TestSandboxToolsetNoSessionReturnsEmpty(t *testing.T) { + ts := NewSandboxToolset(NewSandboxRegistry()) + tools, err := ts.Tools(&mockReadonlyContext{sessionID: ""}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(tools) != 0 { + t.Errorf("expected empty tools, got %d", len(tools)) + } +} + +func TestSandboxToolsetNoRegistryEntryReturnsEmpty(t *testing.T) { + ts := NewSandboxToolset(NewSandboxRegistry()) + tools, err := ts.Tools(&mockReadonlyContext{sessionID: "sess-1"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(tools) != 0 { + t.Errorf("expected empty tools, got %d", len(tools)) + } +} + +func TestSandboxToolsetDelegatesToRegistry(t *testing.T) { + registry := NewSandboxRegistry() + // Inject a fake toolset. + registry.mu.Lock() + registry.entries["sess-1"] = &sandboxEntry{ + mcpURL: "http://test:8080/mcp", + toolset: &fakeToolsetWithTools{}, + } + registry.mu.Unlock() + + ts := NewSandboxToolset(registry) + tools, err := ts.Tools(&mockReadonlyContext{sessionID: "sess-1"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(tools) != 1 { + t.Errorf("expected 1 tool, got %d", len(tools)) + } +} + +// fakeToolsetWithTools returns one fake tool. +type fakeToolsetWithTools struct{} + +func (f *fakeToolsetWithTools) Name() string { return "sandbox" } +func (f *fakeToolsetWithTools) Tools(_ agent.ReadonlyContext) ([]tool.Tool, error) { + return []tool.Tool{&fakeTool{}}, nil +} + +type fakeTool struct{ tool.Tool } + +func (f *fakeTool) Name() string { return "exec" } diff --git a/go/api/adk/types.go b/go/api/adk/types.go index e7bde7128..773ef86d9 100644 --- a/go/api/adk/types.go +++ b/go/api/adk/types.go @@ -427,13 +427,11 @@ func (c *AgentCompressionConfig) UnmarshalJSON(data []byte) error { return nil } -// WorkspaceConfig carries the workspace reference from the Agent CRD through -// config.json so the agent runtime can request a sandbox on session start. +// WorkspaceConfig signals to the agent runtime that sandbox provisioning is +// enabled. The controller resolves the actual workspace details from the +// session's agent CRD, so config.json only carries {enabled: true}. type WorkspaceConfig struct { - APIGroup string `json:"api_group"` - Kind string `json:"kind"` - Name string `json:"name"` - Namespace string `json:"namespace"` + Enabled bool `json:"enabled"` } // See `python/packages/kagent-adk/src/kagent/adk/types.py` for the python version of this diff --git a/go/api/config/crd/bases/kagent.dev_agents.yaml b/go/api/config/crd/bases/kagent.dev_agents.yaml index a41804c5a..02abfdc3a 100644 --- a/go/api/config/crd/bases/kagent.dev_agents.yaml +++ b/go/api/config/crd/bases/kagent.dev_agents.yaml @@ -10162,21 +10162,25 @@ spec: type: array workspace: description: |- - Workspace references an external resource that provides a per-session - sandbox environment (filesystem + shell) for the agent. The referenced - resource is opaque to kagent; a matching SandboxProvider implementation - must be registered in the controller. + Workspace enables a per-session sandbox environment (filesystem + shell) + for the agent. When enabled, the controller provisions an isolated sandbox + pod for each session via the agent-sandbox SandboxClaim API. properties: - apiGroup: - type: string - kind: - type: string - name: - type: string - namespace: + enabled: + default: true + description: |- + Enabled activates workspace/sandbox provisioning. When true, the + controller generates a SandboxTemplate and provisions a sandbox pod + per session with exec, filesystem, and (if configured) skill tools. + type: boolean + templateRef: + description: |- + TemplateRef optionally references a user-provided SandboxTemplate by + name (in the same namespace). When set, the controller uses this + template instead of generating one automatically. type: string required: - - name + - enabled type: object type: object x-kubernetes-validations: diff --git a/go/api/httpapi/types.go b/go/api/httpapi/types.go index 894a6e6bd..a7bd19f50 100644 --- a/go/api/httpapi/types.go +++ b/go/api/httpapi/types.go @@ -194,21 +194,6 @@ type SessionRunsData struct { Runs []any `json:"runs"` } -// CreateSandboxRequest represents a request to create a sandbox for a session. -type CreateSandboxRequest struct { - AgentName string `json:"agent_name"` - Namespace string `json:"namespace"` - Workspace WorkspaceRef `json:"workspace_ref"` -} - -// WorkspaceRef identifies the workspace resource to provision a sandbox from. -type WorkspaceRef struct { - APIGroup string `json:"api_group"` - Kind string `json:"kind"` - Name string `json:"name"` - Namespace string `json:"namespace,omitempty"` -} - // SandboxResponse is returned from sandbox creation/status endpoints. type SandboxResponse struct { SandboxID string `json:"sandbox_id"` diff --git a/go/api/v1alpha2/agent_types.go b/go/api/v1alpha2/agent_types.go index 11615479c..ab3084650 100644 --- a/go/api/v1alpha2/agent_types.go +++ b/go/api/v1alpha2/agent_types.go @@ -189,12 +189,26 @@ type DeclarativeAgentSpec struct { // +optional Context *ContextConfig `json:"context,omitempty"` - // Workspace references an external resource that provides a per-session - // sandbox environment (filesystem + shell) for the agent. The referenced - // resource is opaque to kagent; a matching SandboxProvider implementation - // must be registered in the controller. + // Workspace enables a per-session sandbox environment (filesystem + shell) + // for the agent. When enabled, the controller provisions an isolated sandbox + // pod for each session via the agent-sandbox SandboxClaim API. // +optional - Workspace *TypedReference `json:"workspace,omitempty"` + Workspace *WorkspaceSpec `json:"workspace,omitempty"` +} + +// WorkspaceSpec configures per-session sandbox environments for an agent. +type WorkspaceSpec struct { + // Enabled activates workspace/sandbox provisioning. When true, the + // controller generates a SandboxTemplate and provisions a sandbox pod + // per session with exec, filesystem, and (if configured) skill tools. + // +kubebuilder:default=true + Enabled bool `json:"enabled"` + + // TemplateRef optionally references a user-provided SandboxTemplate by + // name (in the same namespace). When set, the controller uses this + // template instead of generating one automatically. + // +optional + TemplateRef string `json:"templateRef,omitempty"` } // ContextConfig configures context management for an agent. diff --git a/go/api/v1alpha2/zz_generated.deepcopy.go b/go/api/v1alpha2/zz_generated.deepcopy.go index 13d0f2f96..1ca2fcdd0 100644 --- a/go/api/v1alpha2/zz_generated.deepcopy.go +++ b/go/api/v1alpha2/zz_generated.deepcopy.go @@ -492,7 +492,7 @@ func (in *DeclarativeAgentSpec) DeepCopyInto(out *DeclarativeAgentSpec) { } if in.Workspace != nil { in, out := &in.Workspace, &out.Workspace - *out = new(TypedReference) + *out = new(WorkspaceSpec) **out = **in } } @@ -1404,3 +1404,18 @@ func (in *ValueSource) DeepCopy() *ValueSource { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkspaceSpec) DeepCopyInto(out *WorkspaceSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkspaceSpec. +func (in *WorkspaceSpec) DeepCopy() *WorkspaceSpec { + if in == nil { + return nil + } + out := new(WorkspaceSpec) + in.DeepCopyInto(out) + return out +} diff --git a/go/core/internal/controller/translator/agent/adk_api_translator.go b/go/core/internal/controller/translator/agent/adk_api_translator.go index 311b9fd17..9053c6fdc 100644 --- a/go/core/internal/controller/translator/agent/adk_api_translator.go +++ b/go/core/internal/controller/translator/agent/adk_api_translator.go @@ -693,18 +693,12 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent *v1al } } - // Translate workspace reference for sandbox support - if agent.Spec.Declarative.Workspace != nil { - ws := agent.Spec.Declarative.Workspace - cfg.Workspace = &adk.WorkspaceConfig{ - APIGroup: ws.ApiGroup, - Kind: ws.Kind, - Name: ws.Name, - Namespace: ws.Namespace, - } - if cfg.Workspace.Namespace == "" { - cfg.Workspace.Namespace = agent.Namespace - } + // Translate workspace reference for sandbox support. + // config.json only carries {enabled: true} — the controller resolves + // the actual workspace details from the session's agent CRD at sandbox + // creation time. + if ws := agent.Spec.Declarative.Workspace; ws != nil && ws.Enabled { + cfg.Workspace = &adk.WorkspaceConfig{Enabled: true} } for _, tool := range agent.Spec.Declarative.Tools { diff --git a/go/core/internal/controller/translator/agent/sandbox_template_plugin.go b/go/core/internal/controller/translator/agent/sandbox_template_plugin.go new file mode 100644 index 000000000..d727a6451 --- /dev/null +++ b/go/core/internal/controller/translator/agent/sandbox_template_plugin.go @@ -0,0 +1,159 @@ +package agent + +import ( + "context" + "fmt" + + "github.com/kagent-dev/kagent/go/api/v1alpha2" + "github.com/kagent-dev/kagent/go/core/internal/version" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + sandboxv1alpha1 "sigs.k8s.io/agent-sandbox/api/v1alpha1" + extv1alpha1 "sigs.k8s.io/agent-sandbox/extensions/api/v1alpha1" +) + +// DefaultSandboxMCPImageConfig is the image config for the sandbox MCP server +// container that runs inside sandbox pods. +var DefaultSandboxMCPImageConfig = ImageConfig{ + Registry: "ghcr.io", + Tag: version.Get().Version, + PullPolicy: string(corev1.PullIfNotPresent), + Repository: "kagent-dev/kagent-sandbox-mcp", +} + +// SandboxTemplatePlugin is a TranslatorPlugin that generates a per-agent +// SandboxTemplate when workspace is enabled and no user-provided templateRef +// is set. +// +// When skills are also configured, the generated template includes a +// skills-init container that fetches skills into /skills. The sandbox-mcp +// container always runs and exposes exec, filesystem, and (when skills are +// present) get_skill MCP tools. +type SandboxTemplatePlugin struct{} + +var _ TranslatorPlugin = (*SandboxTemplatePlugin)(nil) + +func NewSandboxTemplatePlugin() *SandboxTemplatePlugin { + return &SandboxTemplatePlugin{} +} + +// GetOwnedResourceTypes returns the types this plugin may create. +func (p *SandboxTemplatePlugin) GetOwnedResourceTypes() []client.Object { + return []client.Object{ + &extv1alpha1.SandboxTemplate{}, + } +} + +// ProcessAgent generates a SandboxTemplate when workspace is enabled. +func (p *SandboxTemplatePlugin) ProcessAgent(ctx context.Context, agent *v1alpha2.Agent, outputs *AgentOutputs) error { + if agent.Spec.Type != v1alpha2.AgentType_Declarative { + return nil + } + if agent.Spec.Declarative == nil { + return nil + } + + ws := agent.Spec.Declarative.Workspace + if ws == nil || !ws.Enabled { + return nil + } + + // If the user provided their own templateRef, don't generate one. + if ws.TemplateRef != "" { + return nil + } + + templateName := sandboxTemplateName(agent.Name) + + // Check if skills are configured. + hasSkills := agent.Spec.Skills != nil && + (len(agent.Spec.Skills.Refs) > 0 || len(agent.Spec.Skills.GitRefs) > 0) + + var initContainers []corev1.Container + var volumes []corev1.Volume + var sandboxVolumeMounts []corev1.VolumeMount + var sandboxEnv []corev1.EnvVar + + if hasSkills { + // Build the skills-init container. + var skills []string + var gitRefs []v1alpha2.GitRepo + var gitAuthSecretRef *corev1.LocalObjectReference + if agent.Spec.Skills != nil { + skills = agent.Spec.Skills.Refs + gitRefs = agent.Spec.Skills.GitRefs + gitAuthSecretRef = agent.Spec.Skills.GitAuthSecretRef + } + insecure := agent.Spec.Skills != nil && agent.Spec.Skills.InsecureSkipVerify + + initContainer, extraVolumes, err := buildSkillsInitContainer(gitRefs, gitAuthSecretRef, skills, insecure, nil) + if err != nil { + return fmt.Errorf("failed to build skills init container for sandbox template: %w", err) + } + initContainers = append(initContainers, initContainer) + + volumes = append(volumes, corev1.Volume{ + Name: "kagent-skills", + VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}, + }) + volumes = append(volumes, extraVolumes...) + + sandboxVolumeMounts = append(sandboxVolumeMounts, corev1.VolumeMount{ + Name: "kagent-skills", MountPath: "/skills", ReadOnly: true, + }) + sandboxEnv = append(sandboxEnv, corev1.EnvVar{ + Name: "SKILLS_DIR", Value: "/skills", + }) + } + + template := &extv1alpha1.SandboxTemplate{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "extensions.agents.x-k8s.io/v1alpha1", + Kind: "SandboxTemplate", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: templateName, + Namespace: agent.Namespace, + Labels: map[string]string{ + "app": "kagent", + "kagent.dev/agent": agent.Name, + "kagent.dev/component": "sandbox-template", + }, + }, + Spec: extv1alpha1.SandboxTemplateSpec{ + PodTemplate: sandboxv1alpha1.PodTemplate{ + Spec: corev1.PodSpec{ + InitContainers: initContainers, + Containers: []corev1.Container{ + { + Name: "sandbox", + Image: DefaultSandboxMCPImageConfig.Image(), + Ports: []corev1.ContainerPort{ + {ContainerPort: 8080}, + }, + Env: sandboxEnv, + VolumeMounts: sandboxVolumeMounts, + }, + }, + Volumes: volumes, + }, + }, + }, + } + + outputs.Manifest = append(outputs.Manifest, template) + + return nil +} + +// sandboxTemplateName returns the deterministic name for an agent's auto-generated +// SandboxTemplate. +func sandboxTemplateName(agentName string) string { + name := agentName + "-sandbox" + if len(name) > 63 { + name = name[:63] + } + return name +} diff --git a/go/core/internal/controller/translator/agent/sandbox_template_plugin_test.go b/go/core/internal/controller/translator/agent/sandbox_template_plugin_test.go new file mode 100644 index 000000000..b2de26b73 --- /dev/null +++ b/go/core/internal/controller/translator/agent/sandbox_template_plugin_test.go @@ -0,0 +1,197 @@ +package agent + +import ( + "context" + "testing" + + "github.com/kagent-dev/kagent/go/api/adk" + "github.com/kagent-dev/kagent/go/api/v1alpha2" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + extv1alpha1 "sigs.k8s.io/agent-sandbox/extensions/api/v1alpha1" +) + +func TestSandboxTemplatePlugin_SkipsNonDeclarative(t *testing.T) { + plugin := NewSandboxTemplatePlugin() + agent := &v1alpha2.Agent{ + Spec: v1alpha2.AgentSpec{Type: v1alpha2.AgentType_BYO}, + } + outputs := &AgentOutputs{} + + if err := plugin.ProcessAgent(context.Background(), agent, outputs); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(outputs.Manifest) != 0 { + t.Errorf("expected no manifest objects for BYO agent, got %d", len(outputs.Manifest)) + } +} + +func TestSandboxTemplatePlugin_SkipsDisabled(t *testing.T) { + plugin := NewSandboxTemplatePlugin() + agent := &v1alpha2.Agent{ + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + Workspace: &v1alpha2.WorkspaceSpec{Enabled: false}, + }, + }, + } + outputs := &AgentOutputs{} + + if err := plugin.ProcessAgent(context.Background(), agent, outputs); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(outputs.Manifest) != 0 { + t.Errorf("expected no manifest for disabled workspace, got %d", len(outputs.Manifest)) + } +} + +func TestSandboxTemplatePlugin_SkipsUserTemplateRef(t *testing.T) { + plugin := NewSandboxTemplatePlugin() + agent := &v1alpha2.Agent{ + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + Workspace: &v1alpha2.WorkspaceSpec{ + Enabled: true, + TemplateRef: "my-custom-template", + }, + }, + }, + } + outputs := &AgentOutputs{} + + if err := plugin.ProcessAgent(context.Background(), agent, outputs); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(outputs.Manifest) != 0 { + t.Errorf("expected no manifest when templateRef is set, got %d", len(outputs.Manifest)) + } +} + +func TestSandboxTemplatePlugin_GeneratesStandaloneWorkspace(t *testing.T) { + plugin := NewSandboxTemplatePlugin() + agent := &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-agent", + Namespace: "test-ns", + }, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + // No skills configured — workspace works standalone. + Declarative: &v1alpha2.DeclarativeAgentSpec{ + Workspace: &v1alpha2.WorkspaceSpec{Enabled: true}, + }, + }, + } + outputs := &AgentOutputs{ + Config: &adk.AgentConfig{ + Workspace: &adk.WorkspaceConfig{Enabled: true}, + }, + } + + if err := plugin.ProcessAgent(context.Background(), agent, outputs); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(outputs.Manifest) != 1 { + t.Fatalf("expected 1 manifest object, got %d", len(outputs.Manifest)) + } + + st, ok := outputs.Manifest[0].(*extv1alpha1.SandboxTemplate) + if !ok { + t.Fatalf("expected *SandboxTemplate, got %T", outputs.Manifest[0]) + } + + if st.Name != "my-agent-sandbox" { + t.Errorf("expected name=my-agent-sandbox, got %s", st.Name) + } + + // No skills → no init containers, no volumes. + if len(st.Spec.PodTemplate.Spec.InitContainers) != 0 { + t.Errorf("expected 0 init containers for standalone workspace, got %d", len(st.Spec.PodTemplate.Spec.InitContainers)) + } + if len(st.Spec.PodTemplate.Spec.Volumes) != 0 { + t.Errorf("expected 0 volumes for standalone workspace, got %d", len(st.Spec.PodTemplate.Spec.Volumes)) + } +} + +func TestSandboxTemplatePlugin_GeneratesWithSkills(t *testing.T) { + plugin := NewSandboxTemplatePlugin() + agent := &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-agent", + Namespace: "test-ns", + }, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Skills: &v1alpha2.SkillForAgent{ + Refs: []string{"ghcr.io/org/my-skill:v1"}, + }, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + Workspace: &v1alpha2.WorkspaceSpec{Enabled: true}, + }, + }, + } + outputs := &AgentOutputs{ + Config: &adk.AgentConfig{ + Workspace: &adk.WorkspaceConfig{Enabled: true}, + }, + } + + if err := plugin.ProcessAgent(context.Background(), agent, outputs); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(outputs.Manifest) != 1 { + t.Fatalf("expected 1 manifest object, got %d", len(outputs.Manifest)) + } + + st := outputs.Manifest[0].(*extv1alpha1.SandboxTemplate) + + // With skills → init container present. + if len(st.Spec.PodTemplate.Spec.InitContainers) != 1 { + t.Fatalf("expected 1 init container, got %d", len(st.Spec.PodTemplate.Spec.InitContainers)) + } + if st.Spec.PodTemplate.Spec.InitContainers[0].Name != "skills-init" { + t.Errorf("expected init container named skills-init, got %s", st.Spec.PodTemplate.Spec.InitContainers[0].Name) + } + + // Sandbox container should have skills volume mount. + container := st.Spec.PodTemplate.Spec.Containers[0] + if len(container.VolumeMounts) != 1 || container.VolumeMounts[0].Name != "kagent-skills" { + t.Errorf("expected kagent-skills volume mount, got %v", container.VolumeMounts) + } +} + +func TestSandboxTemplatePlugin_GetOwnedResourceTypes(t *testing.T) { + plugin := NewSandboxTemplatePlugin() + types := plugin.GetOwnedResourceTypes() + if len(types) != 1 { + t.Fatalf("expected 1 owned type, got %d", len(types)) + } + if _, ok := types[0].(*extv1alpha1.SandboxTemplate); !ok { + t.Errorf("expected *SandboxTemplate, got %T", types[0]) + } +} + +func TestSandboxTemplateName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {name: "normal", input: "my-agent", expected: "my-agent-sandbox"}, + {name: "truncation", input: "very-long-agent-name-that-exceeds-kubernetes-name-length-limits-oh-no", expected: "very-long-agent-name-that-exceeds-kubernetes-name-length-limits"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := sandboxTemplateName(tt.input) + if got != tt.expected { + t.Errorf("sandboxTemplateName(%q) = %q, want %q", tt.input, got, tt.expected) + } + if len(got) > 63 { + t.Errorf("name exceeds 63 chars: %d", len(got)) + } + }) + } +} diff --git a/go/core/internal/httpserver/handlers/sandbox.go b/go/core/internal/httpserver/handlers/sandbox.go index d941c00ff..1f9c77cfa 100644 --- a/go/core/internal/httpserver/handlers/sandbox.go +++ b/go/core/internal/httpserver/handlers/sandbox.go @@ -2,13 +2,19 @@ package handlers import ( "context" + "fmt" "net/http" + "strings" "time" - api "github.com/kagent-dev/kagent/go/api/httpapi" - "github.com/kagent-dev/kagent/go/core/internal/controller/sandbox" + "github.com/kagent-dev/kagent/go/api/v1alpha2" + api "github.com/kagent-dev/kagent/go/core/internal/controller/sandbox" "github.com/kagent-dev/kagent/go/core/internal/httpserver/errors" + "github.com/kagent-dev/kagent/go/core/internal/utils" + "k8s.io/apimachinery/pkg/types" ctrllog "sigs.k8s.io/controller-runtime/pkg/log" + + httpapi "github.com/kagent-dev/kagent/go/api/httpapi" ) const ( @@ -19,16 +25,27 @@ const ( // SandboxHandler handles sandbox lifecycle requests for sessions. type SandboxHandler struct { *Base - Provider sandbox.SandboxProvider + Provider api.SandboxProvider } // NewSandboxHandler creates a new SandboxHandler. -func NewSandboxHandler(base *Base, provider sandbox.SandboxProvider) *SandboxHandler { +func NewSandboxHandler(base *Base, provider api.SandboxProvider) *SandboxHandler { return &SandboxHandler{Base: base, Provider: provider} } +// sandboxTemplateName returns the deterministic name for an agent's auto-generated +// SandboxTemplate. Mirrors the function in the translator package. +func sandboxTemplateName(agentName string) string { + name := agentName + "-sandbox" + if len(name) > 63 { + name = name[:63] + } + return name +} + // HandleCreateSandbox handles POST /api/sessions/{session_id}/sandbox. -// It provisions (or returns an existing) sandbox for the given session. +// It resolves the workspace from the session's agent CRD and provisions +// (or returns an existing) sandbox for the given session. func (h *SandboxHandler) HandleCreateSandbox(w ErrorResponseWriter, r *http.Request) { log := ctrllog.FromContext(r.Context()).WithName("sandbox-handler").WithValues("operation", "create") @@ -39,37 +56,71 @@ func (h *SandboxHandler) HandleCreateSandbox(w ErrorResponseWriter, r *http.Requ } log = log.WithValues("session_id", sessionID) - var req api.CreateSandboxRequest - if err := DecodeJSONBody(r, &req); err != nil { - w.RespondWithError(errors.NewBadRequestError("Invalid request body", err)) - return - } - - // Verify session exists + // Verify session exists and get the agent reference. userID, err := getUserIDOrAgentUser(r) if err != nil { w.RespondWithError(errors.NewBadRequestError("Failed to get user ID", err)) return } - _, err = h.DatabaseService.GetSession(r.Context(), sessionID, userID) + session, err := h.DatabaseService.GetSession(r.Context(), sessionID, userID) if err != nil { w.RespondWithError(errors.NewNotFoundError("Session not found", err)) return } - opts := sandbox.CreateSandboxOptions{ - AgentName: req.AgentName, - Namespace: req.Namespace, - WorkspaceRef: sandbox.WorkspaceRef{ - APIGroup: req.Workspace.APIGroup, - Kind: req.Workspace.Kind, - Name: req.Workspace.Name, - Namespace: req.Workspace.Namespace, - }, + if session.AgentID == nil || *session.AgentID == "" { + w.RespondWithError(errors.NewBadRequestError("Session has no agent reference", nil)) + return } - opts.SessionID = sessionID + // Convert the DB agent ID (e.g. "ns__NS__name") back to namespace/name. + k8sRef := utils.ConvertToKubernetesIdentifier(*session.AgentID) + parts := strings.SplitN(k8sRef, "/", 2) + if len(parts) != 2 { + w.RespondWithError(errors.NewBadRequestError( + fmt.Sprintf("Invalid agent reference format: %s", k8sRef), nil)) + return + } + agentNamespace, agentName := parts[0], parts[1] + + // Fetch the Agent CRD to read workspace config. + var agent v1alpha2.Agent + if err := h.KubeClient.Get(r.Context(), types.NamespacedName{ + Namespace: agentNamespace, + Name: agentName, + }, &agent); err != nil { + w.RespondWithError(errors.NewInternalServerError("Failed to fetch agent CRD", err)) + return + } + + // Validate workspace is enabled on this agent. + if agent.Spec.Type != v1alpha2.AgentType_Declarative || + agent.Spec.Declarative == nil || + agent.Spec.Declarative.Workspace == nil || + !agent.Spec.Declarative.Workspace.Enabled { + w.RespondWithError(errors.NewBadRequestError("Workspace is not enabled for this agent", nil)) + return + } + + // Determine the sandbox template name. + ws := agent.Spec.Declarative.Workspace + templateName := ws.TemplateRef + if templateName == "" { + templateName = sandboxTemplateName(agent.Name) + } + + opts := api.CreateSandboxOptions{ + SessionID: sessionID, + AgentName: agentName, + Namespace: agentNamespace, + WorkspaceRef: api.WorkspaceRef{ + APIGroup: "extensions.agents.x-k8s.io", + Kind: "SandboxTemplate", + Name: templateName, + Namespace: agentNamespace, + }, + } ctx, cancel := context.WithTimeout(r.Context(), sandboxCreateTimeout) defer cancel() @@ -80,7 +131,7 @@ func (h *SandboxHandler) HandleCreateSandbox(w ErrorResponseWriter, r *http.Requ return } - resp := api.SandboxResponse{ + resp := httpapi.SandboxResponse{ SandboxID: ep.ID, MCPUrl: ep.MCPUrl, Protocol: ep.Protocol, @@ -114,7 +165,7 @@ func (h *SandboxHandler) HandleGetSandboxStatus(w ErrorResponseWriter, r *http.R return } - resp := api.SandboxResponse{ + resp := httpapi.SandboxResponse{ SandboxID: ep.ID, MCPUrl: ep.MCPUrl, Protocol: ep.Protocol, diff --git a/go/core/pkg/app/app.go b/go/core/pkg/app/app.go index fba671ca3..c07675c12 100644 --- a/go/core/pkg/app/app.go +++ b/go/core/pkg/app/app.go @@ -34,6 +34,7 @@ import ( "github.com/kagent-dev/kagent/go/core/internal/version" "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "github.com/kagent-dev/kagent/go/core/internal/a2a" @@ -124,6 +125,7 @@ type Config struct { ProbeAddr string SecureMetrics bool EnableHTTP2 bool + EnableSandbox bool DefaultModelConfig types.NamespacedName HttpServerAddr string WatchNamespaces string @@ -156,6 +158,8 @@ func (cfg *Config) SetFlags(commandLine *flag.FlagSet) { commandLine.StringVar(&cfg.Webhook.CertKey, "webhook-cert-key", "tls.key", "The name of the webhook server key file.") commandLine.BoolVar(&cfg.EnableHTTP2, "enable-http2", false, "If set, HTTP/2 will be enabled for the metrics and webhook servers") + commandLine.BoolVar(&cfg.EnableSandbox, "enable-k8s-sigs-agent-sandbox", false, + "Enable kubernetes-sigs/agent-sandbox support. Requires agent-sandbox CRDs (SandboxTemplate, SandboxClaim) to be installed.") commandLine.StringVar(&cfg.DefaultModelConfig.Name, "default-model-config-name", "default-model-config", "The name of the default model config.") commandLine.StringVar(&cfg.DefaultModelConfig.Namespace, "default-model-config-namespace", kagentNamespace, "The namespace of the default model config.") @@ -185,6 +189,11 @@ func (cfg *Config) SetFlags(commandLine *flag.FlagSet) { commandLine.StringVar(&agent_translator.DefaultSkillsInitImageConfig.PullPolicy, "skills-init-image-pull-policy", agent_translator.DefaultSkillsInitImageConfig.PullPolicy, "The pull policy to use for the skills init image.") commandLine.StringVar(&agent_translator.DefaultSkillsInitImageConfig.Repository, "skills-init-image-repository", agent_translator.DefaultSkillsInitImageConfig.Repository, "The repository to use for the skills init image.") + commandLine.StringVar(&agent_translator.DefaultSandboxMCPImageConfig.Registry, "k8s-sigs-agent-sandbox-mcp-image-registry", agent_translator.DefaultSandboxMCPImageConfig.Registry, "The registry to use for the kubernetes-sigs/agent-sandbox MCP image.") + commandLine.StringVar(&agent_translator.DefaultSandboxMCPImageConfig.Tag, "k8s-sigs-agent-sandbox-mcp-image-tag", agent_translator.DefaultSandboxMCPImageConfig.Tag, "The tag to use for the kubernetes-sigs/agent-sandbox MCP image.") + commandLine.StringVar(&agent_translator.DefaultSandboxMCPImageConfig.PullPolicy, "k8s-sigs-agent-sandbox-mcp-image-pull-policy", agent_translator.DefaultSandboxMCPImageConfig.PullPolicy, "The pull policy to use for the kubernetes-sigs/agent-sandbox MCP image.") + commandLine.StringVar(&agent_translator.DefaultSandboxMCPImageConfig.Repository, "k8s-sigs-agent-sandbox-mcp-image-repository", agent_translator.DefaultSandboxMCPImageConfig.Repository, "The repository to use for the kubernetes-sigs/agent-sandbox MCP image.") + commandLine.StringVar(&agent_translator.DefaultServiceAccountName, "default-service-account-name", "", "Global default ServiceAccount name for agent pods. When set, agents without an explicit serviceAccountName will use this instead of creating a per-agent ServiceAccount.") } @@ -409,10 +418,24 @@ func Start(getExtensionConfig GetExtensionConfig) { os.Exit(1) } + agentPlugins := extensionCfg.AgentPlugins + + // Register the SandboxTemplatePlugin only when sandbox support is enabled + // AND the agent-sandbox CRDs are installed in the cluster. + if cfg.EnableSandbox { + sandboxTemplateGK := schema.GroupKind{Group: "extensions.agents.x-k8s.io", Kind: "SandboxTemplate"} + if _, err := mgr.GetRESTMapper().RESTMapping(sandboxTemplateGK); err == nil { + setupLog.Info("kubernetes-sigs/agent-sandbox support enabled: CRDs detected") + agentPlugins = append(agentPlugins, agent_translator.NewSandboxTemplatePlugin()) + } else { + setupLog.Info("kubernetes-sigs/agent-sandbox support enabled but CRDs not found; sandbox features will be unavailable", "error", err) + } + } + apiTranslator := agent_translator.NewAdkApiTranslator( mgr.GetClient(), cfg.DefaultModelConfig, - extensionCfg.AgentPlugins, + agentPlugins, cfg.Proxy.URL, ) @@ -537,8 +560,11 @@ func Start(getExtensionConfig GetExtensionConfig) { os.Exit(1) } - // Create sandbox provider using agent-sandbox CRDs. - sandboxProvider := sandboxpkg.NewAgentSandboxProvider(mgr.GetClient()) + // Create sandbox provider only when sandbox support is enabled. + var sandboxProvider sandboxpkg.SandboxProvider + if cfg.EnableSandbox { + sandboxProvider = sandboxpkg.NewAgentSandboxProvider(mgr.GetClient()) + } httpServer, err := httpserver.NewHTTPServer(httpserver.ServerConfig{ Router: router, diff --git a/go/sandbox-mcp/Dockerfile b/go/sandbox-mcp/Dockerfile index ade4624e9..cca0fb2bb 100644 --- a/go/sandbox-mcp/Dockerfile +++ b/go/sandbox-mcp/Dockerfile @@ -7,8 +7,7 @@ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o /sandbox-mcp ./cmd/ -FROM gcr.io/distroless/static:nonroot +FROM python:3.13-slim COPY --from=builder /sandbox-mcp /sandbox-mcp -USER nonroot:nonroot EXPOSE 8080 ENTRYPOINT ["/sandbox-mcp"] diff --git a/go/sandbox-mcp/Dockerfile.local b/go/sandbox-mcp/Dockerfile.local deleted file mode 100644 index e43bae65e..000000000 --- a/go/sandbox-mcp/Dockerfile.local +++ /dev/null @@ -1,4 +0,0 @@ -FROM python:3.13-slim -COPY bin/sandbox-mcp /sandbox-mcp -EXPOSE 8080 -ENTRYPOINT ["/sandbox-mcp"] diff --git a/go/sandbox-mcp/Makefile b/go/sandbox-mcp/Makefile deleted file mode 100644 index cf6af5acc..000000000 --- a/go/sandbox-mcp/Makefile +++ /dev/null @@ -1,23 +0,0 @@ -IMAGE_REGISTRY ?= cr.kagent.dev -IMAGE_NAME ?= kagent-dev/kagent/sandbox-mcp -IMAGE_TAG ?= latest - -.PHONY: build -build: - go build -o bin/sandbox-mcp ./cmd/ - -.PHONY: test -test: - go test ./... - -.PHONY: docker-build -docker-build: - docker build -t $(IMAGE_REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG) . - -.PHONY: docker-push -docker-push: - docker push $(IMAGE_REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG) - -.PHONY: tidy -tidy: - go mod tidy diff --git a/go/sandbox-mcp/cmd/main.go b/go/sandbox-mcp/cmd/main.go index 27e4a3498..05dd6fe20 100644 --- a/go/sandbox-mcp/cmd/main.go +++ b/go/sandbox-mcp/cmd/main.go @@ -4,133 +4,209 @@ import ( "context" "encoding/json" "fmt" - "log" + "log/slog" + "net/http" "os" "github.com/kagent-dev/kagent/go/sandbox-mcp/pkg/tools" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + "github.com/modelcontextprotocol/go-sdk/mcp" ) +type execInput struct { + Command string `json:"command"` + TimeoutMs int `json:"timeout_ms,omitempty"` + WorkingDir string `json:"working_dir,omitempty"` +} + +type readFileInput struct { + Path string `json:"path"` +} + +type writeFileInput struct { + Path string `json:"path"` + Content string `json:"content"` +} + +type listDirInput struct { + Path string `json:"path,omitempty"` +} + +type getSkillInput struct { + Name string `json:"name"` +} + func main() { port := os.Getenv("PORT") if port == "" { port = "8080" } - s := server.NewMCPServer( - "kagent-sandbox-mcp", - "0.1.0", - ) - - // Register exec tool - s.AddTool(mcp.NewTool( - "exec", - mcp.WithDescription("Execute a shell command in the sandbox"), - mcp.WithString("command", mcp.Required(), mcp.Description("The shell command to execute")), - mcp.WithNumber("timeout_ms", mcp.Description("Optional timeout in milliseconds")), - mcp.WithString("working_dir", mcp.Description("Optional working directory")), - ), handleExec) - - // Register read_file tool - s.AddTool(mcp.NewTool( - "read_file", - mcp.WithDescription("Read the content of a file"), - mcp.WithString("path", mcp.Required(), mcp.Description("Absolute path to the file")), - ), handleReadFile) - - // Register write_file tool - s.AddTool(mcp.NewTool( - "write_file", - mcp.WithDescription("Write content to a file (creates parent directories if needed)"), - mcp.WithString("path", mcp.Required(), mcp.Description("Absolute path to the file")), - mcp.WithString("content", mcp.Required(), mcp.Description("Content to write")), - ), handleWriteFile) - - // Register list_dir tool - s.AddTool(mcp.NewTool( - "list_dir", - mcp.WithDescription("List entries in a directory"), - mcp.WithString("path", mcp.Description("Directory path (defaults to current directory)")), - ), handleListDir) + skillsDir := os.Getenv("SKILLS_DIR") + if skillsDir == "" { + skillsDir = "/skills" + } + + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + slog.SetDefault(logger) + + s := mcp.NewServer(&mcp.Implementation{ + Name: "kagent-sandbox-mcp", + Version: "0.1.0", + }, nil) + + mcp.AddTool(s, &mcp.Tool{ + Name: "exec", + Description: "Execute a shell command in the sandbox", + }, handleExec) + + mcp.AddTool(s, &mcp.Tool{ + Name: "read_file", + Description: "Read the content of a file", + }, handleReadFile) + + mcp.AddTool(s, &mcp.Tool{ + Name: "write_file", + Description: "Write content to a file (creates parent directories if needed)", + }, handleWriteFile) + + mcp.AddTool(s, &mcp.Tool{ + Name: "list_dir", + Description: "List entries in a directory", + }, handleListDir) + + mcp.AddTool(s, &mcp.Tool{ + Name: "get_skill", + Description: buildGetSkillDescription(skillsDir), + }, makeHandleGetSkill(skillsDir)) addr := fmt.Sprintf(":%s", port) - log.Printf("Starting kagent-sandbox-mcp on %s", addr) + slog.Info("Starting kagent-sandbox-mcp", "addr", addr, "skillsDir", skillsDir) - httpServer := server.NewStreamableHTTPServer(s) - if err := httpServer.Start(addr); err != nil { - log.Fatalf("Server failed: %v", err) - } -} + handler := mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server { + return s + }, nil) -func handleExec(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - command, _ := request.GetArguments()["command"].(string) - timeoutMs := 0 - if v, ok := request.GetArguments()["timeout_ms"].(float64); ok { - timeoutMs = int(v) + if err := http.ListenAndServe(addr, handler); err != nil { + slog.Error("Server failed", "error", err) + os.Exit(1) } - workingDir, _ := request.GetArguments()["working_dir"].(string) +} - log.Printf("[exec] command=%q working_dir=%q timeout_ms=%d", command, workingDir, timeoutMs) +func handleExec(_ context.Context, _ *mcp.CallToolRequest, input execInput) (*mcp.CallToolResult, any, error) { + slog.Info("exec", "command", input.Command, "working_dir", input.WorkingDir, "timeout_ms", input.TimeoutMs) - result, err := tools.Exec(ctx, command, timeoutMs, workingDir) + result, err := tools.Exec(context.Background(), input.Command, input.TimeoutMs, input.WorkingDir) if err != nil { - log.Printf("[exec] error: %v", err) - return mcp.NewToolResultError(err.Error()), nil + slog.Error("exec failed", "error", err) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: err.Error()}}, + IsError: true, + }, nil, nil } - log.Printf("[exec] exit_code=%d stdout_len=%d stderr_len=%d", result.ExitCode, len(result.Stdout), len(result.Stderr)) + slog.Info("exec completed", "exit_code", result.ExitCode, "stdout_len", len(result.Stdout), "stderr_len", len(result.Stderr)) if result.Stderr != "" { - log.Printf("[exec] stderr: %s", result.Stderr) + slog.Warn("exec stderr", "stderr", result.Stderr) } data, _ := json.Marshal(result) - return mcp.NewToolResultText(string(data)), nil + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: string(data)}}, + }, nil, nil } -func handleReadFile(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - path, _ := request.GetArguments()["path"].(string) - - log.Printf("[read_file] path=%q", path) +func handleReadFile(_ context.Context, _ *mcp.CallToolRequest, input readFileInput) (*mcp.CallToolResult, any, error) { + slog.Info("read_file", "path", input.Path) - content, err := tools.ReadFile(path) + content, err := tools.ReadFile(input.Path) if err != nil { - log.Printf("[read_file] error: %v", err) - return mcp.NewToolResultError(err.Error()), nil + slog.Error("read_file failed", "error", err) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: err.Error()}}, + IsError: true, + }, nil, nil } - log.Printf("[read_file] ok, content_len=%d", len(content)) - return mcp.NewToolResultText(content), nil + slog.Info("read_file completed", "path", input.Path, "content_len", len(content)) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: content}}, + }, nil, nil } -func handleWriteFile(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - path, _ := request.GetArguments()["path"].(string) - content, _ := request.GetArguments()["content"].(string) +func handleWriteFile(_ context.Context, _ *mcp.CallToolRequest, input writeFileInput) (*mcp.CallToolResult, any, error) { + slog.Info("write_file", "path", input.Path, "content_len", len(input.Content)) - log.Printf("[write_file] path=%q content_len=%d", path, len(content)) - - if err := tools.WriteFile(path, content); err != nil { - log.Printf("[write_file] error: %v", err) - return mcp.NewToolResultError(err.Error()), nil + if err := tools.WriteFile(input.Path, input.Content); err != nil { + slog.Error("write_file failed", "error", err) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: err.Error()}}, + IsError: true, + }, nil, nil } - log.Printf("[write_file] ok") + slog.Info("write_file completed", "path", input.Path) data, _ := json.Marshal(map[string]bool{"ok": true}) - return mcp.NewToolResultText(string(data)), nil + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: string(data)}}, + }, nil, nil } -func handleListDir(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - path, _ := request.GetArguments()["path"].(string) - - log.Printf("[list_dir] path=%q", path) +func handleListDir(_ context.Context, _ *mcp.CallToolRequest, input listDirInput) (*mcp.CallToolResult, any, error) { + slog.Info("list_dir", "path", input.Path) - entries, err := tools.ListDir(path) + entries, err := tools.ListDir(input.Path) if err != nil { - log.Printf("[list_dir] error: %v", err) - return mcp.NewToolResultError(err.Error()), nil + slog.Error("list_dir failed", "error", err) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: err.Error()}}, + IsError: true, + }, nil, nil } - log.Printf("[list_dir] ok, entries=%d", len(entries)) + slog.Info("list_dir completed", "path", input.Path, "entries", len(entries)) data, _ := json.Marshal(map[string]any{"entries": entries}) - return mcp.NewToolResultText(string(data)), nil + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: string(data)}}, + }, nil, nil +} + +// buildGetSkillDescription builds the get_skill tool description, embedding a +// brief listing of all available skills so the LLM knows which names to request. +func buildGetSkillDescription(skillsDir string) string { + base := "Load the full content of a skill by name." + skills, err := tools.ListSkills(skillsDir) + if err != nil || len(skills) == 0 { + return base + } + + desc := base + "\n\nAvailable skills:" + for _, s := range skills { + if s.Description != "" { + desc += fmt.Sprintf("\n- %s: %s", s.Name, s.Description) + } else { + desc += fmt.Sprintf("\n- %s", s.Name) + } + } + return desc +} + +// makeHandleGetSkill returns a handler that loads a skill by name. +func makeHandleGetSkill(skillsDir string) func(context.Context, *mcp.CallToolRequest, getSkillInput) (*mcp.CallToolResult, any, error) { + return func(_ context.Context, _ *mcp.CallToolRequest, input getSkillInput) (*mcp.CallToolResult, any, error) { + slog.Info("get_skill", "name", input.Name, "skillsDir", skillsDir) + + content, err := tools.LoadSkill(skillsDir, input.Name) + if err != nil { + slog.Error("get_skill failed", "error", err) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: err.Error()}}, + IsError: true, + }, nil, nil + } + + slog.Info("get_skill completed", "name", input.Name, "content_len", len(content)) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: content}}, + }, nil, nil + } } diff --git a/go/sandbox-mcp/go.mod b/go/sandbox-mcp/go.mod index 745133e50..b604925b6 100644 --- a/go/sandbox-mcp/go.mod +++ b/go/sandbox-mcp/go.mod @@ -2,10 +2,13 @@ module github.com/kagent-dev/kagent/go/sandbox-mcp go 1.24.1 -require github.com/mark3labs/mcp-go v0.31.0 +require github.com/modelcontextprotocol/go-sdk v1.4.0 require ( - github.com/google/uuid v1.6.0 // indirect - github.com/spf13/cast v1.7.1 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.3 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sys v0.40.0 // indirect ) diff --git a/go/sandbox-mcp/go.sum b/go/sandbox-mcp/go.sum index 2b6b394f0..597fc8c0e 100644 --- a/go/sandbox-mcp/go.sum +++ b/go/sandbox-mcp/go.sum @@ -1,26 +1,20 @@ -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mark3labs/mcp-go v0.31.0 h1:4UxSV8aM770OPmTvaVe/b1rA2oZAjBMhGBfUgOGut+4= -github.com/mark3labs/mcp-go v0.31.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= -github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8= +github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= +github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= diff --git a/go/sandbox-mcp/pkg/tools/skills.go b/go/sandbox-mcp/pkg/tools/skills.go new file mode 100644 index 000000000..37e2d952f --- /dev/null +++ b/go/sandbox-mcp/pkg/tools/skills.go @@ -0,0 +1,125 @@ +package tools + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" +) + +// SkillInfo describes a discovered skill. +type SkillInfo struct { + Name string `json:"name"` + Description string `json:"description"` +} + +// ListSkills scans skillsDir for subdirectories containing a SKILL.md file, +// parses YAML frontmatter (name/description), and returns the list. +func ListSkills(skillsDir string) ([]SkillInfo, error) { + if skillsDir == "" { + skillsDir = "/skills" + } + + entries, err := os.ReadDir(skillsDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to read skills directory %s: %w", skillsDir, err) + } + + var skills []SkillInfo + for _, e := range entries { + if !e.IsDir() { + continue + } + skillPath := filepath.Join(skillsDir, e.Name(), "SKILL.md") + info, err := parseSkillFrontmatter(skillPath) + if err != nil { + continue // skip skills that can't be parsed + } + skills = append(skills, *info) + } + return skills, nil +} + +// LoadSkill reads the full content of a skill's SKILL.md file. +func LoadSkill(skillsDir, name string) (string, error) { + if skillsDir == "" { + skillsDir = "/skills" + } + + // Prevent path traversal. + clean := filepath.Clean(name) + if strings.Contains(clean, "..") || strings.ContainsAny(clean, "/\\") { + return "", fmt.Errorf("invalid skill name: %s", name) + } + + skillPath := filepath.Join(skillsDir, clean, "SKILL.md") + data, err := os.ReadFile(skillPath) + if err != nil { + return "", fmt.Errorf("failed to read skill %s: %w", name, err) + } + return string(data), nil +} + +// parseSkillFrontmatter extracts name and description from YAML frontmatter +// in a SKILL.md file. The frontmatter is delimited by "---" lines. +func parseSkillFrontmatter(path string) (*SkillInfo, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + inFrontmatter := false + info := &SkillInfo{} + + for scanner.Scan() { + line := scanner.Text() + trimmed := strings.TrimSpace(line) + + if trimmed == "---" { + if inFrontmatter { + break // end of frontmatter + } + inFrontmatter = true + continue + } + + if !inFrontmatter { + continue + } + + if key, val, ok := parseYAMLLine(trimmed); ok { + switch key { + case "name": + info.Name = val + case "description": + info.Description = val + } + } + } + + if info.Name == "" { + // Fall back to directory name from path. + info.Name = filepath.Base(filepath.Dir(path)) + } + + return info, nil +} + +// parseYAMLLine does simple "key: value" parsing for frontmatter. +func parseYAMLLine(line string) (key, value string, ok bool) { + idx := strings.Index(line, ":") + if idx < 0 { + return "", "", false + } + key = strings.TrimSpace(line[:idx]) + value = strings.TrimSpace(line[idx+1:]) + // Strip surrounding quotes. + value = strings.Trim(value, `"'`) + return key, value, true +} diff --git a/go/sandbox-mcp/pkg/tools/skills_test.go b/go/sandbox-mcp/pkg/tools/skills_test.go new file mode 100644 index 000000000..3a135d972 --- /dev/null +++ b/go/sandbox-mcp/pkg/tools/skills_test.go @@ -0,0 +1,103 @@ +package tools + +import ( + "os" + "path/filepath" + "testing" +) + +func TestListSkillsEmpty(t *testing.T) { + dir := t.TempDir() + skills, err := ListSkills(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(skills) != 0 { + t.Errorf("expected 0 skills, got %d", len(skills)) + } +} + +func TestListSkillsNonExistentDir(t *testing.T) { + skills, err := ListSkills("/nonexistent/skills/dir") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if skills != nil { + t.Errorf("expected nil for nonexistent dir, got %v", skills) + } +} + +func TestListSkillsWithFrontmatter(t *testing.T) { + dir := t.TempDir() + + // Create skill with full frontmatter. + skillDir := filepath.Join(dir, "my-skill") + os.MkdirAll(skillDir, 0o755) + os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(`--- +name: my-skill +description: A test skill +--- + +# My Skill + +Skill content here. +`), 0o644) + + // Create skill with no frontmatter. + skill2Dir := filepath.Join(dir, "bare-skill") + os.MkdirAll(skill2Dir, 0o755) + os.WriteFile(filepath.Join(skill2Dir, "SKILL.md"), []byte("# Bare Skill\nNo frontmatter.\n"), 0o644) + + skills, err := ListSkills(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(skills) != 2 { + t.Fatalf("expected 2 skills, got %d", len(skills)) + } + + found := make(map[string]string) + for _, s := range skills { + found[s.Name] = s.Description + } + + if desc, ok := found["my-skill"]; !ok || desc != "A test skill" { + t.Errorf("expected my-skill with description, got %v", found) + } + if _, ok := found["bare-skill"]; !ok { + t.Errorf("expected bare-skill fallback name, got %v", found) + } +} + +func TestLoadSkill(t *testing.T) { + dir := t.TempDir() + + skillDir := filepath.Join(dir, "test-skill") + os.MkdirAll(skillDir, 0o755) + content := "---\nname: test-skill\n---\n\n# Test Skill\nHello!" + os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644) + + result, err := LoadSkill(dir, "test-skill") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != content { + t.Errorf("unexpected content: %s", result) + } +} + +func TestLoadSkillNotFound(t *testing.T) { + dir := t.TempDir() + _, err := LoadSkill(dir, "nonexistent") + if err == nil { + t.Fatal("expected error for nonexistent skill") + } +} + +func TestLoadSkillPathTraversal(t *testing.T) { + dir := t.TempDir() + _, err := LoadSkill(dir, "../etc/passwd") + if err == nil { + t.Fatal("expected error for path traversal") + } +} diff --git a/helm/kagent-crds/templates/kagent.dev_agents.yaml b/helm/kagent-crds/templates/kagent.dev_agents.yaml index a41804c5a..02abfdc3a 100644 --- a/helm/kagent-crds/templates/kagent.dev_agents.yaml +++ b/helm/kagent-crds/templates/kagent.dev_agents.yaml @@ -10162,21 +10162,25 @@ spec: type: array workspace: description: |- - Workspace references an external resource that provides a per-session - sandbox environment (filesystem + shell) for the agent. The referenced - resource is opaque to kagent; a matching SandboxProvider implementation - must be registered in the controller. + Workspace enables a per-session sandbox environment (filesystem + shell) + for the agent. When enabled, the controller provisions an isolated sandbox + pod for each session via the agent-sandbox SandboxClaim API. properties: - apiGroup: - type: string - kind: - type: string - name: - type: string - namespace: + enabled: + default: true + description: |- + Enabled activates workspace/sandbox provisioning. When true, the + controller generates a SandboxTemplate and provisions a sandbox pod + per session with exec, filesystem, and (if configured) skill tools. + type: boolean + templateRef: + description: |- + TemplateRef optionally references a user-provided SandboxTemplate by + name (in the same namespace). When set, the controller uses this + template instead of generating one automatically. type: string required: - - name + - enabled type: object type: object x-kubernetes-validations: diff --git a/helm/kagent/templates/controller-configmap.yaml b/helm/kagent/templates/controller-configmap.yaml index ed4ed0ecb..2f90eb35c 100644 --- a/helm/kagent/templates/controller-configmap.yaml +++ b/helm/kagent/templates/controller-configmap.yaml @@ -71,3 +71,10 @@ data: {{- if and .Values.controller.agentDeployment .Values.controller.agentDeployment.serviceAccountName (not (eq .Values.controller.agentDeployment.serviceAccountName "")) }} DEFAULT_SERVICE_ACCOUNT_NAME: {{ .Values.controller.agentDeployment.serviceAccountName | quote }} {{- end }} + {{- if .Values.agentSandbox.enabled }} + ENABLE_K8S_SIGS_AGENT_SANDBOX: "true" + K8S_SIGS_AGENT_SANDBOX_MCP_IMAGE_REGISTRY: {{ .Values.agentSandbox.image.registry | default "ghcr.io" | quote }} + K8S_SIGS_AGENT_SANDBOX_MCP_IMAGE_REPOSITORY: {{ .Values.agentSandbox.image.repository | default "kagent-dev/kagent-sandbox-mcp" | quote }} + K8S_SIGS_AGENT_SANDBOX_MCP_IMAGE_TAG: {{ coalesce .Values.agentSandbox.image.tag .Values.tag .Chart.Version | quote }} + K8S_SIGS_AGENT_SANDBOX_MCP_IMAGE_PULL_POLICY: {{ .Values.agentSandbox.image.pullPolicy | default .Values.imagePullPolicy | quote }} + {{- end }} diff --git a/helm/kagent/templates/rbac/agent-sandbox-clusterrole.yaml b/helm/kagent/templates/rbac/agent-sandbox-clusterrole.yaml index 0c957d1db..65cbd7941 100644 --- a/helm/kagent/templates/rbac/agent-sandbox-clusterrole.yaml +++ b/helm/kagent/templates/rbac/agent-sandbox-clusterrole.yaml @@ -1,3 +1,4 @@ +{{- if .Values.agentSandbox.enabled }} apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: @@ -11,3 +12,4 @@ rules: - apiGroups: ["agents.x-k8s.io"] resources: ["sandboxes"] verbs: ["get", "list", "watch"] +{{- end }} diff --git a/helm/kagent/templates/rbac/agent-sandbox-clusterrolebinding.yaml b/helm/kagent/templates/rbac/agent-sandbox-clusterrolebinding.yaml index 056751cc1..f5ce241ef 100644 --- a/helm/kagent/templates/rbac/agent-sandbox-clusterrolebinding.yaml +++ b/helm/kagent/templates/rbac/agent-sandbox-clusterrolebinding.yaml @@ -1,3 +1,4 @@ +{{- if .Values.agentSandbox.enabled }} apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: @@ -12,3 +13,4 @@ subjects: - kind: ServiceAccount name: {{ include "kagent.fullname" . }}-controller namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index acd8c0b40..ab29736b5 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -419,6 +419,18 @@ querydoc: openai: apiKey: "" +# ============================================================================== +# KUBERNETES-SIGS/AGENT-SANDBOX +# ============================================================================== + +agentSandbox: + enabled: false + image: + registry: ghcr.io + repository: kagent-dev/kagent-sandbox-mcp + tag: "" # Defaults to global tag, then Chart version + pullPolicy: "" + # ============================================================================== # OBSERVABILITY # ============================================================================== diff --git a/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py b/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py index c901a60e8..0c5531d4f 100644 --- a/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py +++ b/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py @@ -482,7 +482,8 @@ async def _handle_request( # Sandbox provisioning: if workspace is configured, ensure a sandbox # MCP toolset is attached for this session. - if self._agent_config and getattr(self._agent_config, "workspace", None) is not None: + ws = getattr(self._agent_config, "workspace", None) if self._agent_config else None + if ws is not None and ws.enabled: await self._ensure_sandbox_toolset(session, runner, run_args) # HITL resume: translate A2A approval/rejection to ADK FunctionResponse @@ -619,8 +620,10 @@ async def _ensure_sandbox_toolset( """Provision a sandbox for this session if not already done. Checks session state for an existing sandbox MCP URL. If not present, - POSTs to the controller sandbox endpoint, stores the URL in session - state, and appends a KAgentMcpToolset to the runner's agent tools. + POSTs to the controller sandbox endpoint (empty body — the controller + resolves workspace from the session's agent CRD), stores the URL in + session state, and appends a KAgentMcpToolset to the runner's agent + tools. """ sandbox_mcp_url = (session.state or {}).get("sandbox_mcp_url") if sandbox_mcp_url: @@ -631,25 +634,13 @@ async def _ensure_sandbox_toolset( import os import httpx - from google.adk.tools.mcp_tool import StreamableHTTPConnectionParams kagent_url = os.getenv("KAGENT_URL", "http://localhost:8083") - ws = self._agent_config.workspace session_id = run_args["session_id"] async with httpx.AsyncClient(base_url=kagent_url) as client: resp = await client.post( f"/api/sessions/{session_id}/sandbox", - json={ - "agent_name": runner.app_name, - "namespace": ws.namespace, - "workspace_ref": { - "api_group": ws.api_group, - "kind": ws.kind, - "name": ws.name, - "namespace": ws.namespace, - }, - }, timeout=30.0, ) resp.raise_for_status() diff --git a/python/packages/kagent-adk/src/kagent/adk/types.py b/python/packages/kagent-adk/src/kagent/adk/types.py index 3f94bd576..5aa5f1d69 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -253,12 +253,13 @@ class MemoryConfig(BaseModel): class WorkspaceConfig(BaseModel): - """Workspace reference from the Agent CRD for sandbox provisioning.""" + """Workspace configuration from config.json for sandbox provisioning. - api_group: str - kind: str - name: str - namespace: str + The controller resolves the actual workspace details from the session's + agent CRD, so config.json only carries enabled=true. + """ + + enabled: bool class AgentConfig(BaseModel): From b35aad52ad7adaf35a0ce05c9a97f7275cd2621d Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Fri, 13 Mar 2026 14:22:32 +0000 Subject: [PATCH 5/6] docs: move and update pluggable sandbox architecture doc Move from docs/design/ to docs/architecture/ and update to reflect the actual implementation rather than the original design proposal. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Eitan Yarmush --- docs/architecture/pluggable-sandbox.md | 376 +++++++++++++++++++++++++ 1 file changed, 376 insertions(+) create mode 100644 docs/architecture/pluggable-sandbox.md diff --git a/docs/architecture/pluggable-sandbox.md b/docs/architecture/pluggable-sandbox.md new file mode 100644 index 000000000..c63756ddc --- /dev/null +++ b/docs/architecture/pluggable-sandbox.md @@ -0,0 +1,376 @@ +# Pluggable Sandbox Architecture for Kagent + +**Status:** Implemented +**Created:** 2026-03-12 +**Updated:** 2026-03-13 +**Authors:** Eitan Yarmush, Claude (design partner) + +--- + +## Executive Summary + +Kagent supports per-session sandbox environments for agents via a pluggable `SandboxProvider` interface. The default provider uses [`kubernetes-sigs/agent-sandbox`](https://github.com/kubernetes-sigs/agent-sandbox) (pod-per-sandbox with warm pool support). Both the Python and Go ADKs integrate with the sandbox system transparently — the agent gets `exec`, `read_file`, `write_file`, `list_dir`, and `get_skill` MCP tools without knowing the underlying provider. + +Kagent's role is minimal: it auto-generates a `SandboxTemplate` when an agent opts in, provisions a sandbox on first use, tells the agent where to find it, and cleans up when the session ends. All MCP tool traffic flows directly from the agent to the sandbox — the controller is not in the data path. + +--- + +## Design Principles + +1. **MCP is the contract.** Agents interact with sandboxes through MCP tools. The sandbox provider's job is to return an MCP endpoint. How it provisions the underlying sandbox is opaque. +2. **Session-scoped lifecycle.** Sandboxes are created when a session first needs one (lazy) and destroyed when the session ends. Not tied to agent pod lifecycle. +3. **Upstream-friendly default.** The default provider uses `kubernetes-sigs/agent-sandbox`, a K8s SIG project. No custom infrastructure required. +4. **Provider selection is cluster-level.** Individual agents opt into sandbox support; the cluster admin enables the feature and configures the provider. +5. **Auto-generation by default.** When an agent sets `workspace.enabled: true`, the controller auto-generates the necessary `SandboxTemplate` CR. Users can override with a custom template via `templateRef`. + +--- + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ │ +│ ┌──────────┐ 1. POST /api/sessions/{id}/sandbox │ +│ │ Agent │ ────────────────────────────► ┌───────────────┐ │ +│ │ Runtime │ ◄──── 2. 200 OK {mcp_url}─── │ Controller │ │ +│ │ (Py/Go) │ │ HTTP API │ │ +│ └──────────┘ └───────────────┘ │ +│ │ │ │ +│ │ 3. Store mcp_url in session_state │ │ +│ │ Creates SandboxClaim │ +│ │ 4. Direct MCP calls │ │ +│ ▼ ▼ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Sandbox Pod (provisioned by agent-sandbox controller) │ │ +│ │ │ │ +│ │ ┌─────────────────────────┐ │ │ +│ │ │ kagent-sandbox-mcp │ │ │ +│ │ │ (StreamableHTTP :8080) │ │ │ +│ │ │ │ │ │ +│ │ │ - exec │ │ │ +│ │ │ - read_file │ │ │ +│ │ │ - write_file │ │ │ +│ │ │ - list_dir │ │ │ +│ │ │ - get_skill │ │ │ +│ │ └─────────────────────────┘ │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└───────────────────────────────────────────────────────────────────┘ +``` + +**Key property:** The controller is only in the path for sandbox provisioning (steps 1-2). All MCP tool traffic flows directly from agent to sandbox. + +--- + +## MCP Tool Contract + +Every sandbox exposes an MCP server (StreamableHTTP on port 8080) with these tools. Agents discover tools via standard MCP `tools/list`. + +### Tools + +| Tool | Description | Parameters | Returns | +|------|-------------|------------|---------| +| `exec` | Execute a shell command via `sh -c` | `command: string`, `timeout_ms?: int`, `working_dir?: string` | `stdout: string`, `stderr: string`, `exit_code: int` | +| `read_file` | Read file contents | `path: string` | File contents as string | +| `write_file` | Write file contents (auto-creates parent dirs) | `path: string`, `content: string` | `{ok: true}` | +| `list_dir` | List directory entries | `path?: string` (default: `.`) | `entries: [{name, type: "file"\|"dir", size}]` | +| `get_skill` | Load a skill by name | `name: string` | Full `SKILL.md` content | + +The `get_skill` tool is only available when skills are configured on the agent. The available skill names are embedded in the tool's description so the LLM knows what's available. + +**Implementation:** `go/sandbox-mcp/pkg/tools/` — `exec.go`, `fs.go`, `skills.go` + +--- + +## CRD: WorkspaceSpec + +The Agent CRD has a `workspace` field on `DeclarativeAgentSpec`: + +```go +// go/api/v1alpha2/agent_types.go + +type WorkspaceSpec struct { + // Enabled activates workspace/sandbox provisioning. When true, the + // controller generates a SandboxTemplate and provisions a sandbox pod + // per session with exec, filesystem, and (if configured) skill tools. + // +kubebuilder:default=true + Enabled bool `json:"enabled"` + + // TemplateRef optionally references a user-provided SandboxTemplate by + // name (in the same namespace). When set, the controller uses this + // template instead of generating one automatically. + // +optional + TemplateRef string `json:"templateRef,omitempty"` +} +``` + +### Usage + +Minimal — just enable it: + +```yaml +apiVersion: kagent.dev/v1alpha2 +kind: Agent +metadata: + name: coding-agent + namespace: kagent +spec: + type: Declarative + declarative: + modelConfig: default-model-config + systemMessage: "You are a coding agent with sandbox access." + workspace: + enabled: true +``` + +With a custom template: + +```yaml + workspace: + enabled: true + templateRef: my-custom-sandbox-template +``` + +--- + +## SandboxProvider Interface + +Controller-internal interface in `go/core/internal/controller/sandbox/provider.go`: + +```go +type SandboxProvider interface { + // GetOrCreate provisions a new sandbox or returns the existing one for a + // session. Implementations must be idempotent for the same session ID. + // The call blocks until the sandbox is ready or the context is cancelled. + GetOrCreate(ctx context.Context, opts CreateSandboxOptions) (*SandboxEndpoint, error) + + // Get returns the current sandbox endpoint for a session, or nil if none exists. + Get(ctx context.Context, sessionID string) (*SandboxEndpoint, error) + + // Destroy tears down the sandbox for the given session ID. + Destroy(ctx context.Context, sessionID string) error +} + +type CreateSandboxOptions struct { + SessionID string + AgentName string + Namespace string + WorkspaceRef WorkspaceRef +} + +type WorkspaceRef struct { + APIGroup string + Kind string + Name string + Namespace string +} + +type SandboxEndpoint struct { + ID string `json:"sandbox_id"` + MCPUrl string `json:"mcp_url"` + Protocol string `json:"protocol"` + Headers map[string]string `json:"headers,omitempty"` + Ready bool `json:"ready"` +} +``` + +### AgentSandboxProvider (default implementation) + +Located in `go/core/internal/controller/sandbox/agent_sandbox_provider.go`. Uses `kubernetes-sigs/agent-sandbox` CRDs. + +**How it works:** + +1. `GetOrCreate` generates a deterministic claim name: `"kagent-" + sessionID` (truncated to 63 chars) +2. Attempts to fetch existing claim first (idempotent) +3. If not found, creates a `SandboxClaim` referencing the `SandboxTemplate` from `WorkspaceRef.Name` +4. Polls every 250ms (`wait.PollUntilContextCancel`) until the claim's `Ready` condition is true +5. Detects terminal failures immediately (`TemplateNotFound`, `ReconcilerError`, `SandboxExpired`, `ClaimExpired`) +6. Builds endpoint from the underlying `Sandbox` resource's `ServiceFQDN`: `http://{ServiceFQDN}:8080/mcp` + +**Labels on SandboxClaim:** `kagent.dev/session-id: {sessionID}` + +**Destroy:** Lists claims by session ID label, deletes all matching claims. + +### StubProvider (testing) + +Located in `go/core/internal/controller/sandbox/stub_provider.go`. Returns fake endpoints for unit tests. + +--- + +## SandboxTemplate Auto-Generation + +The `SandboxTemplatePlugin` (`go/core/internal/controller/translator/agent/sandbox_template_plugin.go`) is a translator plugin that auto-generates a `SandboxTemplate` CR during agent reconciliation. + +**When it runs:** +- Agent is `Declarative` type +- `workspace.enabled` is `true` +- No custom `templateRef` is set + +**What it generates:** + +- **Name:** `{agentName}-sandbox` (truncated to 63 chars) +- **Labels:** `app: kagent`, `kagent.dev/agent: {agentName}`, `kagent.dev/component: sandbox-template` +- **Pod spec:** Single container `sandbox` running `kagent-sandbox-mcp` image on port 8080 +- **Skills support:** If the agent has skills configured, adds a `skills-init` init container, an `EmptyDir` volume (`kagent-skills`), and sets `SKILLS_DIR=/skills` on the sandbox container + +**Image configuration** is controlled by `DefaultSandboxMCPImageConfig`, overridable via CLI flags / helm values: +- Registry: `ghcr.io` (default) +- Repository: `kagent-dev/kagent-sandbox-mcp` +- Tag: controller version +- PullPolicy: `IfNotPresent` + +--- + +## HTTP Endpoints + +Registered in `go/core/internal/httpserver/server.go`: + +### POST /api/sessions/{session_id}/sandbox + +**Handler:** `SandboxHandler.HandleCreateSandbox` + +1. Fetches session from DB, extracts agent reference +2. Fetches Agent CRD from Kubernetes +3. Validates `workspace.enabled` is true (400 if not) +4. Determines template name: `workspace.templateRef` or `{agentName}-sandbox` +5. Calls `Provider.GetOrCreate()` with a 5-minute timeout +6. Returns `200 OK` with `SandboxResponse` + +### GET /api/sessions/{session_id}/sandbox + +**Handler:** `SandboxHandler.HandleGetSandboxStatus` + +Returns the current sandbox state for a session, or 404 if none exists. + +### Response Type + +```go +// go/api/httpapi/types.go +type SandboxResponse struct { + SandboxID string `json:"sandbox_id"` + MCPUrl string `json:"mcp_url"` + Protocol string `json:"protocol"` + Headers map[string]string `json:"headers,omitempty"` + Ready bool `json:"ready"` +} +``` + +--- + +## Agent Runtime Integration + +### Python ADK + +In `python/packages/kagent-adk/src/kagent/adk/_agent_executor.py`: + +**`_ensure_sandbox_toolset(session, runner, run_args)`** is called during request handling (in `_handle_request`). It: + +1. Checks `session.state` for an existing `sandbox_mcp_url` — if found, reuses it +2. Otherwise, POSTs to `{KAGENT_URL}/api/sessions/{session_id}/sandbox` (30s timeout) +3. Stores `mcp_url` in session state via a system `Event` with `state_delta` +4. Appends a `KAgentMcpToolset` (StreamableHTTP connection) to `runner.agent.tools` + +### Go ADK + +In `go/adk/pkg/sandbox/`: + +- **`SandboxProvisioner`** — HTTP client that calls `POST /api/sessions/{id}/sandbox` on the controller +- **`SandboxRegistry`** — Thread-safe map of session ID to MCP toolset, with idempotent `GetOrCreate` +- **`SandboxToolset`** — Implements `tool.Toolset`, returns sandbox tools for the current session from the registry + +--- + +## Session Lifecycle + +### Provisioning (lazy, on first message) + +``` +1. Agent receives first message for a session +2. ADK calls: POST /api/sessions/{session_id}/sandbox +3. Controller resolves agent → workspace config → template name +4. Controller calls provider.GetOrCreate(sessionID, templateName) +5. Provider creates SandboxClaim → agent-sandbox controller provisions pod +6. Provider polls until Ready (250ms interval, 5min timeout) +7. Controller returns 200 OK with {mcp_url: "http://{fqdn}:8080/mcp"} +8. ADK stores mcp_url in session_state, adds MCP toolset to runner +9. Agent uses sandbox tools directly for remainder of session +``` + +### Cleanup (on session delete) + +``` +1. Session is deleted (user action or timeout) +2. SessionsHandler calls provider.Destroy(sessionID) (best-effort) +3. Provider lists SandboxClaims by label kagent.dev/session-id +4. Provider deletes matching claims → agent-sandbox terminates pods +``` + +--- + +## Helm Configuration + +### Values (`helm/kagent/values.yaml`) + +```yaml +agentSandbox: + enabled: false # Feature flag — must be true to use workspaces + image: + registry: ghcr.io + repository: kagent-dev/kagent-sandbox-mcp + tag: "" # Defaults to global tag, then Chart version + pullPolicy: "" # Defaults to global imagePullPolicy +``` + +### What `agentSandbox.enabled: true` does + +1. **ConfigMap** (`controller-configmap.yaml`): Sets env vars on the controller: + - `ENABLE_K8S_SIGS_AGENT_SANDBOX: "true"` + - `K8S_SIGS_AGENT_SANDBOX_MCP_IMAGE_REGISTRY` + - `K8S_SIGS_AGENT_SANDBOX_MCP_IMAGE_REPOSITORY` + - `K8S_SIGS_AGENT_SANDBOX_MCP_IMAGE_TAG` + - `K8S_SIGS_AGENT_SANDBOX_MCP_IMAGE_PULL_POLICY` + +2. **RBAC** (`agent-sandbox-clusterrole.yaml`): Creates ClusterRole with permissions for: + - `extensions.agents.x-k8s.io`: `sandboxclaims`, `sandboxtemplates` (full CRUD) + - `agents.x-k8s.io`: `sandboxes` (get, list, watch) + +3. **Controller startup** (`go/core/pkg/app/app.go`): + - Checks if agent-sandbox CRDs are installed in the cluster via REST mapper + - If found: registers `SandboxTemplatePlugin` as a translator plugin and creates `AgentSandboxProvider` + - If not found: logs a warning, sandbox features unavailable + +### Prerequisites + +The `kubernetes-sigs/agent-sandbox` CRDs and controller must be installed separately. Kagent does not install them — it only creates `SandboxClaim` and `SandboxTemplate` resources that the agent-sandbox controller reconciles. + +--- + +## Component Summary + +| Component | Location | Status | +|-----------|----------|--------| +| `WorkspaceSpec` on Agent CRD | `go/api/v1alpha2/agent_types.go` | Implemented | +| `SandboxProvider` interface | `go/core/internal/controller/sandbox/provider.go` | Implemented | +| `AgentSandboxProvider` | `go/core/internal/controller/sandbox/agent_sandbox_provider.go` | Implemented | +| `SandboxTemplatePlugin` | `go/core/internal/controller/translator/agent/sandbox_template_plugin.go` | Implemented | +| HTTP sandbox endpoints | `go/core/internal/httpserver/handlers/sandbox.go` | Implemented | +| Session cleanup hook | `go/core/internal/httpserver/handlers/sessions.go` | Implemented | +| Python ADK integration | `python/packages/kagent-adk/src/kagent/adk/_agent_executor.py` | Implemented | +| Go ADK sandbox packages | `go/adk/pkg/sandbox/` | Implemented | +| `kagent-sandbox-mcp` server | `go/sandbox-mcp/` | Implemented | +| Helm chart support | `helm/kagent/` | Implemented | +| MoatProvider (internal) | — | Not yet implemented | + +--- + +## Decisions Made + +| Decision | Rationale | +|----------|-----------| +| Auto-generate SandboxTemplate | Simplifies user experience — just set `workspace.enabled: true` | +| Blocking `GetOrCreate` (not 202) | Simpler agent logic — no polling needed, controller handles the wait | +| MCP URL in session_state | Agent runtime stores endpoint, connects directly | +| Provider selection is cluster-level | Admin configures once via helm, agents just opt in | +| `kagent-sandbox-mcp` in this repo | Small image, tightly coupled to the MCP tool contract | +| Lazy provisioning on first message | No wasted resources for sessions that never use sandbox | +| Best-effort cleanup on session delete | Provider handles idempotent destroy | +| Deterministic claim name from session ID | Enables idempotent GetOrCreate without external state | From 1fb7041d8e2b7a65df244dd4fd63517a84093047 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Fri, 13 Mar 2026 16:35:33 +0000 Subject: [PATCH 6/6] fix: resolve lint issues in sandbox code Fix gofmt alignment in registry_test.go and use apierrors alias for k8s.io/apimachinery/pkg/api/errors per project linter config. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Eitan Yarmush --- go/adk/pkg/sandbox/registry_test.go | 2 +- .../controller/sandbox/agent_sandbox_provider.go | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/go/adk/pkg/sandbox/registry_test.go b/go/adk/pkg/sandbox/registry_test.go index e0085e43e..0f7d1b3ce 100644 --- a/go/adk/pkg/sandbox/registry_test.go +++ b/go/adk/pkg/sandbox/registry_test.go @@ -13,7 +13,7 @@ type fakeToolset struct { name string } -func (f *fakeToolset) Name() string { return f.name } +func (f *fakeToolset) Name() string { return f.name } func (f *fakeToolset) Tools(_ agent.ReadonlyContext) ([]tool.Tool, error) { return nil, nil } func TestRegistryGetNotFound(t *testing.T) { diff --git a/go/core/internal/controller/sandbox/agent_sandbox_provider.go b/go/core/internal/controller/sandbox/agent_sandbox_provider.go index f209f33e8..92099cbed 100644 --- a/go/core/internal/controller/sandbox/agent_sandbox_provider.go +++ b/go/core/internal/controller/sandbox/agent_sandbox_provider.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "k8s.io/apimachinery/pkg/api/errors" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -81,14 +81,14 @@ func (p *AgentSandboxProvider) GetOrCreate(ctx context.Context, opts CreateSandb existing := &extv1alpha1.SandboxClaim{} err := p.client.Get(ctx, key, existing) if err != nil { - if !errors.IsNotFound(err) { + if !apierrors.IsNotFound(err) { return nil, fmt.Errorf("failed to get SandboxClaim %s/%s: %w", ns, name, err) } // Create the SandboxClaim. claim := p.buildClaim(name, ns, opts) if err := p.client.Create(ctx, claim); err != nil { - if !errors.IsAlreadyExists(err) { + if !apierrors.IsAlreadyExists(err) { return nil, fmt.Errorf("failed to create SandboxClaim: %w", err) } // Race — another caller created it first. @@ -112,7 +112,7 @@ func (p *AgentSandboxProvider) waitForReady(ctx context.Context, key types.Names err := wait.PollUntilContextCancel(ctx, pollInterval, true, func(ctx context.Context) (bool, error) { claim := &extv1alpha1.SandboxClaim{} if err := p.client.Get(ctx, key, claim); err != nil { - if errors.IsNotFound(err) { + if apierrors.IsNotFound(err) { // Cache may not have synced yet after creation — retry. return false, nil } @@ -166,7 +166,7 @@ func (p *AgentSandboxProvider) Destroy(ctx context.Context, sessionID string) er } for i := range list.Items { - if err := p.client.Delete(ctx, &list.Items[i]); err != nil && !errors.IsNotFound(err) { + if err := p.client.Delete(ctx, &list.Items[i]); err != nil && !apierrors.IsNotFound(err) { return fmt.Errorf("failed to delete SandboxClaim %s: %w", list.Items[i].Name, err) } } @@ -219,7 +219,7 @@ func (p *AgentSandboxProvider) endpointFromClaim(ctx context.Context, claim *ext // get the authoritative ServiceFQDN from its status. sb := &sandboxv1alpha1.Sandbox{} if err := p.client.Get(ctx, types.NamespacedName{Name: claim.Name, Namespace: claim.Namespace}, sb); err != nil { - if errors.IsNotFound(err) { + if apierrors.IsNotFound(err) { return ep, nil } return nil, fmt.Errorf("failed to get Sandbox %s/%s: %w", claim.Namespace, claim.Name, err)