Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions crates/ironclaw_reborn_composition/src/auth_prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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.clone());
}
RuntimeCredentialAccountSetup::OAuth { .. } => {
view.challenge_kind = Some(AuthPromptChallengeKind::Other);
}
}
Comment thread
hanakannzashi marked this conversation as resolved.
view.provider = Some(provider);
view
}
6 changes: 3 additions & 3 deletions crates/ironclaw_reborn_composition/src/projection/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn DurableEventLog> = 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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,14 @@ export function gateFromEvent(eventType, prompt) {
// 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 ||
(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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});
Loading