From 085028b7acf49fcf6f4c193fd1aeb3debdd64ee3 Mon Sep 17 00:00:00 2001 From: Mark Turansky Date: Fri, 3 Apr 2026 19:04:49 -0400 Subject: [PATCH 1/7] fix(cp): credential rolebinding and project delete (#1203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Seeds `credential:token-reader` and `credential:reader` roles via migration `202603311216` - Mounts runner BOT_TOKEN as file with 10-min background refresh loop in control-plane - Authorizes runner OIDC service account in `WatchSessionMessages` - Adds exponential backoff retry in informer error handler - Adds `acpctl apply -f` credential manifest support and role-binding commands to CLI ## Test plan - [ ] Deploy to OSD `ambient-s0` via ArgoCD (gitops MR \!94 already merged) - [ ] Verify `credential:token-reader` and `credential:reader` appear in `acpctl get roles` - [ ] Create role binding for `github-agent` in `credential-test` project - [ ] Start agent session and confirm runner pod retrieves token via `GET /credentials/{id}/token` 🤖 Generated with [Claude Code](https://claude.ai/code) ## Summary by CodeRabbit ## Release Notes * **New Features** * Added role seeding and management capabilities to credentials system. * Enhanced CLI with updated login, project context, and resource management commands. * Introduced declarative manifest application via `acpctl apply`. * Added agent creation and session messaging commands. * **Bug Fixes** * Strengthened authorization validation for session message access. * **Documentation** * Expanded CLI reference with comprehensive command examples and usage patterns. --------- Co-authored-by: Ambient Code Bot Co-authored-by: Claude --- .../plugins/credentials/migration.go | 67 ++++++ .../plugins/credentials/plugin.go | 1 + .../plugins/credentials/testmain_test.go | 12 ++ .../plugins/sessions/grpc_handler.go | 5 +- components/ambient-cli/README.md | 202 +++++++++++++++--- 5 files changed, 255 insertions(+), 32 deletions(-) diff --git a/components/ambient-api-server/plugins/credentials/migration.go b/components/ambient-api-server/plugins/credentials/migration.go index 54089b6f4..78cf7bbce 100644 --- a/components/ambient-api-server/plugins/credentials/migration.go +++ b/components/ambient-api-server/plugins/credentials/migration.go @@ -1,9 +1,12 @@ package credentials import ( + "encoding/json" + "gorm.io/gorm" "github.com/go-gormigrate/gormigrate/v2" + "github.com/openshift-online/rh-trex-ai/pkg/api" "github.com/openshift-online/rh-trex-ai/pkg/db" ) @@ -30,3 +33,67 @@ func migration() *gormigrate.Migration { }, } } + +func rolesMigration() *gormigrate.Migration { + type roleRow struct { + ID string + Name string + DisplayName string + Description string + Permissions string + BuiltIn bool + } + + seed := []struct { + name string + displayName string + description string + permissions []string + }{ + { + name: "credential:token-reader", + displayName: "Credential Token Reader", + description: "Retrieve the raw token value for a credential", + permissions: []string{"credential:token"}, + }, + { + name: "credential:reader", + displayName: "Credential Reader", + description: "Read credential metadata (name, provider, description)", + permissions: []string{"credential:read", "credential:list"}, + }, + } + + return &gormigrate.Migration{ + ID: "202603311216", + Migrate: func(tx *gorm.DB) error { + for _, r := range seed { + permsJSON, err := json.Marshal(r.permissions) + if err != nil { + return err + } + row := roleRow{ + ID: api.NewID(), + Name: r.name, + DisplayName: r.displayName, + Description: r.description, + Permissions: string(permsJSON), + BuiltIn: true, + } + if err := tx.Table("roles"). + Where("name = ?", r.name). + FirstOrCreate(&row).Error; err != nil { + return err + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + names := make([]string, len(seed)) + for i, r := range seed { + names[i] = r.name + } + return tx.Table("roles").Where("name IN ?", names).Delete(&roleRow{}).Error + }, + } +} diff --git a/components/ambient-api-server/plugins/credentials/plugin.go b/components/ambient-api-server/plugins/credentials/plugin.go index 8a5ec8270..2b2b849c7 100644 --- a/components/ambient-api-server/plugins/credentials/plugin.go +++ b/components/ambient-api-server/plugins/credentials/plugin.go @@ -82,4 +82,5 @@ func init() { presenters.RegisterKind(&Credential{}, "Credential") db.RegisterMigration(migration()) + db.RegisterMigration(rolesMigration()) } diff --git a/components/ambient-api-server/plugins/credentials/testmain_test.go b/components/ambient-api-server/plugins/credentials/testmain_test.go index db2f2f071..6c2db9408 100644 --- a/components/ambient-api-server/plugins/credentials/testmain_test.go +++ b/components/ambient-api-server/plugins/credentials/testmain_test.go @@ -9,6 +9,18 @@ import ( "github.com/golang/glog" "github.com/ambient-code/platform/components/ambient-api-server/test" + + _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/agents" + _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/inbox" + _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/projectSettings" + _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/projects" + _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/rbac" + _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/roleBindings" + _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/roles" + _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/sessions" + _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/users" + _ "github.com/openshift-online/rh-trex-ai/plugins/events" + _ "github.com/openshift-online/rh-trex-ai/plugins/generic" ) func TestMain(m *testing.M) { diff --git a/components/ambient-api-server/plugins/sessions/grpc_handler.go b/components/ambient-api-server/plugins/sessions/grpc_handler.go index f7c642862..ad7454a42 100644 --- a/components/ambient-api-server/plugins/sessions/grpc_handler.go +++ b/components/ambient-api-server/plugins/sessions/grpc_handler.go @@ -288,7 +288,10 @@ func (h *sessionGRPCHandler) WatchSessionMessages(req *pb.WatchSessionMessagesRe if !middleware.IsServiceCaller(ctx) { username := auth.GetUsernameFromContext(ctx) - if username != "" && (h.grpcServiceAccount == "" || username != h.grpcServiceAccount) { + if username == "" { + return status.Error(codes.PermissionDenied, "not authorized to watch this session") + } + if h.grpcServiceAccount == "" || username != h.grpcServiceAccount { session, svcErr := h.service.Get(ctx, req.GetSessionId()) if svcErr != nil { return grpcutil.ServiceErrorToGRPC(svcErr) diff --git a/components/ambient-cli/README.md b/components/ambient-cli/README.md index fefec5847..fb11903f0 100644 --- a/components/ambient-cli/README.md +++ b/components/ambient-cli/README.md @@ -21,91 +21,231 @@ This produces an `acpctl` binary in the current directory with embedded version ```bash # With a token and API server URL -./acpctl login --token --url http://localhost:8000 --project myproject +acpctl login --token + +# Skip TLS verification (e.g. local Kind cluster) +acpctl login --token --insecure-skip-tls-verify + +# Use RH SSO +acpctl login --use-auth-code --url https://ambient-api-server-ambient-code--ambient-s0.apps.int.spoke.dev.us-east-1.aws.paas.redhat.com # Verify -./acpctl whoami +acpctl whoami +# User: service-account-bob +# Project: myproject ``` ### 2. Configure defaults ```bash # Set or change the default project -./acpctl config set project myproject +acpctl config set project myproject + +# Switch active project context (shorthand) +acpctl project myproject + +# Show the currently active project +acpctl project current # View current settings -./acpctl config get api_url -./acpctl config get project +acpctl config get api_url +acpctl config get project ``` ### 3. List resources ```bash -# List sessions (table format) -./acpctl get sessions +# Sessions +acpctl get sessions +acpctl get sessions -o json -# List projects -./acpctl get projects +# Single session by ID +acpctl get session +acpctl get session -o json -# JSON output -./acpctl get sessions -o json +# Projects +acpctl get projects + +# Agents +acpctl get agents +acpctl get agents -o json -# Single resource by ID -./acpctl get session +# Credentials +acpctl get credentials +acpctl get credentials -o json + +# Roles +acpctl get roles +acpctl get roles -o json ``` ### 4. Create resources ```bash # Create a project -./acpctl create project --name my-project --display-name "My Project" +acpctl create project --name my-project --display-name "My Project" --description "Demo project" # Create a session -./acpctl create session --name fix-bug-123 \ +acpctl create session --name fix-bug-123 \ --prompt "Fix the null pointer in handler.go" \ --repo-url https://github.com/org/repo \ --model sonnet # Create with all options -./acpctl create session --name refactor-auth \ +acpctl create session --name refactor-auth \ --prompt "Refactor the auth middleware" \ --model sonnet \ --max-tokens 4000 \ --temperature 0.7 \ --timeout 3600 + +# Create an agent +acpctl agent create \ + --project-id my-project \ + --name my-agent \ + --prompt "You are a GitHub automation agent." + +# Create a role binding (bind a credential role to an agent) +acpctl create role-binding \ + --user-id \ + --role-id \ + --scope agent \ + --scope-id ``` -### 5. Session lifecycle +### 5. Apply declarative manifests + +`acpctl apply` creates or updates resources from YAML files. Token values can be +injected via environment variables referenced in the manifest. + +```bash +# Apply a credential manifest +cat > credential.yaml <<'EOF' +kind: Credential +name: my-github-pat +provider: github +token: $GITHUB_TOKEN +description: GitHub PAT for CI +EOF + +GITHUB_TOKEN="ghp_..." acpctl apply -f credential.yaml +``` + +Supported `kind` values: `Credential` (additional kinds vary by deployment). + +### 6. Agent sessions + +```bash +# Start a session for a named agent with an initial prompt +acpctl agent start \ + --project-id \ + --prompt "Open a test issue in org/repo" + +# Start and capture the session ID +SESSION_JSON=$(acpctl agent start my-agent --project-id my-project --prompt "..." -o json) +SESSION_ID=$(echo "$SESSION_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") +``` + +### 7. Session lifecycle ```bash # Start a session -./acpctl start +acpctl start # Stop a session -./acpctl stop +acpctl stop +``` + +### 8. Session messages + +```bash +# List all messages for a session (table format) +acpctl session messages + +# JSON output +acpctl session messages -o json + +# Stream new messages live (follow mode) +acpctl session messages -f + +# Stream only messages after a known sequence number +acpctl session messages -f --after 42 + +# Send a user message to a running session (multi-turn) +acpctl session send "Please also update the test file." ``` -### 6. Inspect resources +### 9. Inspect resources ```bash -# Full JSON detail of a session -./acpctl describe session +# Full detail of a session +acpctl describe session -# Full JSON detail of a project -./acpctl describe project +# Full detail of a project +acpctl describe project ``` -### 7. Delete resources +### 10. Delete resources ```bash -./acpctl delete project -./acpctl delete project-settings +acpctl delete session -y +acpctl delete project -y +acpctl delete project-settings +acpctl credential delete --confirm ``` -### 8. Log out +### 11. Log out ```bash -./acpctl logout +acpctl logout +``` + +## Credentials + +Credentials store secrets (e.g. GitHub PATs, API keys) that are injected into +agent sessions at runtime. The runner retrieves the raw token via the +credentials API, so the secret is never embedded in session configuration. + +```bash +# List credentials +acpctl get credentials + +# Create via apply (token injected from env var — never passed as a flag) +GITHUB_TOKEN="ghp_..." acpctl apply -f credential.yaml + +# Delete +acpctl credential delete --confirm +``` + +### Role bindings + +Access to credentials is controlled by role bindings. The relevant roles are: + +| Role | Permission | +|---|---| +| `credential:token-reader` | Retrieve the raw credential token via `GET /credentials/{id}/token` | +| `credential:reader` | Read credential metadata (name, provider, description) | + +```bash +# Look up a role ID +ROLE_ID=$(acpctl get roles -o json | python3 -c " +import sys, json +data = json.load(sys.stdin) +items = data.get('items', []) if isinstance(data, dict) else data +for r in items: + if r.get('name') == 'credential:token-reader': + print(r['id']); break +") + +# Get your user ID +MY_USER_ID=$(acpctl whoami | awk '/^User:/{print $2}') + +# Bind the role to an agent (agent can now retrieve the token) +acpctl create role-binding \ + --user-id "${MY_USER_ID}" \ + --role-id "${ROLE_ID}" \ + --scope agent \ + --scope-id ``` ## Try It Now (No Server Required) @@ -122,12 +262,12 @@ make build ./acpctl create --help # Login and config flow -./acpctl login --token test-token --url http://localhost:8000 --project demo +./acpctl login http://localhost:8000 --token test-token ./acpctl whoami ./acpctl config get api_url ./acpctl config get project ./acpctl config set project other-project -./acpctl config get project +./acpctl project current # Shell completion ./acpctl completion bash From a4cf942772ef4a430c4709e477a0b6c5bf8048de Mon Sep 17 00:00:00 2001 From: Mark Turansky Date: Fri, 3 Apr 2026 19:49:04 -0400 Subject: [PATCH 2/7] fix(control-plane): default BackendURL to AMBIENT_API_SERVER_URL (#1204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The `BackendURL` config field defaulted to `http://backend-service.ambient-code.svc:8080/api` — a legacy service that no longer exists. Runner pods need `BACKEND_API_URL` set to call `GET /credentials/{id}/token`. Since `AMBIENT_API_SERVER_URL` is already set in all deployments, default `BackendURL` to it. Discovered during E2E testing of the credential flow on OSD `ambient-s0`: runner logs showed DNS failures fetching credentials from the old backend URL. ## Test plan - [ ] Runner pod logs show `Successfully fetched github credentials from backend` instead of DNS failure on `backend-service.ambient-code.svc` - [ ] Agent can retrieve GitHub token via `/credentials/{id}/token` and use it 🤖 Generated with [Claude Code](https://claude.ai/code) ## Summary by CodeRabbit * **Chores** * Backend URL configuration now uses environment variable fallback logic (`BACKEND_API_URL` → `AMBIENT_API_SERVER_URL` → default to `http://localhost:8000`), enabling more flexible configuration across different deployment environments. Co-authored-by: Ambient Code Bot Co-authored-by: Claude --- components/ambient-control-plane/internal/config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/ambient-control-plane/internal/config/config.go b/components/ambient-control-plane/internal/config/config.go index 900e434a9..cfd0852d1 100644 --- a/components/ambient-control-plane/internal/config/config.go +++ b/components/ambient-control-plane/internal/config/config.go @@ -57,7 +57,7 @@ func Load() (*ControlPlaneConfig, error) { Reconcilers: parseReconcilers(envOrDefault("RECONCILERS", "tally,kube")), RunnerImage: envOrDefault("RUNNER_IMAGE", "quay.io/ambient_code/vteam_claude_runner:latest"), RunnerGRPCUseTLS: os.Getenv("AMBIENT_GRPC_USE_TLS") == "true", - BackendURL: envOrDefault("BACKEND_API_URL", "http://backend-service.ambient-code.svc:8080/api"), + BackendURL: envOrDefault("BACKEND_API_URL", envOrDefault("AMBIENT_API_SERVER_URL", "http://localhost:8000")), Namespace: envOrDefault("NAMESPACE", "ambient-code"), AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"), VertexEnabled: os.Getenv("USE_VERTEX") == "1" || os.Getenv("USE_VERTEX") == "true", From f82422a23cdbbec8dff1591bc644ec3e476276eb Mon Sep 17 00:00:00 2001 From: Ambient Code Bot Date: Fri, 3 Apr 2026 21:27:35 -0400 Subject: [PATCH 3/7] fix(control-plane): iterate projects to refresh running session tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refreshAllRunningTokens called ForProject(ctx, "") which the SDK rejects with "project is required". Sessions are project-scoped so the cross-project list requires iterating all projects first, then listing running sessions per project. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../internal/reconciler/kube_reconciler.go | 50 +++++++++++++------ 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/components/ambient-control-plane/internal/reconciler/kube_reconciler.go b/components/ambient-control-plane/internal/reconciler/kube_reconciler.go index 93000b263..abd876421 100644 --- a/components/ambient-control-plane/internal/reconciler/kube_reconciler.go +++ b/components/ambient-control-plane/internal/reconciler/kube_reconciler.go @@ -918,29 +918,47 @@ func (r *SimpleKubeReconciler) StartTokenRefreshLoop(ctx context.Context) { } func (r *SimpleKubeReconciler) refreshAllRunningTokens(ctx context.Context) { - sdk, err := r.factory.ForProject(ctx, "") - if err != nil { - r.logger.Warn().Err(err).Msg("token refresh loop: failed to get SDK client") - return - } - - opts := &types.ListOptions{Page: 1, Size: 100, Search: "phase = 'Running'"} + projectOpts := &types.ListOptions{Page: 1, Size: 100} for { - list, err := sdk.Sessions().List(ctx, opts) + projectSDK, err := r.factory.ForProject(ctx, "_") if err != nil { - r.logger.Warn().Err(err).Int("page", opts.Page).Msg("token refresh loop: failed to list running sessions") + r.logger.Warn().Err(err).Msg("token refresh loop: failed to get SDK client") return } - for i := range list.Items { - session := list.Items[i] - namespace := r.namespaceForSession(session) - if err := r.refreshRunnerToken(ctx, namespace, session.ID); err != nil { - r.logger.Warn().Err(err).Str("session_id", session.ID).Str("namespace", namespace).Msg("token refresh loop: failed to refresh token") + projectList, err := projectSDK.Projects().List(ctx, projectOpts) + if err != nil { + r.logger.Warn().Err(err).Int("page", projectOpts.Page).Msg("token refresh loop: failed to list projects") + return + } + for _, project := range projectList.Items { + sdk, err := r.factory.ForProject(ctx, project.ID) + if err != nil { + r.logger.Warn().Err(err).Str("project_id", project.ID).Msg("token refresh loop: failed to get SDK client for project") + continue + } + sessionOpts := &types.ListOptions{Page: 1, Size: 100, Search: "phase = 'Running'"} + for { + list, err := sdk.Sessions().List(ctx, sessionOpts) + if err != nil { + r.logger.Warn().Err(err).Str("project_id", project.ID).Int("page", sessionOpts.Page).Msg("token refresh loop: failed to list running sessions") + break + } + for i := range list.Items { + session := list.Items[i] + namespace := r.namespaceForSession(session) + if err := r.refreshRunnerToken(ctx, namespace, session.ID); err != nil { + r.logger.Warn().Err(err).Str("session_id", session.ID).Str("namespace", namespace).Msg("token refresh loop: failed to refresh token") + } + } + if len(list.Items) == 0 || list.Total <= sessionOpts.Page*sessionOpts.Size { + break + } + sessionOpts.Page++ } } - if len(list.Items) == 0 || list.Total <= opts.Page*opts.Size { + if len(projectList.Items) == 0 || projectList.Total <= projectOpts.Page*projectOpts.Size { break } - opts.Page++ + projectOpts.Page++ } } From 49dcf9352bc11beb44956e6dd1d4e0d5fa9d18dc Mon Sep 17 00:00:00 2001 From: Ambient Code Bot Date: Fri, 3 Apr 2026 21:56:39 -0400 Subject: [PATCH 4/7] fix(control-plane): refresh runner tokens immediately on startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The token refresh ticker fires every 10 minutes. If the control-plane restarts during an active session, existing runner BOT_TOKENs can expire before the first ticker tick fires. Trigger an immediate refresh on startup so any in-flight sessions get fresh tokens right away. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../ambient-control-plane/internal/reconciler/kube_reconciler.go | 1 + 1 file changed, 1 insertion(+) diff --git a/components/ambient-control-plane/internal/reconciler/kube_reconciler.go b/components/ambient-control-plane/internal/reconciler/kube_reconciler.go index abd876421..51a462f13 100644 --- a/components/ambient-control-plane/internal/reconciler/kube_reconciler.go +++ b/components/ambient-control-plane/internal/reconciler/kube_reconciler.go @@ -904,6 +904,7 @@ func (r *SimpleKubeReconciler) refreshRunnerToken(ctx context.Context, namespace func (r *SimpleKubeReconciler) StartTokenRefreshLoop(ctx context.Context) { go func() { + r.refreshAllRunningTokens(ctx) ticker := time.NewTicker(runnerTokenRefreshEvery) defer ticker.Stop() for { From 2faf442499d01a9858cf90d909e5a18740393a17 Mon Sep 17 00:00:00 2001 From: Ambient Code Bot Date: Fri, 3 Apr 2026 23:23:50 -0400 Subject: [PATCH 5/7] fix(api-server): lowercase session ID in runner service DNS name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit K8s service names are lowercased by the control-plane (safeResourceName), but StreamRunnerEvents was using the raw mixed-case session ID in the svc.cluster.local hostname, causing DNS resolution to fail with i/o timeout and a 502 to the client. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- components/ambient-api-server/plugins/sessions/handler.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/ambient-api-server/plugins/sessions/handler.go b/components/ambient-api-server/plugins/sessions/handler.go index 68ceca47a..996fafdb5 100644 --- a/components/ambient-api-server/plugins/sessions/handler.go +++ b/components/ambient-api-server/plugins/sessions/handler.go @@ -5,6 +5,7 @@ import ( "io" "net" "net/http" + "strings" "time" "github.com/golang/glog" @@ -299,7 +300,7 @@ func (h sessionHandler) StreamRunnerEvents(w http.ResponseWriter, r *http.Reques runnerURL := fmt.Sprintf( "http://session-%s.%s.svc.cluster.local:8001/events/%s", - *session.KubeCrName, *session.KubeNamespace, *session.KubeCrName, + strings.ToLower(*session.KubeCrName), *session.KubeNamespace, *session.KubeCrName, ) req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, runnerURL, nil) From c16abb1d18eb1d4fa0bb3cce83d61a382caaed20 Mon Sep 17 00:00:00 2001 From: Ambient Code Bot Date: Fri, 3 Apr 2026 23:29:05 -0400 Subject: [PATCH 6/7] fix(control-plane): reduce runner token refresh interval to 4 minutes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OIDC tokens from Red Hat SSO have a 15-minute TTL. With a 10-minute refresh interval the Secret update and the runner's reconnect can race such that the runner reads an already-expired token. Reducing to 4 minutes ensures the mounted token always has ≥11 minutes of remaining lifetime when the runner reads it on reconnect. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../internal/reconciler/kube_reconciler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/ambient-control-plane/internal/reconciler/kube_reconciler.go b/components/ambient-control-plane/internal/reconciler/kube_reconciler.go index 51a462f13..3a348ebc8 100644 --- a/components/ambient-control-plane/internal/reconciler/kube_reconciler.go +++ b/components/ambient-control-plane/internal/reconciler/kube_reconciler.go @@ -24,7 +24,7 @@ const ( runnerTokenFileName = "bot-token" runnerTokenVolumeKey = "api-token" runnerTokenVolumeName = "runner-token" - runnerTokenRefreshEvery = 10 * time.Minute + runnerTokenRefreshEvery = 4 * time.Minute ) type KubeReconcilerConfig struct { From 5b0cbe8b9f2ba7d54014905e84a47002eaa74c52 Mon Sep 17 00:00:00 2001 From: Ambient Code Bot Date: Sat, 4 Apr 2026 00:02:21 -0400 Subject: [PATCH 7/7] fix(api-server): validate user OIDC JWTs in gRPC stream interceptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds JWT fallback in bearerTokenGRPCUnaryInterceptor and bearerTokenGRPCStreamInterceptor so CLI users sending OIDC JWTs get their username extracted via jwt.ParseUnverified and set in context via auth.SetUsernameContext, enabling WatchSessionMessages to authorize them correctly. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../pkg/middleware/bearer_token_grpc.go | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/components/ambient-api-server/pkg/middleware/bearer_token_grpc.go b/components/ambient-api-server/pkg/middleware/bearer_token_grpc.go index afb11d86b..c82cdd9c1 100644 --- a/components/ambient-api-server/pkg/middleware/bearer_token_grpc.go +++ b/components/ambient-api-server/pkg/middleware/bearer_token_grpc.go @@ -3,7 +3,10 @@ package middleware import ( "context" "crypto/subtle" + "strings" + "github.com/golang-jwt/jwt/v4" + "github.com/openshift-online/rh-trex-ai/pkg/auth" "google.golang.org/grpc" "google.golang.org/grpc/metadata" ) @@ -25,6 +28,9 @@ func bearerTokenGRPCUnaryInterceptor(expectedToken string) grpc.UnaryServerInter if subtle.ConstantTimeCompare([]byte(token), []byte(expectedToken)) == 1 { return handler(withCallerType(ctx, CallerTypeService), req) } + if username := usernameFromJWT(token); username != "" { + return handler(auth.SetUsernameContext(ctx, username), req) + } } } } @@ -45,6 +51,10 @@ func bearerTokenGRPCStreamInterceptor(expectedToken string) grpc.StreamServerInt if subtle.ConstantTimeCompare([]byte(token), []byte(expectedToken)) == 1 { return handler(srv, &serviceCallerStream{ServerStream: ss, ctx: withCallerType(ss.Context(), CallerTypeService)}) } + if username := usernameFromJWT(token); username != "" { + ctx := auth.SetUsernameContext(ss.Context(), username) + return handler(srv, &serviceCallerStream{ServerStream: ss, ctx: ctx}) + } } } } @@ -53,6 +63,24 @@ func bearerTokenGRPCStreamInterceptor(expectedToken string) grpc.StreamServerInt } } +func usernameFromJWT(tokenString string) string { + p := jwt.NewParser() + token, _, err := p.ParseUnverified(tokenString, jwt.MapClaims{}) + if err != nil { + return "" + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return "" + } + for _, key := range []string{"preferred_username", "username", "sub"} { + if v, _ := claims[key].(string); v != "" && !strings.Contains(v, ":") { + return v + } + } + return "" +} + type serviceCallerStream struct { grpc.ServerStream ctx context.Context