Skip to content

Commit 6fd6440

Browse files
Amber Agentclaude
andcommitted
fix(operator,runner): mount runner token as volume for dynamic refresh
Long-running sessions were failing due to ServiceAccount token expiration because tokens were injected as environment variables at pod startup and never refreshed, even though the operator was refreshing the Secret. Changes: - Operator: Mount runner token Secret as volume instead of env var - Operator: Inject BOT_TOKEN_PATH env var pointing to mounted token file - Runner: Read token from BOT_TOKEN_PATH file on each connection/reconnection - Runner: Fall back to BOT_TOKEN env var for backward compatibility - Runner: Improved error messages for token authentication issues The operator already refreshes tokens every 45 minutes via ensureFreshRunnerToken(). Now the runner can read the refreshed token from the mounted Secret volume without requiring pod restart. Fixes #445 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 5553056 commit 6fd6440

File tree

2 files changed

+46
-22
lines changed

2 files changed

+46
-22
lines changed

components/operator/internal/handlers/sessions.go

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -931,6 +931,17 @@ func handleAgenticSessionEvent(obj *unstructured.Unstructured) error {
931931
}
932932
log.Printf("Session %s initiated by user: %s (userId: %s)", name, userName, userID)
933933

934+
// Determine runner token secret name for volume mount
935+
runnerTokenSecretName := ""
936+
if annotations := currentObj.GetAnnotations(); annotations != nil {
937+
if v, ok := annotations["ambient-code.io/runner-token-secret"]; ok && strings.TrimSpace(v) != "" {
938+
runnerTokenSecretName = strings.TrimSpace(v)
939+
}
940+
}
941+
if runnerTokenSecretName == "" {
942+
runnerTokenSecretName = fmt.Sprintf("ambient-runner-token-%s", name)
943+
}
944+
934945
// Create the Job
935946
job := &batchv1.Job{
936947
ObjectMeta: v1.ObjectMeta{
@@ -979,6 +990,14 @@ func handleAgenticSessionEvent(obj *unstructured.Unstructured) error {
979990
},
980991
},
981992
},
993+
{
994+
Name: "runner-token",
995+
VolumeSource: corev1.VolumeSource{
996+
Secret: &corev1.SecretVolumeSource{
997+
SecretName: runnerTokenSecretName,
998+
},
999+
},
1000+
},
9821001
},
9831002

9841003
// InitContainer to ensure workspace directory structure exists
@@ -1037,6 +1056,8 @@ func handleAgenticSessionEvent(obj *unstructured.Unstructured) error {
10371056
// Mount .claude directory for session state persistence
10381057
// This enables SDK's built-in resume functionality
10391058
{Name: "workspace", MountPath: "/app/.claude", SubPath: fmt.Sprintf("sessions/%s/.claude", name), ReadOnly: false},
1059+
// Mount runner token secret as volume for dynamic token refresh
1060+
{Name: "runner-token", MountPath: "/app/runner-token", ReadOnly: true},
10401061
},
10411062

10421063
Env: func() []corev1.EnvVar {
@@ -1153,26 +1174,12 @@ func handleAgenticSessionEvent(obj *unstructured.Unstructured) error {
11531174
base = append(base, corev1.EnvVar{Name: "PARENT_SESSION_ID", Value: parentSessionID})
11541175
log.Printf("Session %s: passing PARENT_SESSION_ID=%s to runner", name, parentSessionID)
11551176
}
1156-
// If backend annotated the session with a runner token secret, inject only BOT_TOKEN
1157-
// Secret contains: 'k8s-token' (for CR updates)
1158-
// Prefer annotated secret name; fallback to deterministic name
1159-
secretName := ""
1160-
if meta, ok := currentObj.Object["metadata"].(map[string]interface{}); ok {
1161-
if anns, ok := meta["annotations"].(map[string]interface{}); ok {
1162-
if v, ok := anns["ambient-code.io/runner-token-secret"].(string); ok && strings.TrimSpace(v) != "" {
1163-
secretName = strings.TrimSpace(v)
1164-
}
1165-
}
1166-
}
1167-
if secretName == "" {
1168-
secretName = fmt.Sprintf("ambient-runner-token-%s", name)
1169-
}
1177+
// Inject BOT_TOKEN_PATH pointing to mounted secret volume
1178+
// Token is mounted from runnerTokenSecretName at /app/runner-token
1179+
// This allows the runner to read refreshed tokens without pod restart
11701180
base = append(base, corev1.EnvVar{
1171-
Name: "BOT_TOKEN",
1172-
ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{
1173-
LocalObjectReference: corev1.LocalObjectReference{Name: secretName},
1174-
Key: "k8s-token",
1175-
}},
1181+
Name: "BOT_TOKEN_PATH",
1182+
Value: "/app/runner-token/k8s-token",
11761183
})
11771184
// Add CR-provided envs last (override base when same key)
11781185
if spec, ok := currentObj.Object["spec"].(map[string]interface{}); ok {

components/runners/runner-shell/runner_shell/core/transport_ws.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,19 @@ async def connect(self):
3030
"""Connect to WebSocket endpoint."""
3131
try:
3232
# Forward Authorization header if BOT_TOKEN (runner SA token) is present
33+
# Read from file if BOT_TOKEN_PATH is set (for dynamic token refresh)
34+
# Otherwise fall back to BOT_TOKEN env var (backward compatibility)
3335
headers: Dict[str, str] = {}
34-
token = (os.getenv("BOT_TOKEN") or "").strip()
36+
token = ""
37+
token_path = (os.getenv("BOT_TOKEN_PATH") or "").strip()
38+
if token_path:
39+
try:
40+
with open(token_path, "r") as f:
41+
token = f.read().strip()
42+
except Exception as e:
43+
logger.warning(f"Failed to read token from {token_path}: {e}")
44+
if not token:
45+
token = (os.getenv("BOT_TOKEN") or "").strip()
3546
if token:
3647
headers["Authorization"] = f"Bearer {token}"
3748

@@ -69,10 +80,16 @@ async def connect(self):
6980
)
7081
# Surface a clearer hint when auth is likely missing
7182
if status == 401:
83+
token_path = (os.getenv("BOT_TOKEN_PATH") or "").strip()
7284
has_token = bool((os.getenv("BOT_TOKEN") or "").strip())
73-
if not has_token:
85+
has_token_path = bool(token_path)
86+
if not has_token and not has_token_path:
7487
logger.error(
75-
"No BOT_TOKEN present; backend project routes require Authorization."
88+
"No BOT_TOKEN or BOT_TOKEN_PATH present; backend project routes require Authorization."
89+
)
90+
elif has_token_path and not token:
91+
logger.error(
92+
f"BOT_TOKEN_PATH is set to {token_path} but token could not be read."
7693
)
7794
raise
7895
except Exception as e:

0 commit comments

Comments
 (0)