diff --git a/crates/app/ironclaw_architecture_tests/tests/reborn_dependency_boundaries.rs b/crates/app/ironclaw_architecture_tests/tests/reborn_dependency_boundaries.rs index e3df2530eb4..9a6212f835e 100644 --- a/crates/app/ironclaw_architecture_tests/tests/reborn_dependency_boundaries.rs +++ b/crates/app/ironclaw_architecture_tests/tests/reborn_dependency_boundaries.rs @@ -822,7 +822,21 @@ fn reborn_contracts_crates_carry_a_checked_size_ceiling() { // host-side like `guidance_doc`'s, and nothing here schedules, // dispatches, or invokes anything. Count read from this test's own // failure message. - ("ironclaw_extension_contracts", 11_451), + // 11_451 -> 11_633 (2026-08-26, PR #7897 connect-link validation): + // the new `connect_link` module — one `validated_connect_link_origin` + // helper plus its unit tests — replaces three near-duplicate + // trim-and-check-non-empty implementations + // (`ironclaw_extension_host::channel_host::configured_origin`, + // `ironclaw_assistant::run_delivery::prompts::extensions_page_link`, + // `ironclaw_extension_manager::install_guidance::personal_setup_link`) + // that never validated `IRONCLAW_REBORN_WEBUI_BASE_URL` was an + // absolute origin, so a scheme-less deployment value rendered a + // relative link into a customer conversation. Pure validation, no + // execution/persistence. Both raises are kept: this row grew twice, + // independently, and each delta has its own reason. 11_633 is the + // count read from this test's own failure message after merging main, + // not the two deltas added — they do not sum. + ("ironclaw_extension_contracts", 11_633), // Raised 17_501 -> 18_570 by #6831 (standardized messaging framework): // the growth is the `messaging` vocabulary — the StandardMessagingOp // enum, the 12-code error taxonomy, compiled-in canonical schema/prompt diff --git a/crates/app/ironclaw_composition/src/extension_host_assembly.rs b/crates/app/ironclaw_composition/src/extension_host_assembly.rs index 4a849c9a38e..5ceadfd3f3e 100644 --- a/crates/app/ironclaw_composition/src/extension_host_assembly.rs +++ b/crates/app/ironclaw_composition/src/extension_host_assembly.rs @@ -584,6 +584,9 @@ pub(crate) fn start_channel_host( }); let workflow_factory = Arc::new(ironclaw_assistant::RebornChannelWorkflowFactory::new( ironclaw_assistant::RebornChannelWorkflowServices { + // Same env read, and the same no-origin fallback, as the channel + // connect notice (#7887). + setup_link_base_url: connect_link_base_url_from_env(), filesystem: Arc::clone(workflow_filesystem), thread_service, turn_coordinator, diff --git a/crates/app/ironclaw_composition/src/factory/test_support.rs b/crates/app/ironclaw_composition/src/factory/test_support.rs index 5b5492e9321..fe9e6e44db7 100644 --- a/crates/app/ironclaw_composition/src/factory/test_support.rs +++ b/crates/app/ironclaw_composition/src/factory/test_support.rs @@ -20,6 +20,16 @@ pub struct ChannelHostAssemblyTestWiring { pub turn_coordinator: Arc, pub identity: ironclaw_extension_host::channel_host::ChannelHostIdentity, pub run_delivery_settings: ironclaw_assistant::RunDeliverySettings, + /// Auth-prompt enrichment for a run parked `BlockedAuth`, forwarded + /// verbatim into `RunDeliveryServices.blocked_auth_prompts`. Production + /// always wires `None` through this test-support path (this fixture has + /// no `product_auth`/pairing-registry-backed source to hand it); an + /// integration test that needs a challenge-kind-specific unserviceable + /// message (`unserviceable_auth_prompt_message`'s ManualToken/DeviceLink + /// arms, #7897) supplies its own fake here rather than composition + /// growing a second real implementation just for the test. + pub blocked_auth_prompts: + Option>, } #[allow( @@ -359,7 +369,7 @@ impl RebornRuntimeStores { auth_interaction: None, identity: wiring.identity, approval_context: None, - blocked_auth_prompts: None, + blocked_auth_prompts: wiring.blocked_auth_prompts, auth_flow_cancel: None, run_delivery_settings: wiring.run_delivery_settings, admin_users, diff --git a/crates/app/ironclaw_composition/src/runtime.rs b/crates/app/ironclaw_composition/src/runtime.rs index 814d0804475..ee5c692a351 100644 --- a/crates/app/ironclaw_composition/src/runtime.rs +++ b/crates/app/ironclaw_composition/src/runtime.rs @@ -1183,6 +1183,7 @@ impl RebornRuntime { turn_coordinator, identity, run_delivery_settings, + blocked_auth_prompts, } = wiring; let attachment_filesystem = self.read_write_workspace_filesystem()?; let inbound_attachments: Arc = @@ -1234,7 +1235,7 @@ impl RebornRuntime { auth_interaction: None, identity, approval_context: None, - blocked_auth_prompts: None, + blocked_auth_prompts, auth_flow_cancel: None, run_delivery_settings, admin_users, diff --git a/crates/contracts/ironclaw_extension_contracts/src/connect_link.rs b/crates/contracts/ironclaw_extension_contracts/src/connect_link.rs new file mode 100644 index 00000000000..b5ccf9984a2 --- /dev/null +++ b/crates/contracts/ironclaw_extension_contracts/src/connect_link.rs @@ -0,0 +1,162 @@ +//! Validation for the deployment-configured public web origin used to build +//! extension connect/setup links (`/extensions`, `/chat?connect=…`) shown to +//! chat users. +//! +//! The origin's source is `IRONCLAW_REBORN_WEBUI_BASE_URL` +//! (`ironclaw_composition::extension_host_assembly::connect_link_base_url_from_env`), +//! read independently by three call sites — the deployment-channel notice +//! (`ironclaw_extension_host::channel_host::configured_origin`), the +//! device-link-unavailable chat prompt +//! (`ironclaw_assistant::run_delivery::prompts::extensions_page_link`), and +//! the personal-account setup nudge +//! (`ironclaw_extension_manager::install_guidance::personal_setup_link`) — +//! that used to trim-and-check-non-empty only, never confirming the value was +//! an absolute origin at all. A deployment misconfigured as +//! `IRONCLAW_REBORN_WEBUI_BASE_URL=app.example.com` (no scheme) rendered the +//! relative `app.example.com/extensions` into a customer conversation, and a +//! value carrying a query or fragment could redirect the link away from the +//! Extensions page. This module is the one place that decides an origin is +//! safe to render. + +/// Validate `base_url` as an absolute `http`/`https` origin with no query +/// string or fragment, and return it with any trailing slash trimmed. +/// +/// Returns `None` for anything that is not a safely renderable origin: no +/// value, blank/whitespace, a scheme-less (relative) value, a non-http(s) +/// scheme, or a value carrying a `?query` or `#fragment` (either would change +/// where the rendered link actually goes). `None` here means "ship the +/// link-free copy" at every call site — never a startup failure. That +/// deliberately differs from the OAuth callback consumer of the same +/// environment variable, which fails startup on a blank value; see +/// `connect_link_base_url_from_env`'s own doc comment for why the two +/// consumers of one variable are allowed to disagree on how unusable costs. +/// +/// `https://x.test/` and `https://x.test` are equivalent — the trailing +/// slash is trimmed, not treated as part of the origin's identity. +pub fn validated_connect_link_origin(base_url: Option<&str>) -> Option<&str> { + let trimmed = base_url?.trim(); + if trimmed.is_empty() { + return None; // silent-ok: blank/whitespace origin means "unset"; every caller ships link-free + } + + let scheme_end = trimmed.find("://")?; // silent-ok: no scheme means a relative value, never safe to render as a link + // Schemes are case-insensitive (RFC 3986 §3.1), and `.claude/rules/types.md` + // requires normalizing case-insensitive external values at the boundary — + // an operator writing `HTTPS://` means the same origin. + let scheme = &trimmed[..scheme_end]; + if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") { + return None; // silent-ok: only http(s) origins are safe to render as a clickable link + } + + let authority = &trimmed[scheme_end + 3..]; + if authority.contains(['?', '#']) { + return None; // silent-ok: a query string or fragment can redirect the link away from its intended page + } + + let authority = authority.trim_end_matches('/'); + if authority.is_empty() { + return None; // silent-ok: a scheme with no host is not a usable origin + } + + Some(&trimmed[..scheme_end + 3 + authority.len()]) +} + +#[cfg(test)] +mod tests { + use super::validated_connect_link_origin; + + #[test] + fn accepts_absolute_https_origin() { + assert_eq!( + validated_connect_link_origin(Some("https://app.example.com")), + Some("https://app.example.com") + ); + } + + #[test] + fn accepts_an_uppercase_scheme() { + // Fail-safe rejection would have shipped link-free for a perfectly + // valid origin. + assert_eq!( + validated_connect_link_origin(Some("HTTPS://app.example.com")), + Some("HTTPS://app.example.com") + ); + } + + #[test] + fn accepts_absolute_http_origin() { + assert_eq!( + validated_connect_link_origin(Some("http://app.example.com")), + Some("http://app.example.com") + ); + } + + #[test] + fn trims_trailing_slash() { + assert_eq!( + validated_connect_link_origin(Some("https://app.example.com/")), + validated_connect_link_origin(Some("https://app.example.com")) + ); + assert_eq!( + validated_connect_link_origin(Some("https://app.example.com/")), + Some("https://app.example.com") + ); + } + + #[test] + fn rejects_scheme_less_value() { + // The exact CodeRabbit-reported case: no scheme renders the RELATIVE + // `app.example.com/extensions` into a customer conversation. + assert_eq!(validated_connect_link_origin(Some("app.example.com")), None); + } + + #[test] + fn rejects_non_http_scheme() { + assert_eq!( + validated_connect_link_origin(Some("ftp://app.example.com")), + None + ); + assert_eq!( + validated_connect_link_origin(Some("javascript://app.example.com")), + None + ); + } + + #[test] + fn rejects_query_string() { + assert_eq!( + validated_connect_link_origin(Some("https://x.test/?a=1")), + None + ); + } + + #[test] + fn rejects_fragment() { + assert_eq!( + validated_connect_link_origin(Some("https://x.test#f")), + None + ); + } + + #[test] + fn rejects_query_and_fragment_together() { + assert_eq!( + validated_connect_link_origin(Some("https://x.test/?a=1#f")), + None + ); + } + + #[test] + fn rejects_blank_whitespace_and_none() { + assert_eq!(validated_connect_link_origin(None), None); + assert_eq!(validated_connect_link_origin(Some("")), None); + assert_eq!(validated_connect_link_origin(Some(" ")), None); + assert_eq!(validated_connect_link_origin(Some("/")), None); + } + + #[test] + fn rejects_scheme_with_no_host() { + assert_eq!(validated_connect_link_origin(Some("https://")), None); + assert_eq!(validated_connect_link_origin(Some("https:///")), None); + } +} diff --git a/crates/contracts/ironclaw_extension_contracts/src/device_link.rs b/crates/contracts/ironclaw_extension_contracts/src/device_link.rs index cfcb4326cc6..fa67457c828 100644 --- a/crates/contracts/ironclaw_extension_contracts/src/device_link.rs +++ b/crates/contracts/ironclaw_extension_contracts/src/device_link.rs @@ -135,6 +135,18 @@ pub enum DeviceLinkErrorCode { VendorUnavailable, /// Host-side custody failed (see [`LinkedSessionError`]). CustodyFailed, + /// The deployment has not finished configuring what this ceremony needs + /// — an operator must supply a credential or setting before ANY user can + /// link. + /// + /// Distinct from [`Self::Internal`], whose contract reserves it for + /// genuinely unclassifiable failures: this one is precisely classifiable + /// and precisely remediable, just not by the person looking at the card. + /// Distinct from [`Self::AccountUnavailable`] in the direction that + /// matters — nothing is wrong with the account, so copy that blames it + /// sends the user to debug something that is not broken (#7887 follow-up). + /// Terminal for the user; retrying changes nothing until an operator acts. + NotConfigured, /// Anything else. Reserved for genuinely unclassifiable failures. Internal, } @@ -529,6 +541,11 @@ pub enum DeviceLinkError { /// Custody failed; the link cannot be made durable. #[error("device-link custody failed")] Custody(#[from] LinkedSessionError), + /// The deployment has not configured what this ceremony needs. `reason` + /// names the missing setting so an operator can act on it; the user + /// cannot. + #[error("device-link is not configured on this deployment: {reason}")] + NotConfigured { reason: &'static str }, /// The adapter failed for a reason the run cannot recover from. #[error("device-link adapter failed: {reason}")] Internal { reason: &'static str }, @@ -542,6 +559,7 @@ impl DeviceLinkError { Self::InvalidInput { .. } | Self::InvalidStep { .. } => { DeviceLinkErrorCode::InvalidInput } + Self::NotConfigured { .. } => DeviceLinkErrorCode::NotConfigured, Self::UnsupportedMode { .. } | Self::Internal { .. } => DeviceLinkErrorCode::Internal, Self::Vendor { code, .. } => *code, Self::Custody(_) => DeviceLinkErrorCode::CustodyFailed, @@ -552,9 +570,10 @@ impl DeviceLinkError { pub fn restartable(&self) -> bool { match self { Self::UnknownFlow | Self::InvalidInput { .. } => true, - Self::InvalidStep { .. } | Self::UnsupportedMode { .. } | Self::Internal { .. } => { - false - } + Self::InvalidStep { .. } + | Self::UnsupportedMode { .. } + | Self::NotConfigured { .. } + | Self::Internal { .. } => false, Self::Vendor { restartable, .. } => *restartable, Self::Custody(_) => false, } diff --git a/crates/contracts/ironclaw_extension_contracts/src/device_link/tests.rs b/crates/contracts/ironclaw_extension_contracts/src/device_link/tests.rs index 19d7480504f..b6aa59be970 100644 --- a/crates/contracts/ironclaw_extension_contracts/src/device_link/tests.rs +++ b/crates/contracts/ironclaw_extension_contracts/src/device_link/tests.rs @@ -472,3 +472,42 @@ fn context_debug_reports_identity_only() { assert!(rendered.contains("example"), "{rendered}"); assert!(!rendered.contains("sensitive-config-value"), "{rendered}"); } + +/// #7887 follow-up: a deployment-configuration gap must not travel as +/// `Internal`, and must not be renderable as "this account cannot be linked". +/// +/// The account is fine; an operator has not finished setup. `Internal`'s own +/// contract reserves it for "genuinely unclassifiable failures", and this one +/// names the missing settings exactly — so it was both misclassified and, +/// downstream, rendered as a false claim about the user's account. +#[test] +fn a_configuration_gap_is_classified_apart_from_an_unclassifiable_failure() { + let not_configured = DeviceLinkError::NotConfigured { + reason: "the deployment has not configured its MTProto application identity", + }; + + assert_eq!(not_configured.code(), DeviceLinkErrorCode::NotConfigured); + assert_ne!( + not_configured.code(), + DeviceLinkErrorCode::Internal, + "a precisely remediable gap is not an unclassifiable failure" + ); + assert_ne!( + not_configured.code(), + DeviceLinkErrorCode::AccountUnavailable, + "nothing is wrong with the user's account" + ); + // Terminal for the user: a restart cannot help until an operator acts. + assert!(!not_configured.restartable()); + // The wire form the card branches on to pick its terminal copy. + assert_eq!( + serde_json::to_string(&DeviceLinkErrorCode::NotConfigured).expect("serialize"), + "\"not_configured\"" + ); + // The reason names the missing settings so an operator can act on it. + assert!( + not_configured + .to_string() + .contains("MTProto application identity") + ); +} diff --git a/crates/contracts/ironclaw_extension_contracts/src/lib.rs b/crates/contracts/ironclaw_extension_contracts/src/lib.rs index f8ca9597414..a0415be46a7 100644 --- a/crates/contracts/ironclaw_extension_contracts/src/lib.rs +++ b/crates/contracts/ironclaw_extension_contracts/src/lib.rs @@ -42,6 +42,7 @@ pub mod auth_prompt; pub mod channel; pub mod channel_adapter; pub mod channel_identity; +pub mod connect_link; pub mod device_link; pub mod egress; pub mod extension; diff --git a/crates/domains/ironclaw_auth/src/product_auth/device_link/driver.rs b/crates/domains/ironclaw_auth/src/product_auth/device_link/driver.rs index 7d84e37a837..6a0da4d9d2d 100644 --- a/crates/domains/ironclaw_auth/src/product_auth/device_link/driver.rs +++ b/crates/domains/ironclaw_auth/src/product_auth/device_link/driver.rs @@ -814,6 +814,9 @@ fn auth_error_for(code: DeviceLinkErrorCode) -> AuthErrorCode { | DeviceLinkErrorCode::VendorUnavailable | DeviceLinkErrorCode::CustodyFailed | DeviceLinkErrorCode::Internal => AuthErrorCode::BackendUnavailable, + // An operator-configuration gap, not a flaky backend: `BackendUnavailable` + // would tell the caller to retry a service that is perfectly healthy. + DeviceLinkErrorCode::NotConfigured => AuthErrorCode::MalformedConfig, } } diff --git a/crates/extensions/ironclaw_extension_host/src/channel_host.rs b/crates/extensions/ironclaw_extension_host/src/channel_host.rs index 428f68a97ec..1bf807abe0f 100644 --- a/crates/extensions/ironclaw_extension_host/src/channel_host.rs +++ b/crates/extensions/ironclaw_extension_host/src/channel_host.rs @@ -78,44 +78,139 @@ const CONNECT_QUERY_VALUE: &percent_encoding::AsciiSet = &percent_encoding::NON_ .remove(b'.') .remove(b'~'); -/// The channel's `connect_required` copy, with a one-click connect link -/// appended when three things hold: the strategy is OAuth, the deployment -/// configured a public web origin, and the channel's own reply adapter -/// declares that it delivers an ephemeral reply privately (#7681). -/// `/chat?connect=` is an authenticated route, so the link rides -/// the WebUI's existing login round-trip unchanged. The other strategies -/// carry their own `connection.deep_link_template`. +/// The deployment's public web origin with any trailing slash removed, or +/// `None` when none is configured or the configured value is not a safely +/// renderable absolute origin. /// -/// The privacy condition is the fail-closed half. This notice is the one a -/// shared conversation shows an unlinked sender, and it is kept out of the -/// room only by an `OutboundVisibility::EphemeralTo` request, which +/// `None` covers blank/whitespace, a relative value, a non-http(s) scheme, and +/// a value carrying a query string or fragment — a notice must never +/// advertise a relative or redirectable destination into a customer +/// conversation. Delegates to the shared validator so this file, `prompts.rs`, +/// and `install_guidance.rs` agree on exactly one definition of "safe to +/// render." +fn configured_origin(base_url: Option<&str>) -> Option<&str> { + ironclaw_extension_contracts::connect_link::validated_connect_link_origin(base_url) +} + +/// The channel's `connect_required` copy, with a destination appended when the +/// deployment configured a public web origin. Which destination — and whether +/// it is safe to render at all — depends on the strategy. +/// +/// **OAuth: `/chat?connect=`, gated on private delivery (#7681).** +/// That route auto-installs and auto-starts a flow, so it is meant for one +/// sender. This notice is the one a shared conversation shows an unlinked +/// sender, and it is kept out of the room only by an +/// `OutboundVisibility::EphemeralTo` request, which /// [`ChannelReply`](ironclaw_extension_contracts::channel_adapter::ChannelReply) /// documents as a hint: an adapter that cannot honor it posts publicly -/// instead. Appending a setup link on strategy alone would therefore broadcast -/// it to every member of the conversation the first time an OAuth-strategy -/// channel without ephemeral support ships. Adapters that have not declared -/// the capability keep the static, link-free manifest copy. +/// instead. Appending on strategy alone would broadcast a one-sender link the +/// first time an OAuth channel without ephemeral support ships, so adapters +/// that have not declared the capability keep the link-free manifest copy. +/// +/// **`WebGeneratedCode` / `DeviceLink`: `/extensions?configure=&setup=`, +/// no privacy condition (#7887).** These ceremonies cannot be completed from +/// the chat surface at all, and — unlike the OAuth route — this link *starts +/// nothing*: it opens the Extensions page, where a bystander who clicks it +/// authenticates as themselves and lands on their own page. Safe in a shared +/// room, so it is not gated on ephemeral delivery. Each strategy names its own +/// `setup` ceremony rather than sharing one link: `useSetupLanding.ts` +/// recognizes exactly `personal_account` and `workspace_bot`, and an +/// omitted/unrecognized value opens the connection-choice screen instead of +/// the ceremony the notice promised — so `WebGeneratedCode` (a workspace bot +/// pairing) sends `setup=workspace_bot` and `DeviceLink` (a personal-account +/// pairing) sends `setup=personal_account`. +/// +/// An earlier revision withheld a link from every non-OAuth strategy on the +/// grounds that they "carry their own `connection.deep_link_template`". That +/// does not serve this case: the template interpolates a `{code}` +/// ([`channel_pairing`]) the sender has not been issued yet, so a stranger who +/// has just messaged the bot was told to go find the extension with nothing to +/// click — the CX cell #7887 reports. +/// +/// `AdminManagedChannels` still renders verbatim: that channel is provisioned +/// by an operator, and sending an end user to a configure page they have no +/// permission to act on is worse than saying nothing. +/// Takes the already-resolved copy rather than the manifest descriptor, so it +/// can be applied to a notice that came from the pairing service as well as +/// one built from the manifest — see [`GenericChannelHostAssembly::connection_notices`], +/// where both branches converge on this call. An earlier revision took the +/// descriptor and was therefore reachable only on the manifest branch, which +/// made it dead code for every `WebGeneratedCode` extension (#7887). fn connect_required_notice( - connection: &ChannelConnectionDescriptor, + text: &str, + strategy: ChannelConnectionStrategy, extension_id: &str, base_url: Option<&str>, supports_private_delivery: bool, ) -> String { - let text = &connection.notices.connect_required; - match base_url { - Some(base) - if supports_private_delivery - && connection.strategy == ChannelConnectionStrategy::OAuth => - { - let base = base.trim_end_matches('/'); - let extension_id = - percent_encoding::utf8_percent_encode(extension_id, CONNECT_QUERY_VALUE); + let Some(base) = configured_origin(base_url) else { + return text.to_string(); + }; + let extension_id = percent_encoding::utf8_percent_encode(extension_id, CONNECT_QUERY_VALUE); + match strategy { + ChannelConnectionStrategy::OAuth if supports_private_delivery => { format!("{text} Or connect directly: {base}/chat?connect={extension_id}") } - _ => text.clone(), + ChannelConnectionStrategy::WebGeneratedCode => { + format!( + "{text} Finish setup here: {base}/extensions?configure={extension_id}&setup=workspace_bot" + ) + } + ChannelConnectionStrategy::DeviceLink => { + format!( + "{text} Finish setup here: {base}/extensions?configure={extension_id}&setup=personal_account" + ) + } + ChannelConnectionStrategy::OAuth | ChannelConnectionStrategy::AdminManagedChannels => { + text.to_string() + } } } +/// Pick the connect-notice policy and append the destination to it. +/// +/// `pairing_policy` is the pairing service's own copy when the extension has +/// one — for every production `WebGeneratedCode` channel, it does, and it wins +/// over the manifest branch below. The append therefore happens **after** the +/// choice, not inside one arm of it: an earlier revision decorated only the +/// manifest arm, which left every pairing-service extension on the raw +/// manifest text and made the fix invisible on the exact path #7887 reported. +/// +/// Split out of `GenericChannelHostAssembly::connection_notices` so the +/// pairing arm is testable: a `ChannelPairingService` needs seventeen +/// constructor fields, nine of them trait objects, and a test that cannot +/// reach the arm cannot protect it. +fn resolve_connection_notices( + pairing_policy: Option, + connection: Option<&ChannelConnectionDescriptor>, + extension_id: &str, + base_url: Option<&str>, + supports_private_delivery: bool, + fallback_name: &str, +) -> ChannelConnectionNoticePolicy { + let mut policy = pairing_policy + .or_else(|| { + connection.map(|connection| ChannelConnectionNoticePolicy { + connect_required: connection.notices.connect_required.clone(), + paired: connection.notices.paired.clone(), + already_paired_same_user: connection.notices.already_paired_same_user.clone(), + already_bound_to_other_user: connection.notices.already_bound_to_other_user.clone(), + expired_or_unknown: connection.notices.expired_or_unknown.clone(), + }) + }) + .unwrap_or_else(|| ChannelConnectionNoticePolicy::generic(fallback_name)); + if let Some(connection) = connection { + policy.connect_required = connect_required_notice( + &policy.connect_required, + connection.strategy, + extension_id, + base_url, + supports_private_delivery, + ); + } + policy +} + /// Whether a channel's bound reply adapter delivers an /// [`OutboundVisibility::EphemeralTo`](ironclaw_extension_contracts::channel_adapter::OutboundVisibility) /// reply privately. A channel with no reply half emits nothing source-routed, @@ -929,40 +1024,31 @@ impl GenericChannelHostAssembly { } /// The connect notice wording for one extension: the pairing service's - /// own policy when it has one, otherwise the manifest connection policy - /// for non-pairing strategies such as `device_link`, then generic copy. + /// own policy when it has one, otherwise the manifest connection policy, + /// then generic copy — with the destination appended either way. + /// + /// Fetches the pairing policy and delegates; the decision itself lives in + /// [`resolve_connection_notices`] so the pairing branch is reachable from + /// a test without standing up a whole [`ChannelPairingService`]. fn connection_notices(&self, source: &HostedChannelSource) -> ChannelConnectionNoticePolicy { - self.deps + let pairing_policy = self + .deps .channel_pairing .as_ref() .and_then(|registry| registry.get(source.extension_id())) - .map(|service| service.connection_notices().clone()) - .or_else(|| { - source - .resolved() - .channel - .as_ref() - .and_then(|channel| channel.connection.as_ref()) - .map(|connection| ChannelConnectionNoticePolicy { - connect_required: connect_required_notice( - connection, - source.extension_id(), - self.deps.connect_link_base_url.as_deref(), - source.supports_private_delivery(), - ), - paired: connection.notices.paired.clone(), - already_paired_same_user: connection - .notices - .already_paired_same_user - .clone(), - already_bound_to_other_user: connection - .notices - .already_bound_to_other_user - .clone(), - expired_or_unknown: connection.notices.expired_or_unknown.clone(), - }) - }) - .unwrap_or_else(|| ChannelConnectionNoticePolicy::generic(&source.resolved().name)) + .map(|service| service.connection_notices().clone()); + resolve_connection_notices( + pairing_policy, + source + .resolved() + .channel + .as_ref() + .and_then(|channel| channel.connection.as_ref()), + source.extension_id(), + self.deps.connect_link_base_url.as_deref(), + source.supports_private_delivery(), + &source.resolved().name, + ) } /// The live conversation-binding service the assembly registered for one @@ -1348,6 +1434,177 @@ mod tests { } } + /// The two strategies must land on DIFFERENT ceremonies. + /// + /// `useSetupLanding.ts` accepts `personal_account` and `workspace_bot`, + /// and opens the connection-CHOICE screen for anything else — including a + /// missing value. So merging these arms back together (they were one arm + /// until #7887) does not fail loudly: it silently offers a workspace-bot + /// pairer the personal-account path, which is the wrong-ceremony + /// confusion this issue exists to remove, with a click attached. + /// + /// Asserted as a difference rather than two literals so the test states + /// the invariant, not just today's strings. Verified red by re-merging the + /// arms: the `assert_ne!` fires. + #[test] + fn each_strategy_names_its_own_setup_ceremony() { + let notice = |strategy| { + let descriptor = connection_descriptor(strategy); + connect_required_notice( + &descriptor.notices.connect_required, + strategy, + "telegram", + Some("https://app.example.com"), + true, + ) + }; + + let workspace = notice(ChannelConnectionStrategy::WebGeneratedCode); + let personal = notice(ChannelConnectionStrategy::DeviceLink); + + assert!(workspace.ends_with("&setup=workspace_bot"), "{workspace}"); + assert!(personal.ends_with("&setup=personal_account"), "{personal}"); + assert_ne!( + workspace, personal, + "a workspace-bot pairing and a personal-account link must not open the same ceremony" + ); + } + + /// #7887 regression: the destination must be appended to the policy the + /// PAIRING SERVICE supplied, not only to one built from the manifest. + /// + /// This is the arm the first fix missed. Every production + /// `WebGeneratedCode` channel registers a pairing service, and + /// `connection_notices` takes that service's copy verbatim before the + /// manifest branch runs — so decorating only the manifest branch left the + /// reported path on the raw, link-free text while every test stayed green. + /// The helper-level tests below cannot catch it: they call + /// `connect_required_notice` directly, and the e2e harness wires + /// `channel_pairing: None`, so both exercise the manifest arm only. + #[test] + fn pairing_service_notices_still_receive_the_destination() { + let descriptor = connection_descriptor(ChannelConnectionStrategy::WebGeneratedCode); + // What the pairing service hands over: its own copy, not the + // manifest's, and deliberately different text so a test that silently + // fell back to the manifest arm would be visible. + let pairing_policy = ChannelConnectionNoticePolicy { + connect_required: "Pair this account from the extension page.".to_string(), + paired: "paired".to_string(), + already_paired_same_user: "already".to_string(), + already_bound_to_other_user: "other".to_string(), + expired_or_unknown: "expired".to_string(), + }; + + let resolved = resolve_connection_notices( + Some(pairing_policy), + Some(&descriptor), + "telegram", + Some("https://app.example.com"), + true, + "Telegram", + ); + + assert_eq!( + resolved.connect_required, + "Pair this account from the extension page. Finish setup here: \ + https://app.example.com/extensions?configure=telegram&setup=workspace_bot", + "the pairing service's copy must keep its wording AND gain the address" + ); + // No origin: the pairing copy survives untouched rather than gaining a + // relative path. + let dark = resolve_connection_notices( + Some(ChannelConnectionNoticePolicy { + connect_required: "Pair this account from the extension page.".to_string(), + paired: "paired".to_string(), + already_paired_same_user: "already".to_string(), + already_bound_to_other_user: "other".to_string(), + expired_or_unknown: "expired".to_string(), + }), + Some(&descriptor), + "telegram", + None, + true, + "Telegram", + ); + assert_eq!( + dark.connect_required, "Pair this account from the extension page.", + "no configured origin must leave the pairing copy exactly as it was" + ); + } + + /// #7887, CX cell "Not paired yet × Telegram": a strategy whose connect + /// ceremony cannot be completed from the chat surface must still hand the + /// user a destination. + /// + /// `deep_link_template` does not serve this case, which is what the + /// `connect_notice_appends_the_link_only_for_oauth_with_a_base_url` copy + /// below used to assume. That template interpolates a `{code}` + /// (`channel_pairing.rs`) the sender has not been issued yet — a stranger + /// who has just DM'd the bot has no code — so Telegram's copy ("Connect + /// this Telegram account … from the Telegram extension in IronClaw, then + /// message me again") left the user with nowhere to click. + /// + /// The appended link is deliberately NOT `/chat?connect=`. That route + /// auto-installs and auto-starts a flow, which is exactly why it stays + /// gated on private delivery below. `/extensions?configure=` starts + /// nothing: a bystander who clicks it authenticates as themselves and + /// lands on their own page, so it is safe in a shared room and carries no + /// privacy condition — asserted here by rendering both + /// `supports_private_delivery` values and requiring the same copy. + #[test] + fn connect_notice_hands_a_web_app_link_to_strategies_that_cannot_self_serve() { + for (strategy, setup_param) in [ + (ChannelConnectionStrategy::WebGeneratedCode, "workspace_bot"), + (ChannelConnectionStrategy::DeviceLink, "personal_account"), + ] { + let descriptor = connection_descriptor(strategy); + for supports_private_delivery in [true, false] { + assert_eq!( + connect_required_notice( + &descriptor.notices.connect_required, + descriptor.strategy, + "telegram", + Some("https://app.example.com"), + supports_private_delivery + ), + format!( + "Connect your account. Finish setup here: \ + https://app.example.com/extensions?configure=telegram&setup={setup_param}" + ), + "{strategy:?} must hand over a destination naming its own ceremony \ + (supports_private_delivery={supports_private_delivery})" + ); + } + // A trailing slash on the configured origin must not produce + // `//extensions`. + assert_eq!( + connect_required_notice( + &descriptor.notices.connect_required, + descriptor.strategy, + "telegram", + Some("https://app.example.com/"), + true + ), + format!( + "Connect your account. Finish setup here: \ + https://app.example.com/extensions?configure=telegram&setup={setup_param}" + ) + ); + // Unset origin still ships dark rather than advertising a + // relative path into a customer conversation. + assert_eq!( + connect_required_notice( + &descriptor.notices.connect_required, + descriptor.strategy, + "telegram", + None, + true + ), + descriptor.notices.connect_required + ); + } + } + /// #7681: the one-click link is appended only for an OAuth-strategy channel /// on a deployment that configured a public web origin, and only when the /// channel's reply adapter delivers ephemeral replies privately. Every @@ -1359,33 +1616,84 @@ mod tests { let oauth = connection_descriptor(ChannelConnectionStrategy::OAuth); assert_eq!( - connect_required_notice(&oauth, "slack", Some("https://app.example.com"), true), + connect_required_notice( + &oauth.notices.connect_required, + oauth.strategy, + "slack", + Some("https://app.example.com"), + true + ), "Connect your account. Or connect directly: https://app.example.com/chat?connect=slack" ); // A trailing slash on the configured origin must not produce `//chat`. assert_eq!( - connect_required_notice(&oauth, "slack", Some("https://app.example.com/"), true), + connect_required_notice( + &oauth.notices.connect_required, + oauth.strategy, + "slack", + Some("https://app.example.com/"), + true + ), "Connect your account. Or connect directly: https://app.example.com/chat?connect=slack" ); // Unset origin ships dark: the notice stays link-free rather than // advertising an unreachable relative path. assert_eq!( - connect_required_notice(&oauth, "slack", None, true), + connect_required_notice( + &oauth.notices.connect_required, + oauth.strategy, + "slack", + None, + true + ), oauth.notices.connect_required ); - // A non-OAuth strategy carries its own deep link, so no link leaks - // into its copy even when an origin IS configured. + // An operator-provisioned channel returns the manifest copy verbatim + // even when an origin IS configured: the end user reading this notice + // cannot act on a configure page, so a link would be noise. + // + // `WebGeneratedCode`/`DeviceLink` were in this loop until #7887 on the + // assumption that they "carry their own deep link". They do not for + // this notice — see + // `connect_notice_hands_a_web_app_link_to_strategies_that_cannot_self_serve`, + // which now owns those two strategies. The invariant this test exists + // to protect is unchanged and asserted below: the *auto-starting* + // `/chat?connect=` route never ships to a room that cannot deliver + // privately. + let admin_managed = connection_descriptor(ChannelConnectionStrategy::AdminManagedChannels); + assert_eq!( + connect_required_notice( + &admin_managed.notices.connect_required, + admin_managed.strategy, + "slack", + Some("https://app.example.com"), + true + ), + admin_managed.notices.connect_required, + "AdminManagedChannels must return the manifest copy verbatim" + ); + // A blank origin is the same answer as an unset one for every + // strategy: a relative path is not a destination. for strategy in [ - ChannelConnectionStrategy::DeviceLink, + ChannelConnectionStrategy::OAuth, ChannelConnectionStrategy::WebGeneratedCode, + ChannelConnectionStrategy::DeviceLink, ChannelConnectionStrategy::AdminManagedChannels, ] { - let other = connection_descriptor(strategy); - assert_eq!( - connect_required_notice(&other, "slack", Some("https://app.example.com"), true), - other.notices.connect_required, - "{strategy:?} must return the manifest copy verbatim" - ); + let descriptor = connection_descriptor(strategy); + for blank in ["", " "] { + assert_eq!( + connect_required_notice( + &descriptor.notices.connect_required, + descriptor.strategy, + "slack", + Some(blank), + true + ), + descriptor.notices.connect_required, + "{strategy:?} must ship dark for a blank origin ({blank:?})" + ); + } } } @@ -1398,7 +1706,13 @@ mod tests { fn connect_notice_withholds_the_link_when_the_adapter_cannot_deliver_privately() { let oauth = connection_descriptor(ChannelConnectionStrategy::OAuth); assert_eq!( - connect_required_notice(&oauth, "slack", Some("https://app.example.com"), false), + connect_required_notice( + &oauth.notices.connect_required, + oauth.strategy, + "slack", + Some("https://app.example.com"), + false + ), oauth.notices.connect_required ); } @@ -1460,7 +1774,8 @@ mod tests { let oauth = connection_descriptor(ChannelConnectionStrategy::OAuth); assert_eq!( connect_required_notice( - &oauth, + &oauth.notices.connect_required, + oauth.strategy, "sl ack&evil=1", Some("https://app.example.com"), true diff --git a/crates/extensions/ironclaw_extension_host/src/channel_host/e2e_auth_challenge.rs b/crates/extensions/ironclaw_extension_host/src/channel_host/e2e_auth_challenge.rs index af767ee95b8..739b2a2256a 100644 --- a/crates/extensions/ironclaw_extension_host/src/channel_host/e2e_auth_challenge.rs +++ b/crates/extensions/ironclaw_extension_host/src/channel_host/e2e_auth_challenge.rs @@ -18,12 +18,36 @@ type AuthChallengeCall = ( Vec, ); -#[derive(Debug, Default)] +#[derive(Debug)] pub(super) struct FakeAuthChallengeProvider { calls: Mutex>, + /// Which challenge the engine serves for [`AUTH_GATE`]. Defaults to + /// `OAuthUrl` (the serviceable shape the setup-link tests drive); + /// [`Self::device_link`] selects the shape that is never serviceable from + /// a chat surface, so delivery takes the unavailable-message path. + kind: AuthPromptChallengeKind, +} + +impl Default for FakeAuthChallengeProvider { + fn default() -> Self { + Self { + calls: Mutex::new(Vec::new()), + kind: AuthPromptChallengeKind::OAuthUrl, + } + } } impl FakeAuthChallengeProvider { + /// A device-link challenge: no authorization URL, and + /// `auth_prompt_is_serviceable` rejects it on every surface, so the run is + /// auto-denied and the user gets the unavailable copy instead of a prompt. + pub(super) fn device_link() -> Self { + Self { + calls: Mutex::new(Vec::new()), + kind: AuthPromptChallengeKind::DeviceLink, + } + } + pub(super) fn assert_single_call(&self) { let calls = self .calls @@ -77,15 +101,22 @@ impl AuthChallengeProvider for FakeAuthChallengeProvider { if gate_ref != AUTH_GATE { return Ok(None); } + let authorization_url = match self.kind { + // A device link has no URL to follow: the exchange happens on the + // vendor's own client, which is exactly why it is unserviceable + // here. + AuthPromptChallengeKind::DeviceLink => None, + _ => Some( + OAuthAuthorizationUrl::new("https://provider.example/oauth".to_string()) + .expect("static OAuth URL should be valid"), // safety: static test URL is valid. + ), + }; Ok(Some(AuthChallengeView { - kind: AuthPromptChallengeKind::OAuthUrl, + kind: self.kind, provider: AuthProviderId::new("provider".to_string()) .expect("static provider id should be valid"), // safety: static test provider id is valid. account_label: None, - authorization_url: Some( - OAuthAuthorizationUrl::new("https://provider.example/oauth".to_string()) - .expect("static OAuth URL should be valid"), // safety: static test URL is valid. - ), + authorization_url, expires_at: None, pairing: None, device_link: None, diff --git a/crates/extensions/ironclaw_extension_host/src/channel_host/e2e_tests.rs b/crates/extensions/ironclaw_extension_host/src/channel_host/e2e_tests.rs index 94994c03f51..62f3ca9bd1d 100644 --- a/crates/extensions/ironclaw_extension_host/src/channel_host/e2e_tests.rs +++ b/crates/extensions/ironclaw_extension_host/src/channel_host/e2e_tests.rs @@ -636,6 +636,9 @@ async fn build_harness_with_options(options: HarnessOptions) -> Harness { let model_preferences = Arc::new(ChannelModelPreferences::default()); let workflow_factory = Arc::new(ironclaw_assistant::RebornChannelWorkflowFactory::new( ironclaw_assistant::RebornChannelWorkflowServices { + // One deployment origin serves both consumers: the connect notice + // and the auth-unavailable message. + setup_link_base_url: options.connect_link_base_url.clone(), filesystem: Arc::new(InMemoryBackend::new()), thread_service: Arc::new(threads.clone()), turn_coordinator: Arc::new(coordinator.clone()), @@ -1625,6 +1628,7 @@ async fn triggered_approval_prompt_route_resolves_dm_approve_on_foreign_scope() let fixture = background_run_notifier_fixture(Arc::clone(&outbound_store)).await; let driver_egress = fixture.driver_egress.clone(); let services = RunDeliveryServices { + setup_link_base_url: None, project_filesystem: Arc::new(ironclaw_assistant::NoProjectFilesystem), binding_service: Arc::new(NoopTriggeredBindingService), thread_service: Arc::new(threads), @@ -1906,6 +1910,7 @@ async fn triggered_auth_prompt_route_delivers_dm_setup_link_on_foreign_scope() { let fixture = background_run_notifier_fixture(Arc::clone(&outbound_store)).await; let driver_egress = fixture.driver_egress.clone(); let services = RunDeliveryServices { + setup_link_base_url: None, project_filesystem: Arc::new(ironclaw_assistant::NoProjectFilesystem), binding_service: Arc::new(NoopTriggeredBindingService), thread_service: Arc::new(threads), @@ -2034,6 +2039,7 @@ async fn triggered_auth_prompt_to_non_dm_channel_redacts_the_link_and_parks_the_ let fixture = background_run_notifier_fixture(Arc::clone(&outbound_store)).await; let driver_egress = fixture.driver_egress.clone(); let services = RunDeliveryServices { + setup_link_base_url: None, project_filesystem: Arc::new(ironclaw_assistant::NoProjectFilesystem), binding_service: Arc::new(NoopTriggeredBindingService), thread_service: Arc::new(threads), @@ -3112,6 +3118,78 @@ async fn slack_dm_delivers_auth_prompt_with_setup_link_after_immediate_ack() { auth_provider.assert_single_call(); } +/// #7887, CX cell "Link owed": a challenge that cannot be completed from a +/// chat surface must hand the user an address, not just name the destination. +/// +/// This is the delivery-path counterpart to the unit coverage on +/// `unserviceable_auth_prompt_message`. A device-link challenge is never +/// serviceable (`auth_prompt_is_serviceable`), so the run is auto-denied and +/// the user receives the unavailable copy instead of a prompt — that copy +/// already said "open the Ironclaw web app" and gave nothing to click. +/// +/// Driven end to end through the real observer and delivery coordinator, and +/// asserted on the message actually posted to the channel, because the string +/// is only a defect once it reaches a user. +#[tokio::test] +async fn slack_dm_device_link_auth_prompt_delivers_the_web_app_address() { + let auth_challenges: Arc = + Arc::new(FakeAuthChallengeProvider::device_link()); + let mut options = HarnessOptions::new(TurnMode::BlockAuth); + options.auth_challenges = Some(auth_challenges); + options.connect_link_base_url = Some("https://app.example.com".to_string()); + let harness = build_harness_with_options(options).await; + + let response = harness + .post_event(dm_message("Ev-auth", "needs auth")) + .await; + assert_eq!(response.status(), StatusCode::OK); + harness.drain().await; + + let messages = harness.slack_messages(); + assert_eq!(messages.len(), 1); + let text = messages[0]["text"].as_str().expect("Slack message text"); + assert!( + text.contains("https://app.example.com/extensions"), + "a device-link user must be handed the web app address: {text}" + ); + // The OAuth setup link is a different, single-use artifact and must not + // appear for a challenge that has no authorization URL at all. + assert!( + !text.contains("Setup link:"), + "a device-link challenge has no OAuth setup link to offer: {text}" + ); +} + +/// The ships-dark half of the test above: with no published origin the copy +/// keeps its wording and never advertises a relative path into a customer +/// conversation. +#[tokio::test] +async fn slack_dm_device_link_auth_prompt_ships_dark_without_a_configured_origin() { + let auth_challenges: Arc = + Arc::new(FakeAuthChallengeProvider::device_link()); + let mut options = HarnessOptions::new(TurnMode::BlockAuth); + options.auth_challenges = Some(auth_challenges); + let harness = build_harness_with_options(options).await; + + let response = harness + .post_event(dm_message("Ev-auth", "needs auth")) + .await; + assert_eq!(response.status(), StatusCode::OK); + harness.drain().await; + + let messages = harness.slack_messages(); + assert_eq!(messages.len(), 1); + let text = messages[0]["text"].as_str().expect("Slack message text"); + assert!( + text.contains("web app"), + "the copy still has to name the destination in words: {text}" + ); + assert!( + !text.contains("/extensions"), + "no origin means no address, never a relative path: {text}" + ); +} + #[tokio::test] async fn slack_channel_auth_prompt_omits_setup_link_after_immediate_ack() { let auth_challenges: Arc = diff --git a/crates/extensions/ironclaw_extension_manager/src/install_guidance.rs b/crates/extensions/ironclaw_extension_manager/src/install_guidance.rs index 4f5d75ec411..a6eb17c6230 100644 --- a/crates/extensions/ironclaw_extension_manager/src/install_guidance.rs +++ b/crates/extensions/ironclaw_extension_manager/src/install_guidance.rs @@ -146,10 +146,7 @@ const SETUP_QUERY_VALUE: &percent_encoding::AsciiSet = &percent_encoding::NON_AL /// its cards on, so the landing resolves against the caller's own inventory /// without a second identity to keep in sync. pub(crate) fn personal_setup_link(base_url: Option<&str>, package_id: &str) -> Option { - let base = base_url?.trim().trim_end_matches('/'); - if base.is_empty() { - return None; - } + let base = ironclaw_extension_contracts::connect_link::validated_connect_link_origin(base_url)?; let package_id = percent_encoding::utf8_percent_encode(package_id, SETUP_QUERY_VALUE); Some(format!( "{base}/extensions?configure={package_id}&setup=personal_account" diff --git a/crates/extensions/packages/telegram/src/linked/login.rs b/crates/extensions/packages/telegram/src/linked/login.rs index 381f124becd..34ccb86c5fc 100644 --- a/crates/extensions/packages/telegram/src/linked/login.rs +++ b/crates/extensions/packages/telegram/src/linked/login.rs @@ -128,10 +128,12 @@ impl TelegramDeviceLinkAdapter { /// failure the manifest promises ("fails closed with an explicit error /// rather than making every existing install invalid"). fn identity(&self) -> Result<&MtprotoAppIdentity, DeviceLinkError> { - self.identity.as_ref().ok_or(DeviceLinkError::Internal { - reason: "the deployment has not configured its MTProto application identity \ - (telegram_api_id / telegram_api_hash)", - }) + self.identity + .as_ref() + .ok_or(DeviceLinkError::NotConfigured { + reason: "the deployment has not configured its MTProto application identity \ + (telegram_api_id / telegram_api_hash)", + }) } /// Abort every parked link, logging out any that Telegram already diff --git a/crates/product/ironclaw_assistant/src/channel_workflow.rs b/crates/product/ironclaw_assistant/src/channel_workflow.rs index ebe568e5310..03c384e31b7 100644 --- a/crates/product/ironclaw_assistant/src/channel_workflow.rs +++ b/crates/product/ironclaw_assistant/src/channel_workflow.rs @@ -122,6 +122,11 @@ pub struct ChannelWorkflowDeliveryServices { /// Everything the factory composes per-extension graphs from. Supplied once by /// composition; nothing here varies per extension. pub struct RebornChannelWorkflowServices { + /// The deployment's public web origin, when one is published. Forwarded + /// into [`RunDeliveryServices`] so a chat user blocked on a challenge + /// their surface cannot satisfy is handed the Extensions page address + /// rather than told to go find it (#7887). + pub setup_link_base_url: Option, /// Substrate the per-extension durable workflow state is mounted on. pub filesystem: Arc, pub thread_service: Arc, @@ -192,6 +197,7 @@ impl RebornChannelWorkflowFactory { } }; let services = RunDeliveryServices { + setup_link_base_url: self.services.setup_link_base_url.clone(), project_filesystem: Arc::clone(&delivery.project_filesystem), binding_service: Arc::new(TriggeredNoopConversationBindingService), thread_service: Arc::clone(&self.services.thread_service), @@ -465,6 +471,7 @@ impl RebornChannelWorkflowFactory { let notice_thread_id = ThreadId::new(format!("{extension_id}-channel-notices")) .map_err(|error| format!("invalid channel-notice thread id: {error}"))?; let services = RunDeliveryServices { + setup_link_base_url: self.services.setup_link_base_url.clone(), project_filesystem: Arc::clone(&delivery.project_filesystem), binding_service: binding, thread_service: Arc::clone(&self.services.thread_service), diff --git a/crates/product/ironclaw_assistant/src/run_delivery.rs b/crates/product/ironclaw_assistant/src/run_delivery.rs index 902a5958513..97e974107f2 100644 --- a/crates/product/ironclaw_assistant/src/run_delivery.rs +++ b/crates/product/ironclaw_assistant/src/run_delivery.rs @@ -27,6 +27,7 @@ use std::num::NonZeroUsize; use std::sync::{Arc, Mutex}; use std::time::Duration; +use ironclaw_extension_contracts::auth_prompt::AuthPromptView; use ironclaw_extension_contracts::channel_adapter::{ OutboundPart, OutboundVisibility, ReactionAction, RunReaction, }; @@ -129,6 +130,17 @@ pub fn triggered_run_delivery_settings() -> RunDeliverySettings { /// `Arc`s; cloning shares them. #[derive(Clone)] pub struct RunDeliveryServices { + /// The deployment's public web origin, when one is published. + /// + /// Lets a chat user be handed the Extensions page address when a + /// challenge cannot be completed from their surface (#7887). `None` keeps + /// the link-free copy rather than advertising a relative path — the same + /// fallback the channel connect notice uses, fed by the same + /// `connect_link_base_url_from_env` read in composition. + /// + /// Lives here rather than in `RunDeliverySettings` because that struct is + /// `Copy` and this is an owned `String`. + pub setup_link_base_url: Option, pub binding_service: Arc, pub thread_service: Arc, pub turn_coordinator: Arc, @@ -412,6 +424,23 @@ pub(crate) fn turn_scope_from_thread_scope( } impl RunDeliveryServices { + /// The notice shown when an auth challenge cannot be completed from a + /// chat surface, with the web app's address appended when this + /// deployment published one. + /// + /// The live-run path (`observer.rs`) and the triggered/background-run + /// path (`triggered.rs`) previously called + /// `prompts::unserviceable_auth_prompt_message` independently, each + /// re-reading `setup_link_base_url` for itself. That is the exact defect + /// class this PR already hit twice (#7887's two arms): a pairing left to + /// two call sites drifts the moment one of them changes and the other + /// does not. This method is the one place that reads + /// `setup_link_base_url` for this message, so the triggered path cannot + /// diverge from the live path. + pub(crate) fn unserviceable_auth_message(&self, view: Option<&AuthPromptView>) -> String { + prompts::unserviceable_auth_prompt_message(view, self.setup_link_base_url.as_deref()) + } + /// Best-effort publication of bounded, metadata-only run state to the /// authenticated WebUI Inbox. External channel delivery remains separate. pub(crate) async fn publish_inbox_notification( diff --git a/crates/product/ironclaw_assistant/src/run_delivery/observer.rs b/crates/product/ironclaw_assistant/src/run_delivery/observer.rs index 90e0eaa3897..5e497270899 100644 --- a/crates/product/ironclaw_assistant/src/run_delivery/observer.rs +++ b/crates/product/ironclaw_assistant/src/run_delivery/observer.rs @@ -1049,7 +1049,7 @@ impl RunDeliveryObserver { scope.clone(), Some(run_id), envelope.external_conversation_ref(), - prompts::unserviceable_auth_prompt_message(view.as_ref()), + &self.services.unserviceable_auth_message(view.as_ref()), format!("auth-unavailable:{run_id}"), ) .await; diff --git a/crates/product/ironclaw_assistant/src/run_delivery/prompts.rs b/crates/product/ironclaw_assistant/src/run_delivery/prompts.rs index d9602f51efe..1ef1eb2c682 100644 --- a/crates/product/ironclaw_assistant/src/run_delivery/prompts.rs +++ b/crates/product/ironclaw_assistant/src/run_delivery/prompts.rs @@ -294,11 +294,43 @@ pub(crate) fn auth_prompt_is_serviceable(view: &AuthPromptView) -> bool { /// The notice for a challenge that cannot be serviced here. Only a typed /// credential-entry challenge earns the "this needs an API key" copy -- /// telling a pairing user to go find an API key is simply wrong. -pub(crate) fn unserviceable_auth_prompt_message(view: Option<&AuthPromptView>) -> &'static str { - match view.and_then(|view| view.challenge_kind) { +/// The Extensions page URL, when the deployment published a public origin. +/// +/// **The whole page, not `?configure=`.** A deep link would need an +/// extension id, and [`AuthPromptView`] carries only `provider` — a *vendor* +/// id. A single vendor can back several distinct extensions, so keying the +/// link off it would send the user to the wrong extension's modal, and the +/// field's own contract says presentation is "selected by `challenge_kind` and +/// `connection.strategy`, never by provider name". The user already knows +/// which extension they asked about; what they lacked was an address. +/// +/// `None` and blank are the same answer: a notice must never advertise a +/// relative path into a customer conversation. +fn extensions_page_link(base_url: Option<&str>) -> Option { + let base = ironclaw_extension_contracts::connect_link::validated_connect_link_origin(base_url)?; + Some(format!("{base}/extensions")) +} + +/// The message shown when a challenge cannot be completed from a chat surface, +/// with the web app's address appended when the deployment published one. +/// +/// Every one of these messages ends by telling the user to open the web app +/// (`prompts/device_link_auth_unavailable.md` and its siblings). Before #7887 +/// that instruction named the destination in words and gave nothing to click. +/// A device-link challenge is never serviceable on a chat surface, so this is +/// the message that reaches the user there — and it dead-ended them. +pub(crate) fn unserviceable_auth_prompt_message( + view: Option<&AuthPromptView>, + setup_link_base_url: Option<&str>, +) -> String { + let message = match view.and_then(|view| view.challenge_kind) { Some(AuthPromptChallengeKind::ManualToken) => MANUAL_TOKEN_AUTH_UNAVAILABLE_MESSAGE, Some(AuthPromptChallengeKind::DeviceLink) => DEVICE_LINK_AUTH_UNAVAILABLE_MESSAGE, _ => AUTH_UNAVAILABLE_MESSAGE, + }; + match extensions_page_link(setup_link_base_url) { + Some(link) => format!("{message} {link}"), + None => message.to_string(), } } @@ -530,7 +562,7 @@ mod tests { "a pairing challenge with no usable code cannot be serviced" ); assert_eq!( - unserviceable_auth_prompt_message(Some(&pairing_view)), + unserviceable_auth_prompt_message(Some(&pairing_view), None), AUTH_UNAVAILABLE_MESSAGE, "a pairing user must not be told to go find an API key" ); @@ -587,7 +619,7 @@ mod tests { "pasting a secret into chat stores it in the conversation" ); assert_eq!( - unserviceable_auth_prompt_message(Some(&token_view)), + unserviceable_auth_prompt_message(Some(&token_view), None), MANUAL_TOKEN_AUTH_UNAVAILABLE_MESSAGE ); } @@ -607,7 +639,7 @@ mod tests { "a device link cannot be driven from a chat surface" ); assert_eq!( - unserviceable_auth_prompt_message(Some(&link_view)), + unserviceable_auth_prompt_message(Some(&link_view), None), DEVICE_LINK_AUTH_UNAVAILABLE_MESSAGE ); assert_eq!( @@ -616,7 +648,7 @@ mod tests { "the body must never fall through to the raw gate text" ); assert_ne!( - unserviceable_auth_prompt_message(Some(&link_view)), + unserviceable_auth_prompt_message(Some(&link_view), None), MANUAL_TOKEN_AUTH_UNAVAILABLE_MESSAGE, "a device-link user has no API key to go and find" ); @@ -624,6 +656,27 @@ mod tests { DEVICE_LINK_AUTH_UNAVAILABLE_MESSAGE.contains("web app"), "the copy has to name where the link CAN be completed" ); + // #7887: naming the destination is not enough — a chat user was told + // to "open the Ironclaw web app" with nothing to click. When the + // deployment published an origin, the address travels with the copy. + assert_eq!( + unserviceable_auth_prompt_message(Some(&link_view), Some("https://app.example.com")), + format!("{DEVICE_LINK_AUTH_UNAVAILABLE_MESSAGE} https://app.example.com/extensions"), + "the copy has to hand over the address, not just name it" + ); + // A trailing slash must not produce `//extensions`, and a blank origin + // is the same answer as an unset one: never advertise a relative path. + assert_eq!( + unserviceable_auth_prompt_message(Some(&link_view), Some("https://app.example.com/")), + format!("{DEVICE_LINK_AUTH_UNAVAILABLE_MESSAGE} https://app.example.com/extensions") + ); + for blank in ["", " "] { + assert_eq!( + unserviceable_auth_prompt_message(Some(&link_view), Some(blank)), + DEVICE_LINK_AUTH_UNAVAILABLE_MESSAGE, + "a blank origin ({blank:?}) must ship dark" + ); + } assert!( !DEVICE_LINK_AUTH_UNAVAILABLE_MESSAGE.ends_with('\n'), "the prompt file's trailing newline must be trimmed before delivery" @@ -643,7 +696,7 @@ mod tests { AuthPromptChallengeKind::Other )))); assert_eq!( - unserviceable_auth_prompt_message(None), + unserviceable_auth_prompt_message(None, None), AUTH_UNAVAILABLE_MESSAGE, "an absent view cannot imply a credential type" ); diff --git a/crates/product/ironclaw_assistant/src/run_delivery/triggered.rs b/crates/product/ironclaw_assistant/src/run_delivery/triggered.rs index 797abbeaddf..e67efbd3f34 100644 --- a/crates/product/ironclaw_assistant/src/run_delivery/triggered.rs +++ b/crates/product/ironclaw_assistant/src/run_delivery/triggered.rs @@ -1154,7 +1154,7 @@ async fn notification_plan_for_state( // or an unknown challenge). Deny: cancel the parked run and // notify every channel. Typing a secret into a chat is // never an option. - let unavailable = prompts::unserviceable_auth_prompt_message(view.as_ref()); + let unavailable = services.unserviceable_auth_message(view.as_ref()); cancel_auth_blocked_run( services.turn_coordinator.as_ref(), services.auth_flow_cancel.as_deref(), diff --git a/crates/product/ironclaw_assistant/tests/run_delivery_contract.rs b/crates/product/ironclaw_assistant/tests/run_delivery_contract.rs index bd00d2c6d96..b9a9dbb14b6 100644 --- a/crates/product/ironclaw_assistant/tests/run_delivery_contract.rs +++ b/crates/product/ironclaw_assistant/tests/run_delivery_contract.rs @@ -1072,6 +1072,7 @@ fn build_harness_with_gate_ports( }, )); let services = RunDeliveryServices { + setup_link_base_url: None, binding_service: Arc::new(StaticBindingService { binding: resolved_binding, fail: bind_fails, @@ -2907,6 +2908,7 @@ fn build_triggered_harness_with_turns_catalog( }, )); let services = RunDeliveryServices { + setup_link_base_url: None, binding_service: Arc::new(StaticBindingService { binding: binding(), fail: true, @@ -4477,6 +4479,7 @@ fn notify_user_fixture( .insert(ReplyTargetBindingRef::new(entry.binding_ref).expect("binding ref")); } let services = RunDeliveryServices { + setup_link_base_url: None, binding_service: Arc::new(StaticBindingService { binding: binding(), fail: true, diff --git a/crates/product/ironclaw_webui/frontend/src/components/device-link-panel.test.ts b/crates/product/ironclaw_webui/frontend/src/components/device-link-panel.test.ts index 744c81d97ba..74fc6c83787 100644 --- a/crates/product/ironclaw_webui/frontend/src/components/device-link-panel.test.ts +++ b/crates/product/ironclaw_webui/frontend/src/components/device-link-panel.test.ts @@ -866,6 +866,30 @@ test("DeviceLinkPanel offers 'start again' on a restartable failure and refuses assert.ok(!stringify(terminalView).includes("deviceLink.startAgain")); assert.ok(stringify(terminalView).includes("deviceLink.cannotRetry")); + // #7887 follow-up: a deployment that never configured this ceremony is also + // terminal, but the account is fine. Reusing `cannotRetry` here asserted + // "this account cannot be linked" and sent the user to debug something that + // was not broken, while the remedy was an admin page away. + const unconfigured = createHarness({ + startResponses: [ + response( + wireFrame({ + step: DEVICE_LINK_STEPS.failed, + instructions: "This deployment is missing the settings this connection needs.", + error_code: "not_configured", + restartable: false, + }), + ), + ], + }); + const unconfiguredView = stringify(await unconfigured.mount()); + assert.ok(!unconfiguredView.includes("deviceLink.startAgain")); + assert.ok(unconfiguredView.includes("deviceLink.setupIncomplete")); + assert.ok( + !unconfiguredView.includes("deviceLink.cannotRetry"), + "a configuration gap must not claim the account cannot be linked", + ); + const conflicted = createHarness({ startResponses: [ response( diff --git a/crates/product/ironclaw_webui/frontend/src/components/device-link-panel.tsx b/crates/product/ironclaw_webui/frontend/src/components/device-link-panel.tsx index 419c18e0af7..3b12d315d55 100644 --- a/crates/product/ironclaw_webui/frontend/src/components/device-link-panel.tsx +++ b/crates/product/ironclaw_webui/frontend/src/components/device-link-panel.tsx @@ -488,7 +488,17 @@ export function DeviceLinkPanel({ ) : (

- {t("deviceLink.cannotRetry", { name })} + {/* A deployment that never configured this ceremony is + terminal for the user but says nothing about their + account; `cannotRetry` asserts the account cannot be + linked, which sends them to debug something that is not + broken (#7887 follow-up). */} + {t( + frame.errorCode === "not_configured" + ? "deviceLink.setupIncomplete" + : "deviceLink.cannotRetry", + { name }, + )}

)} diff --git a/crates/product/ironclaw_webui/frontend/src/components/device-link-translations.ts b/crates/product/ironclaw_webui/frontend/src/components/device-link-translations.ts index 9b6c4ad8c5a..df27266d6a9 100644 --- a/crates/product/ironclaw_webui/frontend/src/components/device-link-translations.ts +++ b/crates/product/ironclaw_webui/frontend/src/components/device-link-translations.ts @@ -42,6 +42,9 @@ registerPack("en", { "deviceLink.revokeHint": "IronClaw now shows up as a device in {name}. If you ever see a device you do not recognize, revoke it there.", "deviceLink.startAgain": "Start again", "deviceLink.cannotRetry": "This {name} account cannot be linked.", + // Terminal for the user, but nothing is wrong with their account — so this + // must not reuse `cannotRetry`, which asserts exactly that (#7887 follow-up). + "deviceLink.setupIncomplete": "Linking a {name} account is not set up on this deployment yet. An administrator needs to finish it in Admin \u2192 Configuration.", "deviceLink.error.expired": "The code expired before it was used.", "deviceLink.error.unknown_flow": "This link is no longer open.", "deviceLink.error.declined": "The device was refused.", @@ -51,5 +54,6 @@ registerPack("en", { "deviceLink.error.identity_conflict": "This account is already linked. Unlink it from the IronClaw account where it is connected, then try again.", "deviceLink.error.vendor_unavailable": "The service is temporarily unavailable.", "deviceLink.error.custody_failed": "The link could not be saved securely.", + "deviceLink.error.not_configured": "This deployment is missing the settings this connection needs.", "deviceLink.error.internal": "Something went wrong while linking.", }); diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/ar.ts b/crates/product/ironclaw_webui/frontend/src/i18n/ar.ts index 4626acf94d2..736ed795dfc 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/ar.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/ar.ts @@ -1676,6 +1676,7 @@ registerPack("ar", { "deviceLink.revokeHint": "يظهر IronClaw الآن كجهاز في {name}. إذا رأيت جهازًا لا تعرفه، فقم بإلغائه من هناك.", "deviceLink.startAgain": "ابدأ من جديد", "deviceLink.cannotRetry": "لا يمكن ربط حساب {name} هذا.", + "deviceLink.setupIncomplete": "ربط حساب {name} غير مُعدّ بعد على هذا النظام. يجب على المسؤول إكماله من الإدارة → الإعدادات.", "deviceLink.error.expired": "انتهت صلاحية الرمز قبل استخدامه.", "deviceLink.error.unknown_flow": "لم يعد هذا الربط مفتوحًا.", "deviceLink.error.declined": "تم رفض الجهاز.", @@ -1685,5 +1686,6 @@ registerPack("ar", { "deviceLink.error.identity_conflict": "هذا الحساب مرتبط بالفعل. ألغِ ربطه من حساب IronClaw المتصل به، ثم حاول مرة أخرى.", "deviceLink.error.vendor_unavailable": "الخدمة غير متاحة مؤقتًا.", "deviceLink.error.custody_failed": "تعذّر حفظ الربط بأمان.", + "deviceLink.error.not_configured": "يفتقر هذا النظام إلى الإعدادات اللازمة لهذا الاتصال.", "deviceLink.error.internal": "حدث خطأ ما أثناء الربط." }); diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/de.ts b/crates/product/ironclaw_webui/frontend/src/i18n/de.ts index 3d4ea2e7492..c81155299d9 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/de.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/de.ts @@ -1676,6 +1676,7 @@ registerPack("de", { "deviceLink.revokeHint": "IronClaw erscheint jetzt als Gerät in {name}. Wenn du dort ein unbekanntes Gerät siehst, widerrufe es.", "deviceLink.startAgain": "Neu starten", "deviceLink.cannotRetry": "Dieses {name}-Konto kann nicht verknüpft werden.", + "deviceLink.setupIncomplete": "Das Verknüpfen eines {name}-Kontos ist auf dieser Instanz noch nicht eingerichtet. Ein Administrator muss dies unter Admin → Konfiguration abschließen.", "deviceLink.error.expired": "Der Code ist abgelaufen, bevor er verwendet wurde.", "deviceLink.error.unknown_flow": "Diese Verknüpfung ist nicht mehr offen.", "deviceLink.error.declined": "Das Gerät wurde abgelehnt.", @@ -1685,5 +1686,6 @@ registerPack("de", { "deviceLink.error.identity_conflict": "Dieses Konto ist bereits verknüpft. Trennen Sie es von dem verbundenen IronClaw-Konto und versuchen Sie es erneut.", "deviceLink.error.vendor_unavailable": "Der Dienst ist vorübergehend nicht verfügbar.", "deviceLink.error.custody_failed": "Die Verknüpfung konnte nicht sicher gespeichert werden.", + "deviceLink.error.not_configured": "Auf dieser Instanz fehlen die Einstellungen, die diese Verbindung benötigt.", "deviceLink.error.internal": "Beim Verknüpfen ist etwas schiefgelaufen." }); diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/es.ts b/crates/product/ironclaw_webui/frontend/src/i18n/es.ts index 763ee0114d7..6fc7acdf0fb 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/es.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/es.ts @@ -1677,6 +1677,7 @@ registerPack("es", { "deviceLink.revokeHint": "IronClaw aparece ahora como un dispositivo en {name}. Si ves un dispositivo que no reconoces, revócalo allí.", "deviceLink.startAgain": "Empezar de nuevo", "deviceLink.cannotRetry": "Esta cuenta de {name} no se puede vincular.", + "deviceLink.setupIncomplete": "La vinculación de una cuenta de {name} aún no está configurada en esta instancia. Un administrador debe completar la configuración en Admin → Configuración.", "deviceLink.error.expired": "El código caducó antes de usarse.", "deviceLink.error.unknown_flow": "Esta vinculación ya no está abierta.", "deviceLink.error.declined": "Se rechazó el dispositivo.", @@ -1686,5 +1687,6 @@ registerPack("es", { "deviceLink.error.identity_conflict": "Esta cuenta ya está vinculada. Desvincúlala de la cuenta de IronClaw donde está conectada y vuelve a intentarlo.", "deviceLink.error.vendor_unavailable": "El servicio no está disponible temporalmente.", "deviceLink.error.custody_failed": "No se pudo guardar la vinculación de forma segura.", + "deviceLink.error.not_configured": "A esta instancia le faltan los ajustes que necesita esta conexión.", "deviceLink.error.internal": "Algo salió mal durante la vinculación." }); diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/fr.ts b/crates/product/ironclaw_webui/frontend/src/i18n/fr.ts index c2fbe68ce4e..92a47d4ede6 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/fr.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/fr.ts @@ -1676,6 +1676,7 @@ registerPack("fr", { "deviceLink.revokeHint": "IronClaw apparaît désormais comme un appareil dans {name}. Si vous voyez un appareil inconnu, révoquez-le depuis cette liste.", "deviceLink.startAgain": "Recommencer", "deviceLink.cannotRetry": "Ce compte {name} ne peut pas être associé.", + "deviceLink.setupIncomplete": "La liaison d'un compte {name} n'est pas encore configurée sur ce déploiement. Un administrateur doit terminer la configuration dans Admin → Configuration.", "deviceLink.error.expired": "Le code a expiré avant d'être utilisé.", "deviceLink.error.unknown_flow": "Cette association n'est plus ouverte.", "deviceLink.error.declined": "L'appareil a été refusé.", @@ -1685,5 +1686,6 @@ registerPack("fr", { "deviceLink.error.identity_conflict": "Ce compte est déjà associé. Dissociez-le du compte IronClaw auquel il est connecté, puis réessayez.", "deviceLink.error.vendor_unavailable": "Le service est temporairement indisponible.", "deviceLink.error.custody_failed": "L'association n'a pas pu être enregistrée en toute sécurité.", + "deviceLink.error.not_configured": "Il manque à ce déploiement les paramètres nécessaires à cette connexion.", "deviceLink.error.internal": "Un problème est survenu pendant l'association." }); diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/hi.ts b/crates/product/ironclaw_webui/frontend/src/i18n/hi.ts index fd569504ce9..11e0504d512 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/hi.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/hi.ts @@ -1676,6 +1676,7 @@ registerPack("hi", { "deviceLink.revokeHint": "IronClaw अब {name} में एक डिवाइस के रूप में दिखता है। कोई अनजान डिवाइस दिखे तो उसे वहीं से हटाएँ।", "deviceLink.startAgain": "फिर से शुरू करें", "deviceLink.cannotRetry": "यह {name} खाता लिंक नहीं किया जा सकता।", + "deviceLink.setupIncomplete": "इस डिप्लॉयमेंट पर {name} खाता जोड़ना अभी सेट अप नहीं है। किसी प्रशासक को एडमिन → कॉन्फ़िगरेशन में इसे पूरा करना होगा।", "deviceLink.error.expired": "कोड इस्तेमाल होने से पहले ही समाप्त हो गया।", "deviceLink.error.unknown_flow": "यह लिंक अब खुला नहीं है।", "deviceLink.error.declined": "डिवाइस अस्वीकार कर दिया गया।", @@ -1685,5 +1686,6 @@ registerPack("hi", { "deviceLink.error.identity_conflict": "यह खाता पहले से लिंक है। जिस IronClaw खाते से यह जुड़ा है, वहाँ से इसे अनलिंक करें और फिर कोशिश करें।", "deviceLink.error.vendor_unavailable": "सेवा फ़िलहाल उपलब्ध नहीं है।", "deviceLink.error.custody_failed": "लिंक सुरक्षित रूप से सहेजा नहीं जा सका।", + "deviceLink.error.not_configured": "इस डिप्लॉयमेंट में इस कनेक्शन के लिए आवश्यक सेटिंग्स नहीं हैं।", "deviceLink.error.internal": "लिंक करते समय कुछ गड़बड़ हो गई।" }); diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/ja.ts b/crates/product/ironclaw_webui/frontend/src/i18n/ja.ts index 7c7172bd34c..b3d271ca15c 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/ja.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/ja.ts @@ -1676,6 +1676,7 @@ registerPack("ja", { "deviceLink.revokeHint": "IronClaw は {name} のデバイス一覧に表示されます。見覚えのないデバイスがあれば、そこで解除してください。", "deviceLink.startAgain": "やり直す", "deviceLink.cannotRetry": "この {name} アカウントは連携できません。", + "deviceLink.setupIncomplete": "この環境では {name} アカウントの連携がまだ設定されていません。管理者が 管理 → 設定 で完了させる必要があります。", "deviceLink.error.expired": "コードは使用される前に期限切れになりました。", "deviceLink.error.unknown_flow": "この連携はすでに終了しています。", "deviceLink.error.declined": "デバイスが拒否されました。", @@ -1685,5 +1686,6 @@ registerPack("ja", { "deviceLink.error.identity_conflict": "このアカウントはすでに連携されています。接続先の IronClaw アカウントから解除して、もう一度お試しください。", "deviceLink.error.vendor_unavailable": "サービスが一時的に利用できません。", "deviceLink.error.custody_failed": "連携を安全に保存できませんでした。", + "deviceLink.error.not_configured": "この環境にはこの接続に必要な設定がありません。", "deviceLink.error.internal": "連携中に問題が発生しました。" }); diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/ko.ts b/crates/product/ironclaw_webui/frontend/src/i18n/ko.ts index a51b96daa14..1f9e10df8a7 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/ko.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/ko.ts @@ -1676,6 +1676,7 @@ registerPack("ko", { "deviceLink.revokeHint": "이제 IronClaw가 {name}의 기기 목록에 표시됩니다. 모르는 기기가 보이면 거기에서 해제하세요.", "deviceLink.startAgain": "다시 시작", "deviceLink.cannotRetry": "이 {name} 계정은 연결할 수 없습니다.", + "deviceLink.setupIncomplete": "이 배포에서는 {name} 계정 연결이 아직 설정되지 않았습니다. 관리자가 관리 → 구성에서 완료해야 합니다.", "deviceLink.error.expired": "코드가 사용되기 전에 만료되었습니다.", "deviceLink.error.unknown_flow": "이 연결은 더 이상 열려 있지 않습니다.", "deviceLink.error.declined": "기기가 거부되었습니다.", @@ -1685,5 +1686,6 @@ registerPack("ko", { "deviceLink.error.identity_conflict": "이 계정은 이미 연결되어 있습니다. 연결된 IronClaw 계정에서 연결을 해제한 후 다시 시도하세요.", "deviceLink.error.vendor_unavailable": "서비스를 일시적으로 사용할 수 없습니다.", "deviceLink.error.custody_failed": "연결을 안전하게 저장하지 못했습니다.", + "deviceLink.error.not_configured": "이 배포에는 이 연결에 필요한 설정이 없습니다.", "deviceLink.error.internal": "연결 중 문제가 발생했습니다." }); diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/pt-BR.ts b/crates/product/ironclaw_webui/frontend/src/i18n/pt-BR.ts index 10531ee732a..c574679b808 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/pt-BR.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/pt-BR.ts @@ -1676,6 +1676,7 @@ registerPack("pt-BR", { "deviceLink.revokeHint": "O IronClaw agora aparece como um dispositivo no {name}. Se vir um dispositivo desconhecido, revogue-o por lá.", "deviceLink.startAgain": "Começar de novo", "deviceLink.cannotRetry": "Esta conta do {name} não pode ser vinculada.", + "deviceLink.setupIncomplete": "A vinculação de uma conta {name} ainda não está configurada nesta implantação. Um administrador precisa concluí-la em Admin → Configuração.", "deviceLink.error.expired": "O código expirou antes de ser usado.", "deviceLink.error.unknown_flow": "Esta vinculação não está mais aberta.", "deviceLink.error.declined": "O dispositivo foi recusado.", @@ -1685,5 +1686,6 @@ registerPack("pt-BR", { "deviceLink.error.identity_conflict": "Esta conta já está vinculada. Desvincule-a da conta do IronClaw onde está conectada e tente novamente.", "deviceLink.error.vendor_unavailable": "O serviço está temporariamente indisponível.", "deviceLink.error.custody_failed": "Não foi possível salvar a vinculação com segurança.", + "deviceLink.error.not_configured": "Esta implantação não tem as configurações necessárias para esta conexão.", "deviceLink.error.internal": "Algo deu errado durante a vinculação." }); diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/uk.ts b/crates/product/ironclaw_webui/frontend/src/i18n/uk.ts index 31483ed6196..61cd48e93e8 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/uk.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/uk.ts @@ -1676,6 +1676,7 @@ registerPack("uk", { "deviceLink.revokeHint": "IronClaw тепер відображається як пристрій у {name}. Якщо побачите незнайомий пристрій, відкличте його там.", "deviceLink.startAgain": "Почати спочатку", "deviceLink.cannotRetry": "Цей обліковий запис {name} не можна прив'язати.", + "deviceLink.setupIncomplete": "Прив'язка облікового запису {name} ще не налаштована в цьому розгортанні. Адміністратор має завершити її в Адмін → Конфігурація.", "deviceLink.error.expired": "Код застарів, перш ніж його використали.", "deviceLink.error.unknown_flow": "Ця прив'язка більше не відкрита.", "deviceLink.error.declined": "Пристрій відхилено.", @@ -1685,5 +1686,6 @@ registerPack("uk", { "deviceLink.error.identity_conflict": "Цей обліковий запис уже прив'язано. Від'єднайте його від облікового запису IronClaw, до якого його підключено, і повторіть спробу.", "deviceLink.error.vendor_unavailable": "Сервіс тимчасово недоступний.", "deviceLink.error.custody_failed": "Не вдалося безпечно зберегти прив'язку.", + "deviceLink.error.not_configured": "У цьому розгортанні бракує налаштувань, потрібних для цього з'єднання.", "deviceLink.error.internal": "Під час прив'язки сталася помилка." }); diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/zh-CN.ts b/crates/product/ironclaw_webui/frontend/src/i18n/zh-CN.ts index b370f6f2446..80914a57109 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/zh-CN.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/zh-CN.ts @@ -1674,6 +1674,7 @@ registerPack("zh-CN", { "deviceLink.revokeHint": "IronClaw 现在会显示在 {name} 的设备列表中。如果看到不认识的设备,请在那里撤销它。", "deviceLink.startAgain": "重新开始", "deviceLink.cannotRetry": "无法关联此 {name} 账号。", + "deviceLink.setupIncomplete": "此部署尚未配置 {name} 账户关联。需要管理员在“管理”→“配置”中完成。", "deviceLink.error.expired": "代码在使用前已过期。", "deviceLink.error.unknown_flow": "此关联已不再有效。", "deviceLink.error.declined": "设备被拒绝。", @@ -1683,5 +1684,6 @@ registerPack("zh-CN", { "deviceLink.error.identity_conflict": "此账号已关联。请先在其所连接的 IronClaw 账号中取消关联,然后重试。", "deviceLink.error.vendor_unavailable": "服务暂时不可用。", "deviceLink.error.custody_failed": "无法安全保存关联。", + "deviceLink.error.not_configured": "此部署缺少此连接所需的设置。", "deviceLink.error.internal": "关联时出现问题。" }); diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 731bca7b8b0..1e2db6d297e 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -304,6 +304,7 @@ One thread, whole real turn. Grouped by what the user experiences. | An inbound channel message is verified and routed by the real generic ingress mount | `extension_ingress.rs` | | An outbound reply is delivered through the real inbound→outbound pipeline | `extension_delivery.rs` | | Pair a Telegram bot actor, run as that verified user, deliver anchored replies and busy notices, disconnect to revoke admission, then pair again to restore delivery (#6643/#6644) | `extension_delivery.rs::paired_telegram_bot_actor_turns_attribute_to_the_user_and_disconnect_revokes_admission` (production generated-code pairing, disconnect/repair, and anchored delivery evidence) | +| A user blocked on a challenge their chat surface can't complete is handed the deployment's Extensions page address when one is configured, and the notice keeps its wording with no relative path when none is (#7897) | `extension_delivery.rs::slack_unserviceable_auth_gate_carries_the_extensions_url_when_an_origin_is_configured` / `::slack_unserviceable_auth_gate_ships_dark_without_a_configured_origin` (driven through the REAL assembled channel-host composition — `start_channel_host_assembly_for_test`, the same wiring `serve` uses — not a hand-built `RunDeliveryServices`; a real `github` install raises `TurnStatus::BlockedAuth`) | | Tenant-admin configuration and per-user install/remove stay separate state machines | `extension_user_lifecycle_isolation.rs` | | A notification inbox belongs to one recipient: knowing another user's notification id grants no read and no mutation | `notification_inbox_user_isolation.rs` | | Connect Telegram through a generated workspace-bot code while personal linked-account tools remain separately protected | `channel_connection_projection.rs` | diff --git a/tests/integration/delivery_user_journeys.rs b/tests/integration/delivery_user_journeys.rs index aad0f2beacc..2c9b7cac2b2 100644 --- a/tests/integration/delivery_user_journeys.rs +++ b/tests/integration/delivery_user_journeys.rs @@ -657,6 +657,7 @@ fn slack_run_delivery_services( Some(harness.binding.actor_user_id.clone()), ); RunDeliveryServices { + setup_link_base_url: None, project_filesystem: Arc::new(ironclaw_assistant::NoProjectFilesystem), binding_service: harness .binding_service_for_test() diff --git a/tests/integration/extension_delivery.rs b/tests/integration/extension_delivery.rs index 37c32da2b7d..1e0ec39dd1d 100644 --- a/tests/integration/extension_delivery.rs +++ b/tests/integration/extension_delivery.rs @@ -365,6 +365,7 @@ fn delivery_run_services( Some(harness.binding.actor_user_id.clone()), ); RunDeliveryServices { + setup_link_base_url: None, binding_service: harness .binding_service_for_test() .expect("group binding service"), @@ -970,8 +971,9 @@ fn start_channel_host_assembly( services: &RebornRuntime, inbound: &RebornIntegrationHarness, ) -> Arc { - services - .start_channel_host_assembly_for_test(ChannelHostAssemblyTestWiring { + start_assembly_serialized( + services, + ChannelHostAssemblyTestWiring { thread_service: inbound .thread_service_for_test() .expect("group thread service"), @@ -983,8 +985,44 @@ fn start_channel_host_assembly( project_id: inbound.binding.project_id.clone(), operator_user_id: inbound.binding.actor_user_id.clone(), }, - }) - .expect("production channel host assembly starts") + blocked_auth_prompts: None, + }, + None, + ) +} + +/// The single caller EVERY `start_channel_host_assembly_for_test` call in +/// this binary goes through (#7897 CodeRabbit follow-up). `extension_host_assembly::start_channel_host` +/// reads process-global `IRONCLAW_REBORN_WEBUI_BASE_URL` synchronously — +/// TWICE (`setup_link_base_url`, `connect_link_base_url`) — inside the +/// assembly call. Rust runs this binary's tests on parallel threads, so +/// without a shared lock around every assembly call, `WebUiBaseUrlEnvGuard` +/// below (which sets/removes that var for the one test that needs a real +/// origin) only serializes against other *writers*: a concurrent unguarded +/// assembly call on another thread can still read the var mid-set, +/// mid-remove, or leaked from a sibling test entirely. +/// +/// `origin: None` — this call site never touches the env var, just needs the +/// lock held for the duration of the assembly call so it can't race a +/// concurrent writer. `origin: Some(value)` — this call site IS the writer: +/// the var is set/unset (see `WebUiBaseUrlEnvGuard::apply`) for exactly the +/// duration of the assembly call, under the SAME lock acquisition. +/// +/// `ironclaw_common::env_helpers::lock_env()` guards a plain, non-reentrant +/// `std::sync::Mutex<()>`. This function takes it exactly once, so the +/// set-then-assemble-then-restore sequence never double-locks (which would +/// deadlock): `WebUiBaseUrlEnvGuard::apply` deliberately does NOT lock itself +/// — only this function does, and only this function may construct one. +fn start_assembly_serialized( + services: &RebornRuntime, + wiring: ChannelHostAssemblyTestWiring, + origin: Option>, +) -> Arc { + let _lock = ironclaw_common::env_helpers::lock_env(); + let _origin_guard = origin.map(WebUiBaseUrlEnvGuard::apply); + services + .start_channel_host_assembly_for_test(wiring) + .expect("the production channel host assembly starts over the composed runtime") } #[tokio::test] @@ -1486,6 +1524,383 @@ async fn slack_final_reply_flows_through_the_real_delivery_coordinator( assert_eq!(authorization.1, format!("Bearer {SLACK_BOT_TOKEN}")); } +/// #7897: a fixed `BlockedAuthPromptSource` standing in for the real +/// `product_auth`/pairing-registry-backed one composition wires in +/// production (this harness has no test double for that yet — see +/// `ChannelHostAssemblyTestWiring::blocked_auth_prompts`'s doc comment). It +/// always reports the challenge as `ManualToken` (github's real credential +/// setup kind, pinned by `auth_gate.rs`'s W4-AUTHGATE-WIRE test), so the +/// unserviceable message renders `MANUAL_TOKEN_AUTH_UNAVAILABLE_MESSAGE` +/// instead of the generic fallback. +struct ManualTokenAuthPromptSource; + +#[async_trait::async_trait] +impl ironclaw_product_contracts::prompt_source::BlockedAuthPromptSource + for ManualTokenAuthPromptSource +{ + async fn auth_prompt_for_blocked_run( + &self, + request: ironclaw_product_contracts::prompt_source::BlockedAuthPromptRequest<'_>, + ) -> Result< + ironclaw_extension_contracts::auth_prompt::AuthPromptView, + ironclaw_host_api::product_adapter_error::ProductAdapterError, + > { + Ok(ironclaw_extension_contracts::auth_prompt::AuthPromptView { + turn_run_id: request.run_id, + auth_request_ref: request.gate_ref.as_str().to_string(), + invocation_id: request.invocation_id, + headline: "Authentication required".to_string(), + body: request.body, + challenge_kind: Some( + ironclaw_extension_contracts::auth_prompt::AuthPromptChallengeKind::ManualToken, + ), + provider: Some("github".to_string()), + account_label: None, + authorization_url: None, + expires_at: None, + connection: None, + pairing: None, + device_link: None, + }) + } +} + +/// #7897: `IRONCLAW_REBORN_WEBUI_BASE_URL` is read directly by +/// `extension_host_assembly::connect_link_base_url_from_env` — the exact +/// production seam `slack_unserviceable_auth_gate_*` below exercises through +/// the assembled channel-host path (`start_channel_host_assembly_for_test`), +/// so unlike everywhere else in this suite the origin has to be a real +/// process env var, not a harness-level override. The read happens exactly +/// once, synchronously, inside that one assembly call — so the guard only +/// ever needs to span a non-async block, never an `.await`, and nothing else +/// in this binary sets or reads this variable. Mirrors +/// `crates/app/ironclaw_composition/tests/service_factory.rs`'s +/// `EnvVarGuard`, scoped to this one key. +/// +/// Deliberately does NOT call `lock_env()` itself (contrast the +/// `EnvVarGuard` this mirrors). `extension_host_assembly::start_channel_host` +/// reads this var synchronously TWICE inside the assembly call, so setting +/// it only under a lock held by `set`/`unset` and then releasing it before +/// the assembly call runs is not enough — some other unguarded assembly call +/// site could still race the read against this guard's own set/restore. The +/// sole constructor, `apply`, and the sole caller, `start_assembly_serialized`, +/// hold `lock_env()` for the guard's ENTIRE lifetime (set through drop), +/// spanning the assembly call too — see that function's doc comment for why +/// the lock can't be taken here as well (it would deadlock; the underlying +/// mutex is not reentrant). +struct WebUiBaseUrlEnvGuard { + previous: Option, +} + +impl WebUiBaseUrlEnvGuard { + const KEY: &'static str = "IRONCLAW_REBORN_WEBUI_BASE_URL"; + + /// `origin: Some(value)` sets the var; `origin: None` removes it. + /// Caller must already hold `lock_env()` for this guard's entire scope — + /// only `start_assembly_serialized` may call this. + fn apply(origin: Option<&str>) -> Self { + let previous = std::env::var_os(Self::KEY); + // SAFETY: caller holds `lock_env()` for this guard's entire scope + // (see `start_assembly_serialized`); restored on `Drop`, still under + // that same lock. + unsafe { + match origin { + Some(value) => std::env::set_var(Self::KEY, value), + None => std::env::remove_var(Self::KEY), + } + } + Self { previous } + } +} + +impl Drop for WebUiBaseUrlEnvGuard { + fn drop(&mut self) { + // SAFETY: caller still holds `lock_env()` (see `apply`). + unsafe { + match &self.previous { + Some(value) => std::env::set_var(Self::KEY, value), + None => std::env::remove_var(Self::KEY), + } + } + } +} + +/// Drive a `builtin.extension_install` of github through a real admitted +/// Slack turn on a fresh `extension_delivery` group. Github has no entry in +/// the provider-instance readiness map, so install always opens the ordinary +/// per-account credential gate through the generic product-auth mechanism — +/// proven by `scenario_extension_install_github_normal_gate.rs` — with no +/// `GithubHarnessAuthorizer`/fixed resolver and no WASM dispatch needed +/// (`github.get_repo` itself needs extra asset-copy/publish wiring this +/// group doesn't carry; installing does not). Raises a REAL +/// `TurnStatus::BlockedAuth` (provider=github, ManualToken — +/// `auth_gate.rs`'s W4-AUTHGATE-WIRE pin). +/// +/// The channel host assembly is the REAL production wiring +/// (`start_channel_host_assembly_for_test` -> `start_channel_host` -> +/// `extension_host_assembly.rs:587-589`), not a hand-built +/// `RunDeliveryServices` literal — codex's "exercise the assembled caller +/// path rather than only this helper" — so `origin` reaches the delivered +/// message the same way a real deployment's `IRONCLAW_REBORN_WEBUI_BASE_URL` +/// would: through the env-guarded assembly call, `channel_workflow.rs:474`'s +/// per-conversation `RunDeliveryServices`, and `observer.rs:1052-1055`. +/// +/// The observer auto-cancels an unserviceable auth gate +/// (`cancel_auth_blocked_run`, a hard cancel — never a resume), so the +/// script needs exactly one entry: the initial `tool_call`. Returns the +/// delivered `chat.postMessage` body text. +async fn unserviceable_auth_gate_delivered_slack_message(origin: Option<&str>) -> String { + let group = RebornIntegrationGroup::extension_delivery() + .await + .expect("delivery group builds"); + activate_slack(&group).await; + let services = reborn_services(&group); + assert!( + services.register_static_channel_egress_credentials_for_test(vec![( + "slack".to_string(), + "slack_bot_token".to_string(), + ironclaw_secrets::SecretMaterial::from(SLACK_BOT_TOKEN.to_string()), + )]), + "the composed runtime must expose channel-egress credential bridging" + ); + // `VendorIngress::production` posts through the REAL manifest route, + // whose signature check reads the ADMIN-CONFIGURED secret (verified by + // `admin_configured_slack_unconnected_dm_gets_connect_notice_without_installation_or_turn`'s + // 401-before/200-after pair) -- a separate axis from `activate_slack`'s + // install-to-Active. `slack_final_reply_flows_through_the_real_delivery_coordinator` + // skips this because it uses `VendorIngress::register` (a manual test-only + // sink registration, not the assembled route this test exercises). + configure_admin_group( + &group, + "extension.slack", + 0, + json!([ + {"handle": "slack_bot_token", "value": SLACK_BOT_TOKEN}, + {"handle": "slack_signing_secret", "value": String::from_utf8_lossy(SLACK_SIGNING_SECRET)}, + {"handle": "slack_team_id", "value": "T-A"}, + {"handle": "slack_api_app_id", "value": "A-ITEST"}, + {"handle": "slack_installation_id", "value": SLACK_INSTALLATION}, + {"handle": "slack_bot_user_id", "value": "U-BOT"}, + {"handle": "slack_oauth_client_id", "value": "slack-oauth-client"}, + {"handle": "slack_oauth_client_secret", "value": "slack-oauth-secret"} + ]), + ) + .await; + // Real admission (the assembled path's own workflow surface, distinct + // from `inbound`'s per-thread harness surface `preresolve_vendor_turn_scope` + // resolves against below) requires this Slack user to already be bound + // AND channel-connected, or a first-contact shared-conversation ping + // rejects `BindingRequired` before ever reaching a turn + // (`observer.rs`'s `post_connect_nudge_if_unbound_user_message`). Drive + // the real identity-binding write the OAuth callback performs + // (`scenario_slack_channel_lifecycle_state_machine.rs`'s + // `connect_provider_user` pattern) for the group's canonical actor under + // vendor subject "U777" — the same user id the event body below names. + group + .channel_connection() + .expect("extension_delivery group composes production channel connection") + .connect_provider_user( + &group.canonical_actor_user(), + "slack", + ironclaw_auth::OAuthProviderIdentity::new( + "U777", + Some("T-A".to_string()), + None, + Some("A-ITEST".to_string()), + ) + .expect("valid slack identity"), + ) + .await + .expect("slack connect succeeds for the canonical actor"); + + let inbound = group + .thread("conv-slack-auth-unserviceable-inbound") + .script([RebornScriptedReply::text("unused")]) + .build() + .await + .expect("inbound thread builds"); + + // The env var is read exactly once, synchronously, inside the assembly + // call — `start_assembly_serialized` holds `lock_env()` (and, since + // `origin` is `Some`, a `WebUiBaseUrlEnvGuard`) for the whole call, never + // spanning an `.await`. + let assembly = start_assembly_serialized( + services, + ChannelHostAssemblyTestWiring { + thread_service: inbound + .thread_service_for_test() + .expect("group thread service"), + turn_coordinator: inbound.turn_coordinator_for_test(), + run_delivery_settings: RunDeliverySettings::default(), + identity: ChannelHostIdentity { + tenant_id: inbound.binding.tenant_id.clone(), + agent_id: inbound.binding.agent_id.clone().expect("binding agent id"), + project_id: inbound.binding.project_id.clone(), + operator_user_id: inbound.binding.actor_user_id.clone(), + }, + blocked_auth_prompts: Some(Arc::new(ManualTokenAuthPromptSource)), + }, + Some(origin), + ); + + let ingress = VendorIngress::production( + services + .extension_ingress_parts() + .expect("composition built the generic ingress"), + ); + let evidence = ProtocolAuthEvidence::test_verified( + AuthRequirement::RequestSignature { + header_name: "X-Slack-Signature".to_string(), + timestamp_header_name: Some("X-Slack-Request-Timestamp".to_string()), + }, + "slack", // real lifecycle-minted installation id ("builtin.extension_install" mints id == extension id) + ); + // The REAL production binding service the assembled admission path + // resolves against — NOT `inbound.binding_service_for_test()` (a + // separate, per-harness resolver `preresolve_vendor_turn_scope` would + // otherwise precompute a scope against that the real admission never + // sees). Mirrors `telegram_update_becomes_a_turn_and_a_coordinated_reply_impl`. + let slack_binding_service = + wait_for_production_registration(&assembly, services, "slack").await; + let body = json!({ + "type": "event_callback", + "event_id": "Ev-auth-unserviceable", + "team_id": "T-A", + "event": { + "type": "app_mention", + "user": "U777", + "channel": "C-AUTH-UNSERVICEABLE", + "text": "<@UBOT> look up a repo for me", + "thread_ts": "1710000600.000050", + "ts": "1710000600.000100" + } + }) + .to_string(); + // `auth_prompt_is_serviceable` never runs for this challenge kind, so + // `can_reply_in_threads` and thread placement do not affect which + // message renders; `true` matches Slack's real manifest value. + let (vendor_scope, _vendor_actor_user_id) = preresolve_vendor_turn_scope( + &slack_binding_service, + &ironclaw_slack_extension::SlackChannelAdapter, + "slack", + "slack", + &[], + &evidence, + &body, + true, + ) + .await; + group + .register_scope_script_for_test( + vendor_scope.clone(), + "auth-unserviceable", + // `github.get_repo`'s dispatchability needs extra wiring this + // group doesn't carry (`file_and_github_auth_tools_profile`'s own + // doc comment: asset copying + publish-to-active-registry beyond + // a bare capability grant). Installing github instead raises the + // SAME real `BlockedAuth`/ManualToken gate through the generic + // product-auth credential-account mechanism, proven by + // `scenario_extension_install_github_normal_gate.rs` (github has + // no readiness-map entry, so install always opens the ordinary + // per-account gate) — no WASM dispatch involved. + [RebornScriptedReply::tool_call( + "builtin.extension_install", + json!({"extension_id": "github"}), + )], + ) + .await + .expect("scripted gateway registers for the vendor scope"); + + let timestamp = now_unix().to_string(); + let signature = slack_signature(×tamp, &body); + let status = ingress + .post( + SLACK_ROUTE, + &body, + vec![ + ("X-Slack-Signature", signature), + ("X-Slack-Request-Timestamp", timestamp), + ], + ) + .await; + assert_eq!(status, StatusCode::OK, "the signed event must be accepted"); + ingress.drain().await; + + // The BlockedAuth transition, auto-cancel, and delivery are all async + // past `drain()` (real turn execution through the loop/executor), so + // poll the wire the same way the Telegram DEL-10 proof does for its + // paused-model working indicator. The FIRST `chat.postMessage` is the + // generic "Working on it…" indicator the run posts while executing, not + // the notice under test — match on content, not just the endpoint. + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + loop { + let requests = inbound.captured_network_requests_for_test(); + if let Some(request) = requests.iter().find(|request| { + request.url.ends_with("/api/chat.postMessage") + && String::from_utf8_lossy(&request.body).contains("Ironclaw web app") + }) { + return String::from_utf8_lossy(&request.body).to_string(); + } + assert!( + tokio::time::Instant::now() < deadline, + "timed out waiting for the unserviceable-auth notice to reach chat.postMessage; \ + captured requests: {:?}", + requests + .iter() + .map(|request| (&request.url, String::from_utf8_lossy(&request.body))) + .collect::>() + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } +} + +/// coderabbit (#7897): "Add integration or E2E coverage for an unserviceable +/// ManualToken or DeviceLink gate. Set a configured origin. Assert the actual +/// live... channel messages contain the Extensions URL." Driven through the +/// REAL assembled composition path — see +/// `unserviceable_auth_gate_delivered_slack_message`'s doc comment. +#[tokio::test(flavor = "multi_thread")] +async fn slack_unserviceable_auth_gate_carries_the_extensions_url_when_an_origin_is_configured() { + // This journey future (group build + real turn + poll loop) exceeds + // libtest's default 2 MiB thread stack even boxed; the root-tests CI job + // already runs this package under `RUST_MIN_STACK=67108864` + // (`.github/workflows/reborn-tests.yml`) for the same reason group + // suites need it. Locally: `RUST_MIN_STACK=67108864 cargo test -p + // ironclaw_integration_tests --test reborn_integration_extension_delivery`. + let delivered = Box::pin(unserviceable_auth_gate_delivered_slack_message(Some( + "https://app.example.com", + ))) + .await; + assert!( + delivered.contains("Ironclaw web app"), + "the copy still has to name the destination in words: {delivered}" + ); + assert!( + delivered.contains("https://app.example.com/extensions"), + "a configured origin must reach the delivered channel message: {delivered}" + ); +} + +/// coderabbit (#7897): "Also assert a blank origin remains link-free." No +/// `IRONCLAW_REBORN_WEBUI_BASE_URL` set means no address, never a relative +/// path into a customer conversation — mirrors the ships-dark half of +/// `channel_host/e2e_tests.rs`'s `slack_dm_device_link_auth_prompt_*` pair, +/// but through the assembled composition path rather than a hand-built +/// `RebornChannelWorkflowServices`. +#[tokio::test(flavor = "multi_thread")] +async fn slack_unserviceable_auth_gate_ships_dark_without_a_configured_origin() { + // See the sibling test above: box before awaiting. + let delivered = Box::pin(unserviceable_auth_gate_delivered_slack_message(None)).await; + assert!( + delivered.contains("Ironclaw web app"), + "the copy still has to name the destination in words: {delivered}" + ); + assert!( + !delivered.contains("/extensions"), + "no origin means no address, never a relative path: {delivered}" + ); +} + /// DEL-10: the bundled Telegram package — one manifest plus the adapter /// crate, zero bespoke host code — installs through the production /// lifecycle tool, consumes the authorized manifest-driven administrator @@ -1529,8 +1944,9 @@ async fn telegram_update_becomes_a_turn_and_a_coordinated_reply_impl(storage: St // durable workflow substrate, and delivery coordinator + outbound // stores are the production wiring. From here NOTHING registers the // telegram sink or observer manually. - let assembly = services - .start_channel_host_assembly_for_test(ChannelHostAssemblyTestWiring { + let assembly = start_assembly_serialized( + services, + ChannelHostAssemblyTestWiring { thread_service: inbound .thread_service_for_test() .expect("group thread service"), @@ -1542,8 +1958,10 @@ async fn telegram_update_becomes_a_turn_and_a_coordinated_reply_impl(storage: St project_id: inbound.binding.project_id.clone(), operator_user_id: inbound.binding.actor_user_id.clone(), }, - }) - .expect("the production channel host assembly starts over the composed runtime"); + blocked_auth_prompts: None, + }, + None, + ); // Admin bot configuration is a separate tenant axis and is valid before // any user installs the channel. Workspace-bot activation and generated @@ -2471,8 +2889,9 @@ async fn telegram_install_reports_already_linked_for_a_caller_with_a_satisfied_d // regression test above — attaches the snapshot watch, ingress registry, // and admin-configuration secret storage the generic host's publish step // for a CHANNEL-declaring package (Telegram) needs. - let _assembly = services - .start_channel_host_assembly_for_test(ChannelHostAssemblyTestWiring { + let _assembly = start_assembly_serialized( + services, + ChannelHostAssemblyTestWiring { thread_service: lifecycle .thread_service_for_test() .expect("group thread service"), @@ -2488,8 +2907,10 @@ async fn telegram_install_reports_already_linked_for_a_caller_with_a_satisfied_d project_id: lifecycle.binding.project_id.clone(), operator_user_id: lifecycle.binding.actor_user_id.clone(), }, - }) - .expect("the production channel host assembly starts over the composed runtime"); + blocked_auth_prompts: None, + }, + None, + ); // Admin bot configuration is required before the generic host will // publish a CHANNEL-declaring package (Telegram) to `Active` — mirrors @@ -2713,8 +3134,9 @@ async fn paired_telegram_bot_actor_turns_attribute_to_the_user_and_disconnect_re .await .expect("inbound thread builds"); - let assembly = services - .start_channel_host_assembly_for_test(ChannelHostAssemblyTestWiring { + let assembly = start_assembly_serialized( + services, + ChannelHostAssemblyTestWiring { thread_service: inbound .thread_service_for_test() .expect("group thread service"), @@ -2726,8 +3148,10 @@ async fn paired_telegram_bot_actor_turns_attribute_to_the_user_and_disconnect_re project_id: inbound.binding.project_id.clone(), operator_user_id: inbound.binding.actor_user_id.clone(), }, - }) - .expect("the production channel host assembly starts over the composed runtime"); + blocked_auth_prompts: None, + }, + None, + ); let lifecycle = group .thread("conv-telegram-paired-lifecycle")