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
134 changes: 130 additions & 4 deletions crates/ironclaw_host_runtime/src/production.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2119,13 +2119,20 @@ fn stable_auth_gate_id(
let mut requirements = credential_requirements
.iter()
.map(|requirement| {
let mut scopes = requirement.provider_scopes.clone();
scopes.sort();
// `setup` MUST be part of the fingerprint (#6299 IronLoop): two
// requirements that agree on provider/extension/provider_scopes but
// differ in `setup` (e.g. a ManualToken record vs a later OAuth or
// Pairing record, or differing OAuth setup scopes) are DIFFERENT auth
// requirements. Omitting it lets them derive the same deterministic
// `for_auth_gate` key; the write-once store then reports
// `GateRecordAlreadyExists` and silently keeps the stale record, so
// the runner reloads and renders the wrong authentication flow.
format!(
"credential={}:{}:{}",
"credential={}:{}:setup={}:{}",
requirement.provider.as_str(),
requirement.requester_extension.as_str(),
scopes.join(",")
stable_setup_token(&requirement.setup),
canonical_scope_list(&requirement.provider_scopes),
)
})
.collect::<Vec<_>>();
Expand All @@ -2138,6 +2145,38 @@ fn stable_auth_gate_id(
.unwrap_or_else(|_| RuntimeGateId::new())
}

/// Canonical, deterministic fingerprint token for a credential-account setup,
/// so [`stable_auth_gate_id`] distinguishes auth requirements that differ only
/// in their setup flow (#6299 IronLoop). Exhaustive by design: a new
/// `RuntimeCredentialAccountSetup` variant fails the build here rather than
/// silently hashing to an existing token. OAuth setup scopes use the same
/// injective [`canonical_scope_list`] encoding as `provider_scopes`.
fn stable_setup_token(setup: &ironclaw_host_api::RuntimeCredentialAccountSetup) -> String {
use ironclaw_host_api::RuntimeCredentialAccountSetup as Setup;
match setup {
Setup::ManualToken => "manual_token".to_string(),
Setup::OAuth { scopes } => format!("oauth:{}", canonical_scope_list(scopes)),
Setup::Pairing => "pairing".to_string(),
Setup::Retired => "retired".to_string(),
}
}
Comment on lines +2154 to +2162

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To avoid unnecessary heap allocations for static variants (like ManualToken, Pairing, and Retired), we can return std::borrow::Cow<'static, str> instead of String. This keeps the code efficient and avoids allocating memory for static strings while still allowing dynamic formatting for the OAuth variant.

Suggested change
fn stable_setup_token(setup: &ironclaw_host_api::RuntimeCredentialAccountSetup) -> String {
use ironclaw_host_api::RuntimeCredentialAccountSetup as Setup;
match setup {
Setup::ManualToken => "manual_token".to_string(),
Setup::OAuth { scopes } => {
let mut scopes = scopes.clone();
scopes.sort();
format!("oauth:{}", scopes.join(","))
}
Setup::Pairing => "pairing".to_string(),
Setup::Retired => "retired".to_string(),
}
}
fn stable_setup_token(setup: &ironclaw_host_api::RuntimeCredentialAccountSetup) -> std::borrow::Cow<'static, str> {
use ironclaw_host_api::RuntimeCredentialAccountSetup as Setup;
match setup {
Setup::ManualToken => std::borrow::Cow::Borrowed("manual_token"),
Setup::OAuth { scopes } => {
let mut scopes = scopes.clone();
scopes.sort();
std::borrow::Cow::Owned(format!("oauth:{}", scopes.join(",")))
}
Setup::Pairing => std::borrow::Cow::Borrowed("pairing"),
Setup::Retired => std::borrow::Cow::Borrowed("retired"),
}
}


/// Injective canonical encoding of a scope list for the auth-gate fingerprint
/// (#6299 IronLoop). Scopes are not validated to exclude a join delimiter, so a
/// plain `join(",")` is ambiguous — `["a,b"]` and `["a", "b"]` would collide and
/// derive the same write-once gate key. Sort (a scope set is order-independent),
/// then length-prefix each element (`<byte_len>:<scope>`) so distinct sets can
/// never share an encoding regardless of which characters the scopes contain.
fn canonical_scope_list(scopes: &[String]) -> String {
let mut sorted = scopes.to_vec();
sorted.sort();
sorted
.iter()
.map(|scope| format!("{}:{scope}", scope.len()))
.collect::<Vec<_>>()
.join("|")
}

fn spawned_process_outcome_from(
result: CapabilitySpawnResult,
capability_id: CapabilityId,
Expand Down Expand Up @@ -2607,6 +2646,93 @@ output_schema_ref = "schemas/test.output.json"
assert_ne!(first_gate.gate_id, second_gate.gate_id);
}

#[test]
fn auth_required_outcome_changes_gate_when_only_setup_changes() {
// Regression (#6299 IronLoop): two requirements identical in provider,
// requester, and `provider_scopes` but differing ONLY in `setup` are
// DIFFERENT auth flows and must NOT collide on the deterministic
// `for_auth_gate` key. Before the fix `setup` was omitted from the
// fingerprint, so e.g. a ManualToken record and a later OAuth/Pairing
// record produced the same gate id; the write-once gate-record store
// then reported `GateRecordAlreadyExists`, kept the stale record, and
// the runner reloaded and rendered the wrong authentication flow.
use ironclaw_host_api::RuntimeCredentialAccountSetup as Setup;
let requirement_with = |setup: Setup| RuntimeCredentialAuthRequirement {
provider: RuntimeCredentialAccountProviderId::new("notion").unwrap(),
setup,
requester_extension: ExtensionId::new("notion").unwrap(),
provider_scopes: vec!["read".to_string()],
};
let gate_id = |setup: Setup| {
let RuntimeCapabilityOutcome::AuthRequired(gate) =
auth_required_outcome(cap(), Vec::new(), vec![requirement_with(setup)])
else {
panic!("expected auth gate");
};
gate.gate_id
};

let manual = gate_id(Setup::ManualToken);
let oauth = gate_id(Setup::OAuth {
scopes: vec!["read".to_string()],
});
let pairing = gate_id(Setup::Pairing);
// Distinct setup KINDS never collide (all `provider_scopes` equal).
assert_ne!(manual, oauth, "ManualToken vs OAuth must not collide");
assert_ne!(manual, pairing, "ManualToken vs Pairing must not collide");
assert_ne!(oauth, pairing, "OAuth vs Pairing must not collide");

// OAuth setups differing ONLY in their setup scopes are distinct flows
// too (`provider_scopes` held fixed at ["read"] above and here).
let oauth_readwrite = gate_id(Setup::OAuth {
scopes: vec!["read".to_string(), "write".to_string()],
});
assert_ne!(
oauth, oauth_readwrite,
"OAuth setups with different setup scopes must not collide"
);

// Injective encoding: a single scope containing the old `,` join
// delimiter must not collide with two scopes that join to the same
// string — `["a,b"]` and `["a", "b"]` are DIFFERENT scope sets. Before
// the length-prefixed `canonical_scope_list`, both encoded to "a,b".
let one_comma_scope = gate_id(Setup::OAuth {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only exercises the OAuth setup-scope encoding; provider_scopes stays fixed. Please add a ManualToken case varying only provider_scopes between ["a,b"] and ["a", "b"], so a regression of that separate fingerprint input is caught.

scopes: vec!["a,b".to_string()],
});
let two_scopes = gate_id(Setup::OAuth {
scopes: vec!["a".to_string(), "b".to_string()],
});
assert_ne!(
one_comma_scope, two_scopes,
"OAuth setup scopes must encode injectively: [\"a,b\"] != [\"a\", \"b\"]",
);

// The same injective guarantee must hold for the per-requirement
// `provider_scopes` list, not only OAuth setup scopes — otherwise a
// revert of the `provider_scopes` encoding alone would go uncaught (the
// cases above hold `provider_scopes` fixed). Fixed ManualToken setup,
// `provider_scopes` `["a,b"]` vs `["a", "b"]`.
let provider_scopes_gate = |scopes: Vec<String>| {
let requirement = RuntimeCredentialAuthRequirement {
provider: RuntimeCredentialAccountProviderId::new("notion").unwrap(),
setup: Setup::ManualToken,
requester_extension: ExtensionId::new("notion").unwrap(),
provider_scopes: scopes,
};
let RuntimeCapabilityOutcome::AuthRequired(gate) =
auth_required_outcome(cap(), Vec::new(), vec![requirement])
else {
panic!("expected auth gate");
};
gate.gate_id
};
assert_ne!(
provider_scopes_gate(vec!["a,b".to_string()]),
provider_scopes_gate(vec!["a".to_string(), "b".to_string()]),
"provider_scopes must encode injectively: [\"a,b\"] != [\"a\", \"b\"]",
);
}

#[test]
fn dispatch_kind_to_failure_pins_every_runtime_dispatch_error_kind() {
// Every RuntimeDispatchErrorKind variant must map to a non-Unknown
Expand Down
5 changes: 5 additions & 0 deletions crates/ironclaw_loop_host/src/capability_port.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1953,6 +1953,11 @@ impl HostRuntimeLoopCapabilityPort {
// turns — derives the SAME content-addressed key and an identical
// record. The write-once store reports `GateRecordAlreadyExists`;
// that is benign (already persisted, byte-identical), never a fault.
// "Byte-identical" holds because the auth-gate fingerprint
// (`stable_auth_gate_id`) covers `setup` as well as provider /
// requester / scopes (#6299 IronLoop), so two requirements that
// differ in their setup flow derive DIFFERENT keys and never reach
// this branch with a stale record.
// Mirrors `persist_replay_payload_for_fresh_gate`'s tolerance of
// `ReplayPayloadAlreadyExists`. Publish + commit like the success path.
Err(RunStateError::GateRecordAlreadyExists { .. }) => {
Expand Down
20 changes: 20 additions & 0 deletions crates/ironclaw_reborn_composition/src/runtime/local_dev.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1205,5 +1205,25 @@ fn host_api_agent_loop_error(
)
}

/// Shared test assertion for the `local_dev` per-capability submodules: the
/// §5.3 collapse maps a recoverable service failure onto `Resolution::Done`
/// carrying a `RecoverableFailure` verdict (the collapse of the old
/// `CapabilityOutcome::Failed`). Consumed by `outbound_delivery`,
/// `project_create`, and further submodules as they migrate to the `Resolution`
/// shape — replacing the byte-identical per-file copies (CodeRabbit #6299).
#[cfg(test)]
pub(crate) fn assert_recoverable_failure(
resolution: &ironclaw_host_api::Resolution,
expected: ironclaw_host_api::FailureKind,
) {
match resolution {
ironclaw_host_api::Resolution::Done(outcome) => assert_eq!(
outcome.verdict,
ironclaw_host_api::ToolVerdict::recoverable_failure(expected)
),
other => panic!("expected Resolution::Done recoverable failure, got {other:?}"),
}
}

#[cfg(test)]
mod tests;
Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,7 @@ fn approval_lease_outcome(
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::local_dev::assert_recoverable_failure;
use ironclaw_product_workflow::RebornServicesErrorKind;
use ironclaw_turns::run_profile::LoopSafeSummary;

Expand All @@ -896,22 +897,6 @@ mod tests {
}
}

/// The §5.3 collapse maps a recoverable service failure onto
/// `Resolution::Done` with a `RecoverableFailure` verdict; the redacted
/// summary rides the outcome (already a host_api `SafeSummary`).
fn assert_recoverable_failure(
resolution: &Resolution,
expected: ironclaw_host_api::FailureKind,
) {
match resolution {
Resolution::Done(outcome) => assert_eq!(
outcome.verdict,
ironclaw_host_api::ToolVerdict::recoverable_failure(expected)
),
other => panic!("expected Resolution::Done recoverable failure, got {other:?}"),
}
}

/// The model-visible summary carried on a recoverable failure / denial.
fn recoverable_summary(resolution: &Resolution) -> String {
match resolution {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ fn effective_user_id(run_context: &LoopRunContext, fallback_user_id: &UserId) ->
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::local_dev::assert_recoverable_failure;

#[test]
fn parse_project_create_input_rejects_missing_name() {
Expand Down Expand Up @@ -297,22 +298,6 @@ mod tests {
assert_recoverable_failure(&outcome, ironclaw_host_api::FailureKind::Unavailable);
}

/// A recoverable model-visible failure is `Resolution::Done` carrying the
/// expected `RecoverableFailure` verdict (the §5.3 collapse of the old
/// `CapabilityOutcome::Failed`).
fn assert_recoverable_failure(
resolution: &ironclaw_host_api::Resolution,
expected: ironclaw_host_api::FailureKind,
) {
match resolution {
ironclaw_host_api::Resolution::Done(outcome) => assert_eq!(
outcome.verdict,
ironclaw_host_api::ToolVerdict::recoverable_failure(expected)
),
other => panic!("expected Resolution::Done recoverable failure, got {other:?}"),
}
}

#[test]
fn internal_error_stays_terminal() {
let error = project_service_outcome(ProjectServiceError::Internal)
Expand Down
Loading
Loading