Skip to content

Commit 8578733

Browse files
markturanskyAmbient Code Botclaude
authored
fix(runner,rbac,cli,routes): credential auth reliability and SSE improvements (#1231)
## Summary - **runner**: On 401 from credential fetch, refresh the CP token and retry once instead of aborting the turn — fixes intermittent turn failures when the cached BOT_TOKEN expires mid-session - **rbac**: Fix `pathToAction()` so `GET /credentials/{id}/token` resolves to action `token` (matching `credential:token-reader` role) rather than `read` — was causing systematic 401s for runner SAs - **cli**: Improve `acpctl session send -f` output with `[thinking]` reasoning blocks, `[ToolName] → result` tool call annotations, and proper streaming newline handling - **routes**: Add `haproxy.router.openshift.io/timeout: 10m` to all `ambient-api-server` OpenShift Routes to prevent 504 Gateway Timeouts on long-running SSE streams ## Test plan - [ ] Send a message via `acpctl session send -f` and confirm `[thinking]` and tool call output appears - [ ] Verify credential fetches succeed on long-running sessions (no intermittent 401 turn aborts) - [ ] Confirm `GET /credentials/{id}/token` returns 200 for runner SA (RBAC fix) - [ ] Verify SSE streams survive beyond 30s without 504 after route annotation is applied 🤖 Generated with [Claude Code](https://claude.ai/code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Automatic token refresh with a single retry on authentication failures * Richer CLI streaming output with reasoning blocks, tool-call annotations, and improved text formatting * Optional support for alternate text-block types when collecting observability output * **Bug Fixes** * Authorization now recognizes special path-based operations for correct permission checks * **Configuration** * Added 10-minute timeout to API server routes <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Ambient Code Bot <bot@ambient-code.local> Co-authored-by: Claude <noreply@anthropic.com>
1 parent bc58a6e commit 8578733

8 files changed

Lines changed: 134 additions & 16 deletions

File tree

components/ambient-api-server/pkg/rbac/middleware.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func (m *DBAuthorizationMiddleware) AuthorizeApi(next http.Handler) http.Handler
5757
}
5858

5959
func (m *DBAuthorizationMiddleware) isAllowed(g *gorm.DB, username, method, path string) (bool, error) {
60-
action := httpMethodToAction(method)
60+
action := pathToAction(method, path)
6161
resource := pathToResource(path)
6262

6363
var bindings []roleBindingRow
@@ -130,6 +130,22 @@ func httpMethodToAction(method string) string {
130130
}
131131
}
132132

133+
func pathToAction(method, path string) string {
134+
parts := strings.Split(strings.TrimPrefix(path, "/"), "/")
135+
for i, p := range parts {
136+
if p == "v1" && i+2 < len(parts) {
137+
last := parts[len(parts)-1]
138+
switch last {
139+
case "token":
140+
return "token"
141+
case "start", "stop", "ignite", "ignition":
142+
return last
143+
}
144+
}
145+
}
146+
return httpMethodToAction(method)
147+
}
148+
133149
func pathToResource(path string) string {
134150
parts := strings.Split(strings.TrimPrefix(path, "/"), "/")
135151
for i, p := range parts {

components/ambient-cli/cmd/acpctl/session/send.go

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ func runSend(cmd *cobra.Command, args []string) error {
7878

7979
out := cmd.OutOrStdout()
8080
scanner := bufio.NewScanner(stream)
81+
var reasoningBuf strings.Builder
82+
var inText bool
8183
for scanner.Scan() {
8284
line := scanner.Text()
8385
if !strings.HasPrefix(line, "data: ") {
@@ -91,18 +93,53 @@ func runSend(cmd *cobra.Command, args []string) error {
9193
}
9294

9395
var evt struct {
94-
Type string `json:"type"`
95-
Delta string `json:"delta"`
96+
Type string `json:"type"`
97+
Delta string `json:"delta"`
98+
ToolCallName string `json:"toolCallName"`
99+
Content string `json:"content"`
96100
}
97101
if err := json.Unmarshal([]byte(data), &evt); err != nil {
98102
continue
99103
}
100-
if evt.Type == "TEXT_MESSAGE_CONTENT" && evt.Delta != "" {
101-
fmt.Fprint(out, evt.Delta)
104+
switch evt.Type {
105+
case "REASONING_MESSAGE_CONTENT":
106+
reasoningBuf.WriteString(evt.Delta)
107+
case "REASONING_END":
108+
if reasoningBuf.Len() > 0 {
109+
fmt.Fprintf(out, "[thinking] %s\n", strings.TrimSpace(reasoningBuf.String()))
110+
reasoningBuf.Reset()
111+
}
112+
case "TEXT_MESSAGE_CONTENT":
113+
if evt.Delta != "" {
114+
inText = true
115+
fmt.Fprint(out, evt.Delta)
116+
}
117+
case "TEXT_MESSAGE_END":
118+
if inText {
119+
fmt.Fprintln(out)
120+
inText = false
121+
}
122+
case "TOOL_CALL_START":
123+
if evt.ToolCallName != "" {
124+
fmt.Fprintf(out, "[%s] ", evt.ToolCallName)
125+
}
126+
case "TOOL_CALL_RESULT":
127+
if evt.Content != "" {
128+
var content string
129+
if err := json.Unmarshal([]byte(evt.Content), &content); err != nil {
130+
content = evt.Content
131+
}
132+
lines := strings.SplitN(strings.TrimSpace(content), "\n", 4)
133+
preview := strings.Join(lines, " | ")
134+
if len(lines) >= 4 {
135+
preview += " ..."
136+
}
137+
fmt.Fprintf(out, "→ %s\n", preview)
138+
}
102139
}
103140
}
104141

105-
if !sendFollowJSON {
142+
if inText {
106143
fmt.Fprintln(out)
107144
}
108145

components/manifests/overlays/local-dev/ambient-api-server-route.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ metadata:
66
labels:
77
app: ambient-api-server
88
component: api
9+
annotations:
10+
haproxy.router.openshift.io/timeout: 10m
911
spec:
1012
to:
1113
kind: Service
@@ -23,6 +25,8 @@ metadata:
2325
labels:
2426
app: ambient-api-server
2527
component: grpc
28+
annotations:
29+
haproxy.router.openshift.io/timeout: 10m
2630
spec:
2731
to:
2832
kind: Service

components/manifests/overlays/mpp-openshift/ambient-api-server-route.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ metadata:
77
app: ambient-api-server
88
component: api
99
shard: internal
10+
annotations:
11+
haproxy.router.openshift.io/timeout: 10m
1012
spec:
1113
to:
1214
kind: Service
@@ -26,6 +28,8 @@ metadata:
2628
app: ambient-api-server
2729
component: grpc
2830
shard: internal
31+
annotations:
32+
haproxy.router.openshift.io/timeout: 10m
2933
spec:
3034
to:
3135
kind: Service

components/manifests/overlays/production/ambient-api-server-route.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ metadata:
66
labels:
77
app: ambient-api-server
88
component: api
9+
annotations:
10+
haproxy.router.openshift.io/timeout: 10m
911
spec:
1012
to:
1113
kind: Service
@@ -23,6 +25,8 @@ metadata:
2325
labels:
2426
app: ambient-api-server
2527
component: grpc
28+
annotations:
29+
haproxy.router.openshift.io/timeout: 10m
2630
spec:
2731
to:
2832
kind: Service

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -515,14 +515,19 @@ def end_turn(
515515
return
516516

517517
try:
518-
from claude_agent_sdk import TextBlock
518+
# Extract text content — TextBlock is optional (claude_agent_sdk may not be installed)
519+
try:
520+
from claude_agent_sdk import TextBlock as _TextBlock
521+
except ImportError:
522+
_TextBlock = None
519523

520-
# Extract text content
521524
text_content = []
522525
message_content = getattr(message, "content", []) or []
523526
for blk in message_content:
524-
if isinstance(blk, TextBlock):
527+
if _TextBlock is not None and isinstance(blk, _TextBlock):
525528
text_content.append(getattr(blk, "text", ""))
529+
elif hasattr(blk, "text") and isinstance(getattr(blk, "text", None), str):
530+
text_content.append(blk.text)
526531

527532
output_text = (
528533
"\n".join(text_content) if text_content else "(no text output)"

components/runners/ambient-runner/ambient_runner/platform/auth.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from urllib.parse import urlparse
1818

1919
from ambient_runner.platform.context import RunnerContext
20-
from ambient_runner.platform.utils import get_bot_token
20+
from ambient_runner.platform.utils import get_bot_token, refresh_bot_token
2121

2222
logger = logging.getLogger(__name__)
2323

@@ -183,18 +183,45 @@ def _do_req():
183183
f"and BOT_TOKEN fallback also failed"
184184
) from fallback_err
185185
if e.code in (401, 403):
186-
logger.warning(
187-
f"{credential_type} credential fetch failed with HTTP {e.code}: {e}"
188-
)
189-
raise PermissionError(
190-
f"{credential_type} authentication failed with HTTP {e.code}"
191-
) from e
186+
# BOT_TOKEN may have expired — refresh from CP endpoint and retry once.
187+
return _retry_with_fresh_bot_token(e.code)
192188
logger.warning(f"{credential_type} credential fetch failed: {e}")
193189
return ""
194190
except Exception as e:
195191
logger.warning(f"{credential_type} credential fetch failed: {e}")
196192
return ""
197193

194+
def _retry_with_fresh_bot_token(original_code: int):
195+
logger.info(
196+
f"{credential_type} got {original_code} with cached BOT_TOKEN — refreshing from CP endpoint and retrying"
197+
)
198+
try:
199+
fresh_bot = refresh_bot_token()
200+
except Exception as refresh_err:
201+
logger.warning(f"{credential_type} CP token refresh failed: {refresh_err}")
202+
raise PermissionError(
203+
f"{credential_type} authentication failed with HTTP {original_code}"
204+
) from refresh_err
205+
retry_req = _urllib_request.Request(url, method="GET")
206+
if fresh_bot:
207+
retry_req.add_header("Authorization", f"Bearer {fresh_bot}")
208+
if context.current_user_id:
209+
retry_req.add_header("X-Runner-Current-User", context.current_user_id)
210+
try:
211+
with _urllib_request.urlopen(retry_req, timeout=10) as resp:
212+
logger.info(f"{credential_type} retry with fresh BOT_TOKEN succeeded")
213+
return resp.read().decode("utf-8", errors="replace")
214+
except _urllib_request.HTTPError as retry_err:
215+
logger.warning(f"{credential_type} retry with fresh BOT_TOKEN failed: {retry_err}")
216+
raise PermissionError(
217+
f"{credential_type} authentication failed with HTTP {retry_err.code}"
218+
) from retry_err
219+
except Exception as retry_err:
220+
logger.warning(f"{credential_type} retry with fresh BOT_TOKEN failed: {retry_err}")
221+
raise PermissionError(
222+
f"{credential_type} authentication failed with HTTP {original_code}"
223+
) from retry_err
224+
198225
resp_text = await loop.run_in_executor(None, _do_req)
199226
if not resp_text:
200227
return {}

components/runners/ambient-runner/ambient_runner/platform/utils.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,27 @@ def get_bot_token() -> str:
7171
return (os.getenv("BOT_TOKEN") or "").strip()
7272

7373

74+
def refresh_bot_token() -> str:
75+
"""Fetch a fresh token from the CP token endpoint and update the in-process cache.
76+
77+
Returns the new token, or the current cached token if the CP endpoint is not
78+
configured (local dev mode). Raises RuntimeError if the CP fetch fails.
79+
"""
80+
cp_token_url = os.getenv("AMBIENT_CP_TOKEN_URL", "")
81+
if not cp_token_url:
82+
return get_bot_token()
83+
84+
public_key_pem = os.getenv("AMBIENT_CP_TOKEN_PUBLIC_KEY", "")
85+
session_id = os.getenv("SESSION_ID", "")
86+
if not public_key_pem or not session_id:
87+
logger.warning("refresh_bot_token: CP env vars incomplete, skipping refresh")
88+
return get_bot_token()
89+
90+
from ambient_runner._grpc_client import _fetch_token_from_cp
91+
92+
return _fetch_token_from_cp(cp_token_url, public_key_pem, session_id)
93+
94+
7495
def is_env_truthy(value: str) -> bool:
7596
"""Return True for "1", "true", or "yes" (case-insensitive)."""
7697
return value.strip().lower() in _TRUTHY_VALUES

0 commit comments

Comments
 (0)