diff --git a/.env.example b/.env.example index de75363d456..7be34f1db9a 100644 --- a/.env.example +++ b/.env.example @@ -429,9 +429,12 @@ SAFETY_INJECTION_CHECK_ENABLED=true # IRONCLAW_REBORN_SECRET_MASTER_KEY=replace-with-independent-secret-key-material # IRONCLAW_REBORN_SERVE_HOST=127.0.0.1 # IRONCLAW_REBORN_SERVE_PORT=3000 -# IRONCLAW_REBORN_SLACK_ENABLED=true # accepts 1/true to enable Slack, 0/false as a kill switch # IRONCLAW_REBORN_CONFIRM_HOST_ACCESS=false # +# There is no Slack or Telegram enablement variable. Channel webhook routes are +# always mounted; a channel goes live when its extension is installed and set up +# in the WebUI at /extensions. +# # WebChat v2 SSO login (Google / GitHub). Setting either CLIENT_ID # surfaces that provider's login button on the `serve` listener; the # matching CLIENT_SECRET is required for the real code exchange. Each diff --git a/README.md b/README.md index 49569ea50b2..2db6fd5d948 100644 --- a/README.md +++ b/README.md @@ -190,13 +190,10 @@ ironclaw config set webui.token --rotate ``` Secret values never accept a positional argument; IronClaw prompts for them -without echoing the value. Configure Slack credentials and channel mappings -from the WebUI Extensions page. To enable its route from the CLI, use: - -```bash -ironclaw config set slack.enabled true -ironclaw service restart -``` +without echoing the value. Channels such as Slack and Telegram have no +configuration-file settings and no CLI enablement key: install the extension +and complete its setup on the WebUI Extensions page, which is what makes the +route serve. Configuration writes never restart the service automatically. Run `ironclaw service restart` after a change that affects the running service, diff --git a/crates/ironclaw_architecture/tests/reborn_extension_specificity.rs b/crates/ironclaw_architecture/tests/reborn_extension_specificity.rs index ab0890d860c..950f45a8ece 100644 --- a/crates/ironclaw_architecture/tests/reborn_extension_specificity.rs +++ b/crates/ironclaw_architecture/tests/reborn_extension_specificity.rs @@ -1400,14 +1400,17 @@ const ALLOWLIST: &[(&str, &str)] = &[ ), ("crates/ironclaw_reborn_config/src/config_file.rs", "gmail"), ("crates/ironclaw_reborn_config/src/config_file.rs", "google"), - ("crates/ironclaw_reborn_config/src/config_file.rs", "slack"), + // The retired-section gravestones: the only place this crate still + // names a vendor, and only as the TOML table name an operator typed. ( - "crates/ironclaw_reborn_config/src/config_file.rs", + "crates/ironclaw_reborn_config/src/retired_sections.rs", + "slack", + ), + ( + "crates/ironclaw_reborn_config/src/retired_sections.rs", "telegram", ), ("crates/ironclaw_reborn_config/src/lib.rs", "google"), - ("crates/ironclaw_reborn_config/src/lib.rs", "slack"), - ("crates/ironclaw_reborn_config/src/lib.rs", "telegram"), // lane-4: dev-deps — sanctioned DEL-7 linkage of concrete channel crates // for the production-shaped channel-host E2E suite; the scanner sees the // crate names in Cargo.toml even though Rust test sources are excluded. @@ -1492,7 +1495,7 @@ const ALLOWLIST: &[(&str, &str)] = &[ /// the gate above (an entry that no longer matches fails); this ceiling is the /// other half — the list cannot *grow* untracked either. Lower it in the same /// PR that deletes entries so the new floor is locked in. -const WS0_EXTENSION_SPECIFICITY_ALLOWLIST_BASELINE: usize = 129; +const WS0_EXTENSION_SPECIFICITY_ALLOWLIST_BASELINE: usize = 125; /// §11.2.8 vendor-scope shrink, armed at the WS0 baseline. #[test] diff --git a/crates/ironclaw_reborn_cli/src/commands/config/capability_config.rs b/crates/ironclaw_reborn_cli/src/commands/config/capability_config.rs index b15e2f14b64..f531da40673 100644 --- a/crates/ironclaw_reborn_cli/src/commands/config/capability_config.rs +++ b/crates/ironclaw_reborn_cli/src/commands/config/capability_config.rs @@ -37,7 +37,6 @@ pub(super) enum ConfigKey { GoogleClientId, GoogleClientSecret, GoogleRedirectUri, - SlackEnabled, WebuiToken, } @@ -62,7 +61,6 @@ impl ConfigKey { "google.client_id" => Some(Self::GoogleClientId), "google.client_secret" => Some(Self::GoogleClientSecret), "google.redirect_uri" => Some(Self::GoogleRedirectUri), - "slack.enabled" => Some(Self::SlackEnabled), "webui.token" => Some(Self::WebuiToken), _ => None, } @@ -71,9 +69,7 @@ impl ConfigKey { pub(super) fn destination(&self) -> ConfigDestination { match self { Self::LlmApiKey { .. } | Self::GoogleClientSecret => ConfigDestination::SecretStorePort, - Self::GoogleClientId | Self::GoogleRedirectUri | Self::SlackEnabled => { - ConfigDestination::ConfigToml - } + Self::GoogleClientId | Self::GoogleRedirectUri => ConfigDestination::ConfigToml, Self::WebuiToken => ConfigDestination::TokenFile, } } @@ -146,13 +142,6 @@ pub(super) fn validate_shape(key: &ConfigKey, value: &str) -> ShapeVerdict { ) } } - ConfigKey::SlackEnabled => { - if value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("false") { - ShapeVerdict::Ok - } else { - ShapeVerdict::Reject(format!("slack.enabled `{value}` must be `true` or `false`")) - } - } ConfigKey::LlmApiKey { .. } | ConfigKey::WebuiToken => ShapeVerdict::Ok, } } @@ -167,19 +156,6 @@ pub(super) fn google_remediation_text() -> String { ironclaw_reborn_config::google_remediation_text() } -/// Slack remediation text: per Correction A in the PR-C plan, Slack has -/// no CLI-settable bot token/signing secret — the only supported surface -/// is the WebUI extension setup flow. Describes WHAT to configure only; -/// the restart apply-step sentence is appended once by the caller (see -/// `set.rs::print_apply_step`), not embedded here — see the module doc. -/// -pub(super) fn slack_remediation_text(base_url: &str) -> String { - format!( - "After restarting, connect your Slack workspace at {base_url}/extensions (workspace \ - OAuth happens there; config set cannot supply Slack app identity or credentials)" - ) -} - #[cfg(test)] mod tests { use super::*; @@ -223,10 +199,6 @@ mod tests { ConfigKey::classify("google.redirect_uri"), Some(ConfigKey::GoogleRedirectUri) ); - assert_eq!( - ConfigKey::classify("slack.enabled"), - Some(ConfigKey::SlackEnabled) - ); assert_eq!( ConfigKey::classify("webui.token"), Some(ConfigKey::WebuiToken) @@ -235,6 +207,10 @@ mod tests { #[test] fn classify_unknown_key_is_none() { + // `slack.enabled` was settable until the section was retired; it now + // classifies as unknown so `set.rs` can answer with migration + // guidance instead of writing a value nothing reads. + assert_eq!(ConfigKey::classify("slack.enabled"), None); assert_eq!(ConfigKey::classify("slack.bot_token"), None); assert_eq!(ConfigKey::classify("slack.signing_secret"), None); assert_eq!(ConfigKey::classify("nonsense.key"), None); @@ -261,10 +237,6 @@ mod tests { ConfigKey::GoogleRedirectUri.destination(), ConfigDestination::ConfigToml ); - assert_eq!( - ConfigKey::SlackEnabled.destination(), - ConfigDestination::ConfigToml - ); assert_eq!( ConfigKey::WebuiToken.destination(), ConfigDestination::TokenFile @@ -353,22 +325,6 @@ mod tests { )); } - #[test] - fn slack_enabled_validator_requires_bool_shape() { - assert_eq!( - validate_shape(&ConfigKey::SlackEnabled, "true"), - ShapeVerdict::Ok - ); - assert_eq!( - validate_shape(&ConfigKey::SlackEnabled, "FALSE"), - ShapeVerdict::Ok - ); - assert!(matches!( - validate_shape(&ConfigKey::SlackEnabled, "yes"), - ShapeVerdict::Reject(_) - )); - } - #[test] fn llm_api_key_and_webui_token_have_no_shape_rejection() { assert_eq!( @@ -404,21 +360,5 @@ mod tests { "google_remediation_text must not embed the restart step itself \ (callers append it exactly once): {google}" ); - - let slack = slack_remediation_text("http://127.0.0.1:3000"); - assert!(slack.contains("http://127.0.0.1:3000/extensions")); - assert!(!slack.contains("config set slack.bot_token")); - // The redirect-URI env var the host-beta lane read is gone on the - // unified extension model — the guidance must not teach a dead step. - assert!( - !slack.contains("IRONCLAW_REBORN_SLACK_PERSONAL_OAUTH_REDIRECT_URI"), - "the retired redirect-URI env var must not be advertised: {slack}" - ); - assert_eq!( - slack.matches("service restart").count(), - 0, - "slack_remediation_text must not embed the restart step itself \ - (callers append it exactly once): {slack}" - ); } } diff --git a/crates/ironclaw_reborn_cli/src/commands/config/read.rs b/crates/ironclaw_reborn_cli/src/commands/config/read.rs index 1be75953f92..c9791a1b5c6 100644 --- a/crates/ironclaw_reborn_cli/src/commands/config/read.rs +++ b/crates/ironclaw_reborn_cli/src/commands/config/read.rs @@ -64,9 +64,11 @@ fn flatten_config( storage: Some(config.storage.clone().unwrap_or_default()), llm: Some(llm), webui: Some(config.webui.clone().unwrap_or_default()), - slack: Some(config.slack.clone().unwrap_or_default()), - telegram: Some(config.telegram.clone().unwrap_or_default()), google: Some(config.google.clone().unwrap_or_default()), + // Deliberately not expanded: a retired section is not a settable key, + // so `config list` must not advertise one. (It is `serde(skip)` as + // well — this is the second, explicit half of the same statement.) + retired_sections: Default::default(), memory: Some(config.memory.clone().unwrap_or_default()), budget: Some(config.budget.clone().unwrap_or_default()), trigger_poller: Some(config.trigger_poller.clone().unwrap_or_default()), diff --git a/crates/ironclaw_reborn_cli/src/commands/config/set.rs b/crates/ironclaw_reborn_cli/src/commands/config/set.rs index 7db848122b7..6b9b18b6e2c 100644 --- a/crates/ironclaw_reborn_cli/src/commands/config/set.rs +++ b/crates/ironclaw_reborn_cli/src/commands/config/set.rs @@ -17,7 +17,7 @@ use crate::context::RebornCliContext; #[derive(Debug, Args)] pub(super) struct ConfigSetCommand { /// Dot-separated config key (e.g. google.client_id, openai.api_key, - /// slack.enabled, webui.token). + /// webui.token). key: String, /// Value to set for non-secret keys. Secret-destination keys /// (`.api_key`, `google.client_secret`) reject positional values @@ -32,6 +32,19 @@ pub(super) struct ConfigSetCommand { impl ConfigSetCommand { pub(super) fn execute(self, context: RebornCliContext) -> anyhow::Result<()> { + // A key whose section this crate has retired gets the migration + // pointer rather than the generic unknown-key list: an operator + // following an old runbook has not made a typo, and telling them so + // sends them looking in the wrong place. Sourced from + // `ironclaw_reborn_config`'s retired-section table so the CLI and the + // boot-time check cannot tell two different stories. + if let Some(guidance) = ironclaw_reborn_config::retired_config_key_guidance(&self.key) { + anyhow::bail!( + "{guidance} Open {base_url}/extensions to manage installed extensions.", + guidance = guidance, + base_url = default_webui_base_url(), + ); + } let Some(key) = ConfigKey::classify(&self.key) else { anyhow::bail!(unknown_key_message(&self.key)); }; @@ -57,7 +70,7 @@ fn unknown_key_message(key: &str) -> String { format!( "unknown config key `{key}` for `config set`\nSupported keys: .api_key \ (default provider `{}`), google.client_id, google.client_secret, google.redirect_uri, \ - slack.enabled, webui.token (--rotate only)\nRun `ironclaw config list` to see \ + webui.token (--rotate only)\nRun `ironclaw config list` to see \ all readable keys", super::init::DEFAULT_LLM_PROVIDER_ID, ) @@ -69,7 +82,6 @@ fn describe_key(key: &ConfigKey) -> String { ConfigKey::GoogleClientId => "google.client_id".to_string(), ConfigKey::GoogleClientSecret => "google.client_secret".to_string(), ConfigKey::GoogleRedirectUri => "google.redirect_uri".to_string(), - ConfigKey::SlackEnabled => "slack.enabled".to_string(), ConfigKey::WebuiToken => "webui.token".to_string(), } } @@ -140,9 +152,6 @@ fn set_value_key( ConfigKey::GoogleClientSecret => { write_google_client_secret(context, &value, store_opener)?; } - ConfigKey::SlackEnabled => { - write_slack_enabled(home, &value)?; - } ConfigKey::WebuiToken => unreachable!("handled by execute_webui_token"), } @@ -154,7 +163,7 @@ fn set_value_key( /// After a successful write, print the remaining BYO setup steps for the /// capability this key belongs to, via -/// `capability_config::google_remediation_text`/`slack_remediation_text`. +/// `capability_config::google_remediation_text`. fn print_remaining_setup_guidance(key: &ConfigKey) { match key { ConfigKey::GoogleClientId @@ -163,13 +172,6 @@ fn print_remaining_setup_guidance(key: &ConfigKey) { println!(); println!("{}", super::capability_config::google_remediation_text()); } - ConfigKey::SlackEnabled => { - println!(); - println!( - "{}", - super::capability_config::slack_remediation_text(&default_webui_base_url()) - ); - } ConfigKey::LlmApiKey { .. } | ConfigKey::WebuiToken => {} } } @@ -196,12 +198,6 @@ fn write_google_field( .map_err(anyhow::Error::from) } -fn write_slack_enabled(home: &RebornHome, value: &str) -> anyhow::Result<()> { - let enabled = value.eq_ignore_ascii_case("true"); - ironclaw_reborn_config::update_slack_enabled(&home.config_file_path(), enabled) - .map_err(anyhow::Error::from) -} - fn write_llm_api_key( context: &RebornCliContext, provider_id: &str, @@ -654,21 +650,36 @@ mod tests { ); } + /// `slack.enabled` used to be settable and used to write TOML that + /// nothing read. Driven through `execute` rather than `set_value_key` + /// because the retired-key check lives at the command entry point — a + /// `ConfigKey` fixture cannot reach it, and the property under test is + /// precisely that the key never becomes a `ConfigKey` at all. #[test] - fn slack_enabled_round_trips() { + fn retired_config_key_is_refused_with_migration_guidance_and_writes_nothing() { let (_tmp, context) = RebornCliContext::test_context(); - set_value_key( - &context, - ConfigKey::SlackEnabled, - Some("true".to_string()), - &mut NeverPromptSource, - &FailingStoreOpener, - ) - .expect("must succeed"); - let toml = config_toml(&context); - assert!(toml.contains("[slack]"), "config: {toml}"); - assert!(toml.contains("enabled = true"), "config: {toml}"); + let error = ConfigSetCommand { + key: "slack.enabled".to_string(), + value: Some("true".to_string()), + rotate: false, + } + .execute(context.clone()) + .expect_err("a retired key must be refused"); + + let message = error.to_string(); + assert!(message.contains("slack.enabled"), "message: {message}"); + assert!(message.contains("retired"), "message: {message}"); + assert!(message.contains("/extensions"), "message: {message}"); + assert!( + !message.contains("unknown config key"), + "a retired key must not be reported as a typo: {message}" + ); + assert!( + config_toml(&context).is_empty(), + "a refused key must not write config.toml: {}", + config_toml(&context) + ); } #[test] diff --git a/crates/ironclaw_reborn_cli/src/commands/serve.rs b/crates/ironclaw_reborn_cli/src/commands/serve.rs index 427a863e4de..71d0ce41bd5 100644 --- a/crates/ironclaw_reborn_cli/src/commands/serve.rs +++ b/crates/ironclaw_reborn_cli/src/commands/serve.rs @@ -136,7 +136,7 @@ impl ServeCommand { ironclaw_reborn_config::RebornConfigFile::load(&boot_config.home().config_file_path()) .map_err(anyhow::Error::from)?; if let Some(file) = config_file.as_ref() { - reject_legacy_slack_config(file, &boot_config.home().config_file_path())?; + reject_retired_config_sections(file, &boot_config.home().config_file_path())?; } // Tenant id is host-trusted (operator-owned config), never @@ -896,42 +896,22 @@ fn trigger_fire_access_policy( .with_tenant_membership(default_agent_id.clone(), default_project_id.cloned()) } -/// The legacy `[slack]` setup fields are a retired configuration surface: -/// Slack is configured by installing the Slack extension and completing -/// workspace OAuth in the WebUI (`/extensions`). A populated setup field -/// means the operator is following retired instructions — fail closed with -/// the migration pointer instead of silently ignoring it. `[slack].enabled` -/// is not rejected: the flag is unused, but existing installs may still -/// carry it and must keep booting. -fn reject_legacy_slack_config( +/// Refuse to serve against a config file that still carries a retired +/// *setup* key, and announce the retired sections that are merely inert. +/// +/// Both halves are data-driven by `ironclaw_reborn_config`'s retired-section +/// table rather than by per-vendor code here: which sections are retired is a +/// fact about the config schema, so the CLI asks rather than re-deriving. The +/// inert half is reported instead of ignored because the failure it catches +/// is an operator setting a flag that documentation once advertised and +/// nothing reads. +fn reject_retired_config_sections( config_file: &ironclaw_reborn_config::RebornConfigFile, config_path: &std::path::Path, ) -> anyhow::Result<()> { - let Some(slack) = config_file.slack.as_ref() else { - return Ok(()); - }; - let offending = [ - ("installation_id", slack.installation_id.is_some()), - ("team_id", slack.team_id.is_some()), - ("api_app_id", slack.api_app_id.is_some()), - ("slack_user_id", slack.slack_user_id.is_some()), - ("user_id", slack.user_id.is_some()), - ( - "shared_subject_user_id", - slack.shared_subject_user_id.is_some(), - ), - ("channel_routes", !slack.channel_routes.is_empty()), - ("signing_secret_env", slack.signing_secret_env.is_some()), - ("bot_token_env", slack.bot_token_env.is_some()), - ]; - if let Some((field, _)) = offending.iter().find(|(_, set)| *set) { - anyhow::bail!( - "`[slack].{field}` in {path} is a retired configuration surface: Slack is \ - configured by installing the Slack extension and completing workspace OAuth \ - in the WebUI (/extensions). Remove the `[slack]` section to continue.", - field = field, - path = config_path.display(), - ); + config_file.retired_section_migration(config_path)?; + for notice in config_file.retired_section_notices(config_path) { + tracing::warn!("{notice}"); } Ok(()) } @@ -1394,7 +1374,7 @@ mod tests { } #[test] - fn serve_startup_rejects_loaded_config_with_legacy_slack_fields() { + fn serve_startup_rejects_loaded_config_with_retired_setup_fields() { let dir = tempfile::tempdir().expect("tempdir"); let config_path = dir.path().join("config.toml"); std::fs::write( @@ -1412,8 +1392,8 @@ slack_user_id = "U123" .expect("config file loads") .expect("config exists"); - let error = reject_legacy_slack_config(&config_file, &config_path) - .expect_err("serve startup must reject legacy Slack config fields"); + let error = reject_retired_config_sections(&config_file, &config_path) + .expect_err("serve startup must reject retired setup fields"); let message = error.to_string(); assert!( @@ -1437,8 +1417,27 @@ slack_user_id = "U123" let config_file = ironclaw_reborn_config::RebornConfigFile::load(&config_path) .expect("config file loads") .expect("config exists"); - reject_legacy_slack_config(&config_file, &config_path) + reject_retired_config_sections(&config_file, &config_path) .expect("an inert [slack].enabled must not block startup"); + + // The check is table-driven, so a section retired later must get the + // same treatment without new code here. `[telegram]` never had a + // setup field, so it is the inert-only case end to end. + std::fs::write( + &config_path, + "api_version = \"ironclaw.runtime/v1\"\n\n[telegram]\nenabled = true\n", + ) + .expect("write config"); + let config_file = ironclaw_reborn_config::RebornConfigFile::load(&config_path) + .expect("config file loads") + .expect("config exists"); + reject_retired_config_sections(&config_file, &config_path) + .expect("an inert [telegram] section must not block startup"); + assert_eq!( + config_file.retired_section_notices(&config_path).len(), + 1, + "an inert retired section must still announce itself" + ); } #[test] diff --git a/crates/ironclaw_reborn_cli/tests/smoke.rs b/crates/ironclaw_reborn_cli/tests/smoke.rs index 7e436757a89..fbb337c7064 100644 --- a/crates/ironclaw_reborn_cli/tests/smoke.rs +++ b/crates/ironclaw_reborn_cli/tests/smoke.rs @@ -1880,13 +1880,19 @@ fn config_set_google_client_id_writes_config_toml() { ); } -/// PR-C round-2 fix: `slack_remediation_text` used to embed its own -/// trailing "run `service restart`" sentence on top of `print_apply_step`'s -/// canonical line, double-printing the restart instruction. Pin the -/// exactly-once invariant the same way the google.client_id test above -/// does. +/// `slack.enabled` was settable until the `[slack]` section was retired, +/// and the value it wrote had no runtime reader — `config set` reported +/// "saved" for a setting that did nothing. Pinned at the binary tier +/// because that is where an operator following an old runbook meets it, and +/// because the refusal happens before key classification, which an +/// in-process `ConfigKey` fixture cannot reach. +/// +/// (The "restart printed exactly once" invariant this test used to carry is +/// not lost with it: `config_set_google_client_id_writes_config_toml` above +/// asserts the same `matches("service restart").count() == 1` on a key that +/// still exists.) #[test] -fn config_set_slack_enabled_prints_restart_exactly_once() { +fn config_set_retired_slack_key_is_refused_with_migration_guidance() { let temp = tempfile::tempdir().expect("tempdir"); let reborn_home = temp.path().join("reborn-home"); @@ -1898,21 +1904,24 @@ fn config_set_slack_enabled_prints_restart_exactly_once() { .expect("ironclaw config set slack.enabled should run"); assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) + !output.status.success(), + "a retired key must be refused, not silently written; stdout: {}", + String::from_utf8_lossy(&output.stdout) ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("slack.enabled: saved"), "stdout: {stdout}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("slack.enabled"), "stderr: {stderr}"); + assert!(stderr.contains("retired"), "stderr: {stderr}"); + assert!(stderr.contains("/extensions"), "stderr: {stderr}"); assert!( - stdout.contains("to apply: ironclaw service restart"), - "config set must never auto-restart; it must print the explicit apply step: {stdout}" + !stderr.contains("unknown config key"), + "a retired key must not be reported as a typo: {stderr}" ); - assert_eq!( - stdout.matches("service restart").count(), - 1, - "the restart instruction must appear exactly once (remediation text plus the \ - apply-step line must not both print it): {stdout}" + + let config_path = reborn_home.join("config.toml"); + let config = std::fs::read_to_string(&config_path).unwrap_or_default(); + assert!( + !config.contains("[slack]"), + "a refused key must not write a `[slack]` section: {config}" ); } diff --git a/crates/ironclaw_reborn_composition/src/runtime.rs b/crates/ironclaw_reborn_composition/src/runtime.rs index 2ae7f2a6286..9d41d896162 100644 --- a/crates/ironclaw_reborn_composition/src/runtime.rs +++ b/crates/ironclaw_reborn_composition/src/runtime.rs @@ -407,8 +407,17 @@ pub use skills::{ use skills::skill_asset_error; use ironclaw_operator::ResolvedRebornLlm; +// Named only by `#[cfg(any(test, feature = "test-support"))]` accessors +// below, so the imports carry the same gate. Without it, any build that +// compiles this crate as a *dependency* without `test-support` — e.g. the +// PR clippy lane when the changed-package set is `{ironclaw, +// ironclaw_reborn_config}` — sees three unused imports and fails `-D +// warnings`. See #7119. +#[cfg(any(test, feature = "test-support"))] use ironclaw_product_contracts::account_setup::ChannelConnectionNoticePolicy; +#[cfg(any(test, feature = "test-support"))] use ironclaw_product_contracts::admin_users::AdminUserService; +#[cfg(any(test, feature = "test-support"))] use ironclaw_product_contracts::channel_config::ChannelConfigProductService; use ironclaw_product_contracts::delivery::ChannelDeliveryResolver; diff --git a/crates/ironclaw_reborn_config/AGENTS.md b/crates/ironclaw_reborn_config/AGENTS.md index 40edd38d09b..aed711e4af9 100644 --- a/crates/ironclaw_reborn_config/AGENTS.md +++ b/crates/ironclaw_reborn_config/AGENTS.md @@ -9,6 +9,8 @@ - `boot.rs`, `config_file.rs` — boot/config file loading. - `doctor.rs` — config diagnostics. - `secrets_guard.rs` — secret/config guardrails. + - `retired_sections.rs` — the compatibility window for `config.toml` + sections this crate used to define and no longer does. - Neighboring consumers: `crates/ironclaw_reborn_cli/AGENTS.md`, `crates/ironclaw_reborn_composition/AGENTS.md`. ## What This Crate Owns @@ -22,6 +24,27 @@ - Runtime execution, product adapter workflow, host-runtime service construction, or CLI command dispatch. - Writes to v1/current IronClaw state. - Network, secret retrieval, database connection, or product side effects. +- **A new per-vendor `config.toml` section.** An extension's live configuration + is package-owned, declared by its manifest `[admin_configuration]` — it does + not get a typed section here. `RebornConfigFile` names no vendor, and the + extension-specificity gate + (`crates/ironclaw_architecture/tests/reborn_extension_specificity.rs`) is + shrink-only, so adding one needs a baseline raise and a reviewed carve-out. + The only vendor tokens this crate may hold are the retired table names in + `retired_sections.rs`. + +## Retiring a config section + +Deleting a section outright breaks every existing operator file, because +`RebornConfigFile` is `deny_unknown_fields`. Instead add a row to +`RETIRED_SECTIONS` (`retired_sections.rs`) naming the section, the *setup* +keys that should fail the boot closed, and what to do instead. Everything +else follows from the table: the parse-time split, the `serve` refusal, the +deprecation notice for inert sections, and `config set`'s migration guidance. +See PROPOSAL §6.10.3 / §12.2 for why the gravestone lives here rather than in +the package. **Grep `docs/` as well as `crates/`** — the Slack retirement +found five operator-facing docs still teaching a flag that had had no reader +for two weeks. ## Validation diff --git a/crates/ironclaw_reborn_config/src/config_file.rs b/crates/ironclaw_reborn_config/src/config_file.rs index b3dd752ca72..69e65fd4dc0 100644 --- a/crates/ironclaw_reborn_config/src/config_file.rs +++ b/crates/ironclaw_reborn_config/src/config_file.rs @@ -43,6 +43,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use thiserror::Error; use crate::RebornProfile; +use crate::retired_sections::{RetiredSectionError, RetiredSections}; use crate::secrets_guard::{InlineSecretError, reject_inline_secret}; /// API version stamp this crate understands. Mirrors @@ -85,10 +86,6 @@ pub struct RebornConfigFile { /// `serve` subcommand is invoked. Optional — sparse configs /// fall back to compiled defaults documented on each field. pub webui: Option, - /// Legacy-compatible Slack host enablement and rejected setup fields. - pub slack: Option, - /// Telegram host enablement. Runtime setup remains extension-owned. - pub telegram: Option, /// Google OAuth client identity for Gmail/Calendar/Drive extensions. /// Public identifiers only; the client secret stays in the secret store. pub google: Option, @@ -108,6 +105,16 @@ pub struct RebornConfigFile { /// host-runtime binding resolver, which owns the profile catalog; this /// config layer only does deployment-agnostic structural validation. pub memory: Option, + /// Sections this crate's schema used to define and no longer does, + /// captured verbatim so an existing operator file still parses and can be + /// answered with migration guidance. `serde(skip)` in both directions: it + /// is never read from a file ([`RebornConfigFile::parse_text`] splits + /// these off the raw document before the typed parse runs, which is what + /// lets the typed schema stay `deny_unknown_fields` without naming a + /// retired section) and never written to one (`config list` must not + /// advertise a retired key as settable). + #[serde(skip)] + pub retired_sections: RetiredSections, } /// `[memory]` config section (issue #3537). @@ -394,57 +401,6 @@ pub struct GoogleSection { pub hosted_domain_hint: Option, } -/// Slack listener enablement. The additional fields remain parseable so old -/// files receive a precise startup migration error rather than an unknown-key -/// parse failure; new setup belongs to the extension lifecycle. -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct SlackSection { - pub enabled: Option, - pub installation_id: Option, - pub team_id: Option, - pub api_app_id: Option, - pub slack_user_id: Option, - pub user_id: Option, - pub shared_subject_user_id: Option, - #[serde(default)] - pub channel_routes: Vec, - pub signing_secret_env: Option, - pub bot_token_env: Option, -} - -impl SlackSection { - pub fn set_enabled(mut self, enabled: bool) -> Self { - self.enabled = Some(enabled); - self - } - - pub fn set_user_id(mut self, value: impl Into) -> Self { - self.user_id = Some(value.into()); - self - } -} - -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct SlackChannelRouteSection { - pub channel_id: Option, - pub subject_user_id: Option, -} - -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct TelegramSection { - pub enabled: Option, -} - -impl TelegramSection { - pub fn set_enabled(mut self, enabled: bool) -> Self { - self.enabled = Some(enabled); - self - } -} - /// `[budget]` section. All limits in USD. **0 = unlimited.** /// /// Composition uses these as defaults when first seeding a user/project @@ -820,22 +776,6 @@ fn ensure_google_table(doc: &mut toml_edit::DocumentMut) { } } -/// Set `[slack].enabled` while preserving unrelated TOML — the one Slack -/// field `config set` may still write (see `SlackSection`'s doc: every -/// other Slack field is boot-rejected). Deliberately a plain function -/// rather than a full update-session type like the LLM/Google ones: a -/// single bool field has no `Keep`/`Remove` distinction worth modeling. -pub fn update_slack_enabled(path: &Path, enabled: bool) -> Result<(), RebornConfigFileUpdateError> { - let _lock_file = acquire_update_lock(path)?; - let mut doc = load_edit_document(path)?; - let root = doc.as_table_mut(); - if root.get("slack").is_none_or(|item| !item.is_table()) { - root.insert("slack", toml_edit::Item::Table(toml_edit::Table::new())); - } - doc["slack"]["enabled"] = toml_edit::value(enabled); - write_edit_document(path, &doc) -} - // ─── Errors ───────────────────────────────────────────────────────────────── #[derive(Debug, Error)] @@ -934,15 +874,54 @@ impl RebornConfigFile { /// Parse + validate a TOML string. Public so callers can drive the /// parser without going through the filesystem (e.g. CLI flag /// `--config-string`, tests). + /// Retired sections (see [`crate::retired_sections`]) are split off the + /// raw document before the typed parse, so the typed schema never names + /// one and can stay `deny_unknown_fields`. + /// + /// The split costs a second parse only for files that actually carry a + /// retired section. That ordering is deliberate: `toml::from_str::` + /// on the original text yields `unknown field` errors carrying a line, + /// column, and caret, while the same error routed through a `toml::Table` + /// loses the span (measured, not assumed — see + /// `unknown_field_errors_keep_their_span_when_no_section_is_retired`). + /// Files with no retired section — every new deployment, and every old + /// one once it migrates — therefore keep the better diagnostics, and the + /// degraded span is confined to files already being told to remove a + /// section. pub fn parse_text(text: &str, attributed_path: &Path) -> Result { - let parsed: Self = toml::from_str(text).map_err(|source| RebornConfigFileError::Toml { + let to_error = |source: toml::de::Error| RebornConfigFileError::Toml { path: attributed_path.display().to_string(), source, - })?; + }; + let mut raw: toml::Table = toml::from_str(text).map_err(to_error)?; + let retired_sections = RetiredSections::split_from(&mut raw); + + let mut parsed: Self = if retired_sections.is_empty() { + toml::from_str(text).map_err(to_error)? + } else { + raw.try_into().map_err(to_error)? + }; + parsed.retired_sections = retired_sections; parsed.validate(attributed_path)?; Ok(parsed) } + /// Fail closed when the file carries a retired *setup* key. + /// + /// Separate from `parse_text` on purpose: every command loads the config, + /// but only a command that is about to act on it should refuse to run. An + /// operator with a stale section still needs `config list` and `config + /// set` to work in order to fix it. + pub fn retired_section_migration(&self, config_path: &Path) -> Result<(), RetiredSectionError> { + self.retired_sections.migration_error(config_path) + } + + /// One notice per inert retired section still present, for a caller that + /// is about to serve. Empty for a clean file. + pub fn retired_section_notices(&self, config_path: &Path) -> Vec { + self.retired_sections.deprecation_notices(config_path) + } + fn validate(&self, attributed_path: &Path) -> Result<(), RebornConfigFileError> { // Inline-secret check on every operator-supplied string before // any later validator can echo the value in a more specific error. @@ -1107,57 +1086,13 @@ impl RebornConfigFile { check(Cow::Borrowed("webui.canonical_host"), host)?; } } - if let Some(slack) = &self.slack { - if let Some(installation_id) = &slack.installation_id { - check(Cow::Borrowed("slack.installation_id"), installation_id)?; - } - if let Some(team_id) = &slack.team_id { - check(Cow::Borrowed("slack.team_id"), team_id)?; - } - if let Some(api_app_id) = &slack.api_app_id { - check(Cow::Borrowed("slack.api_app_id"), api_app_id)?; - } - if let Some(slack_user_id) = &slack.slack_user_id { - check(Cow::Borrowed("slack.slack_user_id"), slack_user_id)?; - } - if let Some(user_id) = &slack.user_id { - check(Cow::Borrowed("slack.user_id"), user_id)?; - } - if let Some(shared_subject_user_id) = &slack.shared_subject_user_id { - check( - Cow::Borrowed("slack.shared_subject_user_id"), - shared_subject_user_id, - )?; - } - for (index, route) in slack.channel_routes.iter().enumerate() { - if let Some(channel_id) = &route.channel_id { - check_non_empty_trimmed( - Cow::Owned(format!("slack.channel_routes[{index}].channel_id")), - channel_id, - )?; - } - if let Some(subject_user_id) = &route.subject_user_id { - check_non_empty_trimmed( - Cow::Owned(format!("slack.channel_routes[{index}].subject_user_id")), - subject_user_id, - )?; - } - } - if let Some(signing_secret_env) = &slack.signing_secret_env { - check_non_empty_trimmed( - Cow::Borrowed("slack.signing_secret_env"), - signing_secret_env, - )?; - validate_env_var_reference( - "slack.signing_secret_env", - signing_secret_env, - attributed_path, - )?; - } - if let Some(bot_token_env) = &slack.bot_token_env { - check_non_empty_trimmed(Cow::Borrowed("slack.bot_token_env"), bot_token_env)?; - validate_env_var_reference("slack.bot_token_env", bot_token_env, attributed_path)?; - } + // Retired sections skip the typed schema, so they would also skip the + // inline-secret guard above. Walk them explicitly: this is a wider net + // than the per-field checks it replaces (which knew only the nine + // hardcoded keys of the one section that had them), because a retired + // section accepts any key. + for (label, value) in self.retired_sections.string_values() { + check(Cow::Owned(label), value)?; } if let Some(google) = &self.google { if let Some(client_id) = &google.client_id { @@ -1895,55 +1830,198 @@ redirect_uri = "http://127.0.0.1:3000/oauth/google/callback" ); } - #[test] - fn update_slack_enabled_writes_new_section() { - let temp = tempfile::tempdir().expect("tempdir"); - let path = temp.path().join("config.toml"); + // ─── Retired sections (the compatibility window) ──────────────────── - update_slack_enabled(&path, true).expect("update config"); + /// The whole point of the window: a file written against the retired + /// schema still parses, so the operator can run `config list` / `config + /// set` to fix it rather than being locked out by a parse failure. + #[test] + fn retired_sections_still_parse_and_do_not_reach_the_typed_schema() { + let toml = "[identity]\ntenant = \"acme\"\n\n[slack]\nenabled = true\n\n\ + [telegram]\nenabled = true\n"; + let cfg = RebornConfigFile::parse_text(toml, &attributed()).expect("retired file parses"); - let cfg = RebornConfigFile::load(&path) - .expect("valid config") - .expect("config present"); assert_eq!( - cfg.slack.expect("slack section present").enabled, - Some(true) + cfg.identity.expect("identity").tenant.as_deref(), + Some("acme") ); + let names: Vec<_> = cfg.retired_sections.section_names().collect(); + assert_eq!(names, vec!["slack", "telegram"]); } + /// An inert retired section must not block a boot that used to work, but + /// must not be silent either — the failure this replaces is an operator + /// setting a documented flag and believing it took effect. #[test] - fn update_slack_enabled_preserves_unrelated_config_and_flips_value() { - let temp = tempfile::tempdir().expect("tempdir"); - let path = temp.path().join("config.toml"); - fs::write( - &path, - "[identity]\ntenant = \"acme\"\n\n[slack]\nenabled = true\n", + fn inert_retired_section_boots_with_a_notice() { + let cfg = RebornConfigFile::parse_text("[slack]\nenabled = true\n", &attributed()) + .expect("inert section parses"); + + cfg.retired_section_migration(&attributed()) + .expect("an inert retired section must not fail the boot"); + + let notices = cfg.retired_section_notices(&attributed()); + assert_eq!(notices.len(), 1, "notices: {notices:?}"); + assert!(notices[0].contains("[slack]"), "notices: {notices:?}"); + assert!(notices[0].contains("/extensions"), "notices: {notices:?}"); + } + + /// A retired *setup* key fails closed, naming the key and the path. + #[test] + fn retired_setup_key_fails_closed_with_migration_guidance() { + let cfg = RebornConfigFile::parse_text( + "[slack]\nenabled = true\nslack_user_id = \"U123\"\n", + &attributed(), ) - .expect("write config"); + .expect("retired setup key still parses"); - update_slack_enabled(&path, false).expect("update config"); + let error = cfg + .retired_section_migration(&attributed()) + .expect_err("a retired setup key must fail closed"); + let message = error.to_string(); + assert!( + message.contains("[slack].slack_user_id"), + "message: {message}" + ); + assert!(message.contains("/extensions"), "message: {message}"); + } - let text = fs::read_to_string(&path).expect("read config"); - assert!(text.contains("[identity]"), "config: {text}"); - assert!(text.contains("tenant = \"acme\""), "config: {text}"); - let cfg = RebornConfigFile::load(&path) - .expect("valid config") - .expect("config present"); - assert_eq!( - cfg.slack.expect("slack section present").enabled, - Some(false) + /// Every rejected key must actually be reachable through the public + /// entry point. A table-driven guard is exactly the shape that rots into + /// a list nothing consults, so drive each row rather than trusting one + /// representative key. + /// + /// **Scope, stated rather than implied:** this proves *reachability* — + /// every declared row reaches the fail-closed path through `parse_text` + /// — not *fidelity*. It builds its input from the same table it checks, + /// so renaming a row (`installation_id` -> `installation_idX`) keeps it + /// green; only deleting a row changes behaviour it can see. Whether the + /// list still matches the schema that shipped is a history question no + /// self-referential test can answer; `git log` on the deleted + /// `SlackSection` is the record. + #[test] + fn every_declared_rejected_key_fails_the_boot_closed() { + for policy in crate::retired_sections::RETIRED_SECTIONS { + for key in policy.rejected_keys { + // `channel_routes` was an array of tables; the rest were + // strings. Give each the shape an operator would have written. + let value = if *key == "channel_routes" { + "[]".to_string() + } else { + "\"x\"".to_string() + }; + let text = format!("[{}]\n{key} = {value}\n", policy.section); + let cfg = RebornConfigFile::parse_text(&text, &attributed()) + .unwrap_or_else(|error| panic!("`{key}` must still parse: {error}")); + let message = cfg + .retired_section_migration(&attributed()) + .expect_err(&format!( + "`[{}].{key}` is declared rejected but did not fail the boot", + policy.section + )) + .to_string(); + assert!( + message.contains(&format!("[{}].{key}", policy.section)), + "message must name the offending key: {message}" + ); + } + } + } + + /// Telegram never had a setup key, so it is inert in both tiers — but it + /// is no longer *silently* inert, which it was before retirement. + #[test] + fn retired_telegram_section_is_inert_but_announced() { + let cfg = RebornConfigFile::parse_text("[telegram]\nenabled = true\n", &attributed()) + .expect("telegram section parses"); + + cfg.retired_section_migration(&attributed()) + .expect("telegram has no setup key to reject"); + let notices = cfg.retired_section_notices(&attributed()); + assert_eq!(notices.len(), 1, "notices: {notices:?}"); + assert!(notices[0].contains("[telegram]"), "notices: {notices:?}"); + } + + /// A retired section takes any key, so it would bypass the typed + /// schema's inline-secret guard unless walked explicitly. + /// + /// The fixture is deliberately *not* a Slack-shaped token: the guard is + /// prefix-based over every known vendor, not keyed to the section it + /// appears in, and a real Slack token shape here would trip GitHub push + /// protection on every future contributor's branch. + #[test] + fn retired_section_values_are_still_inline_secret_checked() { + let err = RebornConfigFile::parse_text( + "[slack]\nbot_token = \"sk-proj-1234567890abcdef1234567890\"\n", + &attributed(), + ) + .expect_err("a secret pasted into a retired section must still be rejected"); + assert!( + matches!(err, RebornConfigFileError::InlineSecret { .. }), + "err: {err:?}" ); + } - // Idempotence: re-setting the same key with the same value must - // edit the existing `[slack]` section in place, not append a - // second one. - update_slack_enabled(&path, false).expect("update config again with the same value"); - let text_after_repeat = fs::read_to_string(&path).expect("read config"); - assert_eq!( - text_after_repeat.matches("[slack]").count(), - 1, - "re-setting the same key must not duplicate the [slack] section header: \ - {text_after_repeat}" + /// Nested shapes (the retired `channel_routes` array of tables) are + /// walked too — a one-level scan would have missed them. + #[test] + fn retired_section_inline_secret_walk_reaches_nested_tables() { + let err = RebornConfigFile::parse_text( + "[[slack.channel_routes]]\ntoken = \"sk-proj-1234567890abcdef1234567890\"\n", + &attributed(), + ) + .expect_err("a secret nested in a retired section must still be rejected"); + assert!( + matches!(err, RebornConfigFileError::InlineSecret { .. }), + "err: {err:?}" + ); + } + + /// `slack = 1` is not a retired *section*; it must still be reported as + /// the unknown top-level key it is, not swallowed by the splitter. + /// + /// The second case is the one that actually exercises the splitter's + /// re-insert. Alone, the scalar leaves `retired_sections` empty, so the + /// fast path re-parses the original text and would catch it regardless — + /// a guard that passes without testing anything. Only when a *genuine* + /// retired section forces the slow path does dropping the re-insert + /// silently bypass `deny_unknown_fields`. Verified by sabotage: removing + /// the re-insert turns the second assertion red and leaves the first + /// green. + #[test] + fn retired_section_name_used_as_a_scalar_is_still_an_unknown_key() { + let err = RebornConfigFile::parse_text("slack = 1\n", &attributed()) + .expect_err("a scalar under a retired name must not be captured as a section"); + assert!( + matches!(err, RebornConfigFileError::Toml { .. }), + "err: {err:?}" + ); + + let err = RebornConfigFile::parse_text( + "slack = 1\n\n[telegram]\nenabled = true\n", + &attributed(), + ) + .expect_err( + "a scalar under a retired name must stay visible to the typed parse even \ + when another retired section routes the file through the splitter", + ); + assert!( + matches!(err, RebornConfigFileError::Toml { .. }), + "err: {err:?}" + ); + } + + /// The split must not cost span quality for files that carry no retired + /// section — that is why the fast path re-parses the original text + /// instead of always routing through a `toml::Table`. + #[test] + fn unknown_field_errors_keep_their_span_when_no_section_is_retired() { + let err = RebornConfigFile::parse_text("[boot]\nbogus_key = 1\n", &attributed()) + .expect_err("unknown field must fail parse"); + let message = err.to_string(); + assert!( + message.contains("line 2"), + "an unknown-field error must still carry its line/column span: {message}" ); } diff --git a/crates/ironclaw_reborn_config/src/lib.rs b/crates/ironclaw_reborn_config/src/lib.rs index 934ff494353..2120b5b5c29 100644 --- a/crates/ironclaw_reborn_config/src/lib.rs +++ b/crates/ironclaw_reborn_config/src/lib.rs @@ -29,6 +29,7 @@ mod config_seed; mod doctor; mod home; mod profile; +mod retired_sections; mod secrets_guard; pub use boot::RebornBootConfig; @@ -47,10 +48,9 @@ pub use config_file::{ GoogleFieldUpdate, GoogleOauthConfigUpdate, GoogleOauthConfigUpdateSession, GoogleSection, HarnessSection, IdentitySection, LlmSlotFieldUpdate, LlmSlotSelection, MemoryAdminOverride, MemorySection, PolicySection, REBORN_CONFIG_API_VERSION, RebornConfigFile, - RebornConfigFileError, RebornConfigFileUpdateError, RunnerSection, SlackChannelRouteSection, - SlackSection, StorageBackend, StorageSection, TelegramSection, TriggerPollerConfigSection, - begin_default_llm_slot_update, begin_google_oauth_config_update, update_default_llm_slot, - update_google_oauth_config, update_slack_enabled, + RebornConfigFileError, RebornConfigFileUpdateError, RunnerSection, StorageBackend, + StorageSection, TriggerPollerConfigSection, begin_default_llm_slot_update, + begin_google_oauth_config_update, update_default_llm_slot, update_google_oauth_config, }; pub use config_seed::{ RebornConfigSeedError, RebornConfigSeedOutcome, seed_default_config_file_if_missing, @@ -58,4 +58,5 @@ pub use config_seed::{ pub use doctor::RebornDoctorReport; pub use home::{REBORN_HOME_ENV, RebornConfigError, RebornHome, RebornHomeSource}; pub use profile::{REBORN_PROFILE_ENV, RebornProfile}; +pub use retired_sections::{RetiredSectionError, RetiredSections, retired_config_key_guidance}; pub use secrets_guard::{InlineSecretError, reject_inline_secret}; diff --git a/crates/ironclaw_reborn_config/src/retired_sections.rs b/crates/ironclaw_reborn_config/src/retired_sections.rs new file mode 100644 index 00000000000..94ca2665415 --- /dev/null +++ b/crates/ironclaw_reborn_config/src/retired_sections.rs @@ -0,0 +1,252 @@ +//! Retired `config.toml` sections — the compatibility window. +//! +//! # Why this module exists, and why it is here rather than in a package +//! +//! `config.toml` once carried per-vendor sections that configured a channel +//! directly (`[slack]`, `[telegram]`). Under the unified extension model an +//! extension is installed and authorized through its own lifecycle, so those +//! sections have no runtime reader left. Deleting them from the schema +//! outright would make every existing operator file fail to parse, because +//! [`crate::RebornConfigFile`] is `deny_unknown_fields` — a typo-catching +//! property worth keeping. +//! +//! So the sections are *retired*, not deleted: an operator's existing file +//! still parses, and the operator is told precisely what to do instead. This +//! is the deprecation window PROPOSAL §12.2 names as a compatibility +//! constraint. +//! +//! **Ownership note.** A retired section is a fact about *this crate's own +//! schema history*, not about the extension that once used it: the check runs +//! at config-parse time, before any extension exists to own it, and this crate +//! may not depend on any IronClaw workspace crate. Live admin configuration +//! for an extension is package-owned (the manifest `[admin_configuration]` +//! model); the gravestone for a schema key this crate used to define stays +//! here. Adding a row below is the whole cost of retiring a future section. +//! +//! # The two tiers +//! +//! A retired section is not automatically an error, because failing a boot +//! that used to work is a worse outcome than ignoring a stale key: +//! +//! - **Rejected keys** — the operator is following retired *setup* +//! instructions. The value would be silently ignored, so the boot fails +//! closed with a migration pointer instead. +//! - **Anything else in the section** — inert. The boot continues and emits a +//! deprecation notice, so an operator who set an advertised-but-unread flag +//! learns that it does nothing rather than believing it took effect. + +use std::collections::BTreeMap; + +use thiserror::Error; + +/// One retired top-level `config.toml` section. +pub(crate) struct RetiredSectionPolicy { + /// The table name exactly as an operator would have written it. + pub section: &'static str, + /// Keys whose presence fails the boot closed. These are the *setup* + /// fields: a value here means the operator followed instructions that no + /// longer connect to anything, and silently ignoring it would leave them + /// debugging a channel that never activates. + pub rejected_keys: &'static [&'static str], + /// What to do instead. Appended to both the hard error and the + /// deprecation notice, so the two never drift apart. + pub migration: &'static str, +} + +/// Every retired section. Adding a row is how a future section is retired. +pub(crate) const RETIRED_SECTIONS: &[RetiredSectionPolicy] = &[ + RetiredSectionPolicy { + section: "slack", + rejected_keys: &[ + "installation_id", + "team_id", + "api_app_id", + "slack_user_id", + "user_id", + "shared_subject_user_id", + "channel_routes", + "signing_secret_env", + "bot_token_env", + ], + migration: "Slack is configured by installing the Slack extension and completing workspace \ + OAuth in the WebUI (/extensions).", + }, + RetiredSectionPolicy { + section: "telegram", + // `enabled` was the only key this section ever accepted, and nothing + // has read it since the unified extension runtime landed. There is no + // setup field to fail closed on, so the whole section is inert. + rejected_keys: &[], + migration: "Telegram is configured by installing the Telegram extension and completing bot \ + setup in the WebUI (/extensions).", + }, +]; + +fn policy_for(section: &str) -> Option<&'static RetiredSectionPolicy> { + RETIRED_SECTIONS + .iter() + .find(|policy| policy.section == section) +} + +/// Migration guidance for a dotted `config set ` argument whose section +/// is retired, or `None` for a key this crate has nothing to say about. +/// +/// Driven by the same table as the boot-time check so a caller cannot answer +/// `config set slack.enabled` and `serve` with two different stories. Without +/// it, a retired key falls through to the generic "unknown config key" list, +/// which tells an operator following an old runbook that they typed something +/// wrong rather than that the setting is gone. +pub fn retired_config_key_guidance(key: &str) -> Option { + let (section, _) = key.split_once('.')?; + let policy = policy_for(section)?; + Some(format!( + "`{key}` is a retired configuration key and is no longer read. {migration}", + key = key, + migration = policy.migration, + )) +} + +/// The retired sections an operator's file actually carried. +/// +/// Captured verbatim rather than parsed into a typed shape: the point is to +/// recognize the section and explain it, not to model fields nothing reads. +#[derive(Debug, Clone, Default)] +pub struct RetiredSections { + entries: BTreeMap, +} + +impl RetiredSections { + /// Remove every retired section from a raw parsed document, returning + /// what was found. The caller deserializes what is left, so the typed + /// schema never has to name a retired section. + pub(crate) fn split_from(raw: &mut toml::Table) -> Self { + let mut entries = BTreeMap::new(); + for policy in RETIRED_SECTIONS { + let Some(value) = raw.remove(policy.section) else { + continue; + }; + // A non-table `slack = 1` is not a retired *section*; put it back + // so the typed parse reports it as the unknown field it is. + match value { + toml::Value::Table(table) => { + entries.insert(policy.section.to_string(), table); + } + other => { + raw.insert(policy.section.to_string(), other); + } + } + } + Self { entries } + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Section names present, in a stable order. + pub fn section_names(&self) -> impl Iterator { + self.entries.keys().map(String::as_str) + } + + /// Fail closed when a retired *setup* key is present. + /// + /// Reports the first offending key in declaration order so the message is + /// deterministic; the operator is told to remove the whole section anyway. + pub fn migration_error( + &self, + config_path: &std::path::Path, + ) -> Result<(), RetiredSectionError> { + for (section, table) in &self.entries { + let Some(policy) = policy_for(section) else { + continue; + }; + for key in policy.rejected_keys { + if !table.contains_key(*key) { + continue; + } + return Err(RetiredSectionError { + section: section.clone(), + field: (*key).to_string(), + path: config_path.display().to_string(), + migration: policy.migration.to_string(), + }); + } + } + Ok(()) + } + + /// One notice per inert retired section still present in the file. + /// + /// Emitted rather than swallowed because the failure mode this replaces is + /// an operator setting a documented flag and believing it took effect. + pub fn deprecation_notices(&self, config_path: &std::path::Path) -> Vec { + self.entries + .keys() + .filter_map(|section| policy_for(section).map(|policy| (section, policy))) + .map(|(section, policy)| { + format!( + "`[{section}]` in {path} is a retired configuration section and is ignored. \ + {migration} Remove the `[{section}]` section to silence this notice.", + section = section, + path = config_path.display(), + migration = policy.migration, + ) + }) + .collect() + } + + /// Every string value in every retired section, keyed by dotted path. + /// + /// Retired sections skip the typed schema, so they would also skip the + /// inline-secret guard that runs over it. Walking them here keeps a + /// pasted secret rejected no matter which section it landed in — a + /// strictly wider net than the per-field checks this replaced, which knew + /// only the nine Slack keys. + pub(crate) fn string_values(&self) -> Vec<(String, &str)> { + let mut found = Vec::new(); + for (section, table) in &self.entries { + collect_table_strings(table, section, &mut found); + } + found + } +} + +fn collect_table_strings<'a>( + table: &'a toml::Table, + prefix: &str, + found: &mut Vec<(String, &'a str)>, +) { + for (key, value) in table { + collect_value_strings(value, &format!("{prefix}.{key}"), found); + } +} + +fn collect_value_strings<'a>( + value: &'a toml::Value, + path: &str, + found: &mut Vec<(String, &'a str)>, +) { + match value { + toml::Value::String(text) => found.push((path.to_string(), text.as_str())), + toml::Value::Table(table) => collect_table_strings(table, path, found), + toml::Value::Array(items) => { + for (index, item) in items.iter().enumerate() { + collect_value_strings(item, &format!("{path}[{index}]"), found); + } + } + _ => {} + } +} + +/// A retired setup key was present, so the boot fails closed. +#[derive(Debug, Error)] +#[error( + "`[{section}].{field}` in {path} is a retired configuration surface: {migration} Remove the \ + `[{section}]` section to continue." +)] +pub struct RetiredSectionError { + pub section: String, + pub field: String, + pub path: String, + pub migration: String, +} diff --git a/docs/capabilities/configuration.mdx b/docs/capabilities/configuration.mdx index fd73ee3594a..fd41b012d53 100644 --- a/docs/capabilities/configuration.mdx +++ b/docs/capabilities/configuration.mdx @@ -79,7 +79,6 @@ that have a routing destination: | `.api_key` | Encrypted secret store (prompts, hidden) | | `google.client_id`, `google.redirect_uri` | `config.toml` | | `google.client_secret` | Encrypted secret store (prompts, hidden) | -| `slack.enabled` | `config.toml` | | `webui.token --rotate` | Web token file | ```bash @@ -274,18 +273,15 @@ See [Routines](/capabilities/routines/cron). -```toml -[slack] -enabled = false -``` - -`slack.*` covers app ids, bot token and signing secret variable names, and channel routes. -`telegram.enabled` turns the Telegram channel on. `google.*` carries the OAuth client id, -redirect URI, and hosted-domain hint. - -Configure these from the web interface rather than by hand — see +Channels are not configured in this file. Slack and Telegram are turned on by installing +their extension in the web interface and completing setup — there is no `[slack]` or +`[telegram]` section, and no key that enables a channel. See [Channels](/channels/overview). +`google.*` carries the OAuth client id, redirect URI, and hosted-domain hint. Set it either +from the web interface or with `ironclaw config set google.client_id` / +`google.client_secret` / `google.redirect_uri` — both paths are supported. + diff --git a/docs/channels/slack.mdx b/docs/channels/slack.mdx index 4b762a8db06..a82bad5cfe7 100644 --- a/docs/channels/slack.mdx +++ b/docs/channels/slack.mdx @@ -109,12 +109,9 @@ URL — one signed endpoint answers both surfaces, so there is no second address up. -The redirect URL is used for per-user ("personal") Slack authorization. Tell IronClaw the -same value so the two agree: - -```bash -export IRONCLAW_REBORN_SLACK_PERSONAL_OAUTH_REDIRECT_URI=https://your-host/api/reborn/product-auth/oauth/slack/callback -``` +The redirect URL is used for per-user ("personal") Slack authorization. IronClaw derives +it from the instance's own public base URL, so there is nothing to set on this side — just +register the value above in the Slack app. The redirect URL must match **exactly** on both sides, including scheme, host, port, and @@ -230,29 +227,20 @@ creating the app. ## Configuration -Slack settings live under `[slack]` in your configuration file. Set them through the web -interface rather than by hand — the tokens belong in the secret store, and the file only -names the variables that hold them. +Slack has no settings in the configuration file. The app id, the bot token, the signing +secret, and channel routing are all configured in the web interface, on the Slack card +under **Extensions** — the tokens go straight into the encrypted secret store. -```toml -[slack] -enabled = true -``` - -| Key | Purpose | -| --- | --- | -| `slack.enabled` | Turn the Slack route on | -| `slack.api_app_id` | Your Slack app id | -| `slack.bot_token_env` | Name of the variable holding the bot token | -| `slack.signing_secret_env` | Name of the variable holding the signing secret | -| `slack.team_id` | Workspace the app is installed in | -| `slack.channel_routes` | Which conversations map to which agent scope | +There is no key that turns Slack on. The webhook route is always mounted, and it starts +accepting events once the Slack extension is installed and its signing secret is +registered. Until then it answers `503`. -Check what's currently set: - -```bash -ironclaw config list | grep slack -``` + +An older configuration file may still carry a `[slack]` section. Nothing reads it. A +leftover setup field — `api_app_id`, `team_id`, `bot_token_env`, `signing_secret_env`, +`channel_routes`, and the rest — now stops startup with a pointer to the web interface +instead of being quietly ignored, so delete the section. + --- @@ -267,9 +255,9 @@ Confirm the instance answers from outside your network, and that you used the ex -The redirect URL in the Slack app and `IRONCLAW_REBORN_SLACK_PERSONAL_OAUTH_REDIRECT_URI` -must match character for character. Check the scheme, the port, and that the path is the -full `/api/reborn/product-auth/oauth/slack/callback`. +The redirect URL registered in the Slack app must match the one IronClaw sends character +for character. Check the scheme, the host, the port, and that the path is the full +`/api/reborn/product-auth/oauth/slack/callback`. diff --git a/docs/channels/telegram.mdx b/docs/channels/telegram.mdx index 6417b435e1e..c2dce2fd40e 100644 --- a/docs/channels/telegram.mdx +++ b/docs/channels/telegram.mdx @@ -84,19 +84,20 @@ default Telegram bots only see messages that mention them. ## Configuration -```toml -telegram.enabled = true -``` - -Check the current state: - -```bash -ironclaw config get telegram.enabled -``` +Telegram has no settings in `config.toml` and no CLI enablement key. The +ingress route is compiled in and mounted unconditionally; it starts serving +once you install the Telegram extension and finish bot setup in the WebUI +(see the steps above). Until then it returns `503`. The bot token lives in the encrypted secret store, not in `config.toml`. See [Configuration](/capabilities/configuration). + + A `[telegram]` section left over from an older release still parses, but + nothing reads it — `ironclaw serve` logs a deprecation notice on boot. + Delete the section to silence it. + + --- ## Troubleshooting diff --git a/docs/reborn/deploy-reborn-cli-docker.md b/docs/reborn/deploy-reborn-cli-docker.md index 74ed0d84d67..3467e8a8da2 100644 --- a/docs/reborn/deploy-reborn-cli-docker.md +++ b/docs/reborn/deploy-reborn-cli-docker.md @@ -191,29 +191,28 @@ IRONCLAW_REBORN_GOOGLE_OAUTH_REDIRECT_URI=https:///api/reborn/pr ## Slack -Slack routes are compiled into the image, but they are disabled by the default -config. On Railway, prefer the env toggle so the seeded config can stay -unchanged: - -```bash -IRONCLAW_REBORN_SLACK_ENABLED=true -``` - -The env var overrides only the Slack route enablement gate. `true`/`1` enables -Slack, while `false`/`0` forces Slack off for the deployment. - -You can also enable Slack by editing `$IRONCLAW_REBORN_HOME/config.toml` or -mounting a config file with: - -```toml -[slack] -enabled = true -``` - -Then configure Slack app ids, the bot token, signing secret, and channel -mappings from WebUI channel setup after the container starts. +Slack routes are compiled into the image and mounted unconditionally. No +environment variable and no `config.toml` key enables or disables Slack for a +deployment, so there is nothing Slack-specific to add to the Railway service +variables or to a mounted config file. The Slack webhook answers +`503 temporarily_unavailable` until the Slack extension's ingress signing +secret is registered. + +Once the container is running, open the WebUI at `/extensions`, install the +Slack extension, and complete its setup. Slack app ids, the bot token, the +signing secret, and channel mappings are all configured there after the +container starts. Set the WebUI identity environment variables as usual. Do not store OAuth, Slack, or LLM secrets in `config.toml`. Slack bot tokens -and signing secrets are stored from WebUI channel setup. +and signing secrets are stored from the WebUI extension setup. + +Migrating an existing config file: a mounted or previously seeded +`config.toml` that still carries a `[slack]` or `[telegram]` section keeps +parsing. A leftover Slack *setup* field (`installation_id`, `team_id`, +`api_app_id`, `slack_user_id`, `user_id`, `shared_subject_user_id`, +`channel_routes`, `signing_secret_env`, `bot_token_env`) fails container +startup with a migration pointer rather than being silently ignored; a section +left with only inert keys still starts, and logs a deprecation notice. Delete +the section from the mounted file — nothing reads it. diff --git a/docs/reborn/extension-runtime/checklist.md b/docs/reborn/extension-runtime/checklist.md index 226f6ca19cb..a2f8bbf71c0 100644 --- a/docs/reborn/extension-runtime/checklist.md +++ b/docs/reborn/extension-runtime/checklist.md @@ -821,6 +821,22 @@ Rules — kept short on purpose: gone; a stale `[slack]` section hard-fails config parse (accepted beta posture, pinned by `rejects_retired_slack_section`); the secrets guard keeps the `xoxb-`/`xoxp-`/`xapp-` prefixes. + > ✎ **Corrected 2026-08-04 — two of these three claims stopped being true + > after this box was ticked, and the citation is a phantom.** `SlackSection` + > and `SlackChannelRouteSection` came *back* as a parse-only shim (with + > `TelegramSection` beside them) and lived on `main` until the WS6 config + > narrowing; `rejects_retired_slack_section` was added by `4c8195a3ca` and + > removed with them, so this line has cited a nonexistent test since. + > A stale `[slack]` section also never hard-failed **parse** on the shipped + > code — the refusal was at `serve`, and only for *setup* fields. Current + > state: the types are gone again, and deliberately so is the parse-time + > refusal — an operator with a stale section must still be able to run + > `config list`/`config set` to fix it. `serve` fails closed on a retired + > setup key; an inert section boots with a deprecation notice. Pinned by + > `retired_setup_key_fails_closed_with_migration_guidance` and + > `inert_retired_section_boots_with_a_notice` + > (`crates/ironclaw_reborn_config/src/config_file.rs`). See PROPOSAL + > §6.10.3. - [x] DEL-4 Slack cleanup constants in product workflow and Slack connection copy in lifecycle are deleted (standard pipeline + manifest display data). — no non-test slack constant remains in `ironclaw_product` diff --git a/docs/reborn/setup-slack-for-reborn-binary.md b/docs/reborn/setup-slack-for-reborn-binary.md index 4f0f5bd17e0..4e019e0110c 100644 --- a/docs/reborn/setup-slack-for-reborn-binary.md +++ b/docs/reborn/setup-slack-for-reborn-binary.md @@ -3,9 +3,10 @@ This guide is for the standalone `ironclaw serve` Slack host path, not the legacy v1 Slack WASM channel. -Slack support ships in the binary. It has one gate: runtime config must set -`[slack].enabled = true`, or the deployment env must set -`IRONCLAW_REBORN_SLACK_ENABLED=true`. +Slack support ships in the binary, and there is no configuration key or +environment variable that turns it on. The Slack webhook route is always +mounted; Slack goes live once the Slack extension is installed and its setup is +completed in the WebUI at `/extensions`. Slack bot token and signing secret are configured in WebUI Slack setup and stored in the Reborn secret store. Do not put OAuth client secrets or LLM keys @@ -30,7 +31,8 @@ cargo build \ --bin ironclaw ``` -Slack is disabled unless the mounted or seeded Reborn config enables it. +Neither command needs a Slack-specific build flag or feature: the route is +compiled in and mounted unconditionally. ## Public Endpoint @@ -85,36 +87,39 @@ IRONCLAW_REBORN_HOME=/data/ironclaw-reborn IRONCLAW_REBORN_PROFILE=local-dev IRONCLAW_REBORN_WEBUI_TOKEN= IRONCLAW_REBORN_WEBUI_USER_ID=reborn-cli -IRONCLAW_REBORN_SLACK_ENABLED=true OPENAI_API_KEY=sk-... ``` ## Reborn Config -Edit `$IRONCLAW_REBORN_HOME/config.toml`. If the file does not exist yet, run -`ironclaw config init` or start the Docker image once to seed it. +The Reborn config file lives at `$IRONCLAW_REBORN_HOME/config.toml`; run +`ironclaw config init` or start the Docker image once to seed it if it does not +exist yet. -Minimal Slack config: +It carries no Slack settings. There is no `[slack]` section to add and no Slack +key to set: `slack.enabled` is retired, so `ironclaw config set slack.enabled` +answers with migration guidance instead of writing anything. -```toml -[slack] -enabled = true -``` - -`enabled` is the only Slack boot setting. You can also set -`IRONCLAW_REBORN_SLACK_ENABLED=true` instead of editing config. The env var -overrides only the route enablement gate: `true`/`1` mounts Slack, while -`false`/`0` acts as a deployment kill switch. - -Slack enablement mounts `POST /webhooks/extensions/slack/events`, exposes the +`POST /webhooks/extensions/slack/events` is mounted unconditionally, and it +answers `503 temporarily_unavailable` until the Slack extension's ingress +signing secret is registered. Installing the Slack extension from `/extensions` +and completing its setup is what registers that secret, exposes the manifest-declared Slack deployment fields in Admin Configuration, and makes a personal Slack connection available through the Slack extension's user OAuth flow. + Slack installation ids, team/app ids, the bot token, the signing secret, OAuth client credentials, and channel mappings are configured after startup from Admin Configuration. These deployment values are never shown in a user's extension setup flow. +> **"Admin Configuration" and the "Slack card" are the same place.** This guide +> uses the operator-facing name; [Slack](/channels/slack) uses the UI path. +> Concretely: web interface -> **Extensions** -> **Channels** tab -> scroll to the +> bottom of the Built-in section -> **Configure** on the Slack card. (Extensions +> opens on the **Registry** tab, which is *not* where channels are connected.) +> Every "Admin Configuration" reference below means that dialog. + As an operator, open Admin, Configuration, then Slack deployment configuration. Save: @@ -138,6 +143,17 @@ membership and credential state does not mutate the operator configuration. Unrouted shared Slack channels fail closed instead of silently inheriting a personal/default user scope. +### Migrating an existing config.toml + +An existing file that still carries a `[slack]` or `[telegram]` section keeps +parsing, so an older deployment does not break on upgrade. A leftover Slack +*setup* field (`installation_id`, `team_id`, `api_app_id`, `slack_user_id`, +`user_id`, `shared_subject_user_id`, `channel_routes`, `signing_secret_env`, +`bot_token_env`) fails `serve` closed with a migration pointer rather than +being silently ignored. A section left with only inert keys still boots, and +logs a deprecation notice. Either way the fix is the same: delete the section, +because nothing reads it. + ## Slack App Configuration Create or edit a Slack app at `api.slack.com/apps`. @@ -306,9 +322,17 @@ Verification checklist: ## Troubleshooting -### Slack routes are not mounted +### Slack events are rejected with 503 or 401 + +There is no config or env enablement gate to check; the route is always mounted. + +A 503 `temporarily_unavailable` means the Slack extension's ingress signing secret is not +registered yet. Register it in Admin Configuration for Slack (the Slack card — see the +note under "Reborn Config" for the exact UI path; landing on the wrong Extensions tab is +the usual reason this step is missed). -Confirm the Reborn config sets [slack].enabled = true, or that the deployment env sets IRONCLAW_REBORN_SLACK_ENABLED=true, then restart `ironclaw`. +A 401 means a signing secret is registered but does not match the app. Compare the value +there against **Basic Information -> Signing Secret** in the Slack app. ### Slack route never receives events diff --git a/docs/reborn/target-architecture/CHECKLIST.md b/docs/reborn/target-architecture/CHECKLIST.md index f3f75941c78..0819aadc1d7 100644 --- a/docs/reborn/target-architecture/CHECKLIST.md +++ b/docs/reborn/target-architecture/CHECKLIST.md @@ -196,8 +196,8 @@ Conventions: every code item lands with its tests and its guidance updates in th - [x] Retire the `local_dev` misnomer (production path renamed; deployment-mode naming ratchets extended to catch it). **Landed with #6691:** `runtime/local_dev` → `runtime/capability_host`, `local_dev_authorization` → `capability_authorization`, `local_dev_mounts` → `runtime_mounts`, `local_dev_boot` → `standalone_boot`, and the ratchet itself `reborn_localdev_typename_ratchet` → `reborn_standalone_typename_ratchet`. One residue for a later PR: the local variable at `composition/src/runtime.rs:3016` is still named `local_runtime`. - [ ] `RebornRuntime` slimmed: ~40 `_for_test` accessors behind `test-support`; re-export wall reduced to the documented snapshot (every survivor names consumer + enforcing test); delete the dead `product_live_adapters` export block (integration harness repointed). - [ ] `ChannelExtensionBinding.extension_id` becomes typed `ExtensionId`; env reads consolidate behind `ironclaw_config`. -- [ ] `config` narrows: vendor sections (`SlackSection`/`TelegramSection`/`GoogleSection`, Google update pipeline, `update_slack_enabled`) and `capability_remediation.rs` move to package-owned admin-config/data; compatibility window: old sections parse into migration guidance for one release. **(compat constraint — PROPOSAL §12.2)** -- [ ] CLI sheds Google-OAuth resolution + `reject_legacy_slack_config` to package-owned steps behind generic seams; dir rename `ironclaw_reborn_cli`→`app/ironclaw_cli` (package name `ironclaw` unchanged) **[decision — severable]**. +- [ ] `config` narrows: vendor sections (`SlackSection`/`TelegramSection`/`GoogleSection`, Google update pipeline, `update_slack_enabled`) and `capability_remediation.rs` move to package-owned admin-config/data; compatibility window: old sections parse into migration guidance for one release. **(compat constraint — PROPOSAL §12.2)** ✎ **Amended 2026-08-04 — the Slack/Telegram half landed; the row's framing was wrong for it, and the Google half is a different problem.** The row says all three sections "move to package-owned admin-config/data". Re-measured on live `main`: **`SlackSection` and `TelegramSection` had nothing to move.** Their only consumers were `reject_legacy_slack_config` (which exists to reject them), `config list`'s display expansion, and `update_slack_enabled`. Zero runtime readers — the enablement gate they fed (`[slack].enabled` / `IRONCLAW_REBORN_SLACK_ENABLED`, and the Telegram equivalents) was **deleted with the unified extension runtime in #6116** (2026-07-21, which removed `serve_slack.rs`/`serve_telegram.rs` outright), and nothing replaced it: the ingress route is generic and always mounted, gated only by whether the extension's signing secret is registered (503 until it is, 401 on mismatch). So `ironclaw config set slack.enabled true` printed `slack.enabled: saved` and changed nothing — a user-visible no-op the docs still instructed operators to run (`setup-slack-for-reborn-binary.md` called it "one gate"; the same file's troubleshooting step could never fix anything). **What landed instead of a move:** the three vendor structs, their three builders, and `update_slack_enabled` are deleted, and `RebornConfigFile` no longer names a vendor at all — retired sections are split off the raw document *before* the typed parse, so the schema keeps `deny_unknown_fields` without declaring a retired key. The compat window is honoured and widened: an existing file still parses, a retired *setup* key still fails `serve` closed with the same message, an inert section still boots and now **says so** instead of being silently ignored, and inline-secret rejection over retired sections goes from nine hardcoded keys to every string at any depth. `config set slack.enabled` now answers with migration guidance rather than a typo report. **Decision recorded under delegated authority (PROPOSAL §6.10.3, dated amendment): a retired section stays in `ironclaw_config`, it does not become package-owned.** A boot-time config-migration check runs before any extension exists and this crate may hold no workspace dependency, so a package cannot own it; the package owns *live* admin configuration, the config crate owns the gravestones for keys it used to define. Alternatives rejected: (a) delete the sections outright — breaks every existing operator file against `deny_unknown_fields`, which is the constraint this row exists for; (b) a `serde(flatten)` catch-all — silently disables `deny_unknown_fields`, trading a typo-catcher for a gravestone. Extension-specificity allowlist **127 → 125** (baseline lowered to match); the two surviving vendor tokens are the TOML table names, quarantined in `retired_sections.rs`. **Still open on this row:** `GoogleSection` + the Google update pipeline (genuinely live — read by the CLI's OAuth resolution, so it moves with the WS6 CLI row below, not before it) and `capability_remediation.rs` (**not dead** — the filename greps to two files, but its five functions have real consumers in four crates: `ironclaw_extension_manager`, `ironclaw_extension_host` ×2, `ironclaw_reborn_cli` ×3; a move is a four-crate change). +- [ ] CLI sheds Google-OAuth resolution + `reject_legacy_slack_config` to package-owned steps behind generic seams; dir rename `ironclaw_reborn_cli`→`app/ironclaw_cli` (package name `ironclaw` unchanged) **[decision — severable]**. ✎ **Amended 2026-08-04 — the `reject_legacy_slack_config` clause is discharged; it landed with the `config` row above, not here, and not "package-owned".** The function is gone: it is now `reject_retired_config_sections`, a five-line call into `ironclaw_reborn_config`'s retired-section table, with the vendor knowledge as data in the config crate rather than as code in the CLI. That is PROPOSAL §12.2's "the existing `reject_legacy_slack_config` shape, relocated" — the two rows described the same seam from opposite sides, so doing it twice would have meant building it twice. Its serve-startup test moved with it (`serve_startup_rejects_loaded_config_with_legacy_slack_fields` → `..._with_retired_setup_fields`) and gained the `[telegram]` case the table-driven form made free. The CLI also shed `ConfigKey::SlackEnabled`, its shape validator, its write path, and `slack_remediation_text` (whose only production caller was that key). **Still open on this row:** the ~200-line Google-OAuth resolution in `runtime/mod.rs` (still reads `GoogleSection`, so it is one slice with the Google half of the `config` row above — do them together or neither). **The dir rename is deliberately NOT done** and is not this row's next step: renames are parked program-wide, and this one would conflict with every open PR touching the CLI. - [ ] Renames executed — decided (2026-07-29, kill the family/crate stutters): `ironclaw_events`→`ironclaw_event_log`, `ironclaw_extensions`→`ironclaw_extension_registry`, `ironclaw_product`→`ironclaw_assistant`; no compatibility re-export shims; all consumers + docs repointed in the same PR. ✎ **2026-07-31, superseded 2026-08-01 by #6996:** the `ironclaw_extensions` rename still has to repoint `reborn_registration_pipeline_boundary.rs`, but it is no longer a *silent* trap. That gate now resolves its owned scopes by crate **name** through the crate inventory and asserts every scope resolves to at least one real file, so a rename that misses it fails loudly with a message naming the crate. Repoint the name; do not raise `REGISTRATION_BOUNDARY_ALLOWLIST_BASELINE`. - [ ] Renames executed — decided (2026-07-30 naming audit): `ironclaw_architecture`→`ironclaw_architecture_tests` (tests-only crate says so; CI lane names updated), `ironclaw_first_party_extensions`→`ironclaw_extension_support` (dir `extensions/ironclaw_extension_support/`), `ironclaw_runner`→`ironclaw_turn_runner`; same no-shim discipline. ✎ **Amended 2026-08-02 (WS2.6): `ironclaw_first_party_extensions`→`ironclaw_extension_support` is DONE**, landed early because WS2's colocation row names the rename too and doing the directory move without it would have touched all 253 occurrences twice. The other two renames on this row are untouched. - [ ] Renames executed — the `reborn_` batch, decided (2026-07-30; the discriminator discriminates nothing): `composition`, `config`, `event_store`, `identity`, `openai_compat`, `reborn_traces`→`trace_commons`, cli directory→`app/ironclaw_cli`, root `reborn_integration_tests`→`integration_tests`; no shims; all consumers + docs repointed in the same PR. diff --git a/docs/reborn/target-architecture/PROPOSAL.md b/docs/reborn/target-architecture/PROPOSAL.md index e0e4898dfcd..157fa867a8a 100644 --- a/docs/reborn/target-architecture/PROPOSAL.md +++ b/docs/reborn/target-architecture/PROPOSAL.md @@ -671,6 +671,18 @@ Compact entries (all: layer `substrates`; forbidden = anything ≥ kernel unless - **Still resident, still owed to their owners:** approval/authorization/trigger-fire policy → `approvals`/`authorization`/`runtime_policy`+`triggers` (the tree is now `capability_authorization` + `trigger_fire_access.rs`); trigger poller lifecycle stays but its trusted-submit *logic* (~4.3k across `automation/trigger_poller*` and the trigger assembly modules) → `triggers`/`conversations`; admin-user directory → `assistant`; trace capture (+ its hooks projection) → `trace_commons` + the turn-runner observer seam; system-prompt content → prompt assets in the loop/product owner; OpenAI-compat + NEAR-login route mounts → `openai_compat`/`operator` factories behind `host_ingress`; project filesystem reader → `identity::projects`; blocked-auth resume fan-out → `assistant`/`auth`; Google OAuth secret store and the NEAR-AI MCP module → package/auth recipes. Env reads consolidate behind `ironclaw_config`. The re-export wall shrinks to the composition-boundary snapshot (every survivor keeps its consumer+test doc, per the house rule). Why a crate: the assembly root — criterion 1 by definition (the only crate allowed to see everything), with `composition_public_api_is_service_shaped` + the mass ratchet keeping it honest. - **6.10.2 `ironclaw_cli`** (directory renamed from `ironclaw_reborn_cli`; **package name stays `ironclaw`**) — retain. The binary: command surface, serve wiring, binding tables (`native_extensions.rs` — the sanctioned concrete-extension linker), first-party registrars, credential-visibility policy, token minter (`AdminApiTokenMinter` impl — the sanctioned inversion). Sheds: the ~200-line Google-OAuth resolution + `reject_legacy_slack_config` → package-owned config/migration steps surfaced through generic seams. Why a crate: the shipped artifact; DEL-7 rule anchors here. - **6.10.3 `ironclaw_config`** — retain, rename (from `ironclaw_reborn_config`), narrow. Boot config contracts: home/profile/boot, `config.toml` schema, seeding, budget env defaults, inline-secret rejection — **minus vendor sections** (`SlackSection`/`TelegramSection`/`GoogleSection`, the Google update pipeline, `update_slack_enabled`) and **minus `capability_remediation.rs`** (100% Google copy) — both become package-owned admin-config schema/data flowing through the manifest `[admin_configuration]` model that already works for Slack. Compatibility window for existing operator `config.toml` files is a named constraint (§12.3). Keeps its zero-workspace-dep rule. Why a crate: the operator-facing boot contract with a machine-enforced no-deps rule. + + > ✎ **Amended 2026-08-04 (delegated authority) — the Slack/Telegram half is done, and "become package-owned admin-config schema/data" was the wrong destination for it.** Decided rather than escalated because it is an architectural placement call, not an empirical claim about production state (§12.11's escalation line). + > + > **Ruling: a retired `config.toml` section stays in `ironclaw_config` as a gravestone, expressed as data in one `retired_sections.rs` table. It does not become package-owned.** A boot-time migration check runs at config-parse time, *before any extension exists to own it*, and this crate may hold no IronClaw workspace dependency (its own machine-enforced rule, and the reason it is a crate at all). The manifest `[admin_configuration]` model this entry points at governs an extension's **live** configuration; there is nothing live left to govern here. The division that actually holds: **a package owns its live admin configuration; the config crate owns the retired keys it once defined itself.** Retiring a future section costs one row. + > + > **What the premise got wrong, measured on live `main` before anything was touched.** The entry reads as if all three sections were live surface needing a new home. `SlackSection` and `TelegramSection` had **zero runtime readers**: their only consumers were `reject_legacy_slack_config` (which exists to reject them), `config list`'s display expansion, and `update_slack_enabled`. The enablement gate they fed — `[slack].enabled` / `IRONCLAW_REBORN_SLACK_ENABLED` and the Telegram equivalents — was **deleted by #6116** (2026-07-21, which removed `serve_slack.rs`/`serve_telegram.rs` outright) and nothing replaced it; the ingress route is generic and always mounted, gated only by signing-secret registration (503 until registered, 401 on mismatch). The user-visible consequence, live until this change: `ironclaw config set slack.enabled true` printed `slack.enabled: saved` and changed nothing, while `docs/reborn/setup-slack-for-reborn-binary.md` called it the binary's "one gate" and offered a troubleshooting step that could never fix anything. So this was a **deletion with a compatibility window**, not a move — and the row's phrasing would have sent an implementer looking for behaviour to relocate that does not exist. + > + > **Alternatives rejected.** (a) *Delete the sections outright.* `RebornConfigFile` is `deny_unknown_fields`, so every existing operator file carrying `[slack]` would stop parsing — breaking exactly the population §12.2 exists to protect. (b) *`#[serde(flatten)]` catch-all for retired keys.* Serde silently disables `deny_unknown_fields` under `flatten`, trading a live typo-catcher for a gravestone; the file's own `recipe.rs` already documents that the two are mutually exclusive. (c) *Two-pass parse for every file.* Rejected on measured evidence, not taste: routing a document through `toml::Table` before deserializing **loses the line/column span** on unknown-field errors (`TOML parse error at line 5, column 1` + caret becomes a bare `unknown field ... in \`boot\``). The shipped form splits retired sections off the raw document but re-parses the **original text** whenever none is present, so every file that is not already carrying a retired section keeps the better diagnostics; the degraded span is confined to files already being told to remove a section. Pinned by `unknown_field_errors_keep_their_span_when_no_section_is_retired`. + > + > **What the compat window now does** (widened, not merely preserved): an existing file still parses; a retired **setup** key still fails `serve` closed with the same message; an inert section still boots **and now says so** rather than being silently ignored; inline-secret rejection over retired sections goes from nine hardcoded keys to every string at any depth. Extension-specificity allowlist **127 → 125**, baseline lowered to match — the two surviving vendor tokens are the TOML table names an operator typed, quarantined in `retired_sections.rs`. + > + > **Still open on this entry, and why they are one slice, not two:** `GoogleSection` + the Google update pipeline are genuinely live (read by the CLI's ~200-line OAuth resolution), so they move with §6.10.2's CLI shed or not at all. `capability_remediation.rs` is **not dead** — the *filename* greps to two files, but its five functions have real consumers in four crates (`ironclaw_extension_manager`, `ironclaw_extension_host` ×2, `ironclaw_reborn_cli` ×3), so moving it is a four-crate change, not a file move. - **6.10.4 `ironclaw_architecture_tests`** (renamed from `ironclaw_architecture`, amended 2026-07-30 — the workspace's one tests-only package no longer hides its kind, matching the integration-tests convention; zero importers, so the cost is CI lane names and muscle memory only) — retain. Test-only enforcement; gains the §11 additions. Why a crate: test-only isolation by definition. **`tools/`**: `ironclaw_stress` — retain as excluded-from-default-work diagnostic (workspace member, app layer; §12.8 notes the option of `default-members` exclusion); `ironclaw_silk_decoder` — retain excluded (decide wiring-or-removal per §12.10); root `fuzz/` — delete or re-point (unresolvable today); `ironclaw_safety/fuzz` — retain. **Workspace root** `ironclaw_reborn_integration_tests` — retain; renamed `ironclaw_integration_tests` with the `reborn_` batch (the last remaining `reborn_` package name; the harness's crate-name churn is tallied in §12.7). @@ -964,7 +976,7 @@ Every current workspace package (66) plus excluded packages. Disposition vocabul **Legacy-v1 classification:** with the enclave already deleted from `main`, the only v1 remnants are *inside* live crates and are handled as deletions above: `auth::loopback_oauth`, `llm::reasoning`, `skills::{registry,catalog,v2,gating}` + its v1 lib.rs doc, `ironclaw_embeddings`, and the stale v1 references across guidance (§11.5). Nothing else qualifies. -**Explicitly identified anti-pattern inventory (per the deliverable checklist):** compatibility shims — `dispatcher`, `turns::{ids,scope,product_adapter}` re-exports, memory_native's six path shims, traces' two re-export modules, product's ~120-symbol facade; transitional bridges — ~~`run_state`~~ (✎ deleted 2026-07-29 with #6696), `first_party_extension_ports` (until W7 shed), config's parse-only `SlackSection`; god-crate modules — composition `runtime.rs`/`factory.rs`/✎`runtime/capability_host/**` (ex `local_dev/**`), extension_host's #6616/#6669 arrivals, host_runtime `obligations.rs`/`first_party_tools/`, runner ✎`subagent/await_edge`+`model_gateway`+`tool_disclosure`, product `reborn_services/**`, loop_host `capability_port.rs`, traces `contribution.rs`, webui `handlers.rs`; backend duplication — product/openai-compat LibSql/Postgres newtype wrappers over the already-backend-neutral fabric (collapse to the generic form), triggers/hooks hand-written SQL (ADR-or-converge); vendor fragmentation — slack across 3 locations, telegram across 2 crates + CLI googlisms + config vendor sections (all resolved into `packages/`); accidental trait/DTO seams — the ~17 single-impl product ports (relocated, not deleted — they are real inversions in the wrong crate), `ToolPermissionOverrideStorePort` & `RouteCurrentRunFinalReply` & memory-native `EmbeddingProvider` (deleted — no inversion), the `ExternalActorRef`/`ExternalConversationRef`/`AttachmentRef`/`SessionThreadService`/`EventStreamManager` name collisions (renamed/unified). +**Explicitly identified anti-pattern inventory (per the deliverable checklist):** compatibility shims — `dispatcher`, `turns::{ids,scope,product_adapter}` re-exports, memory_native's six path shims, traces' two re-export modules, product's ~120-symbol facade; transitional bridges — ~~`run_state`~~ (✎ deleted 2026-07-29 with #6696), `first_party_extension_ports` (until W7 shed), ~~config's parse-only `SlackSection`~~ (✎ 2026-08-04: deleted with `TelegramSection`/`SlackChannelRouteSection`; the parse-only shim is now a generic retired-section table, §6.10.3); god-crate modules — composition `runtime.rs`/`factory.rs`/✎`runtime/capability_host/**` (ex `local_dev/**`), extension_host's #6616/#6669 arrivals, host_runtime `obligations.rs`/`first_party_tools/`, runner ✎`subagent/await_edge`+`model_gateway`+`tool_disclosure`, product `reborn_services/**`, loop_host `capability_port.rs`, traces `contribution.rs`, webui `handlers.rs`; backend duplication — product/openai-compat LibSql/Postgres newtype wrappers over the already-backend-neutral fabric (collapse to the generic form), triggers/hooks hand-written SQL (ADR-or-converge); vendor fragmentation — slack across 3 locations, telegram across 2 crates + CLI googlisms + config vendor sections (all resolved into `packages/`); accidental trait/DTO seams — the ~17 single-impl product ports (relocated, not deleted — they are real inversions in the wrong crate), `ToolPermissionOverrideStorePort` & `RouteCurrentRunFinalReply` & memory-native `EmbeddingProvider` (deleted — no inversion), the `ExternalActorRef`/`ExternalConversationRef`/`AttachmentRef`/`SessionThreadService`/`EventStreamManager` name collisions (renamed/unified). --- @@ -1091,7 +1103,7 @@ Root `CLAUDE.md`/`crates/AGENTS.md`/`crates/Architecture.md` rewritten to the fa *(Constraints and prerequisites only — sequencing/backlog is explicitly out of scope.)* 1. **Security-boundary changes (3, each small but real).** (a) Evidence-mint consolidation (§6.1.2/§11.2.5) touches the webhook-verification and bearer-auth trust seams — prerequisite: the existing ingress/auth contract tests move with the constructors and a refute-style test proves adapters/products cannot mint. ✎ **LANDED 2026-07-31 with PR #6981 (WS1.5), and it was a tightening, not the relocation this entry describes.** The premise underneath the whole item — that the `host-auth-mint` cargo feature sealed the mint family and consolidation merely moved it — is **false, and was measured false before anything was touched**. Cargo unifies features across the packages selected in one invocation, so `ironclaw_webui`'s `ironclaw_product = { features = ["host-auth-mint"] }` (→ `ironclaw_turns/host-auth-mint` → `ironclaw_host_api/host-auth-mint`) compiled `ironclaw_host_api` **once, with the gate on**, for every other crate in the same build. The two-command probe (a test in `ironclaw_agent_loop`, whose manifest names `ironclaw_host_api` with no features, calling `mark_bearer_token_verified("attacker")`): `cargo test -p ironclaw_agent_loop` fails to compile — *"the item is gated behind the `host-auth-mint` feature"* — while `cargo test -p ironclaw_agent_loop -p ironclaw_webui` **compiles and mints a verified bearer claim**. Every workspace-wide build (`cargo test`, `cargo check --all-targets --all-features`, CI) is the second case, so the seal was open in every build that mattered. **The replacement is the repo's existing witness-token idiom** (`host_api::authorized`), which no other crate's manifest can switch on: two distinct zero-sized grants — `HostAuthenticationGrant` ← `HostProtocolAuthenticator` (sole production implementor `ironclaw_webui`, on the module-private `AuthLayerState`) and `VerifiedInboundGrant` ← `ChannelIngressVerifier` (sole production implementor `ironclaw_extension_host`, on `VerifiedEvidenceMint`, the recipe the router just executed) — because one grant would let either minter forge the other's claim shape. Enforcement is 19 refute/seal tests: `reborn_sealed_evidence_mint_ratchet` (10, §11.2.5) plus `host_api/tests/protocol_auth_evidence_seal.rs` (5) and `extension_contracts/tests/verified_inbound_seal.rs` (4). **Recorded residual, not hidden:** `ProtocolAuthEvidence::seal_verified_inbound` accepts a full `AuthRequirement`, so a `VerifiedInboundGrant` holder could attest a bearer-shaped requirement; that holder is the generic ingress verifier — trusted host code by charter — and narrowing it needs a second enum duplicating `AuthRequirement`'s channel half, which was judged worse than the residual. The property this item exists for — **a package or a product handler cannot mint at all** — is unaffected. **Second residual, added by this audit rather than by the slice: the seal's scan half has named evasions.** Because pure cross-crate type-sealing is not expressible in Rust, the seal is deliberately two halves — the compiler enforces "no minting without a grant", and `reborn_sealed_evidence_mint_ratchet` enforces "only one crate may implement each grant trait". The second half is a line-oriented substring scan, and both grant traits mint through *provided* methods on public, unsealed traits, so an import alias or a multiline `impl` header evades that census while it still reports `permitted_impls == 1` (file:line detail and the fail-open reads at §11.2.5). It is hardening rather than a live hole because a grant is only half a forgery: the sibling call-site census `mint_functions_are_named_only_by_their_owners_and_sanctioned_minters` matches the eight frozen mint-function names on word boundaries over the same stripped sources, so an aliased import or a split call still writes the name on some line and is caught. A rogue *workspace* crate is the threat model this bounds — not a package or a handler, which hold no grant either way — and hardening the scan is WS10 work, listed on that wave's guardrail-regression row with the two evasions named. It is recorded here because this item's risk statement should not read as stronger than what shipped. **Generalizable finding for the rest of the program:** treat "a cargo feature gates this" as an unproven claim until measured with the two-command probe above; a feature that any sibling manifest can unify on is not a privilege boundary. §12.1(b)'s secrets tightening and §12.1(c)'s ordering constraint are untouched by this and remain open. (b) Secrets direct-consumer tightening (webui/operator) must not silently reroute a working credential path — prerequisite: enumerate their current call sites (audited: webui session/keys, operator key store) and land the port replacements first. (c) Re-layering `extension_host` below product removes its ability to call product's minting/admission directly — the port inversions must land *before* the layer flip or the crate won't compile; that ordering constraint is the sharpest edge in the whole restructure. -2. **Persistence and migration compatibility.** Family moves and renames touch no storage paths. The risky classes are (i) ✎ **retired as a forward risk** — the journal import of `/turns/rows/v1`, `/turns/state.json`, and `/run-state/**` landed with #6696 under that PR's own rollback contract; this proposal added no schema motion on top and still adds none, so what remains is operational (deployments that have not yet run the import), not architectural, (ii) `config.toml` vendor-section removal (§6.10.3) — constraint: a deprecation window where old sections parse into migration guidance (the existing `reject_legacy_slack_config` shape, relocated), (iii) trigger/hook SQL convergence if chosen (ADR path exists precisely so this is not forced), (iv) ✎ **new:** the shared libSQL runtime is now a *runtime* invariant as well as a code one — any change that moves a libSQL-backed store between crates must keep it on the one admission lane for its database (§11.2.6), or it silently reintroduces the competing-writer defect #6863 fixed. +2. **Persistence and migration compatibility.** Family moves and renames touch no storage paths. The risky classes are (i) ✎ **retired as a forward risk** — the journal import of `/turns/rows/v1`, `/turns/state.json`, and `/run-state/**` landed with #6696 under that PR's own rollback contract; this proposal added no schema motion on top and still adds none, so what remains is operational (deployments that have not yet run the import), not architectural, (ii) `config.toml` vendor-section removal (§6.10.3) — constraint: a deprecation window where old sections parse into migration guidance (the existing `reject_legacy_slack_config` shape, relocated) ✎ **discharged for `[slack]`/`[telegram]` on 2026-08-04; still binding for `[google]`.** The window is built and generic: retired sections are split off the raw document before the typed parse (so the schema keeps `deny_unknown_fields` without naming a retired key), a retired **setup** key fails `serve` closed with a migration pointer, and an inert section boots with a deprecation notice instead of silence. `reject_legacy_slack_config` is now `reject_retired_config_sections`, a call into `ironclaw_config`'s one retired-section table — the relocation this clause specified, with the vendor knowledge as data rather than as CLI code. **The constraint was also under-stated for the case it just covered:** it protects operator files, but the same removal touched a *documented* surface — `docs/reborn/setup-slack-for-reborn-binary.md` and four sibling docs instructed operators to set a flag that had had no reader since #6116, so "compatibility" here meant correcting guidance as well as keeping files parsing. Any future section retirement should assume the same: grep the docs, not just the code, (iii) trigger/hook SQL convergence if chosen (ADR path exists precisely so this is not forced), (iv) ✎ **new:** the shared libSQL runtime is now a *runtime* invariant as well as a code one — any change that moves a libSQL-backed store between crates must keep it on the one admission lane for its database (§11.2.6), or it silently reintroduces the competing-writer defect #6863 fixed. 3. **Process-journal work — ✎ merged 2026-07-29; the contingency is discharged, one item survives as ordinary target work.** All four formerly `[#6696]`-tagged rows are resolved: `processes` widening, `run_state` deletion, and `approvals` widening landed as specified; the `runner` shed landed only in its scheduler half. The residual risk is no longer "an external PR may not land" but the ordinary kind: **runner's `subagent/await_edge` (2.9k lines) is still there**, so any plan that assumed it was gone must be re-costed, and the WS4/WS9 sequencing that waited on this gate can now run in any wave. Do not treat the journal schema as re-openable — it is live and carries production data. 4. **Compile times and feature unification.** Expected net win: contracts crates cut the `product`-sized dependency cones for webui/openai_compat/channel crates; `event_store`/`sandbox` isolation keeps TLS/Docker cones narrow; deleting `reasoning.rs`/dead skills trims a leaf that 8 crates rebuild behind. Watch-items: the three new contracts crates must stay thin (mass ratchet per §11.2.3) or they become new gravity wells; `--all-features` unification already compiles mem0/bedrock — unchanged. 5. **Public API churn.** Internal-only workspace (nothing publishes; `skills` is the one manifest missing `publish = false` — fix). The real churn is import paths: bounded by doing renames without compatibility re-exports (house rule) in move-sized PRs; the integration harness and `.claude` guidance are first-class churn consumers to update in the same changes (§11.5). diff --git a/docs/using/cli.mdx b/docs/using/cli.mdx index 43b6fe4ee38..d0fc3409cc4 100644 --- a/docs/using/cli.mdx +++ b/docs/using/cli.mdx @@ -55,9 +55,9 @@ ironclaw config set google.client_id `config list` and `config get` read every key. `config set` is narrower: it accepts only `.api_key`, `google.client_id`, `google.client_secret`, `google.redirect_uri`, -`slack.enabled`, and `webui.token --rotate`, routing each to the configuration file, the -encrypted secret store, or the web token file. Everything else is edited in `config.toml` -directly. +and `webui.token --rotate`, routing each to the configuration file, the encrypted secret +store, or the web token file. Other supported settings are edited in `config.toml` +directly; retired keys such as `slack.enabled` are refused with migration guidance. `config set` does not restart the running instance — it prints `to apply: ironclaw service restart`. See [Configuration](/capabilities/configuration). diff --git a/docs/zh/channels/telegram.md b/docs/zh/channels/telegram.md index 91247e19a86..994441e429b 100644 --- a/docs/zh/channels/telegram.md +++ b/docs/zh/channels/telegram.md @@ -73,18 +73,17 @@ https://your-host/webhooks/extensions/telegram/updates ## 配置 -```toml -telegram.enabled = true -``` - -查看当前状态: - -```bash -ironclaw config get telegram.enabled -``` +Telegram 在 `config.toml` 中没有任何设置,也没有用于启用的 CLI 配置键。 +入口路由已编译进程序并始终挂载;只有在按上述步骤安装 Telegram 扩展并完成 +机器人设置之后,它才会开始正常服务,在此之前返回 `503`。 机器人令牌保存在加密的密钥存储中,而不是 `config.toml` 里。参见[配置](/capabilities/configuration)(暂仅提供英文版)。 + + 旧版本遗留的 `[telegram]` 配置段仍可被解析,但不会被读取——`ironclaw serve` + 启动时会记录一条弃用提示。删除该配置段即可消除该提示。 + + --- ## 故障排查 diff --git a/scripts/ci/reborn_pr_test_plan.py b/scripts/ci/reborn_pr_test_plan.py index 1a5ce018c25..dd60776afb0 100644 --- a/scripts/ci/reborn_pr_test_plan.py +++ b/scripts/ci/reborn_pr_test_plan.py @@ -31,6 +31,16 @@ "tests/CLAUDE.md", "tests/integration/CLAUDE.md", } +# Repo-root prose/example files with no build or test surface. Root `*.md` is +# already handled inline below; this covers the non-`.md` siblings. +# +# `.env.example` is documentation of environment variables, not an input to +# anything: no crate, test, or workflow reads the file (only doc comments +# mention it by name). It was unclassified until 2026-08-04, so the +# fail-closed arm rejected every PR that corrected an env-var comment — the +# same shape as the `.claude/` gap above, and the same fix: classify it, +# rather than loosen the arm that catches genuinely unknown paths. +IGNORED_ROOT_FILES = (".env.example",) DEDICATED_WORKFLOW_PREFIXES = ("tools/ironclaw_stress/",) QA_HARNESS_PREFIXES = ( "scripts/live-canary/", @@ -377,6 +387,7 @@ def build_plan( if ( path in IGNORED_GUIDANCE_PATHS or path.startswith(IGNORED_PREFIXES) + or path in IGNORED_ROOT_FILES or (path.endswith(".md") and "/" not in path) ): continue diff --git a/scripts/ci/test_reborn_pr_test_plan.py b/scripts/ci/test_reborn_pr_test_plan.py index a59d41c09a7..64f1e8c16f8 100644 --- a/scripts/ci/test_reborn_pr_test_plan.py +++ b/scripts/ci/test_reborn_pr_test_plan.py @@ -477,6 +477,37 @@ def test_repo_wide_test_guidance_selects_no_rust_lane(self) -> None: self.assertEqual(plan["root_partitions"], []) self.assertEqual(plan["integration_lanes"], []) + def test_repo_root_example_env_is_classified_and_selects_no_rust_lane(self) -> None: + """`.env.example` is documentation, like a repo-root `*.md`. + + Regression for the gap PR #7117 hit: root `*.md` was classified but + its non-`.md` sibling was not, so correcting an env-var comment failed + the whole `Tests (Reborn)` roll-up. Nothing reads the file — no crate, + test, or workflow — only doc comments name it. + + Paired assertions, same reason as the `.claude/` test above: the path + must be *accepted* AND select no Rust lane, so a future + "classification" that turns a comment fix into a full matrix fails + here too. + """ + plan = self.plan("pull_request", [".env.example"]) + self.assertEqual(plan["mode"], "none") + self.assertEqual(plan["crate_buckets"], []) + self.assertEqual(plan["root_partitions"], []) + self.assertEqual(plan["integration_lanes"], []) + + # The ignore is per-path, not per-PR: a real change riding along still + # selects its lane. + paired = self.plan( + "pull_request", [".env.example", "crates/alpha/src/lib.rs"] + ) + self.assertEqual(paired["mode"], "selected") + self.assertNotEqual(paired["crate_buckets"], []) + + # And the fail-closed arm still catches a genuinely unknown root file. + with self.assertRaisesRegex(ValueError, "unclassified pull-request path"): + self.plan("pull_request", [".env.local"]) + def test_decided_repo_root_script_paths_are_owned_by_other_workflows(self) -> None: """Repo-root `scripts/` files that another workflow owns.