Skip to content
Closed
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 4 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ pub(super) enum ConfigKey {
GoogleClientId,
GoogleClientSecret,
GoogleRedirectUri,
SlackEnabled,
WebuiToken,
}

Expand All @@ -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,
}
Expand All @@ -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,
}
}
Expand Down Expand Up @@ -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,
}
}
Expand All @@ -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::*;
Expand Down Expand Up @@ -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)
Expand All @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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}"
);
}
}
6 changes: 4 additions & 2 deletions crates/ironclaw_reborn_cli/src/commands/config/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
75 changes: 43 additions & 32 deletions crates/ironclaw_reborn_cli/src/commands/config/set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// (`<provider>.api_key`, `google.client_secret`) reject positional values
Expand All @@ -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));
};
Expand All @@ -57,7 +70,7 @@ fn unknown_key_message(key: &str) -> String {
format!(
"unknown config key `{key}` for `config set`\nSupported keys: <provider>.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,
)
Expand All @@ -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(),
}
}
Expand Down Expand Up @@ -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"),
}

Expand All @@ -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
Expand All @@ -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 => {}
}
}
Expand All @@ -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,
Expand Down Expand Up @@ -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]
Expand Down
Loading
Loading