diff --git a/Makefile b/Makefile index be9f7e49a..dba917051 100644 --- a/Makefile +++ b/Makefile @@ -41,6 +41,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) @@ -48,6 +49,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) @@ -55,6 +57,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) @@ -219,7 +222,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)" @@ -227,6 +230,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 @@ -287,6 +291,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 @@ -374,6 +382,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/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 | 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..0f7d1b3ce --- /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 aee673f09..773ef86d9 100644 --- a/go/api/adk/types.go +++ b/go/api/adk/types.go @@ -427,6 +427,13 @@ func (c *AgentCompressionConfig) UnmarshalJSON(data []byte) error { return nil } +// 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 { + Enabled bool `json:"enabled"` +} + // 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 +446,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 +477,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 +506,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..02abfdc3a 100644 --- a/go/api/config/crd/bases/kagent.dev_agents.yaml +++ b/go/api/config/crd/bases/kagent.dev_agents.yaml @@ -10160,6 +10160,28 @@ spec: rule: '!(!has(self.agent) && self.type == ''Agent'')' maxItems: 20 type: array + workspace: + description: |- + 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: + 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: + - enabled + 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..a7bd19f50 100644 --- a/go/api/httpapi/types.go +++ b/go/api/httpapi/types.go @@ -193,3 +193,12 @@ type SessionRunsResponse struct { type SessionRunsData struct { Runs []any `json:"runs"` } + +// 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..ab3084650 100644 --- a/go/api/v1alpha2/agent_types.go +++ b/go/api/v1alpha2/agent_types.go @@ -188,6 +188,27 @@ type DeclarativeAgentSpec struct { // This includes event compaction (compression) and context caching. // +optional Context *ContextConfig `json:"context,omitempty"` + + // 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 *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 06ed3954e..1ca2fcdd0 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(WorkspaceSpec) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeclarativeAgentSpec. @@ -1399,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/reconciler/reconciler.go b/go/core/internal/controller/reconciler/reconciler.go index 0e854a567..fa26b71e9 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/agent_sandbox_provider.go b/go/core/internal/controller/sandbox/agent_sandbox_provider.go new file mode 100644 index 000000000..92099cbed --- /dev/null +++ b/go/core/internal/controller/sandbox/agent_sandbox_provider.go @@ -0,0 +1,234 @@ +package sandbox + +import ( + "context" + "fmt" + "time" + + 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" + "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 !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 !apierrors.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 apierrors.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 && !apierrors.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 apierrors.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/provider.go b/go/core/internal/controller/sandbox/provider.go new file mode 100644 index 000000000..f26e13628 --- /dev/null +++ b/go/core/internal/controller/sandbox/provider.go @@ -0,0 +1,64 @@ +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). +// +// 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 { + // 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) + + // 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 +} 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..2a986c497 --- /dev/null +++ b/go/core/internal/controller/sandbox/stub_provider.go @@ -0,0 +1,53 @@ +package sandbox + +import ( + "context" + "fmt" + "sync" +) + +// StubProvider is a sandbox provider for testing that returns fake endpoints. +// 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{ + sandboxes: make(map[string]*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, + } + s.sandboxes[opts.SessionID] = ep + return ep, 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) 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/controller/translator/agent/adk_api_translator.go b/go/core/internal/controller/translator/agent/adk_api_translator.go index fb4b2ce16..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,6 +693,14 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent *v1al } } + // 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 { headers, err := tool.ResolveHeaders(ctx, a.kube, agent.Namespace) if err != nil { 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/handlers.go b/go/core/internal/httpserver/handlers/handlers.go index 12ad54e94..496a2da1f 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, sandboxProvider sandbox.SandboxProvider) *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, sandboxProvider), 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, sandboxProvider), } } diff --git a/go/core/internal/httpserver/handlers/sandbox.go b/go/core/internal/httpserver/handlers/sandbox.go new file mode 100644 index 000000000..1f9c77cfa --- /dev/null +++ b/go/core/internal/httpserver/handlers/sandbox.go @@ -0,0 +1,178 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "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 ( + // 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 + Provider api.SandboxProvider +} + +// NewSandboxHandler creates a new 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 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") + + 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) + + // 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 + } + + session, err := h.DatabaseService.GetSession(r.Context(), sessionID, userID) + if err != nil { + w.RespondWithError(errors.NewNotFoundError("Session not found", err)) + return + } + + if session.AgentID == nil || *session.AgentID == "" { + w.RespondWithError(errors.NewBadRequestError("Session has no agent reference", nil)) + return + } + + // 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() + + ep, err := h.Provider.GetOrCreate(ctx, opts) + if err != nil { + w.RespondWithError(errors.NewInternalServerError("Failed to create sandbox", err)) + return + } + + resp := httpapi.SandboxResponse{ + SandboxID: ep.ID, + MCPUrl: ep.MCPUrl, + Protocol: ep.Protocol, + Headers: ep.Headers, + Ready: ep.Ready, + } + + 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. +// 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, 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 + } + + resp := httpapi.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 a6bda8955..20ea70bec 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 + SandboxProvider sandbox.SandboxProvider } // NewSessionsHandler creates a new SessionsHandler -func NewSessionsHandler(base *Base) *SessionsHandler { - return &SessionsHandler{Base: base} +func NewSessionsHandler(base *Base, sandboxProvider sandbox.SandboxProvider) *SessionsHandler { + return &SessionsHandler{Base: base, SandboxProvider: sandboxProvider} } // 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.SandboxProvider != nil { + if err := h.SandboxProvider.Destroy(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 feb21fd30..c204b42ae 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 bdbfde190..5ab8dd218 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" @@ -63,6 +64,7 @@ type ServerConfig struct { Authorizer auth.Authorizer ProxyURL string Reconciler reconciler.KagentReconciler + SandboxProvider sandbox.SandboxProvider } // HTTPServer is the structure that manages the HTTP server @@ -82,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), + handlers: handlers.NewHandlers(config.KubeClient, defaultModelConfig, config.DbClient, config.WatchedNamespaces, config.Authorizer, config.ProxyURL, config.Reconciler, config.SandboxProvider), authenticator: config.Authenticator, }, nil } @@ -280,6 +282,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 57f64fe3d..662230258 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" @@ -44,6 +45,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" @@ -73,6 +75,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 ) @@ -92,6 +96,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 } @@ -119,6 +125,7 @@ type Config struct { ProbeAddr string SecureMetrics bool EnableHTTP2 bool + EnableSandbox bool DefaultModelConfig types.NamespacedName HttpServerAddr string WatchNamespaces string @@ -149,6 +156,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.") @@ -176,6 +185,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.") } @@ -395,10 +409,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, ) @@ -523,6 +551,12 @@ func Start(getExtensionConfig GetExtensionConfig) { os.Exit(1) } + // 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, BindAddr: cfg.HttpServerAddr, @@ -535,6 +569,7 @@ func Start(getExtensionConfig GetExtensionConfig) { Authenticator: extensionCfg.Authenticator, ProxyURL: cfg.Proxy.URL, Reconciler: rcnclr, + SandboxProvider: sandboxProvider, }) if err != nil { setupLog.Error(err, "unable to create HTTP server") diff --git a/go/go.mod b/go/go.mod index 9855348d7..2e43f5c5e 100644 --- a/go/go.mod +++ b/go/go.mod @@ -58,6 +58,12 @@ require ( trpc.group/trpc-go/trpc-a2a-go v0.2.5 ) +require ( + github.com/testcontainers/testcontainers-go v0.41.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.41.0 + sigs.k8s.io/agent-sandbox v0.1.1 +) + require ( cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.123.0 // indirect @@ -107,7 +113,7 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.10.0 // 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 @@ -116,12 +122,22 @@ require ( github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // 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/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.26.0 // indirect @@ -142,7 +158,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/klauspost/compress v1.18.2 // indirect github.com/lestrrat-go/blackmagic v1.0.2 // indirect @@ -154,7 +169,6 @@ require ( github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // 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 @@ -193,8 +207,6 @@ require ( github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/testcontainers/testcontainers-go v0.41.0 // indirect - github.com/testcontainers/testcontainers-go/modules/postgres v0.41.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect @@ -235,7 +247,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 8bbae4ee4..5d447c479 100644 --- a/go/go.sum +++ b/go/go.sum @@ -12,8 +12,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ= entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= @@ -115,7 +115,8 @@ github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7np github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= 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/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 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= @@ -126,24 +127,18 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= -github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw= -github.com/docker/docker v28.2.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= -github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= -github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/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= @@ -177,14 +172,36 @@ 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-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -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= @@ -195,8 +212,6 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 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/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -255,25 +270,16 @@ 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= github.com/kagent-dev/kmcp v0.2.7/go.mod h1:g7wS/3m2wonRo/1DMwVoHxnilr/urPgV2hwV1DwkwrQ= github.com/kagent-dev/mockllm v0.0.5 h1:mm9Ml3NH6/E/YKVMgMwWYMNsNGkDze6I6TC0ppHZAo8= github.com/kagent-dev/mockllm v0.0.5/go.mod h1:tDLemRsTZa1NdHaDbg3sgFk9cT1QWvMPlBtLVD6I2mA= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -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/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= -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= @@ -298,8 +304,6 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -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= @@ -312,22 +316,22 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= -github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= -github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= -github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8= @@ -371,8 +375,6 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= @@ -402,8 +404,6 @@ github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= -github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQUJAsc= -github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -438,8 +438,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/testcontainers/testcontainers-go v0.38.0 h1:d7uEapLcv2P8AvH8ahLqDMMxda2W9gQN1nRbHS28HBw= -github.com/testcontainers/testcontainers-go v0.38.0/go.mod h1:C52c9MoHpWO+C4aqmgSU+hxlR5jlEayWtgYrb8Pzz1w= github.com/testcontainers/testcontainers-go v0.41.0 h1:mfpsD0D36YgkxGj2LrIyxuwQ9i2wCKAD+ESsYM1wais= github.com/testcontainers/testcontainers-go v0.41.0/go.mod h1:pdFrEIfaPl24zmBjerWTTYaY0M6UHsqA1YSvsoU40MI= github.com/testcontainers/testcontainers-go/modules/postgres v0.41.0 h1:AOtFXssrDlLm84A2sTTR/AhvJiYbrIuCO59d+Ro9Tb0= @@ -455,12 +453,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= -github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= -github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= -github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= @@ -485,8 +479,6 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 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= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -551,66 +543,42 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= 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.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -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= @@ -643,6 +611,8 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q= k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM= k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= @@ -667,6 +637,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 b/go/sandbox-mcp/Dockerfile new file mode 100644 index 000000000..cca0fb2bb --- /dev/null +++ b/go/sandbox-mcp/Dockerfile @@ -0,0 +1,13 @@ +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 python:3.13-slim +COPY --from=builder /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 new file mode 100644 index 000000000..05dd6fe20 --- /dev/null +++ b/go/sandbox-mcp/cmd/main.go @@ -0,0 +1,212 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + + "github.com/kagent-dev/kagent/go/sandbox-mcp/pkg/tools" + "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" + } + + 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) + slog.Info("Starting kagent-sandbox-mcp", "addr", addr, "skillsDir", skillsDir) + + handler := mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server { + return s + }, nil) + + if err := http.ListenAndServe(addr, handler); err != nil { + slog.Error("Server failed", "error", err) + os.Exit(1) + } +} + +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(context.Background(), input.Command, input.TimeoutMs, input.WorkingDir) + if err != nil { + slog.Error("exec failed", "error", err) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: err.Error()}}, + IsError: true, + }, nil, nil + } + + slog.Info("exec completed", "exit_code", result.ExitCode, "stdout_len", len(result.Stdout), "stderr_len", len(result.Stderr)) + if result.Stderr != "" { + slog.Warn("exec stderr", "stderr", result.Stderr) + } + + data, _ := json.Marshal(result) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: string(data)}}, + }, nil, nil +} + +func handleReadFile(_ context.Context, _ *mcp.CallToolRequest, input readFileInput) (*mcp.CallToolResult, any, error) { + slog.Info("read_file", "path", input.Path) + + content, err := tools.ReadFile(input.Path) + if err != nil { + slog.Error("read_file failed", "error", err) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: err.Error()}}, + IsError: true, + }, nil, 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(_ context.Context, _ *mcp.CallToolRequest, input writeFileInput) (*mcp.CallToolResult, any, error) { + slog.Info("write_file", "path", input.Path, "content_len", len(input.Content)) + + 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 + } + + slog.Info("write_file completed", "path", input.Path) + data, _ := json.Marshal(map[string]bool{"ok": true}) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: string(data)}}, + }, nil, nil +} + +func handleListDir(_ context.Context, _ *mcp.CallToolRequest, input listDirInput) (*mcp.CallToolResult, any, error) { + slog.Info("list_dir", "path", input.Path) + + entries, err := tools.ListDir(input.Path) + if err != nil { + slog.Error("list_dir failed", "error", err) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: err.Error()}}, + IsError: true, + }, nil, nil + } + + slog.Info("list_dir completed", "path", input.Path, "entries", len(entries)) + data, _ := json.Marshal(map[string]any{"entries": entries}) + 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 new file mode 100644 index 000000000..b604925b6 --- /dev/null +++ b/go/sandbox-mcp/go.mod @@ -0,0 +1,14 @@ +module github.com/kagent-dev/kagent/go/sandbox-mcp + +go 1.24.1 + +require github.com/modelcontextprotocol/go-sdk v1.4.0 + +require ( + 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 new file mode 100644 index 000000000..597fc8c0e --- /dev/null +++ b/go/sandbox-mcp/go.sum @@ -0,0 +1,20 @@ +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= +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/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/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 a34ced941..02abfdc3a 100644 --- a/helm/kagent-crds/templates/kagent.dev_agents.yaml +++ b/helm/kagent-crds/templates/kagent.dev_agents.yaml @@ -10160,6 +10160,28 @@ spec: rule: '!(!has(self.agent) && self.type == ''Agent'')' maxItems: 20 type: array + workspace: + description: |- + 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: + 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: + - enabled + type: object type: object x-kubernetes-validations: - message: systemMessage and systemMessageFrom are mutually exclusive diff --git a/helm/kagent/templates/controller-configmap.yaml b/helm/kagent/templates/controller-configmap.yaml index ffe2abc9e..bc11cc8f9 100644 --- a/helm/kagent/templates/controller-configmap.yaml +++ b/helm/kagent/templates/controller-configmap.yaml @@ -65,3 +65,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/controller-deployment.yaml b/helm/kagent/templates/controller-deployment.yaml index b332a9e7a..79bb6872e 100644 --- a/helm/kagent/templates/controller-deployment.yaml +++ b/helm/kagent/templates/controller-deployment.yaml @@ -83,13 +83,12 @@ spec: httpGet: path: /health port: http - periodSeconds: 15 - initialDelaySeconds: 15 + periodSeconds: 2 readinessProbe: httpGet: path: /health port: http - periodSeconds: 30 + periodSeconds: 2 {{- if gt (len .Values.controller.volumeMounts) 0 }} volumeMounts: {{- with .Values.controller.volumeMounts }} 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..65cbd7941 --- /dev/null +++ b/helm/kagent/templates/rbac/agent-sandbox-clusterrole.yaml @@ -0,0 +1,15 @@ +{{- if .Values.agentSandbox.enabled }} +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"] +{{- end }} 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..f5ce241ef --- /dev/null +++ b/helm/kagent/templates/rbac/agent-sandbox-clusterrolebinding.yaml @@ -0,0 +1,16 @@ +{{- if .Values.agentSandbox.enabled }} +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 }} +{{- end }} diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index 3726c3702..a5eaf28e4 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -427,6 +427,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/_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 034c51638..ecc58dd26 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: @@ -530,6 +532,12 @@ 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. + 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 decision = extract_decision_from_message(context.message) if decision: @@ -667,6 +675,66 @@ 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 (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: + # Already provisioned — just add the toolset + self._add_sandbox_toolset(runner, sandbox_mcp_url) + return + + import os + + import httpx + + kagent_url = os.getenv("KAGENT_URL", "http://localhost:8083") + 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", + 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 8dbab31fc..4229ffda8 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -252,6 +252,16 @@ class MemoryConfig(BaseModel): embedding: EmbeddingConfig | None = None # Embedding model config for memory tools. +class WorkspaceConfig(BaseModel): + """Workspace configuration from config.json for sandbox provisioning. + + 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): model: ModelUnion = Field(discriminator="type") description: str @@ -263,6 +273,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():