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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion FEATURE_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions crates/ironclaw_first_party_extensions/assets/slack/manifest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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"]
1 change: 1 addition & 0 deletions crates/ironclaw_host_runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
6 changes: 6 additions & 0 deletions crates/ironclaw_host_runtime/src/extension_contracts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -23,6 +24,11 @@ pub fn default_host_api_contract_registry() -> Result<HostApiContractRegistry, M
reason: format!("product adapter host API contract registration failed: {error}"),
})?;
registry.register(Arc::new(product_adapter_contract))?;
let host_ingress_contract =
HostIngressHostApiContract::new().map_err(|error| ManifestV2Error::Invalid {
reason: format!("host ingress host API contract registration failed: {error}"),
})?;
registry.register(Arc::new(host_ingress_contract))?;
Ok(registry)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use ironclaw_filesystem::LocalFilesystem;
use ironclaw_host_api::{
CapabilityId, ExtensionId, HOST_RUNTIME_HTTP_EGRESS_PORT_ID, HostPath, HostPortId, VirtualPath,
};
use ironclaw_host_ingress_registry::HOST_INGRESS_HOST_API_ID;
use ironclaw_host_runtime::discover_extensions_with_default_host_api_contracts;
use ironclaw_product_adapter_registry::PRODUCT_ADAPTER_HOST_API_ID;
use tempfile::tempdir;
Expand Down Expand Up @@ -71,6 +72,31 @@ async fn default_host_api_contracts_discover_product_adapter_manifest() {
assert!(package.capabilities.is_empty());
}

#[tokio::test]
async fn default_host_api_contracts_discover_host_ingress_manifest() {
let (_storage, fs) = mounted_extension_fs("slack-v2", HOST_INGRESS_MANIFEST);

let registry = discover_extensions_with_default_host_api_contracts(
&fs,
&VirtualPath::new("/system/extensions").unwrap(),
)
.await
.unwrap();

let package = registry
.get_extension(&ExtensionId::new("slack-v2").unwrap())
.unwrap();
assert_eq!(package.manifest.host_apis.len(), 1);
assert_eq!(
package.manifest.host_apis[0].id.as_str(),
HOST_INGRESS_HOST_API_ID
);
assert_eq!(
package.manifest.host_apis[0].section.as_str(),
"host_ingress.events"
);
}

#[tokio::test]
async fn default_host_port_catalog_rejects_unknown_required_port() {
let manifest = CAPABILITY_PROVIDER_MANIFEST.replace(
Expand Down Expand Up @@ -171,3 +197,36 @@ handle = "telegram_bot_token"
host = "api.telegram.org"
credential_handle = "telegram_bot_token"
"#;

const HOST_INGRESS_MANIFEST: &str = r#"schema_version = "reborn.extension_manifest.v2"
id = "slack-v2"
name = "Slack"
version = "0.1.0"
description = "Slack product adapter"
trust = "third_party"

[runtime]
kind = "wasm"
module = "adapters/slack-v2.wasm"

[[host_api]]
id = "ironclaw.host_ingress/v1"
section = "host_ingress.events"

[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"]
"#;
7 changes: 4 additions & 3 deletions crates/ironclaw_reborn_cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,10 @@ webui-v2-beta = [
"dep:ironclaw_reborn_webui_ingress",
"dep:async-trait",
]
# Compile in the Slack Events API host-beta mount for `ironclaw-reborn serve`.
# Still requires `[slack].enabled = true` at runtime before the route is
# mounted; ambient Slack env vars alone do not enable it.
# Compile in Slack host-beta extension/ingress support for `ironclaw-reborn serve`.
# `[slack].enabled = true` imports host config into the bundled Slack extension;
# an already-enabled Slack extension can also project its events route.
# Ambient Slack env vars alone do not enable it.
slack-v2-host-beta = [
"webui-v2-beta",
"ironclaw_reborn_composition/slack-v2-host-beta",
Expand Down
44 changes: 36 additions & 8 deletions crates/ironclaw_reborn_cli/src/commands/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ use ironclaw_reborn_composition::{
};
#[cfg(feature = "slack-v2-host-beta")]
use ironclaw_reborn_composition::{
SlackOperatorRouteVisibility, build_slack_events_host_ingress_mount,
SlackOperatorRouteVisibility, build_slack_events_host_ingress_mount_from_enabled_extensions,
build_slack_host_beta_mounts, build_webui_services_with_slack_host_beta_mounts,
import_slack_host_beta_config_as_extension_installation,
};
use ironclaw_reborn_config::{IdentitySection, seed_default_config_file_if_missing};
use ironclaw_reborn_webui_ingress::{
Expand Down Expand Up @@ -364,30 +365,55 @@ impl ServeCommand {
.await
.context("failed to assemble Reborn runtime for `serve`")?;
#[cfg(feature = "slack-v2-host-beta")]
let extension_slack_events_mount;
#[cfg(feature = "slack-v2-host-beta")]
let slack_mounts = if let Some(slack_config) = slack_host_beta_config {
import_slack_host_beta_config_as_extension_installation(&runtime, &slack_config)
.await
.context("failed to import Slack config into extension state")?;
let projected_slack_events_mount = if slack_host_ingress_mode.is_generic_shadow()
|| slack_host_ingress_mode.is_generic()
{
build_slack_events_host_ingress_mount_from_enabled_extensions(&runtime)
.await
.context("failed to compose Slack extension host-ingress events route")?
} else {
None
};
Some(if slack_host_ingress_mode.is_generic_shadow() {
let mounts = build_slack_host_beta_mounts(&runtime, slack_config.clone())
.context("failed to compose Slack host-beta routes")?;
build_slack_events_host_ingress_mount(&runtime, slack_config).context(
"failed to validate generic Slack host-ingress events route in shadow mode",
)?;
if projected_slack_events_mount.is_none() {
anyhow::bail!(
"Slack config import did not produce an enabled Slack extension events route"
);
}
tracing::debug!(
target = "ironclaw::reborn::cli::serve",
"generic shadow validated",
"Slack extension host-ingress route projection validated",
);
extension_slack_events_mount = projected_slack_events_mount;
mounts
} else if slack_host_ingress_mode.is_generic() {
let mut mounts = build_slack_host_beta_mounts(&runtime, slack_config.clone())
.context("failed to compose Slack host-beta routes")?;
mounts.events =
build_slack_events_host_ingress_mount(&runtime, slack_config)
.context("failed to compose generic Slack host-ingress events route")?;
mounts.events = projected_slack_events_mount.ok_or_else(|| {
anyhow!(
"Slack config import did not produce an enabled Slack extension events route"
)
})?;
extension_slack_events_mount = None;
mounts
} else {
extension_slack_events_mount = projected_slack_events_mount;
build_slack_host_beta_mounts(&runtime, slack_config)
.context("failed to compose Slack host-beta routes")?
})
} else {
extension_slack_events_mount =
build_slack_events_host_ingress_mount_from_enabled_extensions(&runtime)
.await
.context("failed to compose Slack extension host-ingress events route")?;
None
};
#[cfg(feature = "slack-v2-host-beta")]
Expand Down Expand Up @@ -511,6 +537,8 @@ impl ServeCommand {
.with_public_route_mount(slack_mounts.events)
.with_slack_personal_binding_pairing(slack_mounts.personal_binding_pairing)
.with_slack_channel_routes(slack_mounts.channel_routes);
} else if let Some(events_mount) = extension_slack_events_mount {
serve_config = serve_config.with_public_route_mount(events_mount);
}
// Public NEAR AI login callback route (token redirect target). Built
// from the runtime's LLM seam; absent when no LLM was wired.
Expand Down
1 change: 1 addition & 0 deletions crates/ironclaw_reborn_composition/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ ironclaw_filesystem = { path = "../ironclaw_filesystem" }
ironclaw_first_party_extensions = { path = "../ironclaw_first_party_extensions" }
ironclaw_hooks = { path = "../ironclaw_hooks" }
ironclaw_host_api = { path = "../ironclaw_host_api" }
ironclaw_host_ingress_registry = { path = "../ironclaw_host_ingress_registry" }
ironclaw_host_runtime = { path = "../ironclaw_host_runtime" }
ironclaw_llm = { path = "../ironclaw_llm", optional = true, default-features = false }
ironclaw_loop_support = { path = "../ironclaw_loop_support" }
Expand Down
4 changes: 4 additions & 0 deletions crates/ironclaw_reborn_composition/src/extension_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,10 @@ impl RebornLocalExtensionManagementPort {
.collect())
}

pub(crate) fn installation_store(&self) -> Arc<dyn ExtensionInstallationStore> {
Arc::clone(&self.installation_store)
}

pub(crate) async fn activation_credential_requirements(
&self,
package_ref: &LifecyclePackageRef,
Expand Down
11 changes: 11 additions & 0 deletions crates/ironclaw_reborn_composition/src/factory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Arc<RebornLocalExtensionManagementPort>>,
pub(crate) secret_store: Option<Arc<dyn SecretStore>>,
pub(crate) runtime_http_egress: Option<Arc<dyn RuntimeHttpEgress>>,
pub(crate) host_runtime_http_egress: Option<HostRuntimeHttpEgressPort>,
pub(crate) skill_mounts: MountView,
Expand Down Expand Up @@ -819,6 +820,13 @@ async fn build_local_dev(input: RebornBuildInput) -> Result<RebornServices, Rebo
let secret_store: Arc<dyn SecretStore> = local_dev_secret_store.clone();
#[cfg(not(any(feature = "libsql", feature = "postgres")))]
let secret_store: Arc<dyn SecretStore> = 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()?);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
19 changes: 17 additions & 2 deletions crates/ironclaw_reborn_composition/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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::{
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading