From a1660d442956ddbaeb16db9199d9567e63fe8774 Mon Sep 17 00:00:00 2001 From: serrrfirat Date: Fri, 19 Jun 2026 19:57:56 +0300 Subject: [PATCH] feat(reborn): project slack ingress from extension state --- Cargo.lock | 2 + FEATURE_PARITY.md | 2 +- .../assets/slack/manifest.toml | 21 + crates/ironclaw_host_runtime/Cargo.toml | 1 + .../src/extension_contracts.rs | 6 + .../tests/host_api_contract_composition.rs | 59 +++ crates/ironclaw_reborn_cli/Cargo.toml | 7 +- .../ironclaw_reborn_cli/src/commands/serve.rs | 44 +- crates/ironclaw_reborn_composition/Cargo.toml | 1 + .../src/extension_lifecycle.rs | 4 + .../src/factory.rs | 11 + crates/ironclaw_reborn_composition/src/lib.rs | 19 +- .../src/slack_egress.rs | 68 +++ .../src/slack_extension_settings.rs | 303 ++++++++++++ .../src/slack_host_beta.rs | 468 +++++++++++++++++- .../src/slack_host_ingress.rs | 2 +- docs/reborn-binary.md | 9 +- 17 files changed, 998 insertions(+), 29 deletions(-) create mode 100644 crates/ironclaw_reborn_composition/src/slack_extension_settings.rs diff --git a/Cargo.lock b/Cargo.lock index 70627761469..f750a71de7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4588,6 +4588,7 @@ dependencies = [ "ironclaw_filesystem", "ironclaw_first_party_extensions", "ironclaw_host_api", + "ironclaw_host_ingress_registry", "ironclaw_mcp", "ironclaw_memory", "ironclaw_network", @@ -5033,6 +5034,7 @@ dependencies = [ "ironclaw_first_party_extensions", "ironclaw_hooks", "ironclaw_host_api", + "ironclaw_host_ingress_registry", "ironclaw_host_runtime", "ironclaw_llm", "ironclaw_loop_support", diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index 3276512fd78..fa172b754dc 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -823,7 +823,7 @@ Trace Commons issuer/TenantCtx note: the server-side `zmanian/tracedao-server` s ### P1 - High Priority -- 🚧 Slack channel (real implementation): Reborn host-beta route can be explicitly mounted by `ironclaw-reborn serve` with Slack Events API signing, DM/app-mention routing through Product Workflow/Reborn, final-reply delivery, host-state-backed personal binding pairing, WebUI v2 admin-managed allowed-channel picker, durable WebUI channel-route assignment APIs, provider-side default outbound target inventory for shared channels and explicitly provisioned personal DMs, a host-bundled Reborn extension manifest declaring the Slack ProductAdapter host API, and deterministic chat-side connect action metadata; DMs execute as the paired actor, while shared channel turns route to allowed dynamic or static channel subjects and fail closed for unrouted channels in admin-managed mode; production install/setup hardening and fuller E2E coverage remain follow-up. +- 🚧 Slack channel (real implementation): Reborn host-beta route can be mounted by `ironclaw-reborn serve` with Slack Events API signing, DM/app-mention routing through Product Workflow/Reborn, final-reply delivery, host-state-backed personal binding pairing, WebUI v2 admin-managed allowed-channel picker, durable WebUI channel-route assignment APIs, provider-side default outbound target inventory for shared channels and explicitly provisioned personal DMs, a host-bundled Reborn extension manifest declaring Slack ProductAdapter and host-ingress APIs, extension-state projection for the Slack events webhook route, and deterministic chat-side connect action metadata; DMs execute as the paired actor, while shared channel turns route to allowed dynamic or static channel subjects and fail closed for unrouted channels in admin-managed mode; production install/setup hardening and fuller E2E coverage remain follow-up. - ✅ Telegram channel (WASM, polling-first setup, DM pairing, caption, /start) - ❌ WhatsApp channel - ✅ Multi-provider failover (`FailoverProvider` with retryable error classification) diff --git a/crates/ironclaw_first_party_extensions/assets/slack/manifest.toml b/crates/ironclaw_first_party_extensions/assets/slack/manifest.toml index b9205abc3b4..0e00dfcc9de 100644 --- a/crates/ironclaw_first_party_extensions/assets/slack/manifest.toml +++ b/crates/ironclaw_first_party_extensions/assets/slack/manifest.toml @@ -13,6 +13,10 @@ service = "slack_v2_host_beta" id = "ironclaw.product_adapter/v1" section = "product_adapter.inbound" +[[host_api]] +id = "ironclaw.host_ingress/v1" +section = "host_ingress.events" + [product_adapter.inbound] surface_kind = "external_channel" @@ -35,3 +39,20 @@ handle = "slack_bot_token" [[product_adapter.inbound.egress]] host = "slack.com" credential_handle = "slack_bot_token" + +[host_ingress.events] +route_id = "slack.events" +method = "post" +path = "/webhooks/slack/events" +policy_profile = "slack_events" +ack = "immediate" +drain = "drain_before_runtime_shutdown" + +[host_ingress.events.target] +type = "product_adapter_inbound" +capability_id = "slack.events" +product_adapter_section = "product_adapter.inbound" + +[host_ingress.events.auth] +scheme = "slack_v0_hmac" +credential_handles = ["slack_signing_secret"] diff --git a/crates/ironclaw_host_runtime/Cargo.toml b/crates/ironclaw_host_runtime/Cargo.toml index 5fbbc8871f4..ec89dc94f69 100644 --- a/crates/ironclaw_host_runtime/Cargo.toml +++ b/crates/ironclaw_host_runtime/Cargo.toml @@ -30,6 +30,7 @@ ironclaw_extensions = { path = "../ironclaw_extensions" } ironclaw_filesystem = { path = "../ironclaw_filesystem" } ironclaw_first_party_extensions = { path = "../ironclaw_first_party_extensions" } ironclaw_host_api = { path = "../ironclaw_host_api" } +ironclaw_host_ingress_registry = { path = "../ironclaw_host_ingress_registry" } ironclaw_memory = { path = "../ironclaw_memory" } ironclaw_mcp = { path = "../ironclaw_mcp" } ironclaw_network = { path = "../ironclaw_network" } diff --git a/crates/ironclaw_host_runtime/src/extension_contracts.rs b/crates/ironclaw_host_runtime/src/extension_contracts.rs index e656ff2b614..0ee3c3820eb 100644 --- a/crates/ironclaw_host_runtime/src/extension_contracts.rs +++ b/crates/ironclaw_host_runtime/src/extension_contracts.rs @@ -9,6 +9,7 @@ use ironclaw_host_api::{ HOST_RUNTIME_HTTP_EGRESS_PORT_ID, HostApiError, HostPortCatalog, HostPortCatalogEntry, HostPortId, VirtualPath, }; +use ironclaw_host_ingress_registry::HostIngressHostApiContract; use ironclaw_product_adapter_registry::ProductAdapterHostApiContract; /// Build the host-runtime default set of Extension Manifest v2 host API contracts. @@ -23,6 +24,11 @@ pub fn default_host_api_contract_registry() -> Result Arc { + Arc::clone(&self.installation_store) + } + pub(crate) async fn activation_credential_requirements( &self, package_ref: &LifecyclePackageRef, diff --git a/crates/ironclaw_reborn_composition/src/factory.rs b/crates/ironclaw_reborn_composition/src/factory.rs index 92293aec3be..b09a672e06a 100644 --- a/crates/ironclaw_reborn_composition/src/factory.rs +++ b/crates/ironclaw_reborn_composition/src/factory.rs @@ -474,6 +474,7 @@ pub(crate) struct RebornLocalRuntimeServices { // wiring need scoped storage/registry ownership before this is reused // outside local-dev composition. Tracked in #4091. pub(crate) extension_management: Option>, + pub(crate) secret_store: Option>, pub(crate) runtime_http_egress: Option>, pub(crate) host_runtime_http_egress: Option, pub(crate) skill_mounts: MountView, @@ -819,6 +820,13 @@ async fn build_local_dev(input: RebornBuildInput) -> Result = local_dev_secret_store.clone(); #[cfg(not(any(feature = "libsql", feature = "postgres")))] let secret_store: Arc = Arc::new(ironclaw_secrets::InMemorySecretStore::new()); + if let Some(local_runtime) = Arc::get_mut(&mut store_graph.local_runtime) { + local_runtime.secret_store = Some(Arc::clone(&secret_store)); + } else { + return Err(RebornBuildError::InvalidConfig { + reason: "local-dev secret store could not be attached".to_string(), + }); + } let local_dev_trust_policy = Arc::new(builtin_first_party_trust_policy()?); let local_dev_trust_invalidation_bus = Arc::new(ironclaw_trust::InvalidationBus::new()); let extension_registry = Arc::new(local_dev_builtin_extension_registry()?); @@ -1351,6 +1359,7 @@ fn build_local_dev_store_graph( budget_gate_store, skill_management, extension_management: None, + secret_store: None, runtime_http_egress: None, host_runtime_http_egress: None, skill_mounts, @@ -1481,6 +1490,7 @@ fn build_local_dev_store_graph( budget_gate_store, skill_management, extension_management: None, + secret_store: None, runtime_http_egress: None, host_runtime_http_egress: None, skill_mounts, @@ -3524,6 +3534,7 @@ mod tests { budget_gate_store: Arc::clone(&base_runtime.budget_gate_store), skill_management: Arc::clone(&base_runtime.skill_management), extension_management: base_runtime.extension_management.clone(), + secret_store: base_runtime.secret_store.clone(), runtime_http_egress: base_runtime.runtime_http_egress.clone(), host_runtime_http_egress: base_runtime.host_runtime_http_egress.clone(), skill_mounts: base_runtime.skill_mounts.clone(), diff --git a/crates/ironclaw_reborn_composition/src/lib.rs b/crates/ironclaw_reborn_composition/src/lib.rs index e76b290bb5a..b30348ad167 100644 --- a/crates/ironclaw_reborn_composition/src/lib.rs +++ b/crates/ironclaw_reborn_composition/src/lib.rs @@ -120,6 +120,8 @@ mod slack_dm_open; #[cfg(feature = "slack-v2-host-beta")] mod slack_egress; #[cfg(feature = "slack-v2-host-beta")] +mod slack_extension_settings; +#[cfg(feature = "slack-v2-host-beta")] mod slack_host_beta; #[cfg(feature = "slack-v2-host-beta")] pub mod slack_host_ingress; @@ -295,8 +297,9 @@ pub use slack_egress::{ pub use slack_host_beta::{ SlackHostBetaBuildError, SlackHostBetaChannelRoute, SlackHostBetaConfig, SlackHostBetaConfigInput, SlackHostBetaMounts, build_slack_events_host_ingress_mount, - build_slack_events_route_mount, build_slack_events_route_mount_with_actor_user_resolver, - build_slack_host_beta_mounts, build_triggered_run_delivery_hook, + build_slack_events_host_ingress_mount_from_enabled_extensions, build_slack_events_route_mount, + build_slack_events_route_mount_with_actor_user_resolver, build_slack_host_beta_mounts, + build_triggered_run_delivery_hook, import_slack_host_beta_config_as_extension_installation, }; #[cfg(feature = "slack-v2-host-beta")] pub use slack_personal_binding::{ @@ -786,6 +789,13 @@ pub(crate) fn slack_host_state_mount_view( VirtualPath::new(format!("/tenants/{tenant_id}/shared/slack-channel-routes"))?, MountPermissions::read_write_list_delete(), ), + MountGrant::new( + MountAlias::new("/tenant-shared/slack-extension-installations")?, + VirtualPath::new(format!( + "/tenants/{tenant_id}/shared/slack-extension-installations" + ))?, + MountPermissions::read_write_list_delete(), + ), MountGrant::new( MountAlias::new("/engine/product_workflow/idempotency")?, VirtualPath::new(format!( @@ -1040,6 +1050,11 @@ mod mount_view_tests { "/tenant-shared/slack-channel-routes/install/team/route.json", "slack-channel-routes/install/team/route.json", ), + ( + "/tenant-shared/slack-extension-installations", + "/tenant-shared/slack-extension-installations/install.json", + "slack-extension-installations/install.json", + ), ( "/engine/product_workflow/idempotency", "/engine/product_workflow/idempotency/actions/action.json", diff --git a/crates/ironclaw_reborn_composition/src/slack_egress.rs b/crates/ironclaw_reborn_composition/src/slack_egress.rs index f605c28f8a2..8542b9b5285 100644 --- a/crates/ironclaw_reborn_composition/src/slack_egress.rs +++ b/crates/ironclaw_reborn_composition/src/slack_egress.rs @@ -23,6 +23,7 @@ use ironclaw_product_adapters::{ ProtocolHttpEgressError, RedactedString, }; use ironclaw_secrets::SecretMaterial; +use ironclaw_secrets::{SecretStore, SecretStoreError}; use ironclaw_wasm_product_adapters::{EgressPolicy, EgressPolicyError, EgressPolicyTarget}; use secrecy::{ExposeSecret, SecretString}; use thiserror::Error; @@ -97,6 +98,56 @@ impl SlackEgressCredentialProvider for StaticSlackEgressCredentialProvider { } } +pub(crate) struct SecretStoreSlackEgressCredentialProvider { + secret_store: Arc, + scope: ResourceScope, + egress_handle: EgressCredentialHandle, + secret_handle: SecretHandle, +} + +impl SecretStoreSlackEgressCredentialProvider { + pub(crate) fn new( + secret_store: Arc, + scope: ResourceScope, + egress_handle: EgressCredentialHandle, + secret_handle: SecretHandle, + ) -> Self { + Self { + secret_store, + scope, + egress_handle, + secret_handle, + } + } +} + +#[async_trait] +impl SlackEgressCredentialProvider for SecretStoreSlackEgressCredentialProvider { + async fn resolve_slack_egress_credential( + &self, + handle: &EgressCredentialHandle, + ) -> Result { + if handle != &self.egress_handle { + return Err(SlackEgressCredentialError::UnknownHandle { + handle: handle.as_str().to_string(), + }); + } + let lease = self + .secret_store + .lease_once(&self.scope, &self.secret_handle) + .await + .map_err(map_secret_store_credential_error)?; + let material = self + .secret_store + .consume(&self.scope, lease.id) + .await + .map_err(map_secret_store_credential_error)?; + Ok(SlackEgressCredential::bearer_token( + material.expose_secret().to_string(), + )) + } +} + pub struct SlackProtocolHttpEgress { host_egress: HostRuntimeHttpEgressPort, credentials: Arc, @@ -306,6 +357,23 @@ fn map_credential_error(error: SlackEgressCredentialError) -> ProtocolHttpEgress } } +fn map_secret_store_credential_error(error: SecretStoreError) -> SlackEgressCredentialError { + match error { + SecretStoreError::UnknownSecret { handle, .. } => { + SlackEgressCredentialError::UnknownHandle { + handle: handle.as_str().to_string(), + } + } + SecretStoreError::UnknownLease { .. } + | SecretStoreError::LeaseConsumed { .. } + | SecretStoreError::LeaseRevoked { .. } + | SecretStoreError::LeaseExpired { .. } + | SecretStoreError::SecretExpired + | SecretStoreError::BackendMisconfigured { .. } + | SecretStoreError::StoreUnavailable { .. } => SlackEgressCredentialError::Unavailable, + } +} + fn map_runtime_http_error(error: RuntimeHttpEgressError) -> ProtocolHttpEgressError { match error.reason_code() { ironclaw_host_api::RuntimeHttpEgressReasonCode::PolicyDenied diff --git a/crates/ironclaw_reborn_composition/src/slack_extension_settings.rs b/crates/ironclaw_reborn_composition/src/slack_extension_settings.rs new file mode 100644 index 00000000000..abc4748e616 --- /dev/null +++ b/crates/ironclaw_reborn_composition/src/slack_extension_settings.rs @@ -0,0 +1,303 @@ +use std::sync::Arc; + +use chrono::Utc; +use ironclaw_extensions::ExtensionInstallationId; +use ironclaw_filesystem::{ + CasExpectation, ContentType, Entry, FilesystemError, RootFilesystem, ScopedFilesystem, +}; +use ironclaw_host_api::{ + AgentId, InvocationId, ProjectId, ResourceScope, ScopedPath, TenantId, UserId, +}; +use ironclaw_product_adapters::{AdapterInstallationId, ExternalActorRef}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::slack_host_beta::{SlackHostBetaChannelRoute, SlackHostBetaConfig}; +use crate::slack_serve::SlackTeamId; + +const SLACK_EXTENSION_SETTINGS_ROOT: &str = "/tenant-shared/slack-extension-installations"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SlackExtensionInstallationSettings { + pub(crate) tenant_id: TenantId, + pub(crate) user_id: UserId, + pub(crate) agent_id: AgentId, + pub(crate) project_id: Option, + pub(crate) adapter_installation_id: AdapterInstallationId, + pub(crate) team_id: SlackTeamId, + pub(crate) api_app_id: String, + pub(crate) slack_user_id: Option, + pub(crate) shared_subject_user_id: Option, + pub(crate) channel_routes: Vec, +} + +impl SlackExtensionInstallationSettings { + pub(crate) fn from_host_beta_config(config: &SlackHostBetaConfig) -> Result { + let api_app_id = match &config.installation_selector { + crate::slack_serve::SlackInstallationSelector::AppTeam { api_app_id, .. } => { + api_app_id.as_str().to_string() + } + _ => { + return Err(Error::Invalid { + reason: "Slack extension settings require an app/team selector".to_string(), + }); + } + }; + Ok(Self { + tenant_id: config.tenant_id.clone(), + user_id: config.user_id.clone(), + agent_id: config.agent_id.clone(), + project_id: config.project_id.clone(), + adapter_installation_id: config.installation_id.clone(), + team_id: config.team_id.clone(), + api_app_id, + slack_user_id: config + .slack_actor + .as_ref() + .map(|actor| actor.id().to_string()), + shared_subject_user_id: config.shared_subject_user_id.clone(), + channel_routes: config.channel_routes.clone(), + }) + } + + pub(crate) fn secret_scope(&self) -> ResourceScope { + ResourceScope { + tenant_id: self.tenant_id.clone(), + user_id: self.user_id.clone(), + agent_id: Some(self.agent_id.clone()), + project_id: self.project_id.clone(), + mission_id: None, + thread_id: None, + invocation_id: InvocationId::new(), + } + } + + pub(crate) fn slack_actor(&self) -> Result, Error> { + self.slack_user_id + .as_ref() + .map(|slack_user_id| { + ExternalActorRef::new( + ironclaw_slack_v2_adapter::SLACK_USER_ACTOR_KIND, + slack_user_id.clone(), + None::, + ) + .map_err(|reason| Error::Invalid { + reason: format!("stored Slack user id is invalid: {reason}"), + }) + }) + .transpose() + } +} + +pub(crate) struct FilesystemSlackExtensionSettingsStore +where + F: RootFilesystem + 'static, +{ + filesystem: Arc>, +} + +impl FilesystemSlackExtensionSettingsStore +where + F: RootFilesystem + 'static, +{ + pub(crate) fn new(filesystem: Arc>) -> Self { + Self { filesystem } + } + + pub(crate) async fn upsert( + &self, + installation_id: &ExtensionInstallationId, + settings: &SlackExtensionInstallationSettings, + ) -> Result<(), Error> { + let path = settings_path(installation_id)?; + let body = + serde_json::to_vec_pretty(&StoredSlackExtensionInstallationSettings::from(settings)) + .map_err(|error| Error::Invalid { + reason: format!("Slack extension settings could not be serialized: {error}"), + })?; + self.filesystem + .put( + &ResourceScope::system(), + &path, + Entry::bytes(body).with_content_type(ContentType::json()), + CasExpectation::Any, + ) + .await + .map_err(map_fs_error)?; + Ok(()) + } + + pub(crate) async fn get( + &self, + installation_id: &ExtensionInstallationId, + ) -> Result, Error> { + let path = settings_path(installation_id)?; + let Some(versioned) = self + .filesystem + .get(&ResourceScope::system(), &path) + .await + .map_err(map_fs_error)? + else { + return Ok(None); + }; + let stored: StoredSlackExtensionInstallationSettings = + serde_json::from_slice(&versioned.entry.body).map_err(|error| Error::Invalid { + reason: format!("stored Slack extension settings are invalid JSON: {error}"), + })?; + stored.into_settings().map(Some) + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct StoredSlackExtensionInstallationSettings { + tenant_id: String, + user_id: String, + agent_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + project_id: Option, + adapter_installation_id: String, + team_id: String, + api_app_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + slack_user_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + shared_subject_user_id: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + channel_routes: Vec, + updated_at: chrono::DateTime, +} + +impl From<&SlackExtensionInstallationSettings> for StoredSlackExtensionInstallationSettings { + fn from(settings: &SlackExtensionInstallationSettings) -> Self { + Self { + tenant_id: settings.tenant_id.as_str().to_string(), + user_id: settings.user_id.as_str().to_string(), + agent_id: settings.agent_id.as_str().to_string(), + project_id: settings + .project_id + .as_ref() + .map(|project_id| project_id.as_str().to_string()), + adapter_installation_id: settings.adapter_installation_id.as_str().to_string(), + team_id: settings.team_id.as_str().to_string(), + api_app_id: settings.api_app_id.clone(), + slack_user_id: settings.slack_user_id.clone(), + shared_subject_user_id: settings + .shared_subject_user_id + .as_ref() + .map(|user_id| user_id.as_str().to_string()), + channel_routes: settings + .channel_routes + .iter() + .map(StoredSlackChannelRoute::from) + .collect(), + updated_at: Utc::now(), + } + } +} + +impl StoredSlackExtensionInstallationSettings { + fn into_settings(self) -> Result { + Ok(SlackExtensionInstallationSettings { + tenant_id: TenantId::new(self.tenant_id).map_err(invalid_id("tenant_id"))?, + user_id: UserId::new(self.user_id).map_err(invalid_id("user_id"))?, + agent_id: AgentId::new(self.agent_id).map_err(invalid_id("agent_id"))?, + project_id: self + .project_id + .map(ProjectId::new) + .transpose() + .map_err(invalid_id("project_id"))?, + adapter_installation_id: AdapterInstallationId::new(self.adapter_installation_id) + .map_err(invalid_id("adapter_installation_id"))?, + team_id: SlackTeamId::new(self.team_id), + api_app_id: nonempty("api_app_id", self.api_app_id)?, + slack_user_id: self + .slack_user_id + .map(|value| nonempty("slack_user_id", value)) + .transpose()?, + shared_subject_user_id: self + .shared_subject_user_id + .map(UserId::new) + .transpose() + .map_err(invalid_id("shared_subject_user_id"))?, + channel_routes: self + .channel_routes + .into_iter() + .map(StoredSlackChannelRoute::into_route) + .collect::, _>>()?, + }) + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct StoredSlackChannelRoute { + channel_id: String, + subject_user_id: String, +} + +impl From<&SlackHostBetaChannelRoute> for StoredSlackChannelRoute { + fn from(route: &SlackHostBetaChannelRoute) -> Self { + Self { + channel_id: route.channel_id.clone(), + subject_user_id: route.subject_user_id.as_str().to_string(), + } + } +} + +impl StoredSlackChannelRoute { + fn into_route(self) -> Result { + Ok(SlackHostBetaChannelRoute::new( + nonempty("channel_id", self.channel_id)?, + UserId::new(self.subject_user_id).map_err(invalid_id("subject_user_id"))?, + )) + } +} + +#[derive(Debug, Error)] +pub(crate) enum Error { + #[error("invalid Slack extension settings: {reason}")] + Invalid { reason: String }, + #[error("Slack extension settings store is unavailable: {reason}")] + StoreUnavailable { reason: String }, +} + +fn settings_path(installation_id: &ExtensionInstallationId) -> Result { + ScopedPath::new(format!( + "{}/{}.json", + SLACK_EXTENSION_SETTINGS_ROOT, + path_segment(installation_id.as_str()) + )) + .map_err(|error| Error::Invalid { + reason: format!("Slack extension settings path is invalid: {error}"), + }) +} + +fn path_segment(value: &str) -> String { + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; + URL_SAFE_NO_PAD.encode(value.as_bytes()) +} + +fn nonempty(field: &'static str, value: String) -> Result { + if value.trim().is_empty() || value.trim() != value { + return Err(Error::Invalid { + reason: format!("{field} must be non-empty and trimmed"), + }); + } + Ok(value) +} + +fn invalid_id(field: &'static str) -> impl FnOnce(E) -> Error { + move |error| Error::Invalid { + reason: format!("{field} is invalid: {error}"), + } +} + +fn map_fs_error(error: FilesystemError) -> Error { + Error::StoreUnavailable { + reason: match error { + FilesystemError::BackendInfrastructure { reason, .. } => reason, + other => other.to_string(), + }, + } +} diff --git a/crates/ironclaw_reborn_composition/src/slack_host_beta.rs b/crates/ironclaw_reborn_composition/src/slack_host_beta.rs index 7c7fb5e2c47..8b9d459220e 100644 --- a/crates/ironclaw_reborn_composition/src/slack_host_beta.rs +++ b/crates/ironclaw_reborn_composition/src/slack_host_beta.rs @@ -10,10 +10,17 @@ use std::sync::Arc; use std::time::Duration; use ironclaw_conversations::InMemoryConversationServices; -use ironclaw_host_api::ingress::IngressCredentialHandle; +use ironclaw_extensions::{ + ExtensionActivationState, ExtensionCredentialBinding, ExtensionCredentialHandle, + ExtensionInstallation, ExtensionInstallationId, +}; +use ironclaw_host_api::ingress::{ + HostIngressRouteDeclaration, HostIngressTarget, IngressCredentialHandle, +}; use ironclaw_host_api::{ - AgentId, InvocationId, ProjectId, ResourceScope, SecretHandle, TenantId, UserId, + AgentId, ExtensionId, InvocationId, ProjectId, ResourceScope, SecretHandle, TenantId, UserId, }; +use ironclaw_host_ingress_registry::{SLACK_EVENTS_ROUTE_ID, list_enabled_host_ingress_entries}; use ironclaw_outbound::{DeliveredGateRouteStore, OutboundStateStore, TriggeredRunDeliveryStore}; use ironclaw_product_adapters::{ AdapterInstallationId, DeclaredEgressHost, DeclaredEgressTarget, DeliveryStatus, @@ -22,9 +29,10 @@ use ironclaw_product_adapters::{ }; use ironclaw_product_workflow::{ ConversationBindingService, DefaultInboundTurnService, DefaultProductWorkflow, - ProductActorUserResolutionRequest, ProductActorUserResolver, ProductConversationBindingService, - ProductConversationRouteKey, ProductConversationSubjectRouteResolver, ProductInstallationKey, - ProductInstallationScope, ProductWorkflowError, ResolveBindingRequest, ResolvedBinding, + LifecyclePackageKind, LifecyclePackageRef, ProductActorUserResolutionRequest, + ProductActorUserResolver, ProductConversationBindingService, ProductConversationRouteKey, + ProductConversationSubjectRouteResolver, ProductInstallationKey, ProductInstallationScope, + ProductWorkflowError, ResolveBindingRequest, ResolvedBinding, StaticProductInstallationResolver, }; use ironclaw_product_workflow_storage::RebornFilesystemIdempotencyLedger; @@ -54,7 +62,13 @@ use crate::slack_delivery::{ SlackFinalReplyDeliveryObserver, SlackFinalReplyDeliveryServices, SlackFinalReplyDeliverySettings, TriggeredRunDeliveryDriver, }; -use crate::slack_egress::{SlackProtocolHttpEgress, StaticSlackEgressCredentialProvider}; +use crate::slack_egress::{ + SecretStoreSlackEgressCredentialProvider, SlackEgressCredentialProvider, + SlackProtocolHttpEgress, StaticSlackEgressCredentialProvider, +}; +use crate::slack_extension_settings::{ + FilesystemSlackExtensionSettingsStore, SlackExtensionInstallationSettings, +}; use crate::slack_host_ingress::{ ExtensionInstallationIngressCredentialBinding, ExtensionInstallationIngressCredentialResolver, SlackEventsIngressHandler, SlackHostIngressInstallation, @@ -354,6 +368,19 @@ pub enum SlackHostBetaBuildError { RuntimeHttpEgressUnavailable, #[error("Slack host-beta requires durable host state")] DurableHostStateUnavailable, + #[error("Slack host-beta requires extension lifecycle state")] + ExtensionLifecycleUnavailable, + #[error("Slack host-beta requires the shared host secret store")] + SecretStoreUnavailable, + #[error("Slack host-beta extension installation state failed: {reason}")] + ExtensionInstallation { reason: String }, + #[error("Slack host-beta extension settings failed: {reason}")] + ExtensionSettings { reason: String }, + #[error("Slack host-beta host ingress projection failed: {source}")] + HostIngressProjection { + #[from] + source: ironclaw_host_ingress_registry::Error, + }, #[error("Slack host-beta outbound delivery target registration failed: {reason}")] OutboundDeliveryTargetRegistration { reason: String }, #[error( @@ -374,6 +401,14 @@ pub enum SlackHostBetaBuildError { InvalidConfig { field: &'static str, reason: String }, } +impl From for SlackHostBetaBuildError { + fn from(source: crate::slack_extension_settings::Error) -> Self { + Self::ExtensionSettings { + reason: source.to_string(), + } + } +} + #[non_exhaustive] pub struct SlackHostBetaMounts { pub events: PublicRouteMount, @@ -446,6 +481,300 @@ pub fn build_slack_events_host_ingress_mount( )?) } +pub async fn import_slack_host_beta_config_as_extension_installation( + runtime: &RebornRuntime, + config: &SlackHostBetaConfig, +) -> Result<(), SlackHostBetaBuildError> { + let local_runtime = runtime + .services() + .local_runtime + .as_ref() + .ok_or(SlackHostBetaBuildError::DurableHostStateUnavailable)?; + let extension_management = local_runtime + .extension_management + .as_ref() + .ok_or(SlackHostBetaBuildError::ExtensionLifecycleUnavailable)?; + let secret_store = local_runtime + .secret_store + .clone() + .ok_or(SlackHostBetaBuildError::SecretStoreUnavailable)?; + let store = extension_management.installation_store(); + let extension_id = slack_extension_id()?; + let installation_id = slack_extension_installation_id()?; + if store + .get_installation(&installation_id) + .await + .map_err(map_extension_installation_error)? + .is_none() + { + extension_management + .install(slack_lifecycle_package_ref()?) + .await + .map_err(|error| SlackHostBetaBuildError::ExtensionInstallation { + reason: error.to_string(), + })?; + } + + let settings = SlackExtensionInstallationSettings::from_host_beta_config(config)?; + let settings_store = FilesystemSlackExtensionSettingsStore::new(Arc::clone( + &local_runtime.host_state_filesystem, + )); + settings_store.upsert(&installation_id, &settings).await?; + + let secret_scope = settings.secret_scope(); + let signing_secret_handle = SecretHandle::new(SLACK_SIGNING_SECRET_HANDLE) + .map_err(|reason| invalid_config("slack_signing_secret_handle", reason.to_string()))?; + let bot_token_handle = SecretHandle::new(SLACK_BOT_TOKEN_HANDLE) + .map_err(|reason| invalid_config("slack_bot_token_handle", reason.to_string()))?; + secret_store + .put( + secret_scope.clone(), + signing_secret_handle.clone(), + SecretMaterial::from(config.signing_secret.expose_secret().to_string()), + ) + .await?; + secret_store + .put( + secret_scope, + bot_token_handle.clone(), + SecretMaterial::from(config.bot_token.expose_secret().to_string()), + ) + .await?; + + let current = store + .get_installation(&installation_id) + .await + .map_err(map_extension_installation_error)? + .ok_or_else(|| SlackHostBetaBuildError::ExtensionInstallation { + reason: "Slack extension installation was not available after import install" + .to_string(), + })?; + let imported = ExtensionInstallation::new( + current.installation_id().clone(), + extension_id, + ExtensionActivationState::Enabled, + current.manifest_ref().clone(), + vec![ + ExtensionCredentialBinding::new( + ExtensionCredentialHandle::new(SLACK_SIGNING_SECRET_HANDLE) + .map_err(map_extension_installation_error)?, + signing_secret_handle, + ), + ExtensionCredentialBinding::new( + ExtensionCredentialHandle::new(SLACK_BOT_TOKEN_HANDLE) + .map_err(map_extension_installation_error)?, + bot_token_handle, + ), + ], + chrono::Utc::now(), + ) + .map_err(map_extension_installation_error)?; + store + .upsert_installation(imported) + .await + .map_err(map_extension_installation_error)?; + Ok(()) +} + +pub async fn build_slack_events_host_ingress_mount_from_enabled_extensions( + runtime: &RebornRuntime, +) -> Result, SlackHostBetaBuildError> { + let local_runtime = runtime + .services() + .local_runtime + .as_ref() + .ok_or(SlackHostBetaBuildError::DurableHostStateUnavailable)?; + let extension_management = local_runtime + .extension_management + .as_ref() + .ok_or(SlackHostBetaBuildError::ExtensionLifecycleUnavailable)?; + let secret_store = local_runtime + .secret_store + .clone() + .ok_or(SlackHostBetaBuildError::SecretStoreUnavailable)?; + let store = extension_management.installation_store(); + let settings_store = FilesystemSlackExtensionSettingsStore::new(Arc::clone( + &local_runtime.host_state_filesystem, + )); + let entries = list_enabled_host_ingress_entries(store.as_ref()).await?; + let mut installations = Vec::new(); + let mut credential_bindings = Vec::new(); + let slack_extension_id = slack_extension_id()?; + + for entry in entries { + let installation = entry.installation(); + if installation.extension_id() != &slack_extension_id { + continue; + } + if !is_slack_events_declaration(entry.declaration()) { + continue; + } + let settings = settings_store + .get(installation.installation_id()) + .await? + .ok_or_else(|| SlackHostBetaBuildError::ExtensionInstallation { + reason: format!( + "enabled Slack extension installation `{}` is missing host-owned settings; rerun Slack extension setup/import", + installation.installation_id() + ), + })?; + let secret_scope = settings.secret_scope(); + let bot_secret_handle = + extension_secret_handle(installation, SLACK_BOT_TOKEN_HANDLE)?.clone(); + let bot_token = + read_secret_string(Arc::clone(&secret_store), &secret_scope, &bot_secret_handle) + .await?; + let config = slack_config_from_extension_settings(&settings, bot_token)?; + let (actor_user_resolver, subject_route_resolver) = + build_slack_host_beta_event_resolvers(runtime, &config)?; + let token_handle = slack_bot_token_handle()?; + let egress_credentials: Arc = + Arc::new(SecretStoreSlackEgressCredentialProvider::new( + Arc::clone(&secret_store), + secret_scope.clone(), + token_handle.clone(), + bot_secret_handle, + )); + let assembly = build_slack_events_route_assembly_with_credentials( + runtime, + config, + actor_user_resolver, + Some(subject_route_resolver), + egress_credentials, + b"host-ingress-preverified".to_vec(), + )?; + let credential_handles = declaration_credential_handles(entry.declaration()); + for ingress_credential_handle in &credential_handles { + let secret_handle = + extension_secret_handle(installation, ingress_credential_handle.as_str())?; + credential_bindings.push(ExtensionInstallationIngressCredentialBinding { + candidate_id: assembly.installation_id.as_str().to_string(), + ingress_credential_handle: ingress_credential_handle.clone(), + secret_scope: secret_scope.clone(), + secret_handle: secret_handle.clone(), + }); + } + installations.push( + SlackHostIngressInstallation::new( + assembly.tenant_id, + assembly.installation_id, + assembly.installation_selector, + credential_handles, + assembly.runner, + )? + .with_workflow_observer(assembly.observer), + ); + } + + if installations.is_empty() { + return Ok(None); + } + + let handler = Arc::new(SlackEventsIngressHandler::new(installations)?); + let resolver = Arc::new(ExtensionInstallationIngressCredentialResolver::new( + secret_store, + credential_bindings, + )?); + Ok(Some(public_ingress_route_mount( + slack_events_host_ingress_registrations(handler)?, + resolver, + )?)) +} + +fn is_slack_events_declaration(declaration: &HostIngressRouteDeclaration) -> bool { + if declaration.route().route_id().as_str() != SLACK_EVENTS_ROUTE_ID { + return false; + } + matches!( + declaration.target(), + HostIngressTarget::ProductAdapterInbound { + product_adapter_section, + .. + } if product_adapter_section == "product_adapter.inbound" + ) +} + +fn declaration_credential_handles( + declaration: &HostIngressRouteDeclaration, +) -> Vec { + declaration + .auth() + .iter() + .flat_map(|binding| binding.credential_handles().iter().cloned()) + .collect() +} + +fn extension_secret_handle<'a>( + installation: &'a ExtensionInstallation, + credential_handle: &str, +) -> Result<&'a SecretHandle, SlackHostBetaBuildError> { + installation + .credential_bindings() + .iter() + .find(|binding| binding.credential_handle().as_str() == credential_handle) + .map(|binding| binding.secret_handle()) + .ok_or_else(|| SlackHostBetaBuildError::ExtensionInstallation { + reason: format!( + "enabled Slack extension installation `{}` is missing credential binding `{credential_handle}`", + installation.installation_id() + ), + }) +} + +async fn read_secret_string( + secret_store: Arc, + scope: &ResourceScope, + handle: &SecretHandle, +) -> Result { + let lease = secret_store.lease_once(scope, handle).await?; + Ok(secret_store.consume(scope, lease.id).await?) +} + +fn slack_config_from_extension_settings( + settings: &SlackExtensionInstallationSettings, + bot_token: SecretString, +) -> Result { + Ok(SlackHostBetaConfig { + tenant_id: settings.tenant_id.clone(), + agent_id: settings.agent_id.clone(), + project_id: settings.project_id.clone(), + installation_id: settings.adapter_installation_id.clone(), + team_id: settings.team_id.clone(), + installation_selector: SlackInstallationSelector::app_team( + settings.api_app_id.clone(), + settings.team_id.as_str().to_string(), + ), + slack_actor: settings.slack_actor()?, + user_id: settings.user_id.clone(), + shared_subject_user_id: settings.shared_subject_user_id.clone(), + channel_routes: settings.channel_routes.clone(), + signing_secret: SecretString::from("host-ingress-preverified"), + bot_token, + }) +} + +fn slack_extension_id() -> Result { + ExtensionId::new("slack").map_err(|reason| invalid_config("extension_id", reason.to_string())) +} + +fn slack_extension_installation_id() -> Result { + ExtensionInstallationId::new("slack").map_err(map_extension_installation_error) +} + +fn slack_lifecycle_package_ref() -> Result { + LifecyclePackageRef::new(LifecyclePackageKind::Extension, "slack").map_err(|error| { + SlackHostBetaBuildError::ExtensionInstallation { + reason: error.to_string(), + } + }) +} + +fn map_extension_installation_error(error: impl std::fmt::Display) -> SlackHostBetaBuildError { + SlackHostBetaBuildError::ExtensionInstallation { + reason: error.to_string(), + } +} + #[allow(dead_code)] // Used by the generic builder once step 6b wires it into serve. type SlackHostBetaEventResolvers = ( Arc, @@ -809,6 +1138,30 @@ fn build_slack_events_route_assembly( config: SlackHostBetaConfig, actor_user_resolver: Arc, subject_route_resolver: Option>, +) -> Result { + let token_handle = slack_bot_token_handle()?; + let egress_credentials: Arc = + Arc::new(StaticSlackEgressCredentialProvider::new( + token_handle, + config.bot_token.expose_secret().to_string(), + )); + build_slack_events_route_assembly_with_credentials( + runtime, + config.clone(), + actor_user_resolver, + subject_route_resolver, + egress_credentials, + config.signing_secret.expose_secret().as_bytes().to_vec(), + ) +} + +fn build_slack_events_route_assembly_with_credentials( + runtime: &RebornRuntime, + config: SlackHostBetaConfig, + actor_user_resolver: Arc, + subject_route_resolver: Option>, + egress_credentials: Arc, + runner_signing_secret: Vec, ) -> Result { // The resolver controls inbound Slack actor binding. `config.user_id` // scopes host-mediated Slack bot-token egress and legacy static actor @@ -907,7 +1260,7 @@ fn build_slack_events_route_assembly( WebhookAuth::Hmac(HmacWebhookAuth::new( SLACK_SIGNATURE_HEADER, SLACK_TIMESTAMP_HEADER, - config.signing_secret.expose_secret().as_bytes().to_vec(), + runner_signing_secret, config.installation_id.as_str(), )), NativeProductAdapterRunnerConfig::new( @@ -917,7 +1270,8 @@ fn build_slack_events_route_assembly( ), )); - let egress = slack_protocol_egress(runtime, &config, token_handle)?; + let egress = + slack_protocol_egress_with_provider(runtime, &config, token_handle, egress_credentials)?; let outbound_store: Arc = Arc::clone(&local_runtime.outbound_state); let preferences: Arc = Arc::clone(&local_runtime.outbound_preferences); @@ -966,6 +1320,20 @@ fn slack_protocol_egress( runtime: &RebornRuntime, config: &SlackHostBetaConfig, token_handle: EgressCredentialHandle, +) -> Result, SlackHostBetaBuildError> { + let credentials: Arc = + Arc::new(StaticSlackEgressCredentialProvider::new( + token_handle.clone(), + config.bot_token.expose_secret().to_string(), + )); + slack_protocol_egress_with_provider(runtime, config, token_handle, credentials) +} + +fn slack_protocol_egress_with_provider( + runtime: &RebornRuntime, + config: &SlackHostBetaConfig, + token_handle: EgressCredentialHandle, + credentials: Arc, ) -> Result, SlackHostBetaBuildError> { let local_runtime = runtime .services() @@ -978,10 +1346,7 @@ fn slack_protocol_egress( .ok_or(SlackHostBetaBuildError::RuntimeHttpEgressUnavailable)?; Ok(Arc::new(SlackProtocolHttpEgress::new( host_egress, - Arc::new(StaticSlackEgressCredentialProvider::new( - token_handle.clone(), - config.bot_token.expose_secret().to_string(), - )), + credentials, EgressPolicy::new(slack_declared_egress_targets(token_handle)?), slack_egress_scope_template(config), ))) @@ -1516,6 +1881,85 @@ mod tests { runtime.shutdown().await.expect("runtime shuts down"); } + #[tokio::test] + async fn extension_projected_host_ingress_dispatches_signed_event_callback() { + let egress = Arc::new(RecordingRuntimeHttpEgress::default()); + let (runtime, _root) = runtime_with_host_egress_override(Some(Some( + host_egress_port_for_test(Arc::clone(&egress)), + ))) + .await; + let config = config(); + import_slack_host_beta_config_as_extension_installation(&runtime, &config) + .await + .expect("Slack config imports into extension state"); + let mount = build_slack_events_host_ingress_mount_from_enabled_extensions(&runtime) + .await + .expect("extension projection builds") + .expect("enabled Slack extension projects events route"); + let body = r#"{ + "type":"event_callback", + "team_id":"T0HOST", + "api_app_id":"A0HOST", + "event_id":"Ev-host-beta-extension-dispatch", + "event":{"type":"message","channel_type":"im","user":"U0HOST","channel":"D0HOST","text":"hello from extension","ts":"1710000000.000014"} + }"#; + post_signed_slack_event(&mount, body).await; + if let Some(drain) = mount.drain.as_ref() { + drain.drain().await; + } + + let history = wait_for_slack_thread_history(&runtime).await; + let inbound_message = history + .messages + .iter() + .find(|message| message.content.as_deref() == Some("hello from extension")) + .expect("inbound Slack message should be recorded"); + assert_eq!( + inbound_message.source_binding_id.as_deref(), + Some( + "adapter:8:slack_v2;installation:17:install_host_beta;agent:16:agent:slack-host;project:18:project:slack-host;space:6:T0HOST;conversation:6:D0HOST;topic:0:;" + ) + ); + + let forged_body = r#"{ + "type":"event_callback", + "team_id":"T0HOST", + "api_app_id":"A0HOST", + "event_id":"Ev-host-beta-extension-forged", + "event":{"type":"message","channel_type":"im","user":"U0HOST","channel":"D0HOST","text":"forged extension","ts":"1710000000.000015"} + }"#; + let forged_response = mount + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(SLACK_EVENTS_PATH) + .header(SLACK_TIMESTAMP_HEADER, current_unix_timestamp().to_string()) + .header(SLACK_SIGNATURE_HEADER, "v0=deadbeef") + .body(Body::from(forged_body)) + .expect("forged request builds"), + ) + .await + .expect("router responds to forged request"); + + assert_eq!(forged_response.status(), StatusCode::UNAUTHORIZED); + if let Some(drain) = mount.drain.as_ref() { + drain.drain().await; + } + assert_eq!( + slack_message_count_with_text( + &runtime, + Some(UserId::new(USER).expect("user")), + "forged extension", + ) + .await, + 0 + ); + + runtime.shutdown().await.expect("runtime shuts down"); + } + #[tokio::test] async fn build_slack_events_route_mount_deduplicates_event_after_route_rebuild() { let egress = Arc::new(RecordingRuntimeHttpEgress::default()); diff --git a/crates/ironclaw_reborn_composition/src/slack_host_ingress.rs b/crates/ironclaw_reborn_composition/src/slack_host_ingress.rs index d6c38a613c6..1011be89776 100644 --- a/crates/ironclaw_reborn_composition/src/slack_host_ingress.rs +++ b/crates/ironclaw_reborn_composition/src/slack_host_ingress.rs @@ -371,7 +371,7 @@ pub fn slack_events_host_ingress_declaration( descriptor, HostIngressTarget::ProductAdapterInbound { capability_id, - product_adapter_section: "events".to_string(), + product_adapter_section: "product_adapter.inbound".to_string(), }, vec![auth], IngressAckMode::Immediate, diff --git a/docs/reborn-binary.md b/docs/reborn-binary.md index 5c5f89201d3..4faf4299310 100644 --- a/docs/reborn-binary.md +++ b/docs/reborn-binary.md @@ -122,8 +122,13 @@ Then open **`http://127.0.0.1:3000/v2`** and log in with the (the **CLI flag only**) tells the OS to pick a free ephemeral port — useful for test harnesses, though the banner still prints `:0`. `[webui].listen_port = 0` in `config.toml` is **rejected**, since a config-driven ephemeral port is almost -always a mistake. For the Slack host-beta ingress, build with -`--features slack-v2-host-beta` (it includes `webui-v2-beta`). +always a mistake. Slack host-beta support is still compile-gated: build with +`--features slack-v2-host-beta` (it includes `webui-v2-beta`). When `[slack]` +is configured, `serve` imports that config into the bundled Slack extension and +projects the events webhook route from enabled extension state in generic +host-ingress mode. If the Slack extension is already enabled with host-owned +settings and secret bindings, `serve` can mount that extension-projected events +route without a `[slack]` mount config. ### Choose your model provider