From 758dcfe0b62e5dca0e8710fa400c4c644d4db55c Mon Sep 17 00:00:00 2001 From: Coffee Date: Tue, 16 Jun 2026 07:19:29 +0000 Subject: [PATCH 1/2] fix(webui): avoid token prompt for oauth auth gates --- .../src/auth_prompt.rs | 13 +++- .../src/projection/tests.rs | 6 +- .../src/projection/tests/turn_stream_auth.rs | 78 +++++++++++++++++++ .../static/js/pages/chat/lib/gates.js | 9 ++- .../static/js/pages/chat/lib/gates.test.mjs | 38 +++++++++ 5 files changed, 137 insertions(+), 7 deletions(-) diff --git a/crates/ironclaw_reborn_composition/src/auth_prompt.rs b/crates/ironclaw_reborn_composition/src/auth_prompt.rs index 7fa15b99d22..812a9b3344d 100644 --- a/crates/ironclaw_reborn_composition/src/auth_prompt.rs +++ b/crates/ironclaw_reborn_composition/src/auth_prompt.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; use ironclaw_auth::{ AuthProductError, AuthProviderId, CredentialAccountLabel, OAuthAuthorizationUrl, }; -use ironclaw_host_api::UserId; +use ironclaw_host_api::{RuntimeCredentialAccountSetup, UserId}; use ironclaw_product_adapters::{ AuthPromptChallengeKind, AuthPromptView, ProductAdapterError, RedactedString, }; @@ -120,8 +120,15 @@ fn auth_prompt_from_credential_requirement( return view; }; let provider = requirement.provider.as_str().to_string(); - view.challenge_kind = Some(AuthPromptChallengeKind::ManualToken); view.provider = Some(provider.clone()); - view.account_label = Some(provider); + match &requirement.setup { + RuntimeCredentialAccountSetup::ManualToken => { + view.challenge_kind = Some(AuthPromptChallengeKind::ManualToken); + view.account_label = Some(provider); + } + RuntimeCredentialAccountSetup::OAuth { .. } => { + view.challenge_kind = Some(AuthPromptChallengeKind::Other); + } + } view } diff --git a/crates/ironclaw_reborn_composition/src/projection/tests.rs b/crates/ironclaw_reborn_composition/src/projection/tests.rs index 2d47ef06a4c..6bd9bc8d184 100644 --- a/crates/ironclaw_reborn_composition/src/projection/tests.rs +++ b/crates/ironclaw_reborn_composition/src/projection/tests.rs @@ -13,9 +13,9 @@ use ironclaw_events::{InMemoryDurableEventLog, RuntimeEvent}; use ironclaw_host_api::{ Action, AgentId, ApprovalRequest, ApprovalRequestId, CapabilityId, CorrelationId, ExtensionId, InvocationId, NetworkMethod, NetworkScheme, NetworkTarget, Principal, ResourceEstimate, - ResourceScope, RuntimeCredentialAccountProviderId, RuntimeCredentialAuthRequirement, - RuntimeHttpEgress, RuntimeHttpEgressRequest, RuntimeHttpEgressResponse, RuntimeKind, TenantId, - ThreadId, UserId, + ResourceScope, RuntimeCredentialAccountProviderId, RuntimeCredentialAccountSetup, + RuntimeCredentialAuthRequirement, RuntimeHttpEgress, RuntimeHttpEgressRequest, + RuntimeHttpEgressResponse, RuntimeKind, TenantId, ThreadId, UserId, }; use ironclaw_product_adapters::{ AuthPromptChallengeKind, CapabilityActivityStatusView, ProductOutboundEnvelope, diff --git a/crates/ironclaw_reborn_composition/src/projection/tests/turn_stream_auth.rs b/crates/ironclaw_reborn_composition/src/projection/tests/turn_stream_auth.rs index 6b99cfffc80..22630181160 100644 --- a/crates/ironclaw_reborn_composition/src/projection/tests/turn_stream_auth.rs +++ b/crates/ironclaw_reborn_composition/src/projection/tests/turn_stream_auth.rs @@ -140,6 +140,84 @@ async fn webui_event_stream_uses_credential_requirement_for_manual_token_auth_pr ))); } +#[tokio::test] +async fn webui_event_stream_does_not_downgrade_oauth_requirement_to_manual_token_prompt() { + let tenant_id = TenantId::new("webui-events-tenant").unwrap(); + let user_id = UserId::new("webui-events-user").unwrap(); + let agent_id = AgentId::new("webui-events-agent").unwrap(); + let thread_id = ThreadId::new("webui-events-oauth-fallback-thread").unwrap(); + let turn_run = TurnRunId::new(); + let gate_ref = "gate:auth-required"; + let scope = TurnScope::new( + tenant_id.clone(), + Some(agent_id.clone()), + None, + thread_id.clone(), + ); + let credential_requirements = vec![RuntimeCredentialAuthRequirement { + provider: RuntimeCredentialAccountProviderId::new("google").unwrap(), + setup: RuntimeCredentialAccountSetup::OAuth { + scopes: vec!["https://www.googleapis.com/auth/calendar.readonly".to_string()], + }, + requester_extension: ExtensionId::new("google-calendar").unwrap(), + provider_scopes: vec!["https://www.googleapis.com/auth/calendar.readonly".to_string()], + }]; + let event_log_dyn: Arc = Arc::new(InMemoryDurableEventLog::new()); + let services = build_reborn_projection_services( + event_log_dyn, + ReplyTargetBindingRef::new("webui-events-reply").unwrap(), + ) + .with_turn_events( + Arc::new(FakeTurnEventSource { + events: vec![TurnLifecycleEvent { + cursor: TurnEventCursor(1), + scope: scope.clone(), + occurred_at: Some(chrono::Utc::now()), + owner_user_id: Some(user_id.clone()), + run_id: turn_run, + status: TurnStatus::BlockedAuth, + kind: TurnEventKind::Blocked, + blocked_gate: Some(TurnBlockedGateMetadata { + gate_ref: GateRef::new(gate_ref).unwrap(), + gate_kind: TurnBlockedGateKind::Auth, + credential_requirements: credential_requirements.clone(), + }), + sanitized_reason: Some("Google authentication required".to_string()), + }], + }), + Arc::new(FakeTurnCoordinator { + state: TurnRunState { + credential_requirements, + ..turn_run_state(&scope, &user_id, turn_run, TurnEventCursor(1)) + }, + }), + ); + + let events = services + .webui_event_stream() + .drain(ProjectionSubscriptionRequest { + actor: TurnActor::new(user_id), + scope, + after_cursor: None, + }) + .await + .unwrap(); + + assert!( + events.iter().any(|event| matches!( + event.payload(), + ProductOutboundPayload::AuthPrompt(prompt) + if prompt.turn_run_id == turn_run + && prompt.auth_request_ref == gate_ref + && prompt.challenge_kind == Some(AuthPromptChallengeKind::Other) + && prompt.provider.as_deref() == Some("google") + && prompt.account_label.is_none() + && prompt.authorization_url.is_none() + )), + "events: {events:#?}" + ); +} + #[tokio::test] async fn webui_event_stream_surfaces_auth_challenge_lookup_failure() { let tenant_id = TenantId::new("webui-events-tenant").unwrap(); diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/chat/lib/gates.js b/crates/ironclaw_webui_v2_static/static/js/pages/chat/lib/gates.js index e0b6d839a79..914fb821635 100644 --- a/crates/ironclaw_webui_v2_static/static/js/pages/chat/lib/gates.js +++ b/crates/ironclaw_webui_v2_static/static/js/pages/chat/lib/gates.js @@ -34,12 +34,19 @@ export function gateFromEvent(eventType, prompt) { }; } if (eventType === "auth_required") { + const hasModernChallengeFields = + prompt.challenge_kind || + prompt.provider || + prompt.account_label || + prompt.authorization_url || + prompt.expires_at; return { kind: "auth_required", // Legacy auth_required prompts predate challenge_kind and are manual // token prompts. Explicit unknown/other challenge kinds still route to // the neutral auth card in chat.js. - challengeKind: prompt.challenge_kind || "manual_token", + challengeKind: + prompt.challenge_kind || (hasModernChallengeFields ? "other" : "manual_token"), runId: prompt.turn_run_id, // AuthPromptView carries `auth_request_ref`, but v2's resolve // path is `/runs/{run_id}/gates/{gate_ref}/resolve` — auth diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/chat/lib/gates.test.mjs b/crates/ironclaw_webui_v2_static/static/js/pages/chat/lib/gates.test.mjs index c5fd4e63b12..5e0a67db79c 100644 --- a/crates/ironclaw_webui_v2_static/static/js/pages/chat/lib/gates.test.mjs +++ b/crates/ironclaw_webui_v2_static/static/js/pages/chat/lib/gates.test.mjs @@ -103,3 +103,41 @@ test("gateFromEvent maps approval context into readable approval card props", () ]); assert.match(gate.parameters, /Estimated network egress: 4096 bytes/); }); + +test("gateFromEvent keeps modern auth prompts without challenge kind off token card", () => { + const { gateFromEvent } = loadGates(); + + assert.deepEqual( + plain(gateFromEvent("auth_required", { + turn_run_id: "run-auth", + auth_request_ref: "gate:auth", + headline: "Authentication required", + body: "Google authentication required", + provider: "google", + })), + { + kind: "auth_required", + challengeKind: "other", + runId: "run-auth", + gateRef: "gate:auth", + provider: "google", + accountLabel: "", + authorizationUrl: null, + expiresAt: null, + headline: "Authentication required", + body: "Google authentication required", + }, + ); +}); + +test("gateFromEvent preserves legacy auth prompts as manual token prompts", () => { + const { gateFromEvent } = loadGates(); + + assert.equal( + gateFromEvent("auth_required", { + turn_run_id: "run-auth", + auth_request_ref: "gate:auth", + }).challengeKind, + "manual_token", + ); +}); From fdcc05bc44a798aba1f2f2eb48c1e9adb344aed9 Mon Sep 17 00:00:00 2001 From: Coffee Date: Tue, 16 Jun 2026 08:22:37 +0000 Subject: [PATCH 2/2] chore(webui): address oauth auth gate review comments --- .../ironclaw_reborn_composition/src/auth_prompt.rs | 4 ++-- .../static/js/pages/chat/lib/gates.js | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/ironclaw_reborn_composition/src/auth_prompt.rs b/crates/ironclaw_reborn_composition/src/auth_prompt.rs index 812a9b3344d..2a5ba9b67de 100644 --- a/crates/ironclaw_reborn_composition/src/auth_prompt.rs +++ b/crates/ironclaw_reborn_composition/src/auth_prompt.rs @@ -120,15 +120,15 @@ fn auth_prompt_from_credential_requirement( return view; }; let provider = requirement.provider.as_str().to_string(); - view.provider = Some(provider.clone()); match &requirement.setup { RuntimeCredentialAccountSetup::ManualToken => { view.challenge_kind = Some(AuthPromptChallengeKind::ManualToken); - view.account_label = Some(provider); + view.account_label = Some(provider.clone()); } RuntimeCredentialAccountSetup::OAuth { .. } => { view.challenge_kind = Some(AuthPromptChallengeKind::Other); } } + view.provider = Some(provider); view } diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/chat/lib/gates.js b/crates/ironclaw_webui_v2_static/static/js/pages/chat/lib/gates.js index 914fb821635..f3d39a68307 100644 --- a/crates/ironclaw_webui_v2_static/static/js/pages/chat/lib/gates.js +++ b/crates/ironclaw_webui_v2_static/static/js/pages/chat/lib/gates.js @@ -34,19 +34,19 @@ export function gateFromEvent(eventType, prompt) { }; } if (eventType === "auth_required") { - const hasModernChallengeFields = - prompt.challenge_kind || - prompt.provider || - prompt.account_label || - prompt.authorization_url || - prompt.expires_at; return { kind: "auth_required", // Legacy auth_required prompts predate challenge_kind and are manual // token prompts. Explicit unknown/other challenge kinds still route to // the neutral auth card in chat.js. challengeKind: - prompt.challenge_kind || (hasModernChallengeFields ? "other" : "manual_token"), + prompt.challenge_kind || + (prompt.provider || + prompt.account_label || + prompt.authorization_url || + prompt.expires_at + ? "other" + : "manual_token"), runId: prompt.turn_run_id, // AuthPromptView carries `auth_request_ref`, but v2's resolve // path is `/runs/{run_id}/gates/{gate_ref}/resolve` — auth