diff --git a/.gitignore b/.gitignore index 34ec766f7..03fe7c270 100644 --- a/.gitignore +++ b/.gitignore @@ -146,3 +146,4 @@ hack/ # Personal exports *.csv +components/credential-sidecars/entrypoint/credential-entrypoint diff --git a/Makefile b/Makefile index 48d6f045b..62fb502c7 100755 --- a/Makefile +++ b/Makefile @@ -10,6 +10,7 @@ .PHONY: setup-minio minio-console minio-logs minio-status .PHONY: validate-makefile lint-makefile check-shell makefile-health benchmark benchmark-ci .PHONY: _create-operator-config _auto-port-forward _show-access-info _kind-load-images +.PHONY: build-credential-sidecars build-credential-github build-credential-jira build-credential-k8s build-credential-google # Default target .DEFAULT_GOAL := help @@ -67,6 +68,10 @@ STATE_SYNC_IMAGE ?= vteam_state_sync:$(IMAGE_TAG) PUBLIC_API_IMAGE ?= vteam_public_api:$(IMAGE_TAG) API_SERVER_IMAGE ?= vteam_api_server:$(IMAGE_TAG) OBSERVABILITY_DASHBOARD_IMAGE ?= vteam_observability_dashboard:$(IMAGE_TAG) +GITHUB_MCP_IMAGE ?= vteam_credential_github:$(IMAGE_TAG) +JIRA_MCP_IMAGE ?= vteam_credential_jira:$(IMAGE_TAG) +K8S_MCP_IMAGE ?= vteam_credential_k8s:$(IMAGE_TAG) +GOOGLE_MCP_IMAGE ?= vteam_credential_google:$(IMAGE_TAG) # kind-local overlay always references localhost/vteam_* images. # Podman produces this prefix natively; for Docker we tag before loading. @@ -221,6 +226,36 @@ build-observability-dashboard: ## Build observability dashboard image -t $(OBSERVABILITY_DASHBOARD_IMAGE) . @echo "$(COLOR_GREEN)✓$(COLOR_RESET) Observability dashboard built: $(OBSERVABILITY_DASHBOARD_IMAGE)" +build-credential-sidecars: build-credential-github build-credential-jira build-credential-k8s build-credential-google ## Build all credential sidecar images + +build-credential-github: ## Build GitHub credential sidecar image + @echo "$(COLOR_BLUE)▶$(COLOR_RESET) Building GitHub credential sidecar with $(CONTAINER_ENGINE)..." + @$(CONTAINER_ENGINE) build $(PLATFORM_FLAG) $(BUILD_FLAGS) \ + -f components/credential-sidecars/github/Dockerfile \ + -t $(GITHUB_MCP_IMAGE) . + @echo "$(COLOR_GREEN)✓$(COLOR_RESET) GitHub credential sidecar built: $(GITHUB_MCP_IMAGE)" + +build-credential-jira: ## Build Jira credential sidecar image + @echo "$(COLOR_BLUE)▶$(COLOR_RESET) Building Jira credential sidecar with $(CONTAINER_ENGINE)..." + @$(CONTAINER_ENGINE) build $(PLATFORM_FLAG) $(BUILD_FLAGS) \ + -f components/credential-sidecars/jira/Dockerfile \ + -t $(JIRA_MCP_IMAGE) . + @echo "$(COLOR_GREEN)✓$(COLOR_RESET) Jira credential sidecar built: $(JIRA_MCP_IMAGE)" + +build-credential-k8s: ## Build K8s credential sidecar image + @echo "$(COLOR_BLUE)▶$(COLOR_RESET) Building K8s credential sidecar with $(CONTAINER_ENGINE)..." + @$(CONTAINER_ENGINE) build $(PLATFORM_FLAG) $(BUILD_FLAGS) \ + -f components/credential-sidecars/k8s/Dockerfile \ + -t $(K8S_MCP_IMAGE) . + @echo "$(COLOR_GREEN)✓$(COLOR_RESET) K8s credential sidecar built: $(K8S_MCP_IMAGE)" + +build-credential-google: ## Build Google credential sidecar image + @echo "$(COLOR_BLUE)▶$(COLOR_RESET) Building Google credential sidecar with $(CONTAINER_ENGINE)..." + @$(CONTAINER_ENGINE) build $(PLATFORM_FLAG) $(BUILD_FLAGS) \ + -f components/credential-sidecars/google/Dockerfile \ + -t $(GOOGLE_MCP_IMAGE) . + @echo "$(COLOR_GREEN)✓$(COLOR_RESET) Google credential sidecar built: $(GOOGLE_MCP_IMAGE)" + build-cli: ## Build acpctl CLI binary @echo "$(COLOR_BLUE)▶$(COLOR_RESET) Building acpctl CLI..." @cd components/ambient-cli && make build diff --git a/components/ambient-api-server/pkg/api/openapi/api/openapi.yaml b/components/ambient-api-server/pkg/api/openapi/api/openapi.yaml index f9fb7de81..fb6845f77 100644 --- a/components/ambient-api-server/pkg/api/openapi/api/openapi.yaml +++ b/components/ambient-api-server/pkg/api/openapi/api/openapi.yaml @@ -4963,7 +4963,6 @@ components: type: string required: - name - - project_id - provider type: object example: @@ -4975,7 +4974,6 @@ components: token: token labels: labels updated_at: 2000-01-23T04:56:07.000+00:00 - project_id: project_id provider: github name: name id: id diff --git a/components/ambient-api-server/pkg/api/openapi/model_credential.go b/components/ambient-api-server/pkg/api/openapi/model_credential.go index 8ffa72b2e..d246f74aa 100644 --- a/components/ambient-api-server/pkg/api/openapi/model_credential.go +++ b/components/ambient-api-server/pkg/api/openapi/model_credential.go @@ -29,7 +29,7 @@ type Credential struct { CreatedAt *time.Time `json:"created_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"` // ID of the project this credential belongs to - ProjectId string `json:"project_id"` + ProjectId *string `json:"project_id,omitempty"` Name string `json:"name"` Description *string `json:"description,omitempty"` Provider string `json:"provider"` @@ -47,9 +47,8 @@ type _Credential Credential // This constructor will assign default values to properties that have it defined, // and makes sure properties required by API are set, but the set of arguments // will change when the set of required properties is changed -func NewCredential(projectId string, name string, provider string) *Credential { +func NewCredential(name string, provider string) *Credential { this := Credential{} - this.ProjectId = projectId this.Name = name this.Provider = provider return &this @@ -223,28 +222,35 @@ func (o *Credential) SetUpdatedAt(v time.Time) { o.UpdatedAt = &v } -// GetProjectId returns the ProjectId field value +// GetProjectId returns the ProjectId field value if set, zero value otherwise. func (o *Credential) GetProjectId() string { - if o == nil { + if o == nil || IsNil(o.ProjectId) { var ret string return ret } - - return o.ProjectId + return *o.ProjectId } -// GetProjectIdOk returns a tuple with the ProjectId field value +// GetProjectIdOk returns a tuple with the ProjectId field value if set, nil otherwise // and a boolean to check if the value has been set. func (o *Credential) GetProjectIdOk() (*string, bool) { - if o == nil { + if o == nil || IsNil(o.ProjectId) { return nil, false } - return &o.ProjectId, true + return o.ProjectId, true } -// SetProjectId sets field value +// HasProjectId returns a boolean if a field has been set. +func (o *Credential) HasProjectId() bool { + if o != nil && !IsNil(o.ProjectId) { + return true + } + return false +} + +// SetProjectId gets a reference to the given string and assigns it to the ProjectId field. func (o *Credential) SetProjectId(v string) { - o.ProjectId = v + o.ProjectId = &v } // GetName returns the Name field value @@ -512,7 +518,9 @@ func (o Credential) ToMap() (map[string]interface{}, error) { if !IsNil(o.UpdatedAt) { toSerialize["updated_at"] = o.UpdatedAt } - toSerialize["project_id"] = o.ProjectId + if !IsNil(o.ProjectId) { + toSerialize["project_id"] = o.ProjectId + } toSerialize["name"] = o.Name if !IsNil(o.Description) { toSerialize["description"] = o.Description @@ -541,7 +549,6 @@ func (o *Credential) UnmarshalJSON(data []byte) (err error) { // by unmarshalling the object into a generic map with string keys and checking // that every required field exists as a key in the generic map. requiredProperties := []string{ - "project_id", "name", "provider", } diff --git a/components/ambient-api-server/pkg/rbac/middleware.go b/components/ambient-api-server/pkg/rbac/middleware.go index ebbde87d7..825ef85cc 100644 --- a/components/ambient-api-server/pkg/rbac/middleware.go +++ b/components/ambient-api-server/pkg/rbac/middleware.go @@ -151,7 +151,10 @@ func pathToResource(path string) string { for i, p := range parts { if p == "v1" && i+1 < len(parts) { seg := parts[i+1] - return strings.ReplaceAll(strings.TrimSuffix(seg, "s"), "_", "_") + if seg == "projects" && i+3 < len(parts) { + seg = parts[i+3] + } + return strings.TrimSuffix(seg, "s") } } return "unknown" diff --git a/components/ambient-api-server/pkg/rbac/middleware_test.go b/components/ambient-api-server/pkg/rbac/middleware_test.go new file mode 100644 index 000000000..2a56c1610 --- /dev/null +++ b/components/ambient-api-server/pkg/rbac/middleware_test.go @@ -0,0 +1,58 @@ +package rbac + +import ( + "net/http" + "testing" +) + +func TestPathToResource(t *testing.T) { + tests := []struct { + path string + want string + }{ + {"/api/ambient/v1/credentials", "credential"}, + {"/api/ambient/v1/credentials/abc123", "credential"}, + {"/api/ambient/v1/credentials/abc123/token", "credential"}, + {"/api/ambient/v1/projects/prtest/credentials/abc123/token", "credential"}, + {"/api/ambient/v1/projects/prtest/credentials", "credential"}, + {"/api/ambient/v1/projects", "project"}, + {"/api/ambient/v1/projects/prtest", "project"}, + {"/api/ambient/v1/sessions", "session"}, + {"/api/ambient/v1/role_bindings", "role_binding"}, + {"/api/ambient/v1/roles", "role"}, + {"/foo/bar", "unknown"}, + } + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + got := pathToResource(tt.path) + if got != tt.want { + t.Errorf("pathToResource(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +func TestPathToAction(t *testing.T) { + tests := []struct { + method string + path string + want string + }{ + {http.MethodGet, "/api/ambient/v1/credentials/abc123/token", "fetch_token"}, + {http.MethodGet, "/api/ambient/v1/projects/prtest/credentials/abc123/token", "fetch_token"}, + {http.MethodGet, "/api/ambient/v1/credentials", "read"}, + {http.MethodPost, "/api/ambient/v1/credentials", "create"}, + {http.MethodPatch, "/api/ambient/v1/credentials/abc123", "update"}, + {http.MethodDelete, "/api/ambient/v1/credentials/abc123", "delete"}, + {http.MethodGet, "/api/ambient/v1/agents/abc123/start", "start"}, + {http.MethodGet, "/api/ambient/v1/agents/abc123/stop", "stop"}, + } + for _, tt := range tests { + t.Run(tt.method+" "+tt.path, func(t *testing.T) { + got := pathToAction(tt.method, tt.path) + if got != tt.want { + t.Errorf("pathToAction(%q, %q) = %q, want %q", tt.method, tt.path, got, tt.want) + } + }) + } +} diff --git a/components/ambient-api-server/plugins/credentials/plugin.go b/components/ambient-api-server/plugins/credentials/plugin.go index f70c1233a..677f7b10d 100644 --- a/components/ambient-api-server/plugins/credentials/plugin.go +++ b/components/ambient-api-server/plugins/credentials/plugin.go @@ -61,6 +61,16 @@ func init() { credentialsRouter.HandleFunc("/{cred_id}/token", credentialHandler.GetToken).Methods(http.MethodGet) credentialsRouter.Use(authMiddleware.AuthenticateAccountJWT) credentialsRouter.Use(authzMiddleware.AuthorizeApi) + + projectCredRouter := apiV1Router.PathPrefix("/projects").Subrouter() + projectCredRouter.HandleFunc("/{id}/credentials", credentialHandler.List).Methods(http.MethodGet) + projectCredRouter.HandleFunc("/{id}/credentials", credentialHandler.Create).Methods(http.MethodPost) + projectCredRouter.HandleFunc("/{id}/credentials/{cred_id}", credentialHandler.Get).Methods(http.MethodGet) + projectCredRouter.HandleFunc("/{id}/credentials/{cred_id}", credentialHandler.Patch).Methods(http.MethodPatch) + projectCredRouter.HandleFunc("/{id}/credentials/{cred_id}", credentialHandler.Delete).Methods(http.MethodDelete) + projectCredRouter.HandleFunc("/{id}/credentials/{cred_id}/token", credentialHandler.GetToken).Methods(http.MethodGet) + projectCredRouter.Use(authMiddleware.AuthenticateAccountJWT) + projectCredRouter.Use(authzMiddleware.AuthorizeApi) }) pkgserver.RegisterController("Credentials", func(manager *controllers.KindControllerManager, services pkgserver.ServicesInterface) { diff --git a/components/ambient-api-server/plugins/projects/migration.go b/components/ambient-api-server/plugins/projects/migration.go index e334f4a3f..dc5e08b29 100644 --- a/components/ambient-api-server/plugins/projects/migration.go +++ b/components/ambient-api-server/plugins/projects/migration.go @@ -11,7 +11,6 @@ func migration() *gormigrate.Migration { type Project struct { db.Model Name string `gorm:"uniqueIndex;not null"` - DisplayName *string Description *string Labels *string Annotations *string diff --git a/components/ambient-api-server/plugins/roleBindings/migration.go b/components/ambient-api-server/plugins/roleBindings/migration.go index 677066a77..c0f5999dd 100644 --- a/components/ambient-api-server/plugins/roleBindings/migration.go +++ b/components/ambient-api-server/plugins/roleBindings/migration.go @@ -32,7 +32,7 @@ func migration() *gormigrate.Migration { func typedFKMigration() *gormigrate.Migration { return &gormigrate.Migration{ - ID: "202505130001", + ID: "202603100139", Migrate: func(tx *gorm.DB) error { // Drop the old unique index that depends on scope_id before altering columns if err := tx.Exec(`DROP INDEX IF EXISTS idx_binding_lookup`).Error; err != nil { diff --git a/components/ambient-cli/cmd/acpctl/create/cmd.go b/components/ambient-cli/cmd/acpctl/create/cmd.go index 124da5d28..31e2b7d84 100644 --- a/components/ambient-cli/cmd/acpctl/create/cmd.go +++ b/components/ambient-cli/cmd/acpctl/create/cmd.go @@ -55,6 +55,7 @@ var createArgs struct { bindAgentID string bindSessionID string bindCredID string + scopeID string } func init() { @@ -80,6 +81,7 @@ func init() { Cmd.Flags().StringVar(&createArgs.bindAgentID, "agent-id-fk", "", "Agent FK for role-binding") Cmd.Flags().StringVar(&createArgs.bindSessionID, "session-id-fk", "", "Session FK for role-binding") Cmd.Flags().StringVar(&createArgs.bindCredID, "credential-id-fk", "", "Credential FK for role-binding") + Cmd.Flags().StringVar(&createArgs.scopeID, "scope-id", "", "Scope target ID for role-binding (shorthand for --{scope}-id-fk)") } func run(cmd *cobra.Command, cmdArgs []string) error { @@ -299,6 +301,21 @@ func createRoleBinding(cmd *cobra.Command, ctx context.Context, client *sdkclien return fmt.Errorf("--scope is required") } + if createArgs.scopeID != "" { + switch createArgs.scope { + case "project": + createArgs.bindProjectID = createArgs.scopeID + case "agent": + createArgs.bindAgentID = createArgs.scopeID + case "session": + createArgs.bindSessionID = createArgs.scopeID + case "credential": + createArgs.bindCredID = createArgs.scopeID + default: + return fmt.Errorf("--scope-id not supported for scope %q; use the explicit FK flag", createArgs.scope) + } + } + builder := sdktypes.NewRoleBindingBuilder(). RoleID(createArgs.roleID). Scope(createArgs.scope) diff --git a/components/ambient-cli/cmd/acpctl/create/cmd_test.go b/components/ambient-cli/cmd/acpctl/create/cmd_test.go index 33315e714..2f52b53cb 100644 --- a/components/ambient-cli/cmd/acpctl/create/cmd_test.go +++ b/components/ambient-cli/cmd/acpctl/create/cmd_test.go @@ -249,6 +249,35 @@ func TestCreateRoleBinding_Success(t *testing.T) { } } +func TestCreateRoleBinding_ScopeID(t *testing.T) { + srv := testhelper.NewServer(t) + srv.Handle("/api/ambient/v1/role_bindings", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + credID := "cred-1" + srv.RespondJSON(t, w, http.StatusCreated, &types.RoleBinding{ + ObjectReference: types.ObjectReference{ID: "rb-scope"}, + RoleID: "r-1", + Scope: "credential", + CredentialID: &credID, + }) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "role-binding", + "--role-id", "r-1", + "--scope", "credential", + "--scope-id", "cred-1", + ) + if result.Err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s\nstderr: %s", result.Err, result.Stdout, result.Stderr) + } + if !strings.Contains(result.Stdout, "role-binding/rb-scope") { + t.Errorf("expected 'role-binding/rb-scope created', got: %s", result.Stdout) + } +} + func TestCreateRoleBinding_MissingScope(t *testing.T) { srv := testhelper.NewServer(t) testhelper.Configure(t, srv.URL) diff --git a/components/ambient-control-plane/cmd/ambient-control-plane/main.go b/components/ambient-control-plane/cmd/ambient-control-plane/main.go index b54293c48..3638c584f 100644 --- a/components/ambient-control-plane/cmd/ambient-control-plane/main.go +++ b/components/ambient-control-plane/cmd/ambient-control-plane/main.go @@ -144,6 +144,10 @@ func runKubeMode(ctx context.Context, cfg *config.ControlPlaneConfig) error { RunnerImageNamespace: cfg.RunnerImageNamespace, MCPImage: cfg.MCPImage, MCPAPIServerURL: cfg.MCPAPIServerURL, + GitHubMCPImage: cfg.GitHubMCPImage, + JiraMCPImage: cfg.JiraMCPImage, + K8sMCPImage: cfg.K8sMCPImage, + GoogleMCPImage: cfg.GoogleMCPImage, RunnerLogLevel: cfg.RunnerLogLevel, CPRuntimeNamespace: cfg.CPRuntimeNamespace, CPTokenURL: cfg.CPTokenURL, diff --git a/components/ambient-control-plane/internal/config/config.go b/components/ambient-control-plane/internal/config/config.go index 53db70907..64d3289c8 100755 --- a/components/ambient-control-plane/internal/config/config.go +++ b/components/ambient-control-plane/internal/config/config.go @@ -35,6 +35,10 @@ type ControlPlaneConfig struct { RunnerImageNamespace string MCPImage string MCPAPIServerURL string + GitHubMCPImage string + JiraMCPImage string + K8sMCPImage string + GoogleMCPImage string RunnerLogLevel string ProjectKubeTokenFile string CPTokenListenAddr string @@ -74,6 +78,10 @@ func Load() (*ControlPlaneConfig, error) { RunnerImageNamespace: os.Getenv("RUNNER_IMAGE_NAMESPACE"), MCPImage: os.Getenv("MCP_IMAGE"), MCPAPIServerURL: envOrDefault("MCP_API_SERVER_URL", ""), + GitHubMCPImage: os.Getenv("GITHUB_MCP_IMAGE"), + JiraMCPImage: os.Getenv("JIRA_MCP_IMAGE"), + K8sMCPImage: os.Getenv("K8S_MCP_IMAGE"), + GoogleMCPImage: os.Getenv("GOOGLE_MCP_IMAGE"), RunnerLogLevel: envOrDefault("RUNNER_LOG_LEVEL", "info"), ProjectKubeTokenFile: os.Getenv("PROJECT_KUBE_TOKEN_FILE"), CPTokenListenAddr: envOrDefault("CP_TOKEN_LISTEN_ADDR", ":8080"), diff --git a/components/ambient-control-plane/internal/reconciler/kube_reconciler.go b/components/ambient-control-plane/internal/reconciler/kube_reconciler.go index cf2d739f1..acc3484f7 100644 --- a/components/ambient-control-plane/internal/reconciler/kube_reconciler.go +++ b/components/ambient-control-plane/internal/reconciler/kube_reconciler.go @@ -21,6 +21,39 @@ const ( mcpSidecarURL = "http://localhost:8090" ) +type credentialSidecarSpec struct { + Name string + ImageField string + Port int64 + ProviderEnvs map[string]string +} + +var credentialSidecarRegistry = map[string]credentialSidecarSpec{ + "github": { + Name: "credential-github", + ImageField: "GitHubMCPImage", + Port: 8091, + ProviderEnvs: map[string]string{ + "GITHUB_PERSONAL_ACCESS_TOKEN": "__CREDENTIAL_TOKEN__", + }, + }, + "jira": { + Name: "credential-jira", + ImageField: "JiraMCPImage", + Port: 8092, + }, + "kubeconfig": { + Name: "credential-k8s", + ImageField: "K8sMCPImage", + Port: 8093, + }, + "google": { + Name: "credential-google", + ImageField: "GoogleMCPImage", + Port: 8094, + }, +} + type KubeReconcilerConfig struct { RunnerImage string BackendURL string @@ -36,6 +69,10 @@ type KubeReconcilerConfig struct { RunnerImageNamespace string MCPImage string MCPAPIServerURL string + GitHubMCPImage string + JiraMCPImage string + K8sMCPImage string + GoogleMCPImage string RunnerLogLevel string CPRuntimeNamespace string CPTokenURL string @@ -451,6 +488,25 @@ func (r *SimpleKubeReconciler) ensurePod(ctx context.Context, namespace string, r.logger.Info().Str("session_id", session.ID).Msg("MCP sidecar enabled for session") } + if r.cfg.CPTokenURL != "" && r.cfg.CPTokenPublicKey != "" { + credSidecars, credMCPURLs := r.buildCredentialSidecars(session.ID, namespace, credentialIDs) + containers = append(containers, credSidecars...) + if len(credMCPURLs) > 0 { + raw, err := json.Marshal(credMCPURLs) + if err != nil { + r.logger.Error().Err(err).Str("session_id", session.ID).Msg("failed to marshal credential MCP URLs") + } else { + containers[0].(map[string]interface{})["env"] = append( + containers[0].(map[string]interface{})["env"].([]interface{}), + envVar("CREDENTIAL_MCP_URLS", string(raw)), + ) + } + r.logger.Info().Int("count", len(credSidecars)).Str("session_id", session.ID).Msg("credential sidecars injected") + } + } else if len(credentialIDs) > 0 { + r.logger.Warn().Str("session_id", session.ID).Msg("credential sidecars skipped: CPTokenURL or CPTokenPublicKey not configured") + } + pod := &unstructured.Unstructured{ Object: map[string]interface{}{ "apiVersion": "v1", @@ -827,6 +883,110 @@ func boolToStr(b bool) string { return "false" } +func (r *SimpleKubeReconciler) credentialSidecarImage(provider string) string { + switch provider { + case "github": + return r.cfg.GitHubMCPImage + case "jira": + return r.cfg.JiraMCPImage + case "kubeconfig": + return r.cfg.K8sMCPImage + case "google": + return r.cfg.GoogleMCPImage + default: + return "" + } +} + +func (r *SimpleKubeReconciler) buildCredentialSidecars(sessionID string, namespace string, credentialIDs map[string]string) ([]interface{}, map[string]string) { + var sidecars []interface{} + mcpURLs := map[string]string{} + + credIDsRaw, _ := json.Marshal(credentialIDs) + + for provider := range credentialIDs { + spec, ok := credentialSidecarRegistry[provider] + if !ok { + continue + } + image := r.credentialSidecarImage(provider) + if image == "" { + continue + } + + imagePullPolicy := "Always" + if strings.HasPrefix(image, "localhost/") { + imagePullPolicy = "IfNotPresent" + } + + env := []interface{}{ + envVar("SESSION_ID", sessionID), + envVar("CREDENTIAL_IDS", string(credIDsRaw)), + envVar("AGENTIC_SESSION_NAMESPACE", namespace), + envVar("AMBIENT_API_URL", r.cfg.MCPAPIServerURL), + envVar("AMBIENT_CP_TOKEN_URL", r.cfg.CPTokenURL), + envVar("AMBIENT_CP_TOKEN_PUBLIC_KEY", r.cfg.CPTokenPublicKey), + envVar("SSL_CERT_FILE", "/etc/pki/ca-trust/extracted/pem/service-ca.crt"), + } + for k, v := range spec.ProviderEnvs { + env = append(env, envVar(k, v)) + } + if r.cfg.HTTPProxy != "" { + env = append(env, envVar("HTTP_PROXY", r.cfg.HTTPProxy)) + } + if r.cfg.HTTPSProxy != "" { + env = append(env, envVar("HTTPS_PROXY", r.cfg.HTTPSProxy)) + } + if r.cfg.NoProxy != "" { + env = append(env, envVar("NO_PROXY", r.cfg.NoProxy)) + } + + sidecar := map[string]interface{}{ + "name": spec.Name, + "image": image, + "imagePullPolicy": imagePullPolicy, + "ports": []interface{}{ + map[string]interface{}{ + "name": fmt.Sprintf("cred-%s", provider), + "containerPort": spec.Port, + "protocol": "TCP", + }, + }, + "env": env, + "volumeMounts": []interface{}{ + map[string]interface{}{ + "name": "service-ca", + "mountPath": "/etc/pki/ca-trust/extracted/pem/service-ca.crt", + "subPath": "service-ca.crt", + "readOnly": true, + }, + }, + "resources": map[string]interface{}{ + "requests": map[string]interface{}{ + "cpu": "100m", + "memory": "128Mi", + }, + "limits": map[string]interface{}{ + "cpu": "500m", + "memory": "256Mi", + }, + }, + "securityContext": map[string]interface{}{ + "allowPrivilegeEscalation": false, + "capabilities": map[string]interface{}{ + "drop": []interface{}{"ALL"}, + }, + }, + } + + sidecars = append(sidecars, sidecar) + mcpURLs[provider] = fmt.Sprintf("http://localhost:%d", spec.Port) + r.logger.Debug().Str("provider", provider).Str("image", image).Int64("port", spec.Port).Msg("credential sidecar configured") + } + + return sidecars, mcpURLs +} + func (r *SimpleKubeReconciler) buildMCPSidecar(sessionID string) interface{} { mcpImage := r.cfg.MCPImage imagePullPolicy := "Always" diff --git a/components/ambient-control-plane/internal/reconciler/kube_reconciler_test.go b/components/ambient-control-plane/internal/reconciler/kube_reconciler_test.go new file mode 100644 index 000000000..6e7827796 --- /dev/null +++ b/components/ambient-control-plane/internal/reconciler/kube_reconciler_test.go @@ -0,0 +1,178 @@ +package reconciler + +import ( + "encoding/json" + "testing" +) + +func TestBuildCredentialSidecars_NoCredentials(t *testing.T) { + r := &SimpleKubeReconciler{cfg: KubeReconcilerConfig{}} + sidecars, urls := r.buildCredentialSidecars("test-session", "test-namespace", map[string]string{}) + if len(sidecars) != 0 { + t.Errorf("expected 0 sidecars, got %d", len(sidecars)) + } + if len(urls) != 0 { + t.Errorf("expected 0 urls, got %d", len(urls)) + } +} + +func TestBuildCredentialSidecars_NoImageConfigured(t *testing.T) { + r := &SimpleKubeReconciler{cfg: KubeReconcilerConfig{}} + credentialIDs := map[string]string{"github": "cred-123"} + sidecars, urls := r.buildCredentialSidecars("test-session", "test-namespace", credentialIDs) + if len(sidecars) != 0 { + t.Errorf("expected 0 sidecars (no image configured), got %d", len(sidecars)) + } + if len(urls) != 0 { + t.Errorf("expected 0 urls, got %d", len(urls)) + } +} + +func TestBuildCredentialSidecars_GitHubSidecar(t *testing.T) { + r := &SimpleKubeReconciler{ + cfg: KubeReconcilerConfig{ + GitHubMCPImage: "ghcr.io/github/github-mcp-server:latest", + MCPAPIServerURL: "http://api.svc:8000", + CPTokenURL: "http://cp.svc:8080", + CPTokenPublicKey: "test-key", + }, + } + r.logger = r.logger.With().Logger() + + credentialIDs := map[string]string{"github": "cred-123"} + sidecars, urls := r.buildCredentialSidecars("test-session", "test-namespace", credentialIDs) + + if len(sidecars) != 1 { + t.Fatalf("expected 1 sidecar, got %d", len(sidecars)) + } + if len(urls) != 1 { + t.Fatalf("expected 1 url, got %d", len(urls)) + } + + url, ok := urls["github"] + if !ok { + t.Fatal("expected github url") + } + if url != "http://localhost:8091" { + t.Errorf("expected http://localhost:8091, got %s", url) + } + + sidecar := sidecars[0].(map[string]interface{}) + if sidecar["name"] != "credential-github" { + t.Errorf("expected container name credential-github, got %s", sidecar["name"]) + } + if sidecar["image"] != "ghcr.io/github/github-mcp-server:latest" { + t.Errorf("unexpected image: %s", sidecar["image"]) + } + + ports := sidecar["ports"].([]interface{}) + port := ports[0].(map[string]interface{}) + if port["containerPort"] != int64(8091) { + t.Errorf("expected port 8091, got %v", port["containerPort"]) + } + + secCtx := sidecar["securityContext"].(map[string]interface{}) + if secCtx["allowPrivilegeEscalation"] != false { + t.Error("expected allowPrivilegeEscalation=false") + } +} + +func TestBuildCredentialSidecars_MultipleSidecars(t *testing.T) { + r := &SimpleKubeReconciler{ + cfg: KubeReconcilerConfig{ + GitHubMCPImage: "github-mcp:latest", + JiraMCPImage: "jira-mcp:latest", + K8sMCPImage: "k8s-mcp:latest", + GoogleMCPImage: "google-mcp:latest", + MCPAPIServerURL: "http://api.svc:8000", + CPTokenURL: "http://cp.svc:8080", + CPTokenPublicKey: "test-key", + }, + } + r.logger = r.logger.With().Logger() + + credentialIDs := map[string]string{ + "github": "cred-1", + "jira": "cred-2", + "kubeconfig": "cred-3", + "google": "cred-4", + } + sidecars, urls := r.buildCredentialSidecars("test-session", "test-namespace", credentialIDs) + + if len(sidecars) != 4 { + t.Fatalf("expected 4 sidecars, got %d", len(sidecars)) + } + if len(urls) != 4 { + t.Fatalf("expected 4 urls, got %d", len(urls)) + } + + expectedPorts := map[string]string{ + "github": "http://localhost:8091", + "jira": "http://localhost:8092", + "kubeconfig": "http://localhost:8093", + "google": "http://localhost:8094", + } + for provider, expectedURL := range expectedPorts { + if urls[provider] != expectedURL { + t.Errorf("provider %s: expected %s, got %s", provider, expectedURL, urls[provider]) + } + } +} + +func TestBuildCredentialSidecars_UnknownProvider(t *testing.T) { + r := &SimpleKubeReconciler{cfg: KubeReconcilerConfig{}} + r.logger = r.logger.With().Logger() + + credentialIDs := map[string]string{"unknown-provider": "cred-999"} + sidecars, urls := r.buildCredentialSidecars("test-session", "test-namespace", credentialIDs) + + if len(sidecars) != 0 { + t.Errorf("expected 0 sidecars for unknown provider, got %d", len(sidecars)) + } + if len(urls) != 0 { + t.Errorf("expected 0 urls for unknown provider, got %d", len(urls)) + } +} + +func TestBuildCredentialSidecars_LocalImagePullPolicy(t *testing.T) { + r := &SimpleKubeReconciler{ + cfg: KubeReconcilerConfig{ + GitHubMCPImage: "localhost/github-mcp:latest", + }, + } + r.logger = r.logger.With().Logger() + + credentialIDs := map[string]string{"github": "cred-123"} + sidecars, _ := r.buildCredentialSidecars("test-session", "test-namespace", credentialIDs) + + if len(sidecars) != 1 { + t.Fatalf("expected 1 sidecar, got %d", len(sidecars)) + } + + sidecar := sidecars[0].(map[string]interface{}) + if sidecar["imagePullPolicy"] != "IfNotPresent" { + t.Errorf("expected IfNotPresent for localhost image, got %s", sidecar["imagePullPolicy"]) + } +} + +func TestCredentialMCPURLsJSON(t *testing.T) { + urls := map[string]string{ + "github": "http://localhost:8091", + "jira": "http://localhost:8092", + } + raw, err := json.Marshal(urls) + if err != nil { + t.Fatal(err) + } + + var parsed map[string]string + if err := json.Unmarshal(raw, &parsed); err != nil { + t.Fatal(err) + } + if parsed["github"] != "http://localhost:8091" { + t.Error("round-trip failed for github") + } + if parsed["jira"] != "http://localhost:8092" { + t.Error("round-trip failed for jira") + } +} diff --git a/components/credential-sidecars/entrypoint/go.mod b/components/credential-sidecars/entrypoint/go.mod new file mode 100644 index 000000000..a81d6e6f0 --- /dev/null +++ b/components/credential-sidecars/entrypoint/go.mod @@ -0,0 +1,7 @@ +module github.com/ambient-code/platform/components/credential-sidecars/entrypoint + +go 1.24.4 + +require github.com/ambient-code/platform/components/ambient-mcp v0.0.0 + +replace github.com/ambient-code/platform/components/ambient-mcp => ../../ambient-mcp diff --git a/components/credential-sidecars/entrypoint/main.go b/components/credential-sidecars/entrypoint/main.go new file mode 100644 index 000000000..18f4ff99f --- /dev/null +++ b/components/credential-sidecars/entrypoint/main.go @@ -0,0 +1,165 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "strings" + "syscall" + "time" + + "github.com/ambient-code/platform/components/ambient-mcp/tokenexchange" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintf(os.Stderr, "usage: credential-entrypoint [args...]\n") + os.Exit(1) + } + + tokenURL := os.Getenv("AMBIENT_CP_TOKEN_URL") + publicKey := os.Getenv("AMBIENT_CP_TOKEN_PUBLIC_KEY") + sessionID := os.Getenv("SESSION_ID") + apiURL := os.Getenv("AMBIENT_API_URL") + provider := os.Getenv("CREDENTIAL_PROVIDER") + + if tokenURL == "" || publicKey == "" || sessionID == "" { + fmt.Fprintf(os.Stderr, "AMBIENT_CP_TOKEN_URL, AMBIENT_CP_TOKEN_PUBLIC_KEY, SESSION_ID required\n") + os.Exit(1) + } + + exchanger, err := tokenexchange.New(tokenURL, publicKey, sessionID) + if err != nil { + fmt.Fprintf(os.Stderr, "token exchange init failed: %v\n", err) + os.Exit(1) + } + + bearerToken, err := exchanger.FetchToken() + if err != nil { + fmt.Fprintf(os.Stderr, "token fetch failed: %v\n", err) + os.Exit(1) + } + + if apiURL != "" && provider != "" { + if err := fetchAndSetCredential(bearerToken, apiURL, provider); err != nil { + fmt.Fprintf(os.Stderr, "credential fetch failed: %v\n", err) + } + } + + exchanger.OnRefresh(func(newToken string) { + if apiURL != "" && provider != "" { + if err := fetchAndSetCredential(newToken, apiURL, provider); err != nil { + fmt.Fprintf(os.Stderr, "credential refresh failed: %v\n", err) + } + } + }) + exchanger.StartBackgroundRefresh() + defer exchanger.Stop() + + execCommand(os.Args[1:]) +} + +func fetchAndSetCredential(bearerToken, apiURL, provider string) error { + parsed, err := url.Parse(apiURL) + if err != nil { + return fmt.Errorf("parse API URL: %w", err) + } + hostname := parsed.Hostname() + if !strings.HasSuffix(hostname, ".svc.cluster.local") && + !strings.HasSuffix(hostname, ".svc") && + hostname != "localhost" && + hostname != "127.0.0.1" { + return fmt.Errorf("refusing to send credentials to external host: %s", hostname) + } + + credentialIDs := map[string]string{} + if raw := os.Getenv("CREDENTIAL_IDS"); raw != "" { + if err := json.Unmarshal([]byte(raw), &credentialIDs); err != nil { + return fmt.Errorf("parse CREDENTIAL_IDS: %w", err) + } + } + + credID := credentialIDs[provider] + if credID == "" { + return fmt.Errorf("no credential ID for provider %s in CREDENTIAL_IDS", provider) + } + + credURL := fmt.Sprintf("%s/api/ambient/v1/credentials/%s/token", + strings.TrimRight(apiURL, "/"), credID) + + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest(http.MethodGet, credURL, nil) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+bearerToken) + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("credential request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read credential response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("credential fetch HTTP %d (response length: %d)", resp.StatusCode, len(body)) + } + + var credData map[string]interface{} + if err := json.Unmarshal(body, &credData); err != nil { + return fmt.Errorf("parse credential response: %w", err) + } + + setCredentialEnv(provider, credData) + return nil +} + +func setCredentialEnv(provider string, data map[string]interface{}) { + switch provider { + case "github": + if token, ok := data["token"].(string); ok && token != "" { + os.Setenv("GITHUB_PERSONAL_ACCESS_TOKEN", token) + } + case "jira": + if token, ok := data["apiToken"].(string); ok { + os.Setenv("JIRA_API_TOKEN", token) + } + if url, ok := data["url"].(string); ok { + os.Setenv("JIRA_URL", url) + } + if email, ok := data["email"].(string); ok { + os.Setenv("JIRA_USERNAME", email) + } + case "kubeconfig": + if token, ok := data["token"].(string); ok && token != "" { + path := "/tmp/.ambient_kubeconfig" + if err := os.WriteFile(path, []byte(token), 0600); err != nil { + fmt.Fprintf(os.Stderr, "write kubeconfig failed: %v\n", err) + } + os.Setenv("KUBECONFIG", path) + } + case "google": + if token, ok := data["accessToken"].(string); ok && token != "" { + os.Setenv("GOOGLE_ACCESS_TOKEN", token) + } + } +} + +func execCommand(args []string) { + binary, err := exec.LookPath(args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "command not found: %s\n", args[0]) + os.Exit(1) + } + if err := syscall.Exec(binary, args, os.Environ()); err != nil { + fmt.Fprintf(os.Stderr, "exec failed: %v\n", err) + os.Exit(1) + } +} diff --git a/components/credential-sidecars/github/Dockerfile b/components/credential-sidecars/github/Dockerfile new file mode 100644 index 000000000..d95802871 --- /dev/null +++ b/components/credential-sidecars/github/Dockerfile @@ -0,0 +1,33 @@ +FROM ghcr.io/github/github-mcp-server:latest AS upstream + +FROM registry.access.redhat.com/ubi9/go-toolset:1.24 AS builder + +USER 0 +WORKDIR /build +COPY components/credential-sidecars/entrypoint/*.go ./entrypoint/ +COPY components/ambient-mcp/ ./ambient-mcp/ + +WORKDIR /build/entrypoint +RUN go mod init github.com/ambient-code/platform/components/credential-sidecars/entrypoint && \ + go mod edit -require github.com/ambient-code/platform/components/ambient-mcp@v0.0.0 && \ + go mod edit -replace github.com/ambient-code/platform/components/ambient-mcp=../ambient-mcp && \ + go mod tidy && \ + CGO_ENABLED=0 go build -o /credential-entrypoint . + +FROM registry.access.redhat.com/ubi9/ubi-minimal:latest + +COPY --from=upstream /server/github-mcp-server /usr/local/bin/github-mcp-server +COPY --from=builder /credential-entrypoint /usr/local/bin/credential-entrypoint + +RUN microdnf install -y procps-ng shadow-utils && \ + microdnf clean all && \ + useradd -u 1001 -r -g 0 -s /sbin/nologin runner + +ENV CREDENTIAL_PROVIDER=github + +USER 1001 + +EXPOSE 8091 + +ENTRYPOINT ["credential-entrypoint", "github-mcp-server"] +CMD ["http", "--port", "8091"] diff --git a/components/credential-sidecars/google/Dockerfile b/components/credential-sidecars/google/Dockerfile new file mode 100644 index 000000000..77bd348cf --- /dev/null +++ b/components/credential-sidecars/google/Dockerfile @@ -0,0 +1,30 @@ +FROM registry.access.redhat.com/ubi9/go-toolset:1.24 AS builder + +USER 0 +WORKDIR /build +COPY components/credential-sidecars/entrypoint/*.go ./entrypoint/ +COPY components/ambient-mcp/ ./ambient-mcp/ + +WORKDIR /build/entrypoint +RUN go mod init github.com/ambient-code/platform/components/credential-sidecars/entrypoint && \ + go mod edit -require github.com/ambient-code/platform/components/ambient-mcp@v0.0.0 && \ + go mod edit -replace github.com/ambient-code/platform/components/ambient-mcp=../ambient-mcp && \ + go mod tidy && \ + CGO_ENABLED=0 go build -o /credential-entrypoint . + +FROM registry.access.redhat.com/ubi9/python-312:latest + +USER 0 +RUN pip install --no-cache-dir mcp-proxy 'workspace-mcp==1.17.1' && \ + chown -R 1001:0 /opt/app-root + +COPY --from=builder /credential-entrypoint /opt/app-root/bin/credential-entrypoint + +ENV CREDENTIAL_PROVIDER=google + +USER 1001 + +EXPOSE 8094 + +ENTRYPOINT ["credential-entrypoint", "mcp-proxy", "--sse-port", "8094", "--"] +CMD ["python", "-m", "workspace_mcp", "--permissions", "gmail:send", "drive:full"] diff --git a/components/credential-sidecars/jira/Dockerfile b/components/credential-sidecars/jira/Dockerfile new file mode 100644 index 000000000..20a0ef46c --- /dev/null +++ b/components/credential-sidecars/jira/Dockerfile @@ -0,0 +1,30 @@ +FROM registry.access.redhat.com/ubi9/go-toolset:1.24 AS builder + +USER 0 +WORKDIR /build +COPY components/credential-sidecars/entrypoint/*.go ./entrypoint/ +COPY components/ambient-mcp/ ./ambient-mcp/ + +WORKDIR /build/entrypoint +RUN go mod init github.com/ambient-code/platform/components/credential-sidecars/entrypoint && \ + go mod edit -require github.com/ambient-code/platform/components/ambient-mcp@v0.0.0 && \ + go mod edit -replace github.com/ambient-code/platform/components/ambient-mcp=../ambient-mcp && \ + go mod tidy && \ + CGO_ENABLED=0 go build -o /credential-entrypoint . + +FROM registry.access.redhat.com/ubi9/python-312:latest + +USER 0 +RUN pip install --no-cache-dir mcp-proxy mcp-atlassian && \ + chown -R 1001:0 /opt/app-root + +COPY --from=builder /credential-entrypoint /opt/app-root/bin/credential-entrypoint + +ENV CREDENTIAL_PROVIDER=jira + +USER 1001 + +EXPOSE 8092 + +ENTRYPOINT ["credential-entrypoint", "mcp-proxy", "--sse-port", "8092", "--"] +CMD ["python", "-m", "mcp_atlassian"] diff --git a/components/credential-sidecars/k8s/Dockerfile b/components/credential-sidecars/k8s/Dockerfile new file mode 100644 index 000000000..6614ea4b2 --- /dev/null +++ b/components/credential-sidecars/k8s/Dockerfile @@ -0,0 +1,30 @@ +FROM registry.access.redhat.com/ubi9/go-toolset:1.24 AS builder + +USER 0 +WORKDIR /build +COPY components/credential-sidecars/entrypoint/*.go ./entrypoint/ +COPY components/ambient-mcp/ ./ambient-mcp/ + +WORKDIR /build/entrypoint +RUN go mod init github.com/ambient-code/platform/components/credential-sidecars/entrypoint && \ + go mod edit -require github.com/ambient-code/platform/components/ambient-mcp@v0.0.0 && \ + go mod edit -replace github.com/ambient-code/platform/components/ambient-mcp=../ambient-mcp && \ + go mod tidy && \ + CGO_ENABLED=0 go build -o /credential-entrypoint . + +FROM registry.access.redhat.com/ubi9/python-312:latest + +USER 0 +RUN pip install --no-cache-dir mcp-proxy kubernetes-mcp-server && \ + chown -R 1001:0 /opt/app-root + +COPY --from=builder /credential-entrypoint /opt/app-root/bin/credential-entrypoint + +ENV CREDENTIAL_PROVIDER=kubeconfig + +USER 1001 + +EXPOSE 8093 + +ENTRYPOINT ["credential-entrypoint", "mcp-proxy", "--sse-port", "8093", "--"] +CMD ["python", "-m", "kubernetes_mcp_server", "--kubeconfig", "/tmp/.ambient_kubeconfig", "--disable-multi-cluster"] diff --git a/components/pr-test/install-standard.sh b/components/pr-test/install-standard.sh new file mode 100755 index 000000000..2bc2dc6f7 --- /dev/null +++ b/components/pr-test/install-standard.sh @@ -0,0 +1,651 @@ +#!/usr/bin/env bash +set -euo pipefail + +PR_INPUT="${1:-}" +REGISTRY="${REGISTRY:-quay.io/ambient_code}" +CLI="${OC:-oc}" + +usage() { + echo "Usage: $0 " + echo " pr-url-or-number: e.g. https://github.com/ambient-code/platform/pull/1599 or 1599" + echo "" + echo "Creates namespace pr-, deploys api-server + control-plane + PostgreSQL." + echo "" + echo "Environment variables:" + echo " REGISTRY Image registry prefix (default: quay.io/ambient_code)" + echo " OC oc/kubectl binary (default: oc)" + echo " SKIP_RBAC Set to 1 to skip ClusterRole/ClusterRoleBinding creation" + echo " ANTHROPIC_API_KEY Anthropic API key for runner pods" + echo " VERTEX_SOURCE_NS Namespace to copy ambient-vertex secret from (enables Vertex AI)" + echo "" + echo " Set either ANTHROPIC_API_KEY or VERTEX_SOURCE_NS for runners to work." + exit 1 +} + +[[ -z "$PR_INPUT" ]] && usage + +PR_NUMBER=$(echo "$PR_INPUT" | grep -oE '[0-9]+$') +if [[ -z "$PR_NUMBER" ]]; then + echo "ERROR: Could not extract PR number from: $PR_INPUT" + exit 1 +fi + +NAMESPACE="pr-${PR_NUMBER}" +IMAGE_TAG="pr-${PR_NUMBER}" + +if ! $CLI get namespace "$NAMESPACE" &>/dev/null 2>&1; then + echo "==> Creating namespace $NAMESPACE" + $CLI create namespace "$NAMESPACE" +else + echo "==> Namespace $NAMESPACE already exists" +fi + +echo "==> Installing Ambient into $NAMESPACE (standard OpenShift mode)" +echo " Images: ${REGISTRY}/vteam_*:${IMAGE_TAG}" +echo "" + +echo "==> Step 1: Ensuring secrets" + +if ! $CLI get secret ambient-api-server-db -n "$NAMESPACE" &>/dev/null; then + echo " Creating ambient-api-server-db secret (PostgreSQL credentials)" + DB_PASS=$(python3 -c "import secrets; print(secrets.token_urlsafe(24))") + $CLI create secret generic ambient-api-server-db -n "$NAMESPACE" \ + --from-literal=db.host=ambient-api-server-db \ + --from-literal=db.port=5432 \ + --from-literal=db.user=ambient \ + --from-literal=db.password="$DB_PASS" \ + --from-literal=db.name=ambient +else + echo " Secret OK: ambient-api-server-db" +fi + +if ! $CLI get secret ambient-api-server -n "$NAMESPACE" &>/dev/null; then + echo " Creating ambient-api-server secret (app config — dev mode, no OIDC)" + $CLI create secret generic ambient-api-server -n "$NAMESPACE" \ + --from-literal=clientId=dev-client \ + --from-literal=clientSecret=dev-secret +else + echo " Secret OK: ambient-api-server" +fi + +VERTEX_SOURCE_NS="${VERTEX_SOURCE_NS:-}" +USE_VERTEX=0 +VERTEX_KEY_FILE="${VERTEX_KEY_FILE:-unused}" +VERTEX_PROJECT_ID="${VERTEX_PROJECT_ID:-}" +VERTEX_REGION="${VERTEX_REGION:-global}" + +if [[ -n "$VERTEX_SOURCE_NS" ]]; then + if ! $CLI get secret ambient-vertex -n "$NAMESPACE" &>/dev/null; then + echo " Copying ambient-vertex secret from $VERTEX_SOURCE_NS" + $CLI get secret ambient-vertex -n "$VERTEX_SOURCE_NS" -o json | \ + python3 -c "import json,sys; s=json.load(sys.stdin); s['metadata']={'name':s['metadata']['name'],'namespace':'$NAMESPACE'}; json.dump(s,sys.stdout)" | \ + $CLI apply -n "$NAMESPACE" -f - + else + echo " Secret OK: ambient-vertex" + fi + USE_VERTEX=1 + VERTEX_KEY_FILE=$($CLI get secret ambient-vertex -n "$NAMESPACE" -o jsonpath='{.data}' | python3 -c "import json,sys; print(list(json.load(sys.stdin).keys())[0])") + if [[ -z "$VERTEX_PROJECT_ID" ]]; then + VERTEX_PROJECT_ID=$(echo "$VERTEX_KEY_FILE" | sed 's/\.json$//')-claude + echo " Auto-detected VERTEX_PROJECT_ID=$VERTEX_PROJECT_ID (override with VERTEX_PROJECT_ID env var)" + fi +elif [[ -n "${ANTHROPIC_API_KEY:-}" ]]; then + if ! $CLI get secret ambient-anthropic -n "$NAMESPACE" &>/dev/null; then + echo " Creating ambient-anthropic secret (Anthropic API key)" + $CLI create secret generic ambient-anthropic -n "$NAMESPACE" \ + --from-literal=api-key="$ANTHROPIC_API_KEY" + else + echo " Secret OK: ambient-anthropic" + fi +else + echo " WARNING: Neither ANTHROPIC_API_KEY nor VERTEX_SOURCE_NS set — runners will fail" +fi + +echo "==> Step 2: Ensuring ServiceAccount and RBAC for control-plane" + +SA_NAME="ambient-control-plane" + +$CLI apply -n "$NAMESPACE" -f - </dev/null; then + echo " Creating SA token secret" + $CLI apply -n "$NAMESPACE" -f - < Step 3: Deploying api-server-db (PostgreSQL)" + +$CLI apply -n "$NAMESPACE" -f - < Step 3b: Waiting for PostgreSQL to be ready" +$CLI rollout status deployment/ambient-api-server-db -n "$NAMESPACE" --timeout=120s +echo " Waiting for pg_isready..." +for i in $(seq 1 30); do + if $CLI exec -n "$NAMESPACE" deploy/ambient-api-server-db -- \ + /bin/sh -c 'pg_isready -U "$POSTGRESQL_USER"' &>/dev/null; then + echo " PostgreSQL is accepting connections" + break + fi + if [[ $i -eq 30 ]]; then + echo "ERROR: PostgreSQL did not become ready in time" + exit 1 + fi + sleep 2 +done + +echo "==> Step 4: Deploying api-server (development mode — no JWT)" + +$CLI apply -n "$NAMESPACE" -f - < Step 5: Deploying control-plane" + +$CLI apply -n "$NAMESPACE" -f - < Step 6: Creating Route" + +$CLI apply -n "$NAMESPACE" -f - < Step 7: Waiting for rollouts" +for deploy in ambient-api-server-db ambient-api-server ambient-control-plane; do + echo " Waiting for $deploy..." + $CLI rollout status deployment/"$deploy" -n "$NAMESPACE" --timeout=300s +done + +echo "==> Step 8: Verifying health" +API_HOST=$($CLI get route ambient-api-server -n "$NAMESPACE" \ + -o jsonpath='{.spec.host}' 2>/dev/null || true) + +if [[ -z "$API_HOST" ]]; then + echo "WARNING: Route not found — checking via port-forward" + API_HOST="(use oc port-forward svc/ambient-api-server 8000:8000 -n $NAMESPACE)" +else + HEALTH=$(curl -fsSk --connect-timeout 5 --max-time 20 \ + --retry 3 --retry-all-errors "https://${API_HOST}/api/ambient" 2>&1 || true) + echo " API server: ${HEALTH:-}" +fi + +echo "" +echo "==> Ambient installed successfully in $NAMESPACE" +echo " API server: https://${API_HOST}" +echo " Image tag: $IMAGE_TAG" +echo "" +echo " Login: acpctl login --url https://${API_HOST}" +echo "" +echo " Teardown:" +echo " bash components/pr-test/teardown-standard.sh $PR_NUMBER" diff --git a/components/pr-test/teardown-standard.sh b/components/pr-test/teardown-standard.sh new file mode 100755 index 000000000..414f0feae --- /dev/null +++ b/components/pr-test/teardown-standard.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +PR_INPUT="${1:-}" +CLI="${OC:-oc}" + +usage() { + echo "Usage: $0 " + echo " Tears down the PR test environment created by install-standard.sh" + exit 1 +} + +[[ -z "$PR_INPUT" ]] && usage + +PR_NUMBER=$(echo "$PR_INPUT" | grep -oE '[0-9]+$') +if [[ -z "$PR_NUMBER" ]]; then + echo "ERROR: Could not extract PR number from: $PR_INPUT" + exit 1 +fi + +NAMESPACE="pr-${PR_NUMBER}" +CR_NAME="ambient-control-plane-${NAMESPACE}" + +echo "==> Tearing down PR test environment: $NAMESPACE" + +echo " Deleting ClusterRoleBinding ${CR_NAME}..." +$CLI delete clusterrolebinding "$CR_NAME" --ignore-not-found 2>/dev/null || true + +echo " Deleting ClusterRole ${CR_NAME}..." +$CLI delete clusterrole "$CR_NAME" --ignore-not-found 2>/dev/null || true + +echo " Deleting namespace ${NAMESPACE}..." +$CLI delete namespace "$NAMESPACE" --ignore-not-found --wait=false 2>/dev/null || true + +echo "==> Teardown complete for $NAMESPACE" diff --git a/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py b/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py index 7ef598b4a..a4c8a75b4 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py @@ -193,7 +193,72 @@ def _replace(match: _re.Match) -> str: return _re.sub(r"\$\{([^}]+)}", _replace, value) +_CREDENTIAL_SIDECAR_REGISTRY: dict[str, dict[str, str]] = { + "github": { + "server_name": "github", + "type": "streamable_http", + "path": "/mcp", + }, + "jira": { + "server_name": "mcp-atlassian", + "type": "sse", + "path": "/sse", + }, + "kubeconfig": { + "server_name": "openshift", + "type": "sse", + "path": "/sse", + }, + "google": { + "server_name": "google-workspace", + "type": "sse", + "path": "/sse", + }, +} + + def build_credential_mcp_servers() -> dict: + credential_mcp_urls_raw = os.getenv("CREDENTIAL_MCP_URLS", "").strip() + if credential_mcp_urls_raw: + return _build_sidecar_mcp_servers(credential_mcp_urls_raw) + return _build_subprocess_mcp_servers() + + +def _build_sidecar_mcp_servers(credential_mcp_urls_raw: str) -> dict: + try: + credential_mcp_urls = json.loads(credential_mcp_urls_raw) + except (json.JSONDecodeError, TypeError): + logger.warning("Failed to parse CREDENTIAL_MCP_URLS — skipping credential MCP servers") + return {} + + if not isinstance(credential_mcp_urls, dict): + logger.warning("CREDENTIAL_MCP_URLS is not a JSON object — skipping credential MCP servers") + return {} + + servers: dict = {} + for provider, url in credential_mcp_urls.items(): + if not isinstance(url, str) or not url.strip(): + logger.warning("Skipping credential sidecar %s: invalid URL", provider) + continue + spec = _CREDENTIAL_SIDECAR_REGISTRY.get(provider, {}) + server_name = spec.get("server_name", provider) + transport_type = spec.get("type", "sse") + path = spec.get("path", "/sse") + servers[server_name] = { + "type": transport_type, + "url": f"{url.rstrip('/')}{path}", + } + logger.info( + "Configured %s credential sidecar (%s) at %s", + server_name, + transport_type, + url, + ) + + return servers + + +def _build_subprocess_mcp_servers() -> dict: credential_ids_raw = os.getenv("CREDENTIAL_IDS", "").strip() if not credential_ids_raw: return {} diff --git a/components/runners/ambient-runner/ambient_runner/platform/auth.py b/components/runners/ambient-runner/ambient_runner/platform/auth.py index 68a32ea5b..e4bf89bd1 100755 --- a/components/runners/ambient-runner/ambient_runner/platform/auth.py +++ b/components/runners/ambient-runner/ambient_runner/platform/auth.py @@ -383,13 +383,32 @@ async def fetch_token_for_url(context: RunnerContext, url: str) -> str: return os.getenv("GITHUB_TOKEN") or await fetch_github_token(context) +def _using_credential_sidecars() -> bool: + raw = os.getenv("CREDENTIAL_MCP_URLS", "").strip() + if not raw: + return False + try: + parsed = json.loads(raw) + return isinstance(parsed, dict) and len(parsed) > 0 + except (json.JSONDecodeError, TypeError): + return False + + async def populate_runtime_credentials(context: RunnerContext) -> None: """Fetch all credentials from backend and populate environment variables. Called before each SDK run to ensure MCP servers have fresh tokens. Also configures git identity from GitHub/GitLab credentials. + + When credential sidecars are active (CREDENTIAL_MCP_URLS is set), + integration tokens are NOT injected into the runner environment. + Only git identity (user name/email) is extracted for commit attribution. """ - logger.info("Fetching fresh credentials from backend API...") + sidecar_mode = _using_credential_sidecars() + if sidecar_mode: + logger.info("Credential sidecars active — fetching identity only (no token injection)") + else: + logger.info("Fetching fresh credentials from backend API...") # Fetch all credentials concurrently ( @@ -421,7 +440,7 @@ async def populate_runtime_credentials(context: RunnerContext) -> None: logger.warning(f"Failed to refresh Google credentials: {google_creds}") if isinstance(google_creds, PermissionError): auth_failures.append(str(google_creds)) - elif google_creds.get("token") or google_creds.get("accessToken"): + elif not sidecar_mode and (google_creds.get("token") or google_creds.get("accessToken")): try: if google_creds.get("accessToken"): _GOOGLE_WORKSPACE_CREDS_DIR.mkdir(parents=True, exist_ok=True) @@ -475,7 +494,7 @@ async def populate_runtime_credentials(context: RunnerContext) -> None: logger.warning(f"Failed to refresh Jira credentials: {jira_creds}") if isinstance(jira_creds, PermissionError): auth_failures.append(str(jira_creds)) - elif jira_creds.get("token") or jira_creds.get("apiToken"): + elif not sidecar_mode and (jira_creds.get("token") or jira_creds.get("apiToken")): os.environ["JIRA_URL"] = jira_creds.get("url", "") os.environ["JIRA_API_TOKEN"] = jira_creds.get("apiToken") or jira_creds.get( "token", "" @@ -489,15 +508,14 @@ async def populate_runtime_credentials(context: RunnerContext) -> None: if isinstance(gitlab_creds, PermissionError): auth_failures.append(str(gitlab_creds)) elif gitlab_creds.get("token"): - os.environ["GITLAB_TOKEN"] = gitlab_creds["token"] - # Also write to file so the git credential helper picks up mid-run - # refreshes even after the CLI subprocess has been spawned. - try: - _GITLAB_TOKEN_FILE.write_text(gitlab_creds["token"]) - _GITLAB_TOKEN_FILE.chmod(0o600) - except OSError as e: - logger.warning(f"Failed to write GitLab token file: {e}") - logger.info("Updated GitLab token in environment") + if not sidecar_mode: + os.environ["GITLAB_TOKEN"] = gitlab_creds["token"] + try: + _GITLAB_TOKEN_FILE.write_text(gitlab_creds["token"]) + _GITLAB_TOKEN_FILE.chmod(0o600) + except OSError as e: + logger.warning(f"Failed to write GitLab token file: {e}") + logger.info("Updated GitLab token in environment") if gitlab_creds.get("userName"): git_user_name = gitlab_creds["userName"] if gitlab_creds.get("email"): @@ -509,15 +527,14 @@ async def populate_runtime_credentials(context: RunnerContext) -> None: if isinstance(github_creds, PermissionError): auth_failures.append(str(github_creds)) elif github_creds.get("token"): - os.environ["GITHUB_TOKEN"] = github_creds["token"] - # Also write to file so the git credential helper picks up mid-run - # refreshes even after the CLI subprocess has been spawned. - try: - _GITHUB_TOKEN_FILE.write_text(github_creds["token"]) - _GITHUB_TOKEN_FILE.chmod(0o600) - except OSError as e: - logger.warning(f"Failed to write GitHub token file: {e}") - logger.info("Updated GitHub token in environment") + if not sidecar_mode: + os.environ["GITHUB_TOKEN"] = github_creds["token"] + try: + _GITHUB_TOKEN_FILE.write_text(github_creds["token"]) + _GITHUB_TOKEN_FILE.chmod(0o600) + except OSError as e: + logger.warning(f"Failed to write GitHub token file: {e}") + logger.info("Updated GitHub token in environment") if github_creds.get("userName"): git_user_name = github_creds["userName"] if github_creds.get("email"): @@ -549,7 +566,7 @@ async def populate_runtime_credentials(context: RunnerContext) -> None: logger.warning(f"Failed to refresh kubeconfig credentials: {kubeconfig_creds}") if isinstance(kubeconfig_creds, PermissionError): auth_failures.append(str(kubeconfig_creds)) - elif kubeconfig_creds.get("token"): + elif not sidecar_mode and kubeconfig_creds.get("token"): try: _KUBECONFIG_FILE.write_text(kubeconfig_creds["token"]) _KUBECONFIG_FILE.chmod(0o600) @@ -558,10 +575,13 @@ async def populate_runtime_credentials(context: RunnerContext) -> None: except OSError as e: logger.warning(f"Failed to write kubeconfig file: {e}") - # Configure git identity, credential helper, and gh CLI wrapper + # Configure git identity await configure_git_identity(git_user_name, git_user_email) - install_git_credential_helper() - install_gh_wrapper() + + # Only install credential helper and gh wrapper in legacy mode + if not sidecar_mode: + install_git_credential_helper() + install_gh_wrapper() if auth_failures: raise PermissionError( diff --git a/components/runners/ambient-runner/ambient_runner/platform/prompts.py b/components/runners/ambient-runner/ambient_runner/platform/prompts.py index 7de1f8433..74b793862 100644 --- a/components/runners/ambient-runner/ambient_runner/platform/prompts.py +++ b/components/runners/ambient-runner/ambient_runner/platform/prompts.py @@ -47,6 +47,18 @@ "The token is automatically used by git and the GitHub CLI.\n\n" ) +GITHUB_MCP_PROMPT = ( + "## GitHub Access (MCP)\n" + "GitHub operations are available through the **github** MCP server. " + "Use MCP tools for repository operations:\n" + "- `mcp__github__push_files` to push file changes\n" + "- `mcp__github__create_pull_request` to create pull requests\n" + "- `mcp__github__create_or_update_file` for single file operations\n" + "- `mcp__github__search_repositories`, `mcp__github__list_commits`, etc.\n\n" + "**IMPORTANT**: Do NOT use `git push` or `gh pr create` directly. " + "All GitHub write operations must go through MCP tools.\n\n" +) + GITLAB_TOKEN_PROMPT = ( "## GitLab Access\n" "A `GITLAB_TOKEN` environment variable is set in this session. " @@ -72,6 +84,17 @@ "the feature branch (`{branch}`). If push fails, do NOT fall back to main.\n\n" ) +GIT_PUSH_MCP_STEPS = ( + "\nAfter making changes to any auto-push repository:\n" + "1. Use `git add` to stage your changes\n" + '2. Use `git commit -m "description"` to commit with a descriptive message\n' + "3. Use `mcp__github__push_files` to push changes to branch `{branch}`\n" + "4. Use `mcp__github__create_pull_request` targeting the default branch\n\n" + "**IMPORTANT**: NEVER push directly to `main` or `master`. Always work on " + "the feature branch (`{branch}`). Do NOT use `git push` or `gh pr create` " + "directly — use the MCP tools instead.\n\n" +) + RUBRIC_EVALUATION_HEADER = "## Rubric Evaluation\n\n" RUBRIC_EVALUATION_INTRO = ( @@ -144,6 +167,7 @@ def build_workspace_context_prompt( Returns: Formatted prompt string. """ + credential_mcp_urls = os.getenv("CREDENTIAL_MCP_URLS", "").strip() prompt = WORKSPACE_STRUCTURE_HEADER prompt += WORKSPACE_FIXED_PATHS_PROMPT @@ -217,7 +241,17 @@ def build_workspace_context_prompt( for repo in auto_push_repos: repo_name = repo.get("name", "unknown") prompt += f"- **repos/{repo_name}/**\n" - prompt += GIT_PUSH_STEPS.format(branch=push_branch) + has_github_mcp = False + if credential_mcp_urls: + try: + import json as _json + has_github_mcp = "github" in _json.loads(credential_mcp_urls) + except (ValueError, TypeError): + pass + if has_github_mcp: + prompt += GIT_PUSH_MCP_STEPS.format(branch=push_branch) + else: + prompt += GIT_PUSH_STEPS.format(branch=push_branch) # Human-in-the-loop instructions prompt += HUMAN_INPUT_INSTRUCTIONS @@ -226,9 +260,17 @@ def build_workspace_context_prompt( prompt += MCP_INTEGRATIONS_PROMPT # Token visibility — tell Claude what credentials are available - if os.getenv("GITHUB_TOKEN"): + if credential_mcp_urls: + try: + import json as _json + urls = _json.loads(credential_mcp_urls) + if "github" in urls: + prompt += GITHUB_MCP_PROMPT + except (ValueError, TypeError): + pass + elif os.getenv("GITHUB_TOKEN"): prompt += GITHUB_TOKEN_PROMPT - if os.getenv("GITLAB_TOKEN"): + if not credential_mcp_urls and os.getenv("GITLAB_TOKEN"): prompt += GITLAB_TOKEN_PROMPT # Workflow instructions diff --git a/skills/control-plane/ambient-pr-test/SKILL.md b/skills/control-plane/ambient-pr-test/SKILL.md index 7716361e2..3e1ad0798 100644 --- a/skills/control-plane/ambient-pr-test/SKILL.md +++ b/skills/control-plane/ambient-pr-test/SKILL.md @@ -1,264 +1,143 @@ --- name: ambient-pr-test description: >- - End-to-end workflow for testing a pull request against the MPP dev cluster. - Builds and pushes images, provisions an ephemeral TenantNamespace, deploys - Ambient (mpp-openshift overlay), and tears down. Invoke with a PR URL. + Deploy a PR's images (api-server, control-plane, runner) to any OpenShift + namespace for integration testing. Works on both standard OpenShift clusters + and MPP managed clusters. Auto-detects the environment and chooses the + right script. --- # Ambient PR Test Skill -You are an expert in running ephemeral PR validation environments on the Ambient Code MPP dev cluster. This skill orchestrates the full lifecycle: build → namespace provisioning → Ambient deployment → teardown. +Deploy PR-tagged Ambient images into an OpenShift namespace for integration testing. -**Invoke this skill with a PR URL:** +**Invoke with a PR URL:** ``` -with skills/control-plane/ambient-pr-test https://github.com/ambient-code/platform/pull/1005 +with skills/control-plane/ambient-pr-test https://github.com/ambient-code/platform/pull/1599 ``` -Optional modifiers the user may specify: -- **`--keep-alive`** — do not tear down after the workflow; leave the instance online for human access -- **`provision-only`** / **`deploy-only`** / **`teardown-only`** — run a single phase instead of the full workflow - -> **Overlay:** `components/manifests/overlays/mpp-openshift/` — api-server, control-plane, PostgreSQL only. No frontend, backend, operator, public-api, or CRDs. -> **Spec:** `components/manifests/overlays/mpp-openshift/README.md` — bootstrap steps, secret requirements, architecture. - -Scripts in `components/pr-test/` implement all steps. Prefer them over inline commands. - ---- - -## Cluster Context - -- **Cluster:** `dev-spoke-aws-us-east-1` (context: `ambient-code--ambient-s2/...`) -- **Config namespace:** `ambient-code--config` -- **Namespace pattern:** `ambient-code--` -- **Instance ID pattern:** `pr-` -- **Image tag pattern:** `quay.io/ambient_code/vteam_*:pr-` - -### Permissions - -User tokens (`oc whoami -t`) do **not** have cluster-admin. `install.sh` uses the user token for the kustomize apply — the PR namespace's RBAC is set up by the tenant operator when the TenantNamespace CR is created. ClusterRoles and ClusterRoleBindings in the overlay (e.g. `ambient-control-plane-project-namespaces`) require cluster-admin to apply once; they are already in place on `dev-spoke-aws-us-east-1`. - -### Namespace Type - -PR test namespaces must be provisioned as `type: runtime`. Build namespaces cannot create Routes — the route admission webhook panics in `build` namespaces. - -### No CRDs Required - -The mpp-openshift overlay does **not** use Kubernetes CRDs (`agenticsessions`, `projectsettings`). The control plane manages sessions via the ambient-api-server REST/gRPC API, not via K8s custom resources. - -### TenantNamespace Ready Condition - -This cluster's tenant operator does not emit `Ready` conditions on `TenantNamespace.status.conditions`. `provision.sh` accepts `lastSuccessfulReconciliationTimestamp` as a sufficient signal that the namespace is ready. - ---- - -## Full Workflow - -``` -0. Build: always run build.sh to build and push images tagged pr- -1. Derive instance-id from PR number -2. Provision: bash components/pr-test/provision.sh create -3. Deploy: bash components/pr-test/install.sh -4. Teardown: bash components/pr-test/provision.sh destroy - (skip if --keep-alive) -``` - -Phases can be run individually — see **Individual Phases** below. +Optional modifiers: +- **`--keep-alive`** — skip teardown +- **`deploy-only`** / **`teardown-only`** — single phase +- **`--namespace `** — target namespace (defaults to current project) --- -## Step 0: Build and Push Images +## Environment Detection -Always run `build.sh` — CI may skip builds when no component source files changed (e.g. sync/merge branches), so never rely on CI to have pushed images: ```bash -bash components/pr-test/build.sh https://github.com/ambient-code/platform/pull/1005 +if oc api-resources --api-group=tenant.paas.redhat.com 2>/dev/null < /dev/null | grep -q TenantNamespace; then + echo "MPP" # use provision.sh + install.sh +else + echo "Standard" # use install-standard.sh +fi ``` -This builds and pushes 3 images tagged `pr-`: -- `quay.io/ambient_code/vteam_api_server:pr-` -- `quay.io/ambient_code/vteam_control_plane:pr-` -- `quay.io/ambient_code/vteam_claude_runner:pr-` - -Builds 3 images: `vteam_api_server`, `vteam_control_plane`, `vteam_claude_runner`. - -| Variable | Default | Purpose | -|----------|---------|---------| -| `REGISTRY` | `quay.io/ambient_code` | Registry prefix | -| `PLATFORM` | `linux/amd64` | Build platform | -| `CONTAINER_ENGINE` | `docker` | `docker` or `podman` | - ---- - -## Step 1: Derive Instance ID - -```bash -PR_URL="https://github.com/ambient-code/platform/pull/1005" -PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$') - -INSTANCE_ID="pr-${PR_NUMBER}" -NAMESPACE="ambient-code--${INSTANCE_ID}" -IMAGE_TAG="pr-${PR_NUMBER}" -``` +| | Standard OpenShift | MPP | +|--|-------------------|-----| +| Namespace | Pre-existing | Created via TenantNamespace CR | +| Script | `install-standard.sh` | `provision.sh` + `install.sh` | +| Auth | Development (no JWT) | Production (RH SSO JWT) | +| Secrets | Auto-generated | Copied from runtime-int | --- -## Step 2: Provision Namespace +## Standard OpenShift -```bash -bash components/pr-test/provision.sh create "$INSTANCE_ID" -``` +For any cluster where you have `oc` access and an existing namespace. -Applies the `TenantNamespace` CR to `ambient-code--config`, waits for the namespace to become Active (~10–30s). Uses an atomic ConfigMap lock to prevent concurrent slot collisions; capacity capped at 5 concurrent instances. +### Prerequisites ---- +- `oc` logged in with create permissions for deployments, services, routes, secrets, ClusterRoles +- Existing namespace (e.g. `mturansk`) +- PR images at `quay.io/ambient_code/vteam_*:pr-` -## Step 3: Deploy Ambient +### Deploy ```bash -bash components/pr-test/install.sh "$NAMESPACE" "$IMAGE_TAG" +PR_NUMBER=1599 +NAMESPACE=$(oc project -q) +bash components/pr-test/install-standard.sh "$NAMESPACE" "pr-${PR_NUMBER}" ``` -What `install.sh` does: -1. Verifies secrets exist in `ambient-code--runtime-int`: `ambient-vertex`, `ambient-api-server`, `ambient-api-server-db`, `tenantaccess-ambient-control-plane-token` -2. Copies those secrets to the PR namespace -3. Copies `mpp-openshift` overlay to a tmpdir, sets namespace and image tags, applies via `oc kustomize | oc apply` -4. Waits for rollouts: `ambient-api-server-db`, `ambient-api-server`, `ambient-control-plane` -5. Smoke-checks `GET /api/ambient` on the api-server Route - -Deployed components: -- `ambient-api-server` — REST + gRPC API -- `ambient-api-server-db` — PostgreSQL (in-cluster, `emptyDir` storage) -- `ambient-control-plane` — gRPC fan-out, session orchestration, runner pod lifecycle - ---- - -## Step 4: Teardown +The script: +1. Creates DB and app secrets (auto-generated) +2. Creates CP ServiceAccount + ClusterRole + ClusterRoleBinding +3. Deploys PostgreSQL, api-server (dev mode), control-plane (standard mode) +4. Creates Route (auto-assigned `.apps.*` host) +5. Waits for rollouts, smoke-checks health -Always run teardown after automated workflows, even on failure. +### Verify ```bash -bash components/pr-test/provision.sh destroy "$INSTANCE_ID" +API_HOST=$(oc get route ambient-api-server -n "$NAMESPACE" -o jsonpath='{.spec.host}') +curl -sk "https://${API_HOST}/api/ambient" +acpctl login --url "https://${API_HOST}" ``` -Deletes the `TenantNamespace` CR and waits for the namespace to be gone. Do not `oc delete namespace` directly — the tenant operator handles deletion via finalizers. - -**`--keep-alive`**: skip teardown and leave the instance running. Use when: -- A human needs to log in and manually test the deployment -- Debugging a failure and the environment needs to stay up +### Teardown -When `--keep-alive` is set, print the API server URL prominently and remind the user to tear down manually: ```bash -echo "Instance is LIVE — tear down when finished:" -echo " bash components/pr-test/provision.sh destroy $INSTANCE_ID" +NAMESPACE=$(oc project -q) +oc delete deployment,svc,route,configmap,secret -l app=ambient-api-server -n "$NAMESPACE" +oc delete deployment,svc -l app=ambient-control-plane -n "$NAMESPACE" +oc delete secret ambient-control-plane-token ambient-cp-token-keypair -n "$NAMESPACE" --ignore-not-found +oc delete clusterrole,clusterrolebinding "ambient-control-plane-${NAMESPACE}" --ignore-not-found ``` --- -## Individual Phases - -When the user specifies a single phase, run only that step (always derive instance ID first). - -**`provision-only`** -```bash -bash components/pr-test/provision.sh create "$INSTANCE_ID" -``` -Use when: pre-provisioning before a delayed deploy, or re-provisioning after the namespace was manually deleted. - -**`deploy-only`** -```bash -bash components/pr-test/install.sh "$NAMESPACE" "$IMAGE_TAG" -``` -Confirm the namespace exists before running: -```bash -oc get namespace "$NAMESPACE" 2>/dev/null || echo "ERROR: namespace not found — provision first" -``` -Use when: namespace already exists and you want to (re-)deploy without reprovisioning. - -**`teardown-only`** -```bash -bash components/pr-test/provision.sh destroy "$INSTANCE_ID" -``` -Use when: cleaning up a `--keep-alive` instance, or destroying after a failed deploy. +## MPP Workflow ---- - -## Listing Active Instances +For MPP clusters (`dev-spoke-aws-us-east-1`). See `components/pr-test/MPP-ENVIRONMENT.md`. ```bash -oc get tenantnamespace -n ambient-code--config \ - -l ambient-code/instance-type=s0x \ - -o custom-columns='NAME:.metadata.name,AGE:.metadata.creationTimestamp' +PR_NUMBER=1005; ID="pr-${PR_NUMBER}" +bash components/pr-test/build.sh "https://github.com/ambient-code/platform/pull/${PR_NUMBER}" +bash components/pr-test/provision.sh create "$ID" +bash components/pr-test/install.sh "ambient-code--${ID}" "pr-${PR_NUMBER}" +# teardown: +bash components/pr-test/provision.sh destroy "$ID" ``` --- ## Troubleshooting -### provision.sh times out waiting for Ready +### Images not found -This cluster's tenant operator does not emit `Ready` conditions. Check if the namespace is Active: -```bash -oc get namespace ambient-code--pr-NNN -o jsonpath='{.status.phase}' -oc get tenantnamespace pr-NNN -n ambient-code--config -o jsonpath='{.status}' -``` -If `namespace.phase=Active` and `lastSuccessfulReconciliationTimestamp` is set, the namespace is ready — provision.sh should have exited successfully (it accepts `lastSuccessfulReconciliationTimestamp` as the ready signal). +Check quay.io for the tag: `https://quay.io/repository/ambient_code/vteam_control_plane?tab=tags` -### install.sh — secret missing +### CP can't reach api-server -Required secrets must exist in `ambient-code--runtime-int`. If missing: ```bash -oc get secret ambient-api-server -n ambient-code--runtime-int -oc get secret ambient-api-server-db -n ambient-code--runtime-int -oc get secret tenantaccess-ambient-control-plane-token -n ambient-code--runtime-int -oc get secret ambient-vertex -n ambient-code--runtime-int +oc get deployment ambient-control-plane -n "$NAMESPACE" \ + -o jsonpath='{.spec.template.spec.containers[0].env}' | python3 -m json.tool | grep AMBIENT ``` -### Route host — wrong domain / 503 +### JWT errors in standard mode -The filter script in `install.sh` rewrites the Route host to: -``` -ambient-api-server-.internal-router-shard.mpp-w2-preprod.cfln.p1.openshiftapps.com +Verify `AMBIENT_ENV=development`: +```bash +oc get deployment ambient-api-server -n "$NAMESPACE" \ + -o jsonpath='{.spec.template.spec.containers[?(@.name=="api-server")].env[?(@.name=="AMBIENT_ENV")].value}' ``` -The Route uses `shard: internal` and `tls: termination: edge` — matching the `ambient-code--runtime-int` production install. The `router-default` (`.apps.` domain) does not successfully route to these namespaces; only `internal-router-shard` works. -The internal hostname is not publicly DNS-resolvable. Access requires OCM tunnel or VPN (same as `runtime-int`). `acpctl login` works with this URL when the user has OCM tunnel active. +### Route not resolving -Neither the user token nor the ArgoCD SA token (`tenantaccess-argocd-account`) can **update** Routes in PR namespaces after creation — only create. If the Route is wrong, destroy and re-provision. - -### Control plane can't reach api-server - -`install.sh` Step 4 automatically patches `AMBIENT_API_SERVER_URL` and `AMBIENT_GRPC_SERVER_ADDR` to point at the PR namespace's api-server (the overlay hardcodes `ambient-code--runtime-int`). If the control plane still can't connect, verify the patch applied: ```bash -oc get deployment ambient-control-plane -n "$NAMESPACE" \ - -o jsonpath='{.spec.template.spec.containers[0].env}' | python3 -m json.tool | grep AMBIENT +oc get route ambient-api-server -n "$NAMESPACE" -o jsonpath='{.spec.host}' ``` -### Build fails +### CP keypair secret -Ensure `docker` (or `podman`) is logged in to `quay.io/ambient_code`: +Auto-generated on first start as `ambient-cp-token-keypair`. Check CP logs: ```bash -docker login quay.io +oc logs deployment/ambient-control-plane -n "$NAMESPACE" | grep keypair ``` -### Images not found - -Either `build.sh` was not run or the CI build workflow failed. Check Actions → `Build and Push Component Docker Images` for the PR. - -### Runner pods can't reach external hosts (Squid proxy) - -The MPP cluster routes outbound traffic through a Squid proxy (`proxy.squi-001.prod.iad2.dc.redhat.com:3128`). The `runtime-int` deployments have `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` set in their pod specs, but runner pods spawned by the control plane did not inherit these. - -**Fix (merged):** The control plane reads `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` from its own environment and injects them into both the runner container (`buildEnv()`) and the MCP sidecar container (`buildMCPSidecar()`). No manifest change needed — the CP's deployment already has the proxy vars; they now propagate automatically. - -**Pattern:** When the CP needs to forward platform-level env vars to spawned pods, add the field to `ControlPlaneConfig` → `KubeReconcilerConfig` → `buildEnv()`/`buildMCPSidecar()`. - -### JWT / UNAUTHENTICATED errors in api-server +### Build fails -The production overlay configures JWT against Red Hat SSO. For ephemeral test instances without SSO integration: ```bash -oc set env deployment/ambient-api-server -n "$NAMESPACE" \ - --containers=api-server \ - -- \ - # Remove --jwk-cert-file and --grpc-jwk-cert-url args to disable JWT validation +docker login quay.io # or: podman login quay.io ``` -Or patch the args ConfigMap to remove the JWK flags and restart. diff --git a/skills/control-plane/ambient-pr-test/evals/evals.json b/skills/control-plane/ambient-pr-test/evals/evals.json new file mode 100644 index 000000000..4bf091fe5 --- /dev/null +++ b/skills/control-plane/ambient-pr-test/evals/evals.json @@ -0,0 +1,34 @@ +[ + { + "input": "test PR 1599 on OpenShift", + "expected_tool_call": "Skill", + "expected_args": { + "skill": "ambient-pr-test" + }, + "description": "Deploy a PR for testing on OpenShift" + }, + { + "input": "deploy this PR to my namespace for testing", + "expected_tool_call": "Skill", + "expected_args": { + "skill": "ambient-pr-test" + }, + "description": "Natural language trigger for PR deployment" + }, + { + "input": "run the ambient control plane from PR 1599 on the dev cluster", + "expected_tool_call": "Skill", + "expected_args": { + "skill": "ambient-pr-test" + }, + "description": "Request to test a specific PR on a dev cluster" + }, + { + "input": "tear down the PR test environment", + "expected_tool_call": "Skill", + "expected_args": { + "skill": "ambient-pr-test" + }, + "description": "Request to clean up a PR test deployment" + } +] diff --git a/specs/agents/runner.spec.md b/specs/agents/runner.spec.md index f35099455..c1a833b93 100644 --- a/specs/agents/runner.spec.md +++ b/specs/agents/runner.spec.md @@ -321,23 +321,47 @@ Sequence: ## Credential Management -Credentials are **ephemeral per-turn**. They are populated before each Claude turn and cleared after. +Integration credentials are **isolated in sidecar containers**. The runner container +has no integration tokens in its environment or filesystem. Each credential-bearing +MCP sidecar holds only its own credentials and exposes tools via SSE on a localhost +port. + +LLM provider credentials (Anthropic API key, Vertex AI service account) remain in +the runner container — they are necessary for inference. + +### Sidecar Credential Flow ``` -populate_runtime_credentials(context): - concurrent asyncio.gather: - _fetch_credential("github") → GITHUB_TOKEN, /tmp/.ambient_github_token - _fetch_credential("gitlab") → GITLAB_TOKEN, /tmp/.ambient_gitlab_token - _fetch_credential("google") → GOOGLE_APPLICATION_CREDENTIALS, credentials.json - _fetch_credential("jira") → JIRA_URL, JIRA_API_TOKEN, JIRA_EMAIL - -clear_runtime_credentials(): - unset all env vars + delete all temp files +CP resolves CREDENTIAL_IDS for the Project + → For each bound credential: + CP adds a sidecar container to the pod spec + Sidecar environment contains only its own credential + Sidecar exposes MCP tools on localhost:{port}/sse + → Runner connects to sidecars as SSE MCP clients + → Agent calls MCP tools — never sees raw tokens ``` -The credential fetch uses `context.caller_token` (the user's bearer from `x-caller-token` header) so each user can only access their own credentials. The `BACKEND_API_URL` is validated to be a cluster-local hostname before any request is made (prevents token exfiltration to external hosts). +Credential sidecars manage their own token refresh cycles. The `refresh_credentials` +MCP tool (registered under the `session` MCP server) signals sidecars to re-fetch +tokens from the backend API. Rate-limited to once per 30 seconds. + +The credential-free fallback: Projects with no bound credentials get no credential +sidecars. The runner operates without integration credentials. + +### Git Operations + +The runner container has no git credential helper and no GitHub/GitLab tokens. +Git write operations use MCP tools exclusively: + +- **Push commits**: `github-mcp` → `PushFiles` tool (commits and pushes via GitHub API) +- **Create PRs**: `github-mcp` → `CreatePullRequest` tool +- **Clone repos**: Init container (runs before the agent, credential-isolated) -The `refresh_credentials` MCP tool (registered under the `session` MCP server) lets Claude proactively refresh credentials mid-turn. Rate-limited to once per 30 seconds. +Direct `git push` and `gh pr create` from the runner container are not supported +— they require tokens in the runner environment, which violates the isolation +model. System prompts instruct the agent to use MCP tools for all git write +operations. See the [MCP server spec](../integrations/mcp-server.spec.md) for +sidecar details. --- @@ -348,27 +372,26 @@ The runner assembles the full MCP server configuration at setup time. Claude see | Server | Transport | Tools | Source | |--------|-----------|-------|--------| | External (`.mcp.json`) | stdio / SSE | whatever the server exposes | user config | -| `ambient-mcp` | SSE (`AMBIENT_MCP_URL`) | platform-provided tools | operator-injected | +| `ambient` | SSE (`AMBIENT_MCP_URL`) | 16 platform tools (sessions, agents, projects) | CP-injected sidecar | +| `github-mcp` | SSE (`:8091`) | GitHub API tools (repos, issues, PRs, actions) | CP-injected sidecar, only if `github` credential bound | +| `jira-mcp` | SSE (`:8093`) | Jira API tools (issues, search, transitions) | CP-injected sidecar, only if `jira` credential bound | +| `k8s-mcp` | SSE (`:8094`) | Kubernetes tools (kubectl via MCP) | CP-injected sidecar, only if `kubeconfig` credential bound | +| `google-mcp` | SSE (`:8095`) | Google Workspace tools (Gmail, Drive) | CP-injected sidecar, only if `google` credential bound | | `session` | in-process | `refresh_credentials` | always registered | | `rubric` | in-process | `evaluate_rubric` | registered if `.ambient/rubric.md` found | | `corrections` | in-process | `log_correction` | always registered | -| `acp` | in-process | `acp_*` (9 tools) | always registered | -### `acp` MCP Server Tools +### Migration: `acp` In-Process MCP Server Removed -Claude can call these tools to interact with the Ambient platform: +The previous `acp` in-process MCP server (9 tools: `acp_list_sessions`, +`acp_get_session`, `acp_create_session`, `acp_stop_session`, `acp_send_message`, +`acp_get_session_status`, `acp_restart_session`, `acp_list_workflows`, +`acp_get_api_reference`) is replaced by the `ambient` SSE sidecar on `:8090`. -| Tool | Description | -|------|-------------| -| `acp_list_sessions` | List sessions with phase/search/pagination filtering | -| `acp_get_session` | Read full session object | -| `acp_create_session` | Create a child session (inherits parent credentials via `parentSessionId`) | -| `acp_stop_session` | Stop a running session | -| `acp_send_message` | Send a message to a session's AG-UI run endpoint | -| `acp_get_session_status` | Session details + recent text messages | -| `acp_restart_session` | Stop then start | -| `acp_list_workflows` | List OOTB workflows | -| `acp_get_api_reference` | Full Ambient REST API docs with current context values | +The `ambient-mcp` sidecar exposes the same platform tools (sessions, agents, +projects) via the MCP protocol over SSE. Tool names change from `acp_*` prefix +to unprefixed (`list_sessions`, `get_session`, etc.). Existing agent prompts +referencing `acp_*` tool names must be updated. --- @@ -466,7 +489,7 @@ The resolved `(cwd_path, add_dirs)` tuple is passed to the Claude SDK via `Claud | Bridge ABC over direct Claude dependency | Enables Gemini CLI, LangGraph, and future bridges without changing app or platform layer | | `SessionWorker` isolates Claude subprocess | Claude SDK uses anyio internally — running it in a background asyncio.Task with queue-based API prevents anyio/asyncio event loop conflicts | | `_setup_platform()` deferred to first run | App startup must be fast; credential fetching, MCP server loading, and system prompt construction are I/O-heavy and done once per pod lifetime | -| Credentials cleared after every turn | Enforces per-user isolation; prevents a second user's run from inheriting credentials from the first user's turn | +| Credentials isolated in sidecar containers | Prevents token exfiltration by the agent via Bash/Read tools; each sidecar holds only its own credential | | RSA-OAEP for CP token auth | CP SA cannot create `tokenreviews` at cluster scope (tenant RBAC restriction); asymmetric encryption with a self-generated keypair (persisted in S0 Secret) requires no cluster-scoped permissions | | `set_bot_token()` module-level cache | CP-fetched OIDC token must be available to `get_bot_token()` for all HTTP API calls (credential fetches, backend tools); gRPC token and HTTP token are the same identity | | `GRPCMessageWriter` stores only last `MESSAGES_SNAPSHOT` | Each snapshot is a complete replacement; accumulating all would waste memory for long turns | @@ -474,3 +497,4 @@ The resolved `(cwd_path, add_dirs)` tuple is passed to the Claude SDK via `Claud | SSE queue pre-registered before `INITIAL_PROMPT` push | Backend opens `GET /events/{thread_id}` before `PushSessionMessage`; pre-registration in lifespan eliminates the race | | `--resume` via persisted session IDs | Claude Code saves state to `.claude/` on graceful subprocess shutdown; session IDs survive `mark_dirty()` rebuilds via JSON file and `_saved_session_ids` snapshot | | Credential URL validated to cluster-local hostname | Prevents exfiltration of user tokens to external hosts if `BACKEND_API_URL` is tampered with | +| LLM credentials (Anthropic/Vertex) remain in runner | These are necessary for inference and cannot be moved to sidecars without changing the SDK contract | diff --git a/specs/integrations/mcp-server.spec.md b/specs/integrations/mcp-server.spec.md index 47b7d53be..f56d18b0c 100644 --- a/specs/integrations/mcp-server.spec.md +++ b/specs/integrations/mcp-server.spec.md @@ -967,47 +967,88 @@ platform resources ## Sidecar Deployment -### Annotation +### Platform MCP Sidecar (`ambient-mcp`) -Sessions opt into the MCP sidecar by setting the annotation: +Sessions opt into the platform MCP sidecar by setting the annotation: ``` ambient-code.io/mcp-sidecar: "true" ``` -This annotation is set on the Session resource at creation time. The operator reads it and injects the `ambient-mcp` container into the runner Job pod. +This annotation is set on the Session resource at creation time. The CP reads it and injects the `ambient-mcp` container into the runner Job pod. + +### Integration Credential Sidecars + +For each credential bound to the session's Project (via `CREDENTIAL_IDS`), the CP +injects an additional sidecar container running the corresponding MCP server. Each +sidecar has its own isolated environment containing only its credential. The runner +container has **no** integration credential tokens in its environment or filesystem. + +| Credential Provider | Sidecar Name | Image | Port | Env Vars Injected | +|---|---|---|---|---| +| `github` | `github-mcp` | `ghcr.io/github/github-mcp-server` | `:8091` | `GITHUB_PERSONAL_ACCESS_TOKEN`, `AMBIENT_API_URL`, `AMBIENT_CP_TOKEN_URL`, `SESSION_ID` | +| `gitlab` | `gitlab-mcp` | TBD — no official MCP server exists yet; will require a community or custom image | `:8092` | `GITLAB_TOKEN`, `GITLAB_HOST`, `AMBIENT_API_URL`, `AMBIENT_CP_TOKEN_URL`, `SESSION_ID` | +| `jira` | `jira-mcp` | `uvx mcp-atlassian` (init + run) | `:8093` | `JIRA_URL`, `JIRA_API_TOKEN`, `JIRA_EMAIL`, `AMBIENT_API_URL`, `AMBIENT_CP_TOKEN_URL`, `SESSION_ID` | +| `kubeconfig` | `k8s-mcp` | `uvx kubernetes-mcp-server` (init + run) | `:8094` | `KUBECONFIG` (file mount), `AMBIENT_API_URL`, `AMBIENT_CP_TOKEN_URL`, `SESSION_ID` | +| `google` | `google-mcp` | `uvx workspace-mcp` (init + run) | `:8095` | `GOOGLE_OAUTH_*`, `USER_GOOGLE_EMAIL`, `AMBIENT_API_URL`, `AMBIENT_CP_TOKEN_URL`, `SESSION_ID` | + +The runner connects to each sidecar as an SSE MCP client on `http://localhost:{port}/sse`. + +Each credential sidecar receives `AMBIENT_API_URL`, `AMBIENT_CP_TOKEN_URL`, and +`SESSION_ID` so it can re-fetch tokens from the backend API when credentials +approach expiry. The sidecar authenticates to the backend using the same +RSA-OAEP token exchange mechanism as the `ambient-mcp` sidecar. + +When no credentials are bound to the Project, no credential sidecars are injected. +The runner operates without integration credentials — this is the credential-free +fallback. + +### Git Operations Without Token Exposure + +The runner container has no git credential helper and no GitHub/GitLab tokens. +The agent performs git operations exclusively through MCP tools: + +- **Push commits**: `github-mcp` → `PushFiles` tool (commits and pushes in one call) +- **Create PRs**: `github-mcp` → `CreatePullRequest` tool +- **Clone repos**: Init container (runs before the agent, has its own isolated credentials) + +The agent SHOULD NOT use `git push` or `gh pr create` directly — these require +tokens in the runner environment, which violates the isolation model. System +prompts instruct the agent to use MCP tools for all git write operations. ### Pod Layout ``` Job Pod (session-{id}-runner) -├── container: claude-code-runner -│ CLAUDE_CODE_MCP_CONFIG=/etc/mcp/config.json -│ reads config → connects to ambient-mcp via stdio +├── container: runner +│ Environment: +│ SESSION_ID, PROJECT_NAME, WORKSPACE_PATH, LLM_MODEL, ... +│ USE_VERTEX, ANTHROPIC_API_KEY or GOOGLE_APPLICATION_CREDENTIALS +│ AMBIENT_MCP_URL=http://localhost:8090 +│ CREDENTIAL_MCP_URLS={"github":"http://localhost:8091", ...} +│ NO integration tokens: no GITHUB_TOKEN, JIRA_API_TOKEN, etc. +│ NO token files: no /tmp/.ambient_github_token, etc. +│ Connects to sidecars via SSE MCP on localhost ports │ -└── container: ambient-mcp - image: localhost/vteam_ambient_mcp:latest - MCP_TRANSPORT=stdio - AMBIENT_API_URL=http://ambient-api-server.ambient-code.svc:8000 - AMBIENT_TOKEN={session bearer token from projected volume} -``` - -### MCP Config (injected by operator) - -```json -{ - "mcpServers": { - "ambient": { - "command": "./ambient-mcp", - "args": [], - "env": { - "MCP_TRANSPORT": "stdio", - "AMBIENT_API_URL": "http://ambient-api-server.ambient-code.svc:8000", - "AMBIENT_TOKEN": "${AMBIENT_TOKEN}" - } - } - } -} +├── container: ambient-mcp +│ image: localhost/vteam_ambient_mcp:latest +│ MCP_TRANSPORT=sse, MCP_BIND_ADDR=:8090 +│ AMBIENT_API_URL, AMBIENT_CP_TOKEN_URL, AMBIENT_CP_TOKEN_PUBLIC_KEY +│ SESSION_ID (for RSA-OAEP token exchange) +│ +├── container: github-mcp (only if github credential bound) +│ image: ghcr.io/github/github-mcp-server +│ GITHUB_PERSONAL_ACCESS_TOKEN={from backend API} +│ GITHUB_TOOLSETS=repos,issues,pull_requests,code_security +│ AMBIENT_API_URL, AMBIENT_CP_TOKEN_URL, SESSION_ID +│ Listens :8091 (SSE) +│ +├── container: jira-mcp (only if jira credential bound) +│ JIRA_URL, JIRA_API_TOKEN, JIRA_EMAIL +│ AMBIENT_API_URL, AMBIENT_CP_TOKEN_URL, SESSION_ID +│ Listens :8093 +│ +└── ... (additional credential sidecars as needed) ``` --- diff --git a/specs/security/security.spec.md b/specs/security/security.spec.md index 66089ad58..b07be8226 100644 --- a/specs/security/security.spec.md +++ b/specs/security/security.spec.md @@ -165,23 +165,62 @@ bound to. Credential tokens SHALL be write-only in the API. - WHEN the caller is not cluster-internal - THEN the request is denied to prevent token exfiltration +### Requirement: Agent Credential Isolation + +Integration credentials (GitHub, GitLab, Jira, Google, kubeconfig) SHALL +NOT be visible to the agent process. The runner container's environment SHALL NOT +contain integration credential tokens. The agent SHALL access external services +exclusively through MCP tools exposed by sidecar containers with isolated environments. + +LLM provider credentials (Anthropic API key, Vertex AI service account) are exempt +from this requirement — they are necessary for the agent's own inference and MAY +remain in the runner container. + +#### Scenario: Agent cannot read integration tokens + +- GIVEN a runner pod with bound GitHub and Jira credentials +- WHEN the agent enumerates environment variables or reads `/tmp/` +- THEN no integration tokens are present — `GITHUB_TOKEN`, `JIRA_API_TOKEN`, etc. are absent +- AND the agent can only interact with GitHub/Jira via MCP tools + +#### Scenario: Credential sidecar holds tokens in isolation + +- GIVEN a GitHub credential bound to a Project +- WHEN the CP provisions the session pod +- THEN a `github-mcp` sidecar container is added to the pod spec +- AND the sidecar's environment contains `GITHUB_PERSONAL_ACCESS_TOKEN` +- AND the sidecar exposes MCP tools on a localhost port +- AND the runner container does NOT have `GITHUB_TOKEN` in its environment + +#### Scenario: Git write operations use MCP tools, not tokens + +- GIVEN the agent needs to push commits to a GitHub repository +- WHEN the agent performs the push +- THEN the agent calls the `github-mcp` sidecar's `PushFiles` or `CreatePullRequest` MCP tools +- AND the sidecar executes the GitHub API call using its isolated token +- AND the runner container never has a git credential helper or token +- AND direct `git push` / `gh pr create` from the runner container SHALL fail (no credentials available) + ### Requirement: MCP Credential Lifecycle MCP server credentials SHALL follow the same RoleBinding-scoped access model as other -integration credentials. The Control Plane SHOULD support dynamic credential updates -without requiring full pod restarts. +integration credentials. Each integration credential bound to a Project SHALL be +materialized as a sidecar container with its own isolated environment. The sidecar +SHALL manage its own credential refresh cycle. -#### Scenario: Sidecar mode credential update +#### Scenario: Sidecar credential refresh -- GIVEN an MCP sidecar running alongside a runner -- WHEN the Project's MCP credentials are updated -- THEN the CP triggers a pod rolling restart with updated environment +- GIVEN a credential MCP sidecar running alongside a runner +- WHEN the credential token approaches expiry +- THEN the sidecar re-fetches the token from the backend API using its own auth +- AND the agent is not interrupted or restarted -#### Scenario: Pod mode credential update (proposed) +#### Scenario: Credential-free fallback -- GIVEN an MCP server running as an independent Pod -- WHEN the Project's MCP credentials are updated -- THEN the CP updates the MCP Pod configuration without affecting the runner +- GIVEN a Project with no bound credentials +- WHEN a session is provisioned +- THEN no credential sidecars are injected +- AND the runner operates without integration credentials ### Requirement: Per-Session Service Account Isolation @@ -380,7 +419,8 @@ These endpoints MUST validate the caller is cluster-internal to prevent token ex 2. No runner session can operate beyond the user's own authorization scope 3. Integration credentials are global, bound to Projects via RoleBindings, and fetched at runtime, never baked in 4. The Control Plane SA is the only identity that spans Projects -5. MCP lifecycle (sidecar vs. pod) is determined by operational requirements, not security compromise +5. Integration credentials are isolated in sidecar containers; the agent process has no access to integration tokens via environment, filesystem, or process inheritance +6. LLM provider credentials (Anthropic API key, Vertex SA) are exempt from sidecar isolation — they remain in the runner container ## Design Decisions @@ -396,6 +436,7 @@ These endpoints MUST validate the caller is cluster-internal to prevent token ex | Union-only permissions | No deny rules — simpler mental model for fleet operators. | | Token stored in database, encrypted at rest | Single authoritative store. A future Vault integration can be adopted by pointing the DB row at a Vault path without changing the API surface. | | `google` token serialized as a string | Service Account JSON is serialized into the single `token` field. Keeps the schema uniform across all providers. | +| Integration credentials isolated in sidecars, not runner env | Prevents token exfiltration by the agent via `Bash`/`Read` tools. The agent interacts with external services only through MCP tools. Sidecar containers have isolated environments containing only their own credentials. | | No validation on creation | First-use error is acceptable. Avoids a network call to the provider at creation time and the failure modes that come with it. | | Credential rotation is user-managed | Users update the token via `PATCH` or `acpctl credential update`. No platform-side rotation or expiry tracking. | | No migration utility for existing K8s Secrets | Users re-enter credentials via the new API. The old Secret-based path is removed when the new API is live. |