Skip to content

Commit 961a409

Browse files
authored
feat(credentials): Wave 5 — CP resolves credentials and injects CREDENTIAL_IDS into runner pods (RHOAIENG-55817) (#1128)
## Summary - **CP**: `resolveCredentialIDs()` pages SDK credentials and builds a `provider→id` map; `ensureCredentialRoleBindings()` grants `credential:token-reader` RoleBinding per credential to the session SA; `buildEnv()` injects `CREDENTIAL_IDS` JSON env var into runner pods - **Runner**: `_fetch_credential()` migrated from legacy endpoint to `GET /api/ambient/v1/credentials/{id}/token` using `CREDENTIAL_IDS` map; Jira `apiToken`→`token`, Google `accessToken`→`token` (full SA JSON written to `GOOGLE_APPLICATION_CREDENTIALS`) - **Tests**: 8 existing credential tests updated for new `CREDENTIAL_IDS` contract; 1 new test added; 29/29 credential tests passing, 622 total pass ## Credential Flow ``` Session start → CP calls sdk.Credentials().ListAll() → builds {"github": "id1", "jira": "id2"} map → grants credential:token-reader RoleBinding per provider to session SA → injects CREDENTIAL_IDS={"github": "id1", ...} into pod env Runner start → reads CREDENTIAL_IDS → calls GET /api/ambient/v1/credentials/{id}/token per provider → sets GITHUB_TOKEN, GITLAB_TOKEN, JIRA_API_TOKEN, GOOGLE_APPLICATION_CREDENTIALS ``` ## Files Changed < /dev/null | Component | File | Change | |---|---|---| | CP | `kube_reconciler.go` | `resolveCredentialIDs`, `ensureCredentialRoleBindings`, `buildEnv` updated, `provisionSession` wired | | Runner | `platform/auth.py` | `_fetch_credential` URL + field mapping; Google/Jira updated; dead stub removed | | Tests | `test_shared_session_credentials.py` | Updated for `CREDENTIAL_IDS` contract; 1 new test | | Docs | `control-plane.spec.md` | Removed `other` from token response; updated status to implemented | | Docs | `control-plane.guide.md` | Restructured with dev context reference; gap table updated | | Docs | `.claude/context/control-plane-development.md` | Fixed operator→CP refs; added `CREDENTIAL_IDS` section | ## Test plan - [x] `cd components/runners/ambient-runner && python -m pytest tests/ -k credential` — 29/29 pass - [x] `cd components/ambient-control-plane && go build ./... && go vet ./...` — clean - [x] `gofmt -l internal/reconciler/kube_reconciler.go` — no output (clean) - [x] `uv run ruff check .` — no errors - [ ] Deploy to MPP cluster and verify `CREDENTIAL_IDS` injected into pod env - [ ] Verify `credential-token-reader-{provider}` RoleBindings created per session - [ ] Verify `GITLAB_TOKEN`/`GITHUB_TOKEN` set in running pod 🤖 Generated with [Claude Code](https://claude.ai/code)
2 parents b2b353c + 236a418 commit 961a409

25 files changed

Lines changed: 463 additions & 304 deletions

File tree

.claude/context/control-plane-development.md

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
- **Protocol:** gRPC (proto definitions in `components/ambient-api-server/proto/ambient/v1/`)
1010
- **Runner entry:** `components/runners/ambient-runner/main.py`
1111
- **gRPC bridge:** `components/runners/ambient-runner/ambient_runner/bridges/claude/grpc_transport.py`
12-
- **Runner env:** controlled by operator via pod env vars — see `operator-development.md`
12+
- **Runner env:** controlled by CP via pod env vars — see `kube_reconciler.go:buildEnv()`
1313

1414
---
1515

@@ -19,7 +19,7 @@ The CP was once reverted from upstream because it interfered with the runner's S
1919

2020
| Concern | Runner expects | CP must preserve |
2121
|---|---|---|
22-
| Session start | Job pod scheduled by operator | CP does not reschedule |
22+
| Session start | Pod provisioned by CP | CP does not reschedule |
2323
| Event emission | Runner pushes AG-UI events via gRPC | CP forwards in order, never drops |
2424
| `RUN_FINISHED` | Emitted once, last | CP forwards exactly once — never duplicated |
2525
| `MESSAGES_SNAPSHOT` | Emitted periodically | CP forwards in order |
@@ -74,11 +74,27 @@ Two classes:
7474

7575
**`GRPCMessageWriter`** — per-turn event consumer. Accumulates `MESSAGES_SNAPSHOT` content. On `RUN_FINISHED` or `RUN_ERROR`, calls `PushSessionMessage(event_type="assistant", payload=assistant_text)` — writes the durable DB record.
7676

77-
Only active when `AMBIENT_GRPC_ENABLED=true` (set by operator when `AMBIENT_GRPC_URL` is non-empty).
77+
Only active when `AMBIENT_GRPC_ENABLED=true` (set by CP when `AMBIENT_GRPC_URL` is non-empty).
7878

7979
### Inbox drain at session start
8080

81-
The runner drains the agent's inbox before starting the Claude Code session. All unread messages are assembled into `INITIAL_PROMPT` via `assembleInitialPrompt()` in the operator (see `kube_reconciler.go`). The runner receives this as the `INITIAL_PROMPT` env var.
81+
The runner drains the agent's inbox before starting the Claude Code session. All unread messages are assembled into `INITIAL_PROMPT` via `assembleInitialPrompt()` in the CP (`reconciler/kube_reconciler.go`). The runner receives this as the `INITIAL_PROMPT` env var.
82+
83+
### Credential fetch (Wave 5)
84+
85+
The CP resolves credentials for the session before pod creation. It calls `sdk.Credentials().ListAll()` — the API server applies RBAC-scoped filtering server-side, returning only credentials visible to the session's service account. The CP takes the first credential per provider (first-match wins; ordering is server-determined). It then:
86+
87+
1. Builds `CREDENTIAL_IDS` — a JSON map of `provider → credential_id` — and injects it into the runner pod env
88+
2. Grants `credential:token-reader` on each credential ID to the runner pod's service account
89+
90+
The runner reads `CREDENTIAL_IDS` at startup and calls `GET /api/ambient/v1/credentials/{id}/token` per provider. Response always uses `token` field (uniform across all providers). See `platform/auth.py:_fetch_credential()`.
91+
92+
| Provider | Env var(s) set | File written |
93+
|----------|---------------|--------------|
94+
| `github` | `GITHUB_TOKEN` | `/tmp/.ambient_github_token` |
95+
| `gitlab` | `GITLAB_TOKEN` | `/tmp/.ambient_gitlab_token` |
96+
| `jira` | `JIRA_URL`, `JIRA_API_TOKEN`, `JIRA_EMAIL` ||
97+
| `google` | `USER_GOOGLE_EMAIL` | `credentials.json` (token value is full SA JSON) |
8298

8399
### AG-UI event order (invariant)
84100

@@ -102,7 +118,7 @@ The api-server does not have a built-in proxy to runner pods. Runner pods are ad
102118
http://session-{KubeCrName}.{KubeNamespace}.svc.cluster.local:8001
103119
```
104120

105-
The `Session` model stores `KubeCrName` and `KubeNamespace` — both available from the DB. The runner listens on port `8001` (set via `AGUI_PORT` env var by the operator; runner default is `8000` but the operator overrides it).
121+
The `Session` model stores `KubeCrName` and `KubeNamespace` — both available from the DB. The runner listens on port `8001` (set via `AGUI_PORT` env var by the CP; runner default is `8000` but the CP overrides it).
106122

107123
This pattern is used by `components/backend/websocket/agui_proxy.go` (V1 backend). Any new proxy endpoint in the api-server must implement this same addressing.
108124

components/ambient-control-plane/internal/reconciler/kube_reconciler.go

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package reconciler
22

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
67
"strings"
78
"time"
@@ -39,7 +40,6 @@ type KubeReconcilerConfig struct {
3940
CPRuntimeNamespace string
4041
}
4142

42-
4343
type SimpleKubeReconciler struct {
4444
factory *SDKClientFactory
4545
kube *kubeclient.KubeClient
@@ -150,7 +150,19 @@ func (r *SimpleKubeReconciler) provisionSession(ctx context.Context, session typ
150150
return fmt.Errorf("ensuring service account: %w", err)
151151
}
152152

153-
if err := r.ensurePod(ctx, namespace, session, sessionLabel, sdk); err != nil {
153+
credentialIDs, err := r.resolveCredentialIDs(ctx, sdk)
154+
if err != nil {
155+
r.logger.Warn().Err(err).Str("session_id", session.ID).Msg("credential resolution failed; continuing without credentials")
156+
credentialIDs = map[string]string{}
157+
}
158+
159+
if len(credentialIDs) > 0 {
160+
if err := r.ensureCredentialRoleBindings(ctx, namespace, serviceAccountName(session.ID), credentialIDs); err != nil {
161+
return fmt.Errorf("ensuring credential role bindings: %w", err)
162+
}
163+
}
164+
165+
if err := r.ensurePod(ctx, namespace, session, sessionLabel, sdk, credentialIDs); err != nil {
154166
return fmt.Errorf("ensuring pod: %w", err)
155167
}
156168

@@ -204,7 +216,6 @@ func (r *SimpleKubeReconciler) cleanupSession(ctx context.Context, session types
204216
return nil
205217
}
206218

207-
208219
func (r *SimpleKubeReconciler) ensureService(ctx context.Context, namespace string, session types.Session, labelSelector string) error {
209220
name := serviceName(session.ID)
210221

@@ -361,7 +372,7 @@ func (r *SimpleKubeReconciler) ensureServiceAccount(ctx context.Context, namespa
361372
return nil
362373
}
363374

364-
func (r *SimpleKubeReconciler) ensurePod(ctx context.Context, namespace string, session types.Session, labelSelector string, sdk *sdkclient.Client) error {
375+
func (r *SimpleKubeReconciler) ensurePod(ctx context.Context, namespace string, session types.Session, labelSelector string, sdk *sdkclient.Client, credentialIDs map[string]string) error {
365376
name := podName(session.ID)
366377

367378
if _, err := r.nsKube().GetPod(ctx, namespace, name); err == nil {
@@ -394,7 +405,7 @@ func (r *SimpleKubeReconciler) ensurePod(ctx context.Context, namespace string,
394405
},
395406
},
396407
"volumeMounts": r.buildVolumeMounts(),
397-
"env": r.buildEnv(ctx, session, sdk, secretName, useMCPSidecar),
408+
"env": r.buildEnv(ctx, session, sdk, secretName, useMCPSidecar, credentialIDs),
398409
"resources": map[string]interface{}{
399410
"requests": map[string]interface{}{
400411
"cpu": "500m",
@@ -536,7 +547,7 @@ func (r *SimpleKubeReconciler) ensureVertexSecret(ctx context.Context, namespace
536547
return nil
537548
}
538549

539-
func (r *SimpleKubeReconciler) buildEnv(ctx context.Context, session types.Session, sdk *sdkclient.Client, credSecretName string, useMCPSidecar bool) []interface{} {
550+
func (r *SimpleKubeReconciler) buildEnv(ctx context.Context, session types.Session, sdk *sdkclient.Client, credSecretName string, useMCPSidecar bool, credentialIDs map[string]string) []interface{} {
540551
useVertex := "0"
541552
if r.cfg.VertexEnabled {
542553
useVertex = "1"
@@ -603,9 +614,75 @@ func (r *SimpleKubeReconciler) buildEnv(ctx context.Context, session types.Sessi
603614
env = append(env, envVar("REPOS_JSON", fmt.Sprintf(`[{"url":%q}]`, session.RepoURL)))
604615
}
605616

617+
if len(credentialIDs) > 0 {
618+
raw, err := json.Marshal(credentialIDs)
619+
if err == nil {
620+
env = append(env, envVar("CREDENTIAL_IDS", string(raw)))
621+
}
622+
}
623+
606624
return env
607625
}
608626

627+
func (r *SimpleKubeReconciler) resolveCredentialIDs(ctx context.Context, sdk *sdkclient.Client) (map[string]string, error) {
628+
result := map[string]string{}
629+
630+
it := sdk.Credentials().ListAll(ctx, &types.ListOptions{Size: 100})
631+
for it.Next() {
632+
cred := it.Item()
633+
if cred.Provider == "" || cred.ID == "" {
634+
continue
635+
}
636+
if _, already := result[cred.Provider]; !already {
637+
result[cred.Provider] = cred.ID
638+
}
639+
}
640+
if err := it.Err(); err != nil {
641+
return nil, fmt.Errorf("listing credentials: %w", err)
642+
}
643+
644+
r.logger.Info().Int("count", len(result)).Msg("resolved credential IDs for session")
645+
return result, nil
646+
}
647+
648+
func (r *SimpleKubeReconciler) ensureCredentialRoleBindings(ctx context.Context, namespace, saName string, credentialIDs map[string]string) error {
649+
for provider, credID := range credentialIDs {
650+
name := fmt.Sprintf("%s-credential-%s", saName, provider)
651+
rb := &unstructured.Unstructured{
652+
Object: map[string]interface{}{
653+
"apiVersion": "rbac.authorization.k8s.io/v1",
654+
"kind": "RoleBinding",
655+
"metadata": map[string]interface{}{
656+
"name": name,
657+
"namespace": namespace,
658+
},
659+
"roleRef": map[string]interface{}{
660+
"apiGroup": "rbac.authorization.k8s.io",
661+
"kind": "ClusterRole",
662+
"name": "credential:token-reader",
663+
},
664+
"subjects": []interface{}{
665+
map[string]interface{}{
666+
"kind": "ServiceAccount",
667+
"name": saName,
668+
"namespace": namespace,
669+
},
670+
},
671+
},
672+
}
673+
if _, err := r.nsKube().CreateRoleBinding(ctx, namespace, rb); err != nil && !k8serrors.IsAlreadyExists(err) {
674+
return fmt.Errorf("creating credential role binding %s for provider %s (id=%s): %w", name, provider, credID, err)
675+
}
676+
r.logger.Debug().
677+
Str("role_binding", name).
678+
Str("namespace", namespace).
679+
Str("provider", provider).
680+
Str("credential_id", credID).
681+
Msg("credential token-reader role binding ensured")
682+
}
683+
return nil
684+
}
685+
609686
func (r *SimpleKubeReconciler) assembleInitialPrompt(ctx context.Context, session types.Session, sdk *sdkclient.Client) string {
610687
var parts []string
611688

components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,7 @@ def _emit_task_event(self, message: Any) -> "CustomEvent":
607607
TaskProgressMessage,
608608
TaskNotificationMessage,
609609
)
610+
610611
if isinstance(message, TaskStartedMessage):
611612
return self._emit_task_started(message)
612613
elif isinstance(message, TaskProgressMessage):
@@ -633,6 +634,7 @@ def drain_hook_events(self) -> list:
633634
sid = val.get("session_id", "")
634635
if sid:
635636
from pathlib import Path
637+
636638
base = Path.home() / ".claude" / "projects"
637639
if base.exists():
638640
expected = f"agent-{agent_id}.jsonl"
@@ -670,7 +672,9 @@ def _emit_task_progress(self, message: Any) -> "CustomEvent":
670672
existing = self._task_registry.get(message.task_id, {})
671673
existing.update(progress_value)
672674
self._task_registry[message.task_id] = existing
673-
return CustomEvent(type=EventType.CUSTOM, name="task:progress", value=progress_value)
675+
return CustomEvent(
676+
type=EventType.CUSTOM, name="task:progress", value=progress_value
677+
)
674678

675679
def _emit_task_notification(self, message: Any) -> "CustomEvent":
676680
usage = getattr(message, "usage", None)
@@ -687,7 +691,9 @@ def _emit_task_notification(self, message: Any) -> "CustomEvent":
687691
self._task_registry[message.task_id] = existing
688692
if output_file:
689693
self._task_outputs[message.task_id] = output_file
690-
return CustomEvent(type=EventType.CUSTOM, name="task:completed", value=notification_value)
694+
return CustomEvent(
695+
type=EventType.CUSTOM, name="task:completed", value=notification_value
696+
)
691697

692698
async def _stream_claude_sdk(
693699
self,
@@ -1163,7 +1169,10 @@ def flush_pending_msg():
11631169
):
11641170
yield event
11651171

1166-
elif isinstance(message, (TaskStartedMessage, TaskProgressMessage, TaskNotificationMessage)):
1172+
elif isinstance(
1173+
message,
1174+
(TaskStartedMessage, TaskProgressMessage, TaskNotificationMessage),
1175+
):
11671176
yield self._emit_task_event(message)
11681177

11691178
elif isinstance(message, SystemMessage):
@@ -1364,4 +1373,3 @@ def flush_pending_msg():
13641373
# Re-raise to let run() emit RunErrorEvent
13651374
if stream_error is not None:
13661375
raise stream_error
1367-

components/runners/ambient-runner/ag_ui_claude_sdk/hooks.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,14 @@
2222
logger = logging.getLogger(__name__)
2323

2424
# Default hook event names to register (only what the UI consumes).
25-
_DEFAULT_HOOKS = frozenset({
26-
"SubagentStart",
27-
"SubagentStop",
28-
"Notification",
29-
"Stop",
30-
})
25+
_DEFAULT_HOOKS = frozenset(
26+
{
27+
"SubagentStart",
28+
"SubagentStop",
29+
"Notification",
30+
"Stop",
31+
}
32+
)
3133

3234
# Keys stripped from payloads (internal paths the frontend should not see).
3335
_SANITIZE_KEYS = frozenset({"transcript_path", "cwd"})
@@ -51,7 +53,9 @@ async def _forward_hook_as_custom_event(
5153
event_name = hook_input.get("hook_event_name", "unknown")
5254
payload = {k: v for k, v in hook_input.items() if k not in _SANITIZE_KEYS}
5355

54-
logger.debug("[Hook] %s fired (agent_id=%s)", event_name, hook_input.get("agent_id", "n/a"))
56+
logger.debug(
57+
"[Hook] %s fired (agent_id=%s)", event_name, hook_input.get("agent_id", "n/a")
58+
)
5559

5660
await queue.put(
5761
CustomEvent(

components/runners/ambient-runner/ambient_runner/_inbox_messages_api.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,16 @@ class _InboxMessageProto:
189189
8: updated_at (Timestamp, wire 2)
190190
"""
191191

192-
__slots__ = ("id", "agent_id", "from_agent_id", "from_name", "body", "read", "created_at", "updated_at")
192+
__slots__ = (
193+
"id",
194+
"agent_id",
195+
"from_agent_id",
196+
"from_name",
197+
"body",
198+
"read",
199+
"created_at",
200+
"updated_at",
201+
)
193202

194203
def __init__(self) -> None:
195204
self.id: str = ""

components/runners/ambient-runner/ambient_runner/_session_messages_api.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,6 @@ def watch(
174174
# ---------------------------------------------------------------------------
175175

176176

177-
178177
def _encode_string(field_number: int, value: str) -> bytes:
179178
encoded = value.encode("utf-8")
180179
tag = (field_number << 3) | 2

components/runners/ambient-runner/ambient_runner/app.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,7 @@ async def lifespan(app: FastAPI):
151151
active_streams = getattr(bridge, "_active_streams", None)
152152
if active_streams is not None and session_id not in active_streams:
153153
active_streams[session_id] = asyncio.Queue(maxsize=100)
154-
logger.info(
155-
"Pre-registered SSE queue for session=%s", session_id
156-
)
154+
logger.info("Pre-registered SSE queue for session=%s", session_id)
157155

158156
# Auto-execute prompts when present (skipped only for resumes,
159157
# where the conversation is continued rather than re-started).

0 commit comments

Comments
 (0)