Skip to content

RemoteRuntime.ResumeElicitation double-submits for OAuth and panics for non‑OAuth elicitations #1087

@jeanlaurent

Description

@jeanlaurent

Summary

  • Context: The RemoteRuntime.ResumeElicitation method 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 handleOAuthElicitation and then calls client.ResumeElicitation again, 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 ResumeElicitation calls 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)

  1. User accepts an OAuth authorization request
  2. ResumeElicitation(ctx, "accept", {...}) is called
  3. Line 211: handleOAuthElicitation is invoked with the pending OAuth request
  4. handleOAuthElicitation completes the OAuth flow and sends the token:
    • Line 381: r.client.ResumeElicitation(ctx, r.sessionID, "accept", tokenData)First call
  5. handleOAuthElicitation returns nil (success)
  6. Line 217: r.client.ResumeElicitation(ctx, r.sessionID, "accept", {...})Second call
  7. Result: Server receives TWO elicitation responses for the same request

Scenario 2: Non-OAuth Elicitation (pendingOAuthElicitation is nil)

  1. User submits a form elicitation (not OAuth)
  2. ResumeElicitation(ctx, "accept", {form_data}) is called
  3. Line 211: handleOAuthElicitation(ctx, nil) is invoked
  4. Line 226: Attempts to access req.Meta["cagent/server_url"]PANIC: nil pointer dereference
  5. Application crashes before sending any response to the server

Logical Proof

The bug is evident from the control flow:

  1. ResumeElicitation unconditionally calls handleOAuthElicitation at line 211
  2. handleOAuthElicitation always calls r.client.ResumeElicitation internally:
    • 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"
  3. After handleOAuthElicitation returns (whether success or error), line 217 calls r.client.ResumeElicitation again
  4. Therefore, r.client.ResumeElicitation is always called at least twice when handleOAuthElicitation succeeds

For the nil pointer case:

  1. When pendingOAuthElicitation is nil, handleOAuthElicitation receives nil as req
  2. Line 226 attempts to access req.Meta["cagent/server_url"]
  3. This causes a nil pointer dereference panic
  4. 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:

  1. The remote server sends an ElicitationRequestEvent through the event stream
  2. RunStream receives this event and stores it in pendingOAuthElicitation (line 144)
  3. The UI displays the elicitation request to the user
  4. User responds (accept/decline/cancel with optional form data)
  5. UI calls ResumeElicitation with the user's response
  6. ResumeElicitation should send the response back to the remote server via client.ResumeElicitation

The bug occurs at step 6, where the implementation incorrectly:

  • Always treats every elicitation as OAuth (even simple form submissions)
  • Calls client.ResumeElicitation twice 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.go for CLI mode

Why has this bug gone undetected?

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. OAuth flows might appear to work intermittently: Depending on how the remote server handles duplicate ResumeElicitation calls, 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.

  6. 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
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    kind/bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions