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
55 changes: 32 additions & 23 deletions crates/ironclaw_agent_loop/src/executor/gates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,29 +73,38 @@ impl ExecutorStage<GateInput> for GateStage {
.and_then(capability_activity_id_from_resume_token);
let auth_replay = auth_resume.and_then(|r| r.replay.clone());
let auth_prior_approval = auth_resume.and_then(|r| r.prior_approval.clone());
let approval_resume = input.approval_resume;
state.pending_approval_resume = approval_resume.map(|resume| {
let activity_id =
capability_activity_id_from_resume_token(&resume.resume_token);
PendingApprovalResume {
gate_ref: gate_ref.clone(),
capability_id: call.capability_id.clone(),
approval_request_id: resume.approval_request_id,
resume_token: resume.resume_token,
activity_id,
correlation_id: resume.correlation_id,
surface_version: call.surface_version.clone(),
input_ref: resume.input_ref,
effective_capability_ids: call.effective_capability_ids.clone(),
provider_replay: call.provider_replay.clone(),
input: resume.input,
estimate: resume.estimate,
// Disposition is stamped by PlannedDriver at resume time;
// GateStage writes the initial (blocking) checkpoint where
// no denial has occurred yet.
disposition: None,
}
});
if matches!(kind, GateKind::Approval) {
let approval_resume = input.approval_resume;
state.pending_approval_resume = approval_resume.map(|resume| {
let activity_id =
capability_activity_id_from_resume_token(&resume.resume_token);
PendingApprovalResume {
gate_ref: gate_ref.clone(),
capability_id: call.capability_id.clone(),
approval_request_id: resume.approval_request_id,
resume_token: resume.resume_token,
activity_id,
correlation_id: resume.correlation_id,
surface_version: call.surface_version.clone(),
input_ref: resume.input_ref,
effective_capability_ids: call.effective_capability_ids.clone(),
provider_replay: call.provider_replay.clone(),
input: resume.input,
estimate: resume.estimate,
// Disposition is stamped by PlannedDriver at resume time;
// GateStage writes the initial (blocking) checkpoint where
// no denial has occurred yet.
disposition: None,
}
});
} else if matches!(kind, GateKind::Auth) {
// Auth gates fold any prior approval identity into
// pending_auth_resume.prior_approval below. Keeping a
// pending approval slot for the same gate makes resume
// disposition stamping ambiguous and can re-dispatch the
// approval path before the auth denial is consumed.
state.pending_approval_resume = None;
}
if matches!(kind, GateKind::Auth) {
// CapabilityStage shapes auth-resume metadata; GateStage
// only persists it at the blocking checkpoint.
Expand Down
4 changes: 4 additions & 0 deletions crates/ironclaw_agent_loop/src/executor/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4930,6 +4930,10 @@ async fn auth_resume_after_approval_carries_resume_token_and_approval_request_id
pending_auth_pa.approval_request_id, approval_request_id,
"pending_auth_resume.prior_approval.approval_request_id must match the approval request"
);
assert!(
phase2_bb.pending_approval_resume.is_none(),
"phase 2 auth gate must fold prior approval into pending_auth_resume and clear pending_approval_resume"
);

// ── Phase 3: auth-resume → Completed ─────────────────────────────────────
let phase3_exit = executor
Expand Down
21 changes: 19 additions & 2 deletions crates/ironclaw_event_streams/src/types.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
use std::sync::Arc;

use ironclaw_event_projections::{
ProjectionCursor, ProjectionReplay, ProjectionScope, ProjectionSnapshot,
CapabilityActivityStatus, ProjectionCursor, ProjectionReplay, ProjectionScope,
ProjectionSnapshot,
};
use ironclaw_host_api::{
CapabilityId, ExtensionId, InvocationId, MissionId, ProcessId, RuntimeKind, ThreadId,
};
use ironclaw_host_api::{CapabilityId, InvocationId, MissionId, ProcessId, ThreadId};
use ironclaw_outbound::{OutboundPushKind, ProjectionUpdateRef};
use ironclaw_turns::{ReplyTargetBindingRef, TurnActor, TurnRunId, TurnScope};
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -177,6 +180,16 @@ pub enum ThreadLiveProjectionItem {
run_id: TurnRunId,
invocation_id: InvocationId,
capability_id: CapabilityId,
#[serde(default = "default_capability_activity_status")]
status: CapabilityActivityStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
provider: Option<ExtensionId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
runtime: Option<RuntimeKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
output_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
error_kind: Option<String>,
},
WorkSummary {
id: String,
Expand All @@ -192,6 +205,10 @@ pub enum ThreadLiveProjectionItem {
},
}

fn default_capability_activity_status() -> CapabilityActivityStatus {
CapabilityActivityStatus::Started
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ThreadLiveWorkSummaryPhase {
Expand Down
7 changes: 5 additions & 2 deletions crates/ironclaw_product_adapters/src/outbound.rs
Original file line number Diff line number Diff line change
Expand Up @@ -927,8 +927,11 @@ impl<'de> Deserialize<'de> for ApprovalPromptDetailView {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthPromptChallengeKind {
/// Browser must open `authorization_url` in a new tab and wait for the
/// OAuth callback to resume the run server-side.
/// Browser-based OAuth challenge. When `authorization_url` is present, the
/// browser can open it in a new tab and wait for the OAuth callback to
/// resume the run server-side. When the provider is unavailable or
/// unconfigured, the URL may be absent so UI can still render an
/// OAuth-specific unavailable state instead of the generic auth fallback.
#[serde(rename = "oauth_url")]
OAuthUrl,
/// User must type a manual token (PAT, API key) into the chat form.
Expand Down
90 changes: 18 additions & 72 deletions crates/ironclaw_product_workflow/src/auth_interaction/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ use ironclaw_auth::{
CredentialAccountId, CredentialSelectionInput,
};
use ironclaw_turns::{
CancelRunRequest, GateRef, GateResumeDisposition, GetRunStateRequest, ResumeTurnPrecondition,
ResumeTurnRequest, SanitizedCancelReason, TurnCoordinator, TurnError, TurnErrorCategory,
TurnRunId, TurnStatus,
GateRef, GateResumeDisposition, GetRunStateRequest, ResumeTurnPrecondition, ResumeTurnRequest,
TurnCoordinator, TurnError, TurnErrorCategory, TurnRunId, TurnStatus,
};

use super::types::is_pending_auth_status;
Expand Down Expand Up @@ -158,6 +157,15 @@ impl DefaultAuthInteractionService {
}
};
validate_completion_ref(&gate, completion)?;
self.resume_auth_gate(request, run_id, None).await
}

async fn resume_auth_gate(
&self,
request: ResolveAuthInteractionRequest,
run_id: TurnRunId,
resume_disposition: Option<GateResumeDisposition>,
) -> Result<ResolveAuthInteractionResponse, ProductWorkflowError> {
let state = self
.turn_coordinator
.get_run_state(GetRunStateRequest {
Expand All @@ -177,7 +185,7 @@ impl DefaultAuthInteractionService {
source_binding_ref: state.source_binding_ref,
reply_target_binding_ref: state.reply_target_binding_ref,
idempotency_key: request.idempotency_key,
resume_disposition: None,
resume_disposition,
})
.await
.map_err(map_auth_resume_error)?;
Expand Down Expand Up @@ -256,30 +264,8 @@ impl DefaultAuthInteractionService {
run_id: TurnRunId,
) -> Result<ResolveAuthInteractionResponse, ProductWorkflowError> {
self.cancel_auth_flow_if_active(&gate).await?;
let state = self
.turn_coordinator
.get_run_state(GetRunStateRequest {
scope: request.scope.clone(),
run_id,
})
self.resume_auth_gate(request, run_id, Some(GateResumeDisposition::Denied))
.await
.map_err(map_auth_resume_error)?;
let response = self
.turn_coordinator
.resume_turn(ResumeTurnRequest {
scope: request.scope,
actor: request.actor,
run_id,
gate_resolution_ref: request.gate_ref,
precondition: ResumeTurnPrecondition::BlockedAuthGate,
source_binding_ref: state.source_binding_ref,
reply_target_binding_ref: state.reply_target_binding_ref,
idempotency_key: request.idempotency_key,
resume_disposition: Some(GateResumeDisposition::Denied),
})
.await
.map_err(map_auth_resume_error)?;
Ok(ResolveAuthInteractionResponse::Resumed(response))
}

async fn replay_denied_auth(
Expand All @@ -293,52 +279,11 @@ impl DefaultAuthInteractionService {
// check, so this is idempotent regardless of current run state. A
// fresh key on a finished run still errors via the precondition
// (correctly StaleAuth).
let state = self
.turn_coordinator
.get_run_state(GetRunStateRequest {
scope: request.scope.clone(),
run_id,
})
self.resume_auth_gate(request, run_id, Some(GateResumeDisposition::Denied))
.await
.map_err(map_auth_resume_error)?;
let response = self
.turn_coordinator
.resume_turn(ResumeTurnRequest {
scope: request.scope,
actor: request.actor,
run_id,
gate_resolution_ref: request.gate_ref,
precondition: ResumeTurnPrecondition::BlockedAuthGate,
source_binding_ref: state.source_binding_ref,
reply_target_binding_ref: state.reply_target_binding_ref,
idempotency_key: request.idempotency_key,
resume_disposition: Some(GateResumeDisposition::Denied),
})
.await
.map_err(map_auth_resume_error)?;
Ok(ResolveAuthInteractionResponse::Resumed(response))
}

async fn cancel_auth_run(
&self,
request: ResolveAuthInteractionRequest,
run_id: TurnRunId,
) -> Result<ResolveAuthInteractionResponse, ProductWorkflowError> {
let response = self
.turn_coordinator
.cancel_run(CancelRunRequest {
scope: request.scope,
actor: request.actor,
run_id,
reason: SanitizedCancelReason::UserRequested,
idempotency_key: request.idempotency_key,
})
.await
.map_err(map_auth_resume_error)?;
Ok(ResolveAuthInteractionResponse::Canceled(response))
}

async fn cancel_parked_auth_without_flow(
async fn resume_denied_auth_without_flow(
&self,
request: ResolveAuthInteractionRequest,
run_id: TurnRunId,
Expand All @@ -349,7 +294,8 @@ impl DefaultAuthInteractionService {
return Err(auth_rejected(AuthInteractionRejectionKind::MissingAuth));
}
}
self.cancel_auth_run(request, run_id).await
self.resume_auth_gate(request, run_id, Some(GateResumeDisposition::Denied))
.await
}
}

Expand Down Expand Up @@ -399,7 +345,7 @@ impl AuthInteractionService for DefaultAuthInteractionService {
let Some(run_id) = request.run_id_hint else {
return Err(auth_rejected(AuthInteractionRejectionKind::MissingAuth));
};
return self.cancel_parked_auth_without_flow(request, run_id).await;
return self.resume_denied_auth_without_flow(request, run_id).await;
}
Err(error) => return Err(error),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -961,7 +961,7 @@ async fn idempotent_auth_deny_replay_returns_same_resumed_response_as_first_deny
}

#[tokio::test]
async fn denied_auth_without_flow_record_cancels_parked_auth_run() {
async fn denied_auth_without_flow_record_resumes_parked_auth_run() {
let actor = TurnActor::new(UserId::new("alice").unwrap());
let scope = turn_scope("alice", "thread-a");
let run_id = TurnRunId::new();
Expand Down Expand Up @@ -992,14 +992,23 @@ async fn denied_auth_without_flow_record_cancels_parked_auth_run() {

assert!(matches!(
response,
ResolveAuthInteractionResponse::Canceled(_)
ResolveAuthInteractionResponse::Resumed(_)
));
assert!(
flow_manager.cancellations().is_empty(),
"no auth flow record should mean there is no flow to cancel"
);
assert_eq!(coordinator.cancellations().len(), 1);
assert!(coordinator.resumes().is_empty());
assert_eq!(coordinator.cancellations().len(), 0);
let resumes = coordinator.resumes();
assert_eq!(resumes.len(), 1);
assert_eq!(
resumes[0].precondition,
ResumeTurnPrecondition::BlockedAuthGate
);
assert!(matches!(
resumes[0].resume_disposition,
Some(GateResumeDisposition::Denied)
));
}

#[tokio::test]
Expand Down
2 changes: 1 addition & 1 deletion crates/ironclaw_reborn_composition/src/auth_prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ fn auth_prompt_from_credential_requirement(
view.account_label = Some(provider.clone());
}
RuntimeCredentialAccountSetup::OAuth { .. } => {
view.challenge_kind = Some(AuthPromptChallengeKind::Other);
view.challenge_kind = Some(AuthPromptChallengeKind::OAuthUrl);
}
}
view.provider = Some(provider);
Expand Down
Loading
Loading