-
Notifications
You must be signed in to change notification settings - Fork 201
Description
Summary
- Context: The
RemoteRuntime.ResumeElicitationmethod handles user responses to elicitation requests (OAuth authorization, form submissions, etc.) in the remote runtime, sending them back to the remote server. - Bug: The method unconditionally calls
handleOAuthElicitationand then callsclient.ResumeElicitationagain, resulting in double submission to the server for OAuth flows and a nil pointer panic for non-OAuth elicitations. - Actual vs. expected: For OAuth elicitations, the server receives two
ResumeElicitationcalls instead of one. For non-OAuth elicitations, the application crashes with a nil pointer dereference instead of successfully submitting the response. - Impact: All elicitation functionality in remote runtime is broken - OAuth flows fail due to duplicate submissions, and non-OAuth elicitations crash the application.
Code with bug
func (r *RemoteRuntime) ResumeElicitation(ctx context.Context, action tools.ElicitationAction, content map[string]any) error {
slog.Debug("Resuming remote runtime with elicitation response", "agent", r.currentAgent, "action", action, "session_id", r.sessionID)
err := r.handleOAuthElicitation(ctx, r.pendingOAuthElicitation) // <-- BUG 🔴 Always called, even for non-OAuth
if err != nil {
return err
}
// TODO: once we get here and the elicitation is the OAuth type, we need to start the managed OAuth flow
if err := r.client.ResumeElicitation(ctx, r.sessionID, action, content); err != nil { // <-- BUG 🔴 Second call
return err
}
return nil
}The handleOAuthElicitation method internally calls r.client.ResumeElicitation:
func (r *RemoteRuntime) handleOAuthElicitation(ctx context.Context, req *ElicitationRequestEvent) error {
slog.Debug("Handling OAuth elicitation request", "server_url", req.Meta["cagent/server_url"]) // <-- BUG 🔴 Panics if req is nil
// Extract OAuth parameters from metadata
serverURL, ok := req.Meta["cagent/server_url"].(string)
if !ok {
err := fmt.Errorf("server_url missing from elicitation metadata")
slog.Error("Failed to extract server_url", "error", err)
_ = r.client.ResumeElicitation(ctx, r.sessionID, "decline", nil) // <-- Called here
return err
}
// ... OAuth flow ...
// Send token back to server via ResumeElicitation
if err := r.client.ResumeElicitation(ctx, r.sessionID, tools.ElicitationActionAccept, tokenData); err != nil { // <-- Also called here on success
slog.Error("Failed to send token to server", "error", err)
return fmt.Errorf("failed to send token to server: %w", err)
}
slog.Debug("OAuth flow completed successfully")
return nil
}Evidence
Example
Scenario 1: OAuth Elicitation (pendingOAuthElicitation is set)
- User accepts an OAuth authorization request
ResumeElicitation(ctx, "accept", {...})is called- Line 211:
handleOAuthElicitationis invoked with the pending OAuth request handleOAuthElicitationcompletes the OAuth flow and sends the token:- Line 381:
r.client.ResumeElicitation(ctx, r.sessionID, "accept", tokenData)← First call
- Line 381:
handleOAuthElicitationreturns nil (success)- Line 217:
r.client.ResumeElicitation(ctx, r.sessionID, "accept", {...})← Second call - Result: Server receives TWO elicitation responses for the same request
Scenario 2: Non-OAuth Elicitation (pendingOAuthElicitation is nil)
- User submits a form elicitation (not OAuth)
ResumeElicitation(ctx, "accept", {form_data})is called- Line 211:
handleOAuthElicitation(ctx, nil)is invoked - Line 226: Attempts to access
req.Meta["cagent/server_url"]← PANIC: nil pointer dereference - Application crashes before sending any response to the server
Logical Proof
The bug is evident from the control flow:
ResumeElicitationunconditionally callshandleOAuthElicitationat line 211handleOAuthElicitationalways callsr.client.ResumeElicitationinternally:- On success at line 381 with
action="accept"and OAuth token data - On any error at lines 233, 242, 251, 256, 271, 284, 298, 305, 313, 337, 344, 362 with
action="decline"
- On success at line 381 with
- After
handleOAuthElicitationreturns (whether success or error), line 217 callsr.client.ResumeElicitationagain - Therefore,
r.client.ResumeElicitationis always called at least twice whenhandleOAuthElicitationsucceeds
For the nil pointer case:
- When
pendingOAuthElicitationis nil,handleOAuthElicitationreceives nil asreq - Line 226 attempts to access
req.Meta["cagent/server_url"] - This causes a nil pointer dereference panic
- No nil check exists before accessing
req.Meta
Inconsistency within the codebase
Reference code
pkg/runtime/runtime.go (LocalRuntime implementation):
func (r *LocalRuntime) ResumeElicitation(ctx context.Context, action tools.ElicitationAction, content map[string]any) error {
slog.Debug("Resuming runtime with elicitation response", "agent", r.currentAgent, "action", action)
result := ElicitationResult{
Action: action,
Content: content,
}
select {
case <-ctx.Done():
slog.Debug("Context cancelled while sending elicitation response")
return ctx.Err()
case r.elicitationRequestCh <- result:
slog.Debug("Elicitation response sent successfully", "action", action)
return nil
default:
slog.Debug("Elicitation channel not ready")
return fmt.Errorf("no elicitation request in progress")
}
}Current code
pkg/runtime/remote_runtime.go:
func (r *RemoteRuntime) ResumeElicitation(ctx context.Context, action tools.ElicitationAction, content map[string]any) error {
slog.Debug("Resuming remote runtime with elicitation response", "agent", r.currentAgent, "action", action, "session_id", r.sessionID)
err := r.handleOAuthElicitation(ctx, r.pendingOAuthElicitation)
if err != nil {
return err
}
if err := r.client.ResumeElicitation(ctx, r.sessionID, action, content); err != nil {
return err
}
return nil
}Contradiction
The LocalRuntime implementation simply forwards the elicitation response to the waiting handler through a channel, making a single submission. The RemoteRuntime implementation attempts to handle OAuth specially but ends up making two calls to client.ResumeElicitation - once inside handleOAuthElicitation and once after it returns. This violates the expected behavior that an elicitation response should be submitted exactly once.
Full context
The RemoteRuntime is an implementation of the Runtime interface that communicates with a remote agent server via HTTP API. When a remote MCP server requests user authorization (OAuth) or form input (elicitation), the flow is:
- The remote server sends an
ElicitationRequestEventthrough the event stream RunStreamreceives this event and stores it inpendingOAuthElicitation(line 144)- The UI displays the elicitation request to the user
- User responds (accept/decline/cancel with optional form data)
- UI calls
ResumeElicitationwith the user's response ResumeElicitationshould send the response back to the remote server viaclient.ResumeElicitation
The bug occurs at step 6, where the implementation incorrectly:
- Always treats every elicitation as OAuth (even simple form submissions)
- Calls
client.ResumeElicitationtwice for OAuth flows - Crashes with nil pointer dereference for non-OAuth elicitations
This breaks the contract between client and server, causing:
- OAuth flows to fail due to duplicate token submissions
- Non-OAuth elicitations to crash the application
- Potential server-side corruption or invalid state due to duplicate requests
The code is called from the TUI dialog handlers when users interact with authorization prompts:
pkg/tui/dialog/oauth_authorization.go(lines 77, 80)pkg/cli/runner.gofor CLI mode
Why has this bug gone undetected?
-
Remote runtime is not the primary use case: Most users run the local runtime. The remote runtime is used when connecting to a remote agent server, which is likely a less common deployment scenario.
-
OAuth elicitation is a recent feature: The OAuth rework was added in commit 814d46a (October 2025), and the bug was introduced shortly after in commit be219c2 when adding "unmanaged mode for oauth flow". This is relatively new code.
-
The TODO comment indicates incomplete implementation: Line 215 has a TODO comment: "once we get here and the elicitation is the OAuth type, we need to start the managed OAuth flow". This suggests the developer knew the logic was incomplete.
-
Non-OAuth elicitations immediately crash: Any attempt to use non-OAuth elicitations would immediately crash with a nil pointer panic, making it obvious and causing users to avoid that code path entirely. Users encountering this would likely report it as "crashes" rather than investigate the double-call issue.
-
OAuth flows might appear to work intermittently: Depending on how the remote server handles duplicate
ResumeElicitationcalls, the OAuth flow might sometimes succeed (if the server ignores duplicate requests) or fail unpredictably (if it rejects them). This makes debugging harder as the issue isn't consistently reproducible. -
Limited testing of edge cases: The remote runtime likely has fewer integration tests than the local runtime, especially for elicitation flows which require complex setup with mock MCP servers.
Recommended fix
The fix requires checking whether the pending elicitation is an OAuth request before calling handleOAuthElicitation, and avoiding the duplicate call:
func (r *RemoteRuntime) ResumeElicitation(ctx context.Context, action tools.ElicitationAction, content map[string]any) error {
slog.Debug("Resuming remote runtime with elicitation response", "agent", r.currentAgent, "action", action, "session_id", r.sessionID)
// Check if this is an OAuth elicitation (indicated by cagent/server_url in metadata)
if r.pendingOAuthElicitation != nil && r.pendingOAuthElicitation.Meta["cagent/server_url"] != nil { // <-- FIX 🟢 Check for OAuth
// Handle OAuth flow
err := r.handleOAuthElicitation(ctx, r.pendingOAuthElicitation)
// handleOAuthElicitation already calls client.ResumeElicitation, so return here
return err // <-- FIX 🟢 Don't call client.ResumeElicitation again
}
// For non-OAuth elicitations, forward the response directly
if err := r.client.ResumeElicitation(ctx, r.sessionID, action, content); err != nil {
return err
}
return nil
}Additionally, handleOAuthElicitation should have a nil check for safety:
func (r *RemoteRuntime) handleOAuthElicitation(ctx context.Context, req *ElicitationRequestEvent) error {
if req == nil { // <-- Add safety check
return fmt.Errorf("elicitation request is nil")
}
slog.Debug("Handling OAuth elicitation request", "server_url", req.Meta["cagent/server_url"])
// ... rest of implementation
}