//! CLI subcommand definitions and handlers.
//!
//! Uses clap derive to define the subcommand hierarchy:
//! - `start` (default) -- start the gateway server
//! - `config show|get|set|path` -- read/write configuration
//! - `status` -- query a running instance for health info
//! - `logs` -- tail log entries from a running instance
//! - `plugins` -- inspect and manage plugins on a running instance
//! - `version` -- print build/version info
//! - `backup` -- create a backup archive of all gateway data
//! - `restore` -- restore from a backup archive
//! - `reset` -- clear specific data categories
//! - `setup` -- interactive first-run configuration wizard
//! - `import` -- import configuration from another tool
//! - `pair` -- pair with a remote gateway node
//! - `update` -- check for updates or self-update
//! - `task` -- manage long-running objective tasks

pub mod backup_crypto;
pub mod chat;

use clap::{Parser, Subcommand};
use tokio::io::AsyncWriteExt;

/// Carapace gateway server for AI assistants.
#[derive(Parser, Debug)]
#[command(
    name = "cara",
    version = env!("CARGO_PKG_VERSION"),
    about = "Carapace — a secure gateway server for AI assistants"
)]
pub struct Cli {
    #[command(subcommand)]
    pub command: Option<Command>,
}

#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum VerifyOutcomeSelection {
    Auto,
    LocalChat,
    Discord,
    Telegram,
    Matrix,
    Hooks,
    Autonomy,
}

#[derive(Subcommand, Debug)]
pub enum Command {
    /// Start the gateway server (default when no subcommand is given).
    Start,

    /// Read or write configuration values.
    #[command(subcommand)]
    Config(ConfigCommand),

    /// Query a running instance for health/status information.
    Status {
        /// Port of the running instance (default: from config or 18789).
        #[arg(short, long)]
        port: Option<u16>,

        /// Host of the running instance.
        #[arg(long, default_value = "127.0.0.1")]
        host: String,

        /// Print JSON instead of human-readable output.
        #[arg(long)]
        json: bool,
    },

    /// Tail log entries from a running instance.
    Logs {
        /// Number of recent log lines to show (default: 50).
        #[arg(short = 'n', long, default_value_t = 50)]
        lines: usize,

        /// Port of the running instance (default: from config or 18789).
        #[arg(short, long)]
        port: Option<u16>,

        /// Host of the running instance.
        #[arg(long, default_value = "127.0.0.1")]
        host: String,

        /// Use TLS (wss://) for remote connections.
        #[arg(long)]
        tls: bool,

        /// Accept invalid TLS certificates (only with --tls).
        #[arg(long)]
        trust: bool,

        /// Allow plaintext WebSocket (ws scheme) for non-loopback hosts (unsafe).
        #[arg(long)]
        allow_plaintext: bool,
    },

    /// Inspect and manage plugins on a running instance.
    #[command(subcommand)]
    Plugins(PluginsCommand),

    /// Print version, build date, and git commit information.
    Version,

    /// Create a backup archive of all gateway data.
    Backup {
        /// Output file path (default: ./carapace-backup-{timestamp}.tar.gz).
        #[arg(short, long)]
        output: Option<String>,

        /// Overwrite the output file if it already exists.
        ///
        /// Without this flag, `cara backup` refuses to clobber an
        /// existing path. The default timestamp-suffixed name (no
        /// `--output`) is unique and never collides.
        #[arg(long)]
        force: bool,
    },

    /// Restore from a backup archive.
    Restore {
        /// Path to the backup archive file.
        path: String,

        /// Overwrite existing data without confirmation prompt.
        #[arg(long)]
        force: bool,
    },

    /// Clear specific categories of gateway data.
    Reset {
        /// Delete all session data.
        #[arg(long)]
        sessions: bool,

        /// Delete all cron job data.
        #[arg(long)]
        cron: bool,

        /// Reset usage tracking data.
        #[arg(long)]
        usage: bool,

        /// Delete agent memory stores.
        #[arg(long)]
        memory: bool,

        /// Delete everything (equivalent to all flags).
        #[arg(long)]
        all: bool,

        /// Proceed without confirmation prompt.
        #[arg(long)]
        force: bool,
    },

    /// Run the interactive setup wizard for first-time configuration.
    Setup {
        /// Overwrite existing configuration if it already exists.
        #[arg(long)]
        force: bool,

        /// Provider to configure for first run.
        #[arg(long, value_enum)]
        provider: Option<SetupProvider>,

        /// Provider auth mode. Required for non-interactive Gemini and Anthropic setup.
        #[arg(long, value_enum)]
        auth_mode: Option<SetupAuthModeSelection>,

        /// Default model. Canonical form is `provider:<model-id>`; bare IDs without `:` are
        /// also accepted and auto-prefixed with `--provider`. Required for
        /// non-interactive setup; skips the model prompt in interactive mode.
        #[arg(long)]
        model: Option<String>,
    },

    /// Pair with a remote gateway node.
    Pair {
        /// Gateway URL to pair with (e.g., https://gateway.local:3001).
        url: String,

        /// Device name for this node (defaults to hostname).
        #[arg(long)]
        name: Option<String>,

        /// Accept the gateway's TLS fingerprint without verification.
        #[arg(long)]
        trust: bool,
    },

    /// Check for updates or install a specific version.
    Update {
        /// Check for updates without installing.
        #[arg(long)]
        check: bool,

        /// Install a specific version (e.g., "0.2.0").
        #[arg(long)]
        version: Option<String>,
    },

    /// Manage long-running objective tasks.
    #[command(subcommand)]
    Task(TaskCommand),

    /// Manage Matrix / Element device verification and E2EE store operations.
    #[command(subcommand)]
    Matrix(MatrixCommand),

    /// Start an interactive chat session.
    Chat {
        /// Start a new session instead of resuming.
        #[arg(long)]
        new: bool,

        /// Port of a running instance to connect to.
        #[arg(short, long)]
        port: Option<u16>,
    },

    /// Verify outcome paths, including long-running autonomy execution.
    Verify {
        /// Which outcome to verify (default: infer from config).
        #[arg(long, value_enum, default_value_t = VerifyOutcomeSelection::Auto)]
        outcome: VerifyOutcomeSelection,

        /// Port of a running instance to connect to.
        #[arg(short, long)]
        port: Option<u16>,

        /// Discord channel ID for send-path verification.
        #[arg(long)]
        discord_to: Option<String>,

        /// Telegram chat ID for send-path verification.
        #[arg(long)]
        telegram_to: Option<String>,

        /// Matrix room ID for send-path verification.
        #[arg(long)]
        matrix_to: Option<String>,
    },

    /// Import configuration from another tool.
    Import {
        /// Source tool to import from.
        #[arg(value_enum)]
        source: ImportSource,

        /// Overwrite existing Carapace configuration if it already exists.
        #[arg(long)]
        force: bool,
    },

    /// Manage mTLS certificates for gateway-to-gateway communication.
    #[command(subcommand)]
    Tls(TlsCommand),
}

#[derive(clap::ValueEnum, Clone, Debug, PartialEq, Eq)]
pub enum ImportSource {
    /// Import from OpenClaw (~/.openclaw/ or ~/.clawdbot/).
    Openclaw,
    /// Import from OpenCode (~/.opencode.json).
    Opencode,
    /// Import from Aider (~/.aider.conf.yml and .env).
    Aider,
    /// Import from NemoClaw (~/.nemoclaw/config.json).
    Nemoclaw,
}

#[derive(Subcommand, Debug)]
pub enum TlsCommand {
    /// Generate a new cluster CA certificate.
    InitCa {
        /// Output directory for CA files (default: ~/.config/carapace/cluster-ca).
        #[arg(long)]
        output: Option<String>,
    },

    /// Issue a node certificate signed by the cluster CA.
    IssueCert {
        /// Node ID to embed in the certificate CN.
        node_id: String,

        /// Directory containing the cluster CA files.
        #[arg(long)]
        ca_dir: Option<String>,

        /// Output directory for the node certificate and key.
        #[arg(long)]
        output: Option<String>,
    },

    /// Revoke a node certificate by fingerprint.
    RevokeCert {
        /// SHA-256 fingerprint of the certificate to revoke (colon-separated hex).
        fingerprint: String,

        /// Node ID associated with the certificate.
        #[arg(long)]
        node_id: String,

        /// Directory containing the cluster CA files.
        #[arg(long)]
        ca_dir: Option<String>,

        /// Reason for revocation.
        #[arg(long)]
        reason: Option<String>,
    },

    /// Show cluster CA information and certificate revocation list.
    ShowCa {
        /// Directory containing the cluster CA files.
        #[arg(long)]
        ca_dir: Option<String>,
    },
}

#[derive(clap::Args, Debug, Clone)]
pub struct WsConnectionArgs {
    /// Port of the running instance (default: from config or 18789).
    #[arg(short, long)]
    port: Option<u16>,

    /// Host of the running instance.
    #[arg(long, default_value = "127.0.0.1")]
    host: String,

    /// Use TLS (wss://) for remote connections.
    #[arg(long)]
    tls: bool,

    /// Accept invalid TLS certificates (only with --tls).
    #[arg(long)]
    trust: bool,

    /// Allow plaintext WebSocket (ws scheme) for non-loopback hosts (unsafe).
    #[arg(long)]
    allow_plaintext: bool,
}

#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginSourceSelection {
    Managed,
    Config,
}

#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginStateSelection {
    Active,
    Disabled,
    Ignored,
    Failed,
}

#[derive(Subcommand, Debug)]
pub enum PluginsCommand {
    /// Show plugin activation state and restart requirements.
    Status {
        /// Print JSON instead of human-readable output.
        #[arg(long)]
        json: bool,

        /// Filter by configured plugin name.
        #[arg(long)]
        name: Option<String>,

        /// Filter by instantiated plugin ID.
        #[arg(long = "plugin-id")]
        plugin_id: Option<String>,

        /// Filter by plugin source.
        #[arg(long, value_enum)]
        source: Option<PluginSourceSelection>,

        /// Filter by plugin activation state.
        #[arg(long, value_enum)]
        state: Option<PluginStateSelection>,

        /// Show only failed plugin entries.
        #[arg(long)]
        only_failed: bool,

        /// Exit nonzero if activation errors exist or filtered results are not all active.
        #[arg(long)]
        strict: bool,

        #[command(flatten)]
        connection: WsConnectionArgs,
    },

    /// List managed plugin binaries present on disk.
    Bins {
        /// Print JSON instead of human-readable output.
        #[arg(long)]
        json: bool,

        #[command(flatten)]
        connection: WsConnectionArgs,
    },

    /// Install a managed plugin.
    Install(PluginMutationArgs),

    /// Update a managed plugin.
    Update(PluginMutationArgs),
}

#[derive(clap::Args, Debug)]
#[command(group(
    clap::ArgGroup::new("source")
        .required(true)
        .multiple(false)
        .args(["url", "file"])
))]
pub struct PluginMutationArgs {
    /// Managed plugin name.
    name: String,

    /// Download URL for the plugin artifact.
    #[arg(long, group = "source")]
    url: Option<String>,

    /// Local plugin artifact to copy into the managed plugins directory.
    #[arg(long, group = "source")]
    file: Option<PathBuf>,

    /// Optional plugin version string.
    #[arg(long)]
    version: Option<String>,

    /// Optional publisher public key.
    #[arg(long = "publisher-key")]
    publisher_key: Option<String>,

    /// Optional detached plugin signature.
    #[arg(long)]
    signature: Option<String>,

    /// Print JSON instead of human-readable output.
    #[arg(long)]
    json: bool,

    #[command(flatten)]
    connection: WsConnectionArgs,
}

#[derive(clap::Args, Debug)]
pub struct TaskConnectionArgs {
    /// Port of the running instance (default: from config or 18789).
    #[arg(short, long)]
    port: Option<u16>,

    /// Host of the running instance.
    #[arg(long, default_value = "127.0.0.1")]
    host: String,
}

#[derive(Subcommand, Debug)]
pub enum TaskCommand {
    /// Create a durable objective task.
    Create {
        /// Task payload as JSON (CronPayload shape).
        #[arg(long)]
        payload: String,

        /// Optional Unix-ms time when the task becomes runnable.
        #[arg(long = "next-run-at-ms")]
        next_run_at_ms: Option<u64>,

        /// Optional max retry attempts for this task.
        #[arg(long = "max-attempts")]
        max_attempts: Option<u32>,

        /// Optional max wall-clock budget in milliseconds from creation.
        #[arg(long = "max-total-runtime-ms")]
        max_total_runtime_ms: Option<u64>,

        /// Optional max tool-turn budget for this task.
        #[arg(long = "max-turns")]
        max_turns: Option<u32>,

        /// Optional max timeout (seconds) for each spawned agent run.
        #[arg(long = "max-run-timeout-seconds")]
        max_run_timeout_seconds: Option<u32>,

        #[command(flatten)]
        connection: TaskConnectionArgs,
    },
    /// List durable objective tasks.
    List {
        /// Optional task state filter.
        #[arg(long)]
        state: Option<String>,

        /// Optional max number of tasks to return.
        #[arg(long)]
        limit: Option<usize>,

        #[command(flatten)]
        connection: TaskConnectionArgs,
    },
    /// Get a single durable objective task by ID.
    Get {
        /// Task ID.
        id: String,

        #[command(flatten)]
        connection: TaskConnectionArgs,
    },
    /// Cancel a durable objective task.
    Cancel {
        /// Task ID.
        id: String,

        /// Optional cancellation reason.
        #[arg(long)]
        reason: Option<String>,

        #[command(flatten)]
        connection: TaskConnectionArgs,
    },
    /// Retry a durable objective task.
    Retry {
        /// Task ID.
        id: String,

        /// Retry delay in milliseconds (default: immediate).
        #[arg(long = "delay-ms")]
        delay_ms: Option<u64>,

        /// Optional retry reason.
        #[arg(long)]
        reason: Option<String>,

        #[command(flatten)]
        connection: TaskConnectionArgs,
    },
    /// Resume a blocked durable objective task.
    Resume {
        /// Task ID.
        id: String,

        /// Resume delay in milliseconds (default: immediate).
        #[arg(long = "delay-ms")]
        delay_ms: Option<u64>,

        /// Optional resume reason.
        #[arg(long)]
        reason: Option<String>,

        #[command(flatten)]
        connection: TaskConnectionArgs,
    },
    /// Update payload/policy fields for a durable objective task.
    Update {
        /// Task ID.
        id: String,

        /// Replacement task payload as JSON (CronPayload shape).
        #[arg(long)]
        payload: Option<String>,

        /// Optional max retry attempts for this task.
        #[arg(long = "max-attempts")]
        max_attempts: Option<u32>,

        /// Optional max wall-clock budget in milliseconds from creation.
        #[arg(long = "max-total-runtime-ms")]
        max_total_runtime_ms: Option<u64>,

        /// Optional max tool-turn budget for this task.
        #[arg(long = "max-turns")]
        max_turns: Option<u32>,

        /// Optional max timeout (seconds) for each spawned agent run.
        #[arg(long = "max-run-timeout-seconds")]
        max_run_timeout_seconds: Option<u32>,

        /// Optional operator note stored on the task.
        #[arg(long)]
        reason: Option<String>,

        #[command(flatten)]
        connection: TaskConnectionArgs,
    },
}

#[derive(clap::Args, Debug)]
pub struct MatrixConnectionArgs {
    /// Port of the running instance (default: from config or 18789).
    #[arg(short, long)]
    port: Option<u16>,

    /// Host of the running instance.
    #[arg(long, default_value = "127.0.0.1")]
    host: String,
}

#[derive(clap::Args, Debug)]
#[command(group(
    clap::ArgGroup::new("sas_result")
        .required(true)
        .multiple(false)
        .args(["matches", "no_match"])
))]
pub struct MatrixConfirmArgs {
    /// Verification flow ID.
    flow: String,

    /// Confirm that the short authentication string matches.
    #[arg(long = "match", group = "sas_result")]
    matches: bool,

    /// Reject the short authentication string.
    #[arg(long = "no-match", group = "sas_result")]
    no_match: bool,

    /// SECURITY WARNING: skip the interactive "have you compared
    /// the SAS values?" prompt. Use ONLY when the comparison has
    /// already been performed out-of-band — e.g., from an automation
    /// script that already showed the SAS to a human and got their
    /// approval through a separate channel. Without this flag,
    /// `cara matrix confirm` fetches the flow's current SAS
    /// emoji+decimals, displays them, and refuses to send the
    /// confirm RPC unless the operator types `yes` at the prompt —
    /// closing the SSH-shell-access attack where someone with shell
    /// could run `confirm --match` without the human comparing the
    /// values. Bypassing the prompt without out-of-band human
    /// comparison defeats the MITM resistance the SAS step provides.
    /// Each use emits an audit-warn log naming the flow_id so the
    /// bypass leaves a journal trace.
    #[arg(long = "unsafe-skip-sas-prompt")]
    unsafe_skip_sas_prompt: bool,

    #[command(flatten)]
    connection: MatrixConnectionArgs,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MatrixSasResult {
    Match,
    NoMatch,
}

impl MatrixConfirmArgs {
    fn sas_result(&self) -> Result<MatrixSasResult, Box<dyn std::error::Error>> {
        match (self.matches, self.no_match) {
            (true, false) => Ok(MatrixSasResult::Match),
            (false, true) => Ok(MatrixSasResult::NoMatch),
            _ => Err("exactly one of --match or --no-match is required".into()),
        }
    }
}

#[derive(Subcommand, Debug)]
pub enum MatrixRecoveryKeyCommand {
    /// Show the locally persisted Matrix recovery key.
    Show {
        /// Allow printing the recovery key when stdout is not a terminal.
        #[arg(long = "allow-non-terminal")]
        allow_non_terminal: bool,
    },

    /// Restore a Matrix recovery key from --key-file, --stdin, or an interactive prompt.
    ///
    /// May exit non-zero after writing the key if stale rotation artifacts
    /// cannot be cleaned up; resolve that cleanup failure before restart.
    Restore {
        /// Read recovery key material from a file; conflicts with --stdin.
        #[arg(long = "key-file")]
        key_file: Option<PathBuf>,

        /// Read recovery key material from stdin instead of prompting.
        #[arg(long, conflicts_with = "key_file")]
        stdin: bool,
    },

    /// Rotate the Matrix recovery key.
    ///
    /// **DESTRUCTIVE**: the previous recovery key is abandoned and any
    /// encrypted Matrix backup it secured is no longer recoverable
    /// without that key. Requires `--yes` for non-interactive use; an
    /// interactive run prompts for confirmation on the TTY.
    Rotate {
        /// Skip the interactive confirmation prompt. Required for
        /// non-interactive (piped stdin / no TTY) invocations.
        #[arg(long)]
        yes: bool,
    },
}

#[derive(Subcommand, Debug)]
pub enum MatrixCommand {
    /// List Matrix devices known to the daemon.
    Devices {
        #[command(flatten)]
        connection: MatrixConnectionArgs,
    },

    /// List pending Matrix verification flows.
    Verifications {
        #[command(flatten)]
        connection: MatrixConnectionArgs,
    },

    /// Start a Matrix device verification flow.
    Verify {
        /// Matrix user ID to verify.
        user: String,

        /// Optional Matrix device ID.
        device: Option<String>,

        /// Hex-encoded raw Matrix device ID from `cara matrix devices`.
        #[arg(long = "device-id-hex")]
        raw_device_id_hex: Option<String>,

        #[command(flatten)]
        connection: MatrixConnectionArgs,
    },

    /// Accept a Matrix verification flow.
    Accept {
        /// Verification flow ID.
        flow: String,

        #[command(flatten)]
        connection: MatrixConnectionArgs,
    },

    /// Confirm whether a Matrix SAS verification matches.
    Confirm(MatrixConfirmArgs),

    /// Cancel a Matrix verification flow.
    Cancel {
        /// Verification flow ID.
        flow: String,

        #[command(flatten)]
        connection: MatrixConnectionArgs,
    },

    /// Show or restore the Matrix recovery key.
    #[command(subcommand)]
    RecoveryKey(MatrixRecoveryKeyCommand),

    /// Rekey the Matrix SDK store.
    RekeyStore {
        /// Require creation of a new store key.
        #[arg(long)]
        new: bool,
    },
}

#[derive(Subcommand, Debug)]
pub enum ConfigCommand {
    /// Print the fully loaded configuration (secrets redacted) as JSON.
    Show,

    /// Print a specific configuration value by dot-notation path.
    Get {
        /// Dot-notation key (e.g. "server.port", "gateway.bind").
        key: String,
    },

    /// Set a configuration value and write to disk.
    Set {
        /// Dot-notation key (e.g. "gateway.port").
        key: String,

        /// Value to set (interpreted as JSON; bare strings allowed).
        value: String,
    },

    /// Print the resolved configuration file path.
    Path,
}

// ---------------------------------------------------------------------------
// Subcommand handlers
// ---------------------------------------------------------------------------

use crate::channels::discord::{DiscordChannel, DISCORD_DEFAULT_API_BASE_URL};
use crate::channels::telegram::{TelegramChannel, TELEGRAM_DEFAULT_API_BASE_URL};
use crate::config;
use crate::credentials;
use crate::logging::buffer::LogLevel;
use crate::onboarding::codex::CODEX_DEFAULT_SENTINEL;
use crate::onboarding::vertex::VERTEX_DEFAULT_SENTINEL;
use crate::runtime_bridge::{run_blocking_cleanup, run_sync_blocking_send};
use crate::server::bind::DEFAULT_PORT;
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use base64::Engine;
use ed25519_dalek::{Signer, SigningKey};
use futures_util::{SinkExt, StreamExt};
use getrandom::fill;
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{DigitallySignedStruct, Error as RustlsError, SignatureScheme};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::collections::HashSet;
#[cfg(not(test))]
use std::io::IsTerminal;
use std::sync::{LazyLock, Mutex};
use std::time::Duration;
use tokio_tungstenite::{
    connect_async, connect_async_tls_with_config, tungstenite::Message, Connector,
};
use url::{Host, Url};
use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};

fn codex_noninteractive_setup_error() -> String {
    let command = crate::onboarding::setup::SetupProvider::Codex.setup_command(None);
    format!(
        "non-interactive Codex sign-in is not supported; rerun `{command}` \
         from an interactive terminal, or use the Control UI browser OAuth entrypoint."
    )
}

/// Secret-key patterns used by `cara config show` redaction. Sourced
/// from the canonical list in `logging::redact` to prevent drift —
/// the prior local copy was missing `recovery*` and `accesskeyid`
/// entries, leaving operator secrets visible in plaintext via the
/// CLI while the WS endpoint redacted them.
fn secret_keys() -> &'static [&'static str] {
    crate::logging::redact::canonical_secret_key_names()
}

/// Run the `config show` subcommand.
pub fn handle_config_show() -> Result<(), Box<dyn std::error::Error>> {
    let cfg = config::load_config()?;
    let redacted = redact_secrets(cfg);
    let pretty = serde_json::to_string_pretty(&redacted)?;
    println!("{}", pretty);
    Ok(())
}

/// Run the `config get <key>` subcommand.
pub fn handle_config_get(key: &str) -> Result<(), Box<dyn std::error::Error>> {
    match config_get_value_for_display(key) {
        Some(value) => {
            let pretty = serde_json::to_string_pretty(&value)?;
            println!("{}", pretty);
        }
        None => {
            eprintln!("Key not found: {}", key);
            std::process::exit(1);
        }
    }
    Ok(())
}

fn config_get_value_for_display(key: &str) -> Option<Value> {
    let snapshot = crate::server::ws::read_config_snapshot();
    get_value_at_path(&snapshot.parsed, key).map(|mut value| {
        redact_config_value_for_display(&mut value, key);
        value
    })
}

fn redact_config_value_for_display(value: &mut Value, key: &str) {
    let leaf_name = key.rsplit('.').next().unwrap_or(key);
    crate::logging::redact::redact_value_at_key(value, leaf_name);
    crate::logging::redact::redact_json_value(value);
}

/// Run the `config set <key> <value>` subcommand.
pub fn handle_config_set(key: &str, raw_value: &str) -> Result<(), Box<dyn std::error::Error>> {
    // Parse value as JSON first; fall back to treating it as a plain string.
    let value: Value =
        serde_json::from_str(raw_value).unwrap_or_else(|_| Value::String(raw_value.to_string()));

    let config_path = config::get_config_path();
    let key = key.trim().to_string();
    let value_for_update = value.clone();
    crate::server::ws::update_config_file(&config_path, |cfg| {
        let before = cfg.clone();
        if !set_value_at_path(cfg, &key, value_for_update) {
            return Err(format!(
                "Cannot apply `cara config set {key}`: config base is not a writable object \
                 (file may be unparseable on disk)"
            ));
        }
        let protected = config::changed_protected_config_prefixes(&before, cfg);
        if protected.is_empty() {
            Ok(())
        } else {
            Err(format!(
                "Cannot modify protected configuration with `cara config set`: {}",
                protected.join(", ")
            ))
        }
    })
    .map_err(std::io::Error::other)?;

    let mut printed_value = value;
    redact_config_value_for_display(&mut printed_value, &key);
    println!("Set {} = {}", key, serde_json::to_string(&printed_value)?);
    Ok(())
}

/// Run the `config path` subcommand.
pub fn handle_config_path() {
    println!("{}", config::get_config_path().display());
}

async fn read_response_json_value(
    response: reqwest::Response,
) -> Result<Value, Box<dyn std::error::Error>> {
    let body_text = crate::net_util::read_response_body_text_capped(
        response,
        crate::net_util::MAX_RESPONSE_BODY_BYTES,
    )
    .await?;
    Ok(serde_json::from_str(&body_text)?)
}

async fn fetch_optional_status_json(client: &reqwest::Client, url: &str) -> Option<Value> {
    let response = client.get(url).send().await.ok()?;
    if !response.status().is_success() {
        return None;
    }
    read_response_json_value(response).await.ok()
}

fn status_json_payload(health: Value, control_status: Option<Value>) -> Value {
    let mut payload = serde_json::Map::new();
    payload.insert("health".to_string(), health);
    if let Some(control_status) = control_status {
        payload.insert("controlStatus".to_string(), control_status);
    }
    Value::Object(payload)
}

/// Run status and write successful stdout output to `out`.
///
/// Connection and non-2xx health failures intentionally preserve the public
/// CLI behavior: diagnostics go to stderr and the process exits non-zero.
async fn handle_status_with_writer<W: std::io::Write + ?Sized>(
    host: &str,
    port: Option<u16>,
    json: bool,
    out: &mut W,
) -> Result<(), Box<dyn std::error::Error>> {
    let port = resolve_port(port);
    let url = format!("http://{}:{}/health", host, port);
    let control_url = format!("http://{}:{}/control/status", host, port);

    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(5))
        .build()?;

    let response = match client.get(&url).send().await {
        Ok(r) => r,
        Err(e) => {
            eprintln!("Could not connect to carapace at {}:{}", host, port);
            // SECURITY: `reqwest::Error` Display embeds the request URL,
            // which can include `userinfo` if the operator passed e.g.
            // `--host=user:secret@example.com`. Strip via `without_url()`
            // so the credential never lands in stderr / captured logs.
            eprintln!("  Error: {}", e.without_url());
            eprintln!();
            eprintln!("Is the server running? Start it with: cara start");
            std::process::exit(1);
        }
    };

    if !response.status().is_success() {
        let status = response.status();
        let body = crate::net_util::read_response_body_text_capped(
            response,
            crate::net_util::MAX_RESPONSE_BODY_BYTES,
        )
        .await
        .unwrap_or_default();
        // SECURITY: `--host` may point at a hostile or proxied
        // endpoint; strip terminal-control / bidi / zero-width
        // chars from the unfamiliar response body before printing.
        eprintln!(
            "Health endpoint returned HTTP {}: {}",
            status,
            crate::logging::redact::strip_terminal_unsafe_chars(&body)
        );
        std::process::exit(1);
    }

    let body = read_response_json_value(response).await?;

    if json {
        let control_status = fetch_optional_status_json(&client, &control_url).await;
        write_pretty_json(&status_json_payload(body, control_status), out)?;
        return Ok(());
    }

    // Pretty-print the status summary.
    writeln!(out, "Carapace gateway status")?;
    writeln!(out, "=======================")?;
    if let Some(version) = body.get("version").and_then(|v| v.as_str()) {
        writeln!(out, "  Version:  {}", version)?;
    }
    if let Some(uptime) = body.get("uptimeSeconds").and_then(|v| v.as_i64()) {
        writeln!(out, "  Uptime:   {}", format_duration(uptime))?;
    }
    writeln!(out, "  Address:  {}:{}", host, port)?;
    if let Some(status) = body.get("status").and_then(|v| v.as_str()) {
        writeln!(out, "  Status:   {}", status)?;
    }

    // If the control endpoint is available, try to get richer info.
    let control_status = fetch_optional_status_json(&client, &control_url).await;
    if let Some(ctrl) = control_status.as_ref() {
        if let Some(ch) = ctrl.get("connectedChannels").and_then(|v| v.as_u64()) {
            let total = ctrl
                .get("totalChannels")
                .and_then(|v| v.as_u64())
                .unwrap_or(0);
            writeln!(out, "  Channels: {}/{} connected", ch, total)?;
        }
        if let Some(rt) = ctrl.get("runtime").and_then(|v| v.as_object()) {
            if let (Some(platform), Some(arch)) = (
                rt.get("platform").and_then(|v| v.as_str()),
                rt.get("arch").and_then(|v| v.as_str()),
            ) {
                writeln!(out, "  Platform: {} ({})", platform, arch)?;
            }
        }
    }

    Ok(())
}

/// Run the `status` subcommand -- connect to a running instance's health endpoint.
pub async fn handle_status(
    host: &str,
    port: Option<u16>,
    json: bool,
) -> Result<(), Box<dyn std::error::Error>> {
    let mut stdout = std::io::stdout();
    handle_status_with_writer(host, port, json, &mut stdout).await
}

/// Run the `task` subcommand family -- manage durable objective tasks.
pub async fn handle_task(command: TaskCommand) -> Result<(), Box<dyn std::error::Error>> {
    match command {
        TaskCommand::Create {
            payload,
            next_run_at_ms,
            max_attempts,
            max_total_runtime_ms,
            max_turns,
            max_run_timeout_seconds,
            connection,
        } => {
            handle_task_create(
                &connection.host,
                connection.port,
                TaskCreateOptions {
                    payload,
                    next_run_at_ms,
                    max_attempts,
                    max_total_runtime_ms,
                    max_turns,
                    max_run_timeout_seconds,
                },
            )
            .await
        }
        TaskCommand::List {
            state,
            limit,
            connection,
        } => handle_task_list(&connection.host, connection.port, state, limit).await,
        TaskCommand::Get { id, connection } => {
            handle_task_get(&connection.host, connection.port, &id).await
        }
        TaskCommand::Cancel {
            id,
            reason,
            connection,
        } => handle_task_cancel(&connection.host, connection.port, &id, reason).await,
        TaskCommand::Retry {
            id,
            delay_ms,
            reason,
            connection,
        } => handle_task_retry(&connection.host, connection.port, &id, delay_ms, reason).await,
        TaskCommand::Resume {
            id,
            delay_ms,
            reason,
            connection,
        } => handle_task_resume(&connection.host, connection.port, &id, delay_ms, reason).await,
        TaskCommand::Update {
            id,
            payload,
            max_attempts,
            max_total_runtime_ms,
            max_turns,
            max_run_timeout_seconds,
            reason,
            connection,
        } => {
            handle_task_update(
                &connection.host,
                connection.port,
                &id,
                TaskUpdateOptions {
                    payload,
                    max_attempts,
                    max_total_runtime_ms,
                    max_turns,
                    max_run_timeout_seconds,
                    reason,
                },
            )
            .await
        }
    }
}

/// Run the `matrix` subcommand family.
pub async fn handle_matrix(command: MatrixCommand) -> Result<(), Box<dyn std::error::Error>> {
    match command {
        MatrixCommand::Devices { connection } => {
            handle_matrix_devices(&connection.host, connection.port).await
        }
        MatrixCommand::Verifications { connection } => {
            handle_matrix_verifications(&connection.host, connection.port).await
        }
        MatrixCommand::Verify {
            user,
            device,
            raw_device_id_hex,
            connection,
        } => {
            handle_matrix_verify(
                &connection.host,
                connection.port,
                user,
                device,
                raw_device_id_hex,
            )
            .await
        }
        MatrixCommand::Accept { flow, connection } => {
            handle_matrix_flow_action(
                &connection.host,
                connection.port,
                &flow,
                MatrixFlowAction::Accept,
                None,
            )
            .await
        }
        MatrixCommand::Confirm(args) => {
            let matches = match args.sas_result()? {
                MatrixSasResult::Match => true,
                MatrixSasResult::NoMatch => false,
            };
            // SECURITY: display the SAS emoji+decimals and require an
            // explicit "yes" before sending the confirm RPC. Without
            // this gate, an attacker with SSH access (or via
            // social-engineering an operator into pasting a confirm
            // command) bypasses the human comparison step that's the
            // entire MITM-resistance of the SAS protocol.
            // `--unsafe-skip-sas-prompt` is the documented escape
            // hatch for automation that's already performed the
            // comparison out-of-band.
            if args.unsafe_skip_sas_prompt {
                // Audit trail. The bypass is an explicit operator
                // decision but it defeats the MITM-resistance of the
                // SAS protocol; we log the flow_id, host, port, and
                // PID so an after-the-fact security audit (someone
                // gets the operator to copy-paste a malicious command
                // with this flag) has a journal entry to follow.
                let sas_pid = std::process::id();
                tracing::warn!(
                    audit_event = "matrix_sas_unsafe_skip",
                    flow_id = %args.flow,
                    host = %args.connection.host,
                    port = args.connection.port,
                    pid = sas_pid,
                    matches = matches,
                    "matrix confirm: --unsafe-skip-sas-prompt bypassed the human \
                     SAS comparison step; this flow's MITM resistance now relies \
                     entirely on out-of-band verification."
                );
                // SECURITY: durable audit so an after-the-fact
                // security investigation (e.g. operator pasted a
                // confirm command from a phishing message that
                // included `--unsafe-skip-sas-prompt`) finds the
                // bypass in audit.jsonl, not just in a possibly-
                // rotated tracing log. AUDIT_LOG is not initialized
                // in CLI processes; audit_durable_for_state_dir
                // falls through to audit_blocking writing directly
                // to state_dir/audit.jsonl.
                let sas_state_dir = crate::server::ws::resolve_state_dir();
                if let Err(err) = crate::logging::audit::audit_durable_for_state_dir(
                    sas_state_dir,
                    crate::logging::audit::AuditEvent::MatrixSasUnsafeSkip {
                        flow_id: crate::logging::audit::truncate_audit_free_text_field(
                            &args.flow,
                            crate::logging::audit::AUDIT_FREE_TEXT_FIELD_MAX_BYTES,
                        ),
                        host: crate::logging::audit::truncate_audit_free_text_field(
                            &args.connection.host,
                            crate::logging::audit::AUDIT_FREE_TEXT_FIELD_MAX_BYTES,
                        ),
                        port: args.connection.port,
                        pid: sas_pid,
                        matches,
                    },
                ) {
                    tracing::warn!(
                        error = %err,
                        "failed to write matrix_sas_unsafe_skip audit event; tracing-warn is the only forensic signal"
                    );
                }
                // SECURITY: strip terminal-control chars from
                // `args.flow` before printing the WARNING. The
                // social-engineering scenario this whole block guards
                // against (operator pasting a malicious confirm
                // command, see SECURITY comment above) extends to
                // ANSI cursor-up + clear-line / bidi sequences
                // embedded in the flow ID itself — without the strip
                // the WARNING line is the attacker's repaint target
                // and the operator sees no indication the SAS check
                // was skipped.
                eprintln!(
                    "WARNING: --unsafe-skip-sas-prompt bypassed the SAS comparison \
                     for flow {} — make sure the SAS values were verified by a \
                     human through a separate channel.",
                    crate::logging::redact::strip_terminal_unsafe_chars(&args.flow)
                );
            } else {
                display_sas_and_prompt_confirm(
                    &args.connection.host,
                    args.connection.port,
                    &args.flow,
                    matches,
                )
                .await?;
            }
            handle_matrix_flow_action(
                &args.connection.host,
                args.connection.port,
                &args.flow,
                MatrixFlowAction::Confirm,
                Some(json!({ "match": matches })),
            )
            .await
        }
        MatrixCommand::Cancel { flow, connection } => {
            handle_matrix_flow_action(
                &connection.host,
                connection.port,
                &flow,
                MatrixFlowAction::Cancel,
                None,
            )
            .await
        }
        MatrixCommand::RecoveryKey(sub) => handle_matrix_recovery_key(sub).await,
        MatrixCommand::RekeyStore { new } => handle_matrix_rekey_store(new),
    }
}

async fn handle_matrix_devices(
    host: &str,
    port: Option<u16>,
) -> Result<(), Box<dyn std::error::Error>> {
    let response = send_control_request(
        host,
        resolve_port(port),
        reqwest::Method::GET,
        "/control/matrix/devices",
        &[],
        None,
    )
    .await?;
    print_pretty_json(&response)
}

async fn handle_matrix_verifications(
    host: &str,
    port: Option<u16>,
) -> Result<(), Box<dyn std::error::Error>> {
    let response = send_control_request(
        host,
        resolve_port(port),
        reqwest::Method::GET,
        "/control/matrix/verifications",
        &[],
        None,
    )
    .await?;
    print_pretty_json(&response)
}

async fn handle_matrix_verify(
    host: &str,
    port: Option<u16>,
    user: String,
    device: Option<String>,
    raw_device_id_hex: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
    let user_id = user.trim();
    if user_id.is_empty() {
        return Err("Matrix user ID cannot be empty".into());
    }
    if device.is_some() && raw_device_id_hex.is_some() {
        return Err("Pass either a positional device ID or --device-id-hex, not both".into());
    }
    let mut body = serde_json::Map::new();
    body.insert("userId".to_string(), Value::from(user_id));
    if let Some(raw_device_id_hex) = raw_device_id_hex {
        let raw_device_id_hex = raw_device_id_hex.trim();
        if raw_device_id_hex.is_empty() {
            return Err("--device-id-hex cannot be empty".into());
        }
        body.insert("rawDeviceIdHex".to_string(), Value::from(raw_device_id_hex));
    } else if let Some(device) = device {
        let device = device.trim();
        if !device.is_empty() {
            body.insert("deviceId".to_string(), Value::from(device));
        }
    }
    let response = send_control_request(
        host,
        resolve_port(port),
        reqwest::Method::POST,
        "/control/matrix/verifications",
        &[],
        Some(Value::Object(body)),
    )
    .await?;
    print_pretty_json(&response)
}

/// Verification flow actions exposed via `/control/matrix/verifications/{id}/{action}`.
/// Enum-typed at the CLI layer so a typo at any call site is a compile
/// error rather than a silent 404 from the daemon.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MatrixFlowAction {
    Accept,
    Confirm,
    Cancel,
}

impl MatrixFlowAction {
    fn as_path_segment(self) -> &'static str {
        match self {
            Self::Accept => "accept",
            Self::Confirm => "confirm",
            Self::Cancel => "cancel",
        }
    }
}

/// Fetch the SAS data for a verification flow, display it to the
/// operator, and prompt for explicit confirmation before allowing
/// `cara matrix confirm` to submit. Without this, an attacker with
/// SSH/shell access could run `cara matrix confirm <flow> --match`
/// without ever having seen the SAS emoji or decimals — bypassing
/// the human comparison step that's the entire MITM-resistance of
/// the SAS protocol. The `--unsafe-skip-sas-prompt` flag is the
/// documented escape hatch for automation.
async fn display_sas_and_prompt_confirm(
    host: &str,
    port: Option<u16>,
    flow_id: &str,
    matches: bool,
) -> Result<(), Box<dyn std::error::Error>> {
    let response = send_control_request(
        host,
        resolve_port(port),
        reqwest::Method::GET,
        "/control/matrix/verifications",
        &[],
        None,
    )
    .await?;
    let verifications = response
        .get("verifications")
        .and_then(|v| v.as_array())
        .ok_or("control API did not return a `verifications` array")?;
    let flow = verifications
        .iter()
        .find(|entry| entry.get("flowId").and_then(|v| v.as_str()) == Some(flow_id))
        .ok_or_else(|| {
            format!(
                "verification flow {flow_id:?} not found; \
                 run `cara matrix verifications` to list active flows"
            )
        })?;
    let state = flow
        .get("state")
        .and_then(|v| v.as_str())
        .unwrap_or("unknown");
    let user_id = flow
        .get("userId")
        .and_then(|v| v.as_str())
        .unwrap_or("<unknown>");
    let device_id = flow
        .get("deviceId")
        .and_then(|v| v.as_str())
        .unwrap_or("<unknown>");

    // SECURITY: strip terminal-control chars from peer-supplied
    // fields. SAS verification's entire purpose is MITM-resistance:
    // a hostile / MITM-attacked homeserver can embed ANSI cursor-
    // up + clear-line sequences (or bidi overrides) in `flow_id`,
    // `user_id`, `device_id`, `state`, or the emoji `symbol` /
    // `description` to paint a fake "matches=true" prompt while the
    // operator sees forged emoji — directly defeating the security
    // property this prompt exists to enforce.
    let strip = crate::logging::redact::strip_terminal_unsafe_chars;
    println!();
    println!("=== Matrix SAS verification confirmation ===");
    println!("  Flow:    {}", strip(flow_id));
    println!(
        "  Peer:    {} (device {})",
        strip(user_id),
        strip(device_id)
    );
    println!("  State:   {}", strip(state));

    let sas = flow.get("sas");
    let emoji = sas
        .and_then(|v| v.get("emoji"))
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_default();
    let decimals = sas
        .and_then(|v| v.get("decimals"))
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_default();
    if emoji.is_empty() && decimals.is_empty() {
        return Err(format!(
            "verification flow {flow_id:?} has no SAS data yet \
             (state={state:?}); poll `cara matrix verifications` \
             until the `sas` field appears, then retry"
        )
        .into());
    }
    if !emoji.is_empty() {
        println!("  Emoji:");
        for entry in &emoji {
            let symbol = entry.get("symbol").and_then(|v| v.as_str()).unwrap_or("?");
            let description = entry
                .get("description")
                .and_then(|v| v.as_str())
                .unwrap_or("");
            // Same homeserver-controlled-field strip as the header
            // above. Emoji `symbol` is the SAS visual-match field —
            // hostile bytes here are the highest-impact place to
            // forge a match.
            println!("    {}  {}", strip(symbol), strip(description));
        }
    }
    if !decimals.is_empty() {
        let formatted: Vec<String> = decimals
            .iter()
            .filter_map(|v| v.as_i64().map(|n| n.to_string()))
            .collect();
        println!("  Decimals: {}", formatted.join(" "));
    }
    let intent = if matches {
        "CONFIRM A MATCH"
    } else {
        "REJECT (no match)"
    };
    println!();
    // SECURITY: same threat model as the header strip at the top of
    // this function — peer-supplied `flow_id` in the prompt body must
    // not carry ANSI cursor-up + clear-line / bidi sequences that
    // could repaint the (already-stripped) emoji line the operator
    // just visually compared. This re-uses the `strip` closure
    // captured above. Without this, the asymmetric strip leaves an
    // attacker-controlled repaint vector at the most operator-
    // sensitive line of the confirm flow.
    println!(
        "Compare the values above with the OTHER device's display.\n\
         You are about to {intent} for flow {}.\n\
         A 'no match' confirms the values DO NOT match (potential MITM).",
        strip(flow_id)
    );
    use std::io::IsTerminal;
    if !std::io::stdin().is_terminal() {
        return Err(
            "Matrix SAS confirmation requires an interactive terminal; piped stdin is refused"
                .into(),
        );
    }
    // SECURITY (round-14 CLI footguns HIGH): the emoji + decimals
    // displayed above go through `println!`, which on a piped stdout
    // (e.g. `cara matrix confirm ... 2>&1 | tee /tmp/sas.log`)
    // block-buffers. The operator would see only the prompt on
    // their TTY without the SAS values, defeating the MITM-resistance
    // the whole comparison is supposed to provide. Refuse when stdout
    // is not a TTY so the operator can't accidentally confirm blind.
    if !std::io::stdout().is_terminal() {
        return Err(
            "Matrix SAS confirmation requires an interactive terminal; piped stdout would \
             hide the SAS values before the prompt — refusing to confirm blind"
                .into(),
        );
    }
    print!("Type `yes` to proceed, anything else to abort: ");
    use std::io::Write;
    std::io::stdout()
        .flush()
        .map_err(|err| format!("flush prompt: {err}"))?;
    let mut input = String::new();
    std::io::stdin()
        .read_line(&mut input)
        .map_err(|err| format!("read confirmation: {err}"))?;
    if input.trim() != "yes" {
        return Err(
            "Matrix SAS confirmation aborted by operator (input did not match `yes`)".into(),
        );
    }
    Ok(())
}

async fn handle_matrix_flow_action(
    host: &str,
    port: Option<u16>,
    flow: &str,
    action: MatrixFlowAction,
    body: Option<Value>,
) -> Result<(), Box<dyn std::error::Error>> {
    let flow_id = flow.trim();
    if flow_id.is_empty() {
        return Err("Matrix verification flow ID cannot be empty".into());
    }
    let path = format!(
        "/control/matrix/verifications/{}/{}",
        urlencoding::encode(flow_id),
        action.as_path_segment()
    );
    let response = send_control_request(
        host,
        resolve_port(port),
        reqwest::Method::POST,
        &path,
        &[],
        body,
    )
    .await?;
    print_pretty_json(&response)
}

async fn handle_matrix_recovery_key(
    command: MatrixRecoveryKeyCommand,
) -> Result<(), Box<dyn std::error::Error>> {
    let state_dir = crate::server::ws::resolve_state_dir();
    let path = matrix_recovery_key_path_for_state_dir(&state_dir);
    // Refuse Show / Restore when matrix.encrypted=false. Pre-fix Show
    // happily printed a dormant key file (irrelevant under
    // encrypted=false; the daemon never consults it via
    // `maybe_restore_recovery_key`'s early-return guard at
    // matrix.rs:4310-4312), and Restore wrote a dormant file with the
    // misleading "Restart … for the restored key to take effect"
    // message — restart would NOT activate it. Symmetric with the
    // existing guards on `Rotate` (line ~1545) and `rekey-store --new`
    // (line 2024). Loading config here is cheap (a few KB JSON) and
    // matches what Restore already does for the daemon-pid guard.
    if matches!(
        command,
        MatrixRecoveryKeyCommand::Show { .. } | MatrixRecoveryKeyCommand::Restore { .. }
    ) {
        let cfg = config::load_config()?;
        match crate::channels::matrix::resolve_matrix_config(&cfg)? {
            crate::channels::matrix::MatrixConfigResolve::Configured(config) => {
                if !matches!(
                    config.security,
                    crate::channels::matrix::MatrixSecurity::Encrypted { .. }
                ) {
                    return Err(format!(
                        "cara matrix recovery-key {} requires matrix.encrypted=true; \
                         the key file is unused while encrypted=false. Flip \
                         matrix.encrypted=true (and configure a passphrase source) before \
                         using this command, or wipe {}/matrix/ if you intend to migrate \
                         encryption state.",
                        match command {
                            MatrixRecoveryKeyCommand::Show { .. } => "show",
                            MatrixRecoveryKeyCommand::Restore { .. } => "restore",
                            _ => unreachable!(),
                        },
                        state_dir.display()
                    )
                    .into());
                }
            }
            crate::channels::matrix::MatrixConfigResolve::Disabled => {
                let verb = match command {
                    MatrixRecoveryKeyCommand::Show { .. } => "show",
                    MatrixRecoveryKeyCommand::Restore { .. } => "restore",
                    _ => unreachable!(),
                };
                return Err(
                    format!("matrix recovery-key {verb} requires matrix.enabled=true").into(),
                );
            }
            crate::channels::matrix::MatrixConfigResolve::Missing => {
                let verb = match command {
                    MatrixRecoveryKeyCommand::Show { .. } => "show",
                    MatrixRecoveryKeyCommand::Restore { .. } => "restore",
                    _ => unreachable!(),
                };
                return Err(
                    format!("matrix recovery-key {verb} requires Matrix configuration").into(),
                );
            }
        }
    }
    match command {
        MatrixRecoveryKeyCommand::Show { allow_non_terminal } => {
            use std::io::IsTerminal;
            if !allow_non_terminal && !std::io::stdout().is_terminal() {
                return Err(
                    "Matrix recovery-key show refuses non-terminal stdout; rerun with \
                     --allow-non-terminal only when intentional capture is required"
                        .into(),
                );
            }
            // Wrap in Zeroizing so the key bytes are wiped from the
            // heap when this scope exits — symmetric with the
            // daemon-side `maybe_restore_recovery_key` discipline.
            // Use `File::take(cap + 1)` instead of `read_to_string`
            // to cap the read at MATRIX_RECOVERY_KEY_FILE_MAX_BYTES
            // and avoid the growing-Vec reallocation leak that leaves
            // multiple intermediate plaintext copies on the heap
            // outside the Zeroizing wipe path — symmetric with the
            // `read_matrix_recovery_key_input` Restore-side reader.
            use std::io::Read;
            let cap = crate::channels::matrix::MATRIX_RECOVERY_KEY_FILE_MAX_BYTES;
            // O_NONBLOCK via the shared helper so a planted FIFO at
            // the recovery-key path doesn't hang `cara matrix
            // recovery-key show`. Symlinks intentionally followed
            // (operator-routed secret-management tooling per the
            // documented design); the post-open is_file() check
            // refuses FIFO/socket.
            let file = match crate::paths::open_regular_file_no_hang(&path) {
                Ok(Some(file)) => file,
                Ok(None) => {
                    return Err(
                        format!("Matrix recovery key unavailable at {}", path.display()).into(),
                    );
                }
                Err(e) => {
                    return Err(format!(
                        "Matrix recovery key unavailable at {}: {e}",
                        path.display()
                    )
                    .into());
                }
            };
            let mut key = zeroize::Zeroizing::new(String::with_capacity(128));
            file.take(cap + 1).read_to_string(&mut key).map_err(|e| {
                format!("failed to read Matrix recovery key {}: {e}", path.display())
            })?;
            if key.len() as u64 > cap {
                return Err(format!(
                    "Matrix recovery key file {} exceeds {} bytes; refusing to read",
                    path.display(),
                    cap
                )
                .into());
            }
            // Write directly through the locked stdout handle and
            // flush before the Zeroizing wrapper goes out of scope.
            // `println!` goes through a LineWriter that retains
            // plaintext in stdio buffers past the Zeroizing drop;
            // the heap String is zeroed but the libc/tokio buffer
            // still holds the bytes until the next flush. Explicit
            // lock+write+flush+drop closes that window.
            use std::io::Write as _;
            let trimmed = key.trim();
            {
                let mut stdout = std::io::stdout().lock();
                stdout
                    .write_all(trimmed.as_bytes())
                    .and_then(|()| stdout.write_all(b"\n"))
                    .and_then(|()| stdout.flush())
                    .map_err(|e| format!("failed to write Matrix recovery key: {e}"))?;
            }
            Ok(())
        }
        MatrixRecoveryKeyCommand::Restore { key_file, stdin } => {
            let _running_daemon_guard = ensure_no_running_daemon_for_matrix_secret_mutation(
                &state_dir,
                "cara matrix recovery-key restore",
            )
            .map_err(|err| format!("Matrix recovery-key restore refused: {err}"))?;
            // Resume path: if a prior restore wrote the key but
            // crashed before completing cleanup, the daemon refuses
            // to boot until the cleanup journal is resolved (see
            // `inspect_matrix_recovery_cleanup_journal`). Re-running
            // this command must resume that cleanup, not refuse with
            // "key already exists" — the latter would steer a
            // panicked operator into deleting their only recovery
            // copy.
            let key_present = cli_path_exists_strict(&path, "Matrix recovery key")?;
            if key_present {
                if let Some(existing_journal) = load_matrix_recovery_cleanup_journal(&state_dir)? {
                    let artifact_labels: Vec<_> = existing_journal
                        .artifacts
                        .iter()
                        .map(|artifact| matrix_recovery_cleanup_artifact_audit_label(artifact.role))
                        .collect();
                    if let Err(audit_err) = crate::logging::audit::audit_blocking(
                        state_dir.to_path_buf(),
                        crate::logging::audit::AuditEvent::MatrixRecoveryKeyRestoreCleanupResumed {
                            artifacts: artifact_labels,
                        },
                    ) {
                        tracing::warn!(
                            error = %audit_err,
                            "matrix recovery key restore-cleanup resume audit emission failed"
                        );
                    }
                    tracing::warn!(
                        audit_event = "matrix_recovery_key_restore_cleanup_resumed",
                        state_dir = %state_dir.display(),
                        path = %path.display(),
                        pid = std::process::id(),
                        "matrix recovery key already on disk with an outstanding cleanup journal; \
                         resuming prior restore cleanup"
                    );
                    cleanup_matrix_recovery_pending_key_after_restore(&state_dir)?;
                    println!(
                        "Matrix recovery key already restored at {}; \
                         resumed and completed pending cleanup from prior restore.",
                        path.display()
                    );
                    println!(
                        "Restart any running carapace daemon so the Matrix runtime re-opens the \
                         SDK store with the restored recovery secret."
                    );
                    return Ok(());
                }
            }
            let key = read_matrix_recovery_key_input(key_file.as_deref(), stdin)?;
            let trimmed = key.trim();
            if trimmed.is_empty() {
                return Err("Matrix recovery key cannot be empty".into());
            }
            validate_matrix_recovery_key_format(trimmed)?;
            // Pre-check existence so the operator gets a recovery
            // hint instead of a bare EEXIST. Without this, a second
            // run of `cara matrix recovery-key restore` (e.g. after a
            // typo on the first attempt) emits "File exists (os error
            // 17)" with no path context. A panicked operator might rm
            // the existing file — destroying their only recovery
            // copy.
            if key_present {
                return Err(format!(
                    "Matrix recovery key already exists at {}; refuse to overwrite. \
                     To replace it: stop the daemon, remove the file, then re-run.",
                    path.display()
                )
                .into());
            }
            // Anchor the cleanup journal in Started phase BEFORE writing the
            // key file. The journal records the intent so that a crash
            // between key write and cleanup completion is recoverable: the
            // daemon's recovery startup probe refuses to boot on an
            // unresolved journal (matrix.rs:inspect_matrix_recovery_cleanup_journal),
            // and a re-run of `cara matrix recovery-key restore` will resume
            // from the existing journal. Writing the journal AFTER the key
            // (the original order) created a window in which the key was on
            // disk but the journal was not, leaving stale rotating/pending
            // markers that the daemon would treat as an interrupted
            // rotation, refusing startup after a successful restore.
            anchor_matrix_recovery_cleanup_journal_for_restore(&state_dir)?;
            write_owner_only_cli_secret_no_replace(&path, trimmed)?;
            cleanup_matrix_recovery_pending_key_after_restore(&state_dir)?;
            let restore_pid = std::process::id();
            tracing::warn!(
                audit_event = "matrix_recovery_key_restore",
                path = %path.display(),
                pid = restore_pid,
                "matrix recovery key restored locally; daemon restart required"
            );
            // SECURITY: companion durable audit so an incident-
            // response query against `audit.jsonl` shows the
            // restore happened. The tracing-warn rotates; the
            // recovery-key + cleanup journal writes above are both
            // irreversible state changes. AUDIT_LOG is not
            // initialized in CLI processes, so
            // audit_durable_for_state_dir falls through to
            // audit_blocking which writes directly to
            // state_dir/audit.jsonl with 0o600 enforced.
            if let Err(err) = crate::logging::audit::audit_durable_for_state_dir(
                state_dir.clone(),
                crate::logging::audit::AuditEvent::MatrixRecoveryKeyRestored { pid: restore_pid },
            ) {
                tracing::warn!(
                    error = %err,
                    "failed to write matrix_recovery_key_restore audit event; tracing-warn is the only forensic signal"
                );
            }
            println!("Matrix recovery key restored at {}", path.display());
            // The running daemon (if any) has already opened the SDK
            // store and won't pick up the restored key without a
            // restart. Make this explicit so an operator who ran the
            // command against a live process knows their restore is
            // staged-only.
            println!(
                "Restart any running carapace daemon for the restored key to take effect: \
                 stop the daemon, then start it again so the Matrix runtime re-opens the \
                 SDK store with the new recovery secret."
            );
            Ok(())
        }
        MatrixRecoveryKeyCommand::Rotate { yes } => {
            // SECURITY (round-14 CLI footguns HIGH): rotating the
            // Matrix recovery key abandons the previous key. Any
            // server-side encrypted backup it secured is then
            // permanently unrecoverable without that prior key.
            // Require an interactive confirmation OR an explicit
            // `--yes` for automation.
            if !yes {
                use std::io::IsTerminal;
                let stdin = std::io::stdin();
                let stdout = std::io::stdout();
                if !stdin.is_terminal() || !stdout.is_terminal() {
                    return Err(
                        "matrix recovery-key rotate refused: not a TTY. Re-run with --yes \
                         to confirm rotation in non-interactive contexts. Rotation abandons \
                         the prior recovery key and any backup it secured."
                            .into(),
                    );
                }
                println!("WARNING: rotating the Matrix recovery key abandons the previous key.");
                println!(
                    "         Server-side encrypted backups it secured will not be recoverable."
                );
                print!("Type 'rotate' to confirm: ");
                use std::io::Write;
                std::io::stdout().flush().ok();
                let mut line = String::new();
                std::io::stdin()
                    .read_line(&mut line)
                    .map_err(|err| format!("failed to read confirmation from stdin: {err}"))?;
                if line.trim() != "rotate" {
                    return Err("matrix recovery-key rotate aborted by operator".into());
                }
            }
            let cfg = config::load_config()?;
            let matrix_config = match crate::channels::matrix::resolve_matrix_config(&cfg)? {
                crate::channels::matrix::MatrixConfigResolve::Configured(config) => config,
                crate::channels::matrix::MatrixConfigResolve::Disabled => {
                    return Err("matrix recovery-key rotate requires matrix.enabled=true".into())
                }
                crate::channels::matrix::MatrixConfigResolve::Missing => {
                    return Err("matrix recovery-key rotate requires Matrix configuration".into())
                }
            };
            let _running_daemon_guard = ensure_no_running_daemon_for_matrix_secret_mutation(
                &state_dir,
                "cara matrix recovery-key rotate",
            )
            .map_err(|err| format!("Matrix recovery-key rotate refused: {err}"))?;
            let outcome: crate::channels::matrix::MatrixRecoveryKeyRotateOutcome =
                crate::channels::matrix::rotate_matrix_recovery_key_for_cli(
                    &matrix_config,
                    &state_dir,
                )
                .await?;
            println!("Matrix recovery key rotated at {}", outcome.path.display());
            println!(
                "The previous Matrix recovery key is abandoned. Capture the new key from the owner-only local file before relying on encrypted Matrix backup."
            );
            Ok(())
        }
    }
}

fn read_matrix_recovery_key_input(
    key_file: Option<&Path>,
    stdin_requested: bool,
) -> Result<zeroize::Zeroizing<String>, Box<dyn std::error::Error>> {
    // Cap CLI input the same way the daemon-side reader caps its on-disk
    // file (`read_recovery_key_file_to_string_bounded` enforces
    // MATRIX_RECOVERY_KEY_FILE_MAX_BYTES = 4 KiB). A valid recovery key
    // is ~50-90 base58 chars; 4 KiB is generous for unusual encodings
    // and trailing whitespace. Without this cap an operator who
    // accidentally pipes `/dev/zero`, a tail log, or any other large
    // stream into `cara matrix recovery-key restore --stdin` (or
    // points `--key-file` at a large file) buffers gigabytes into the
    // CLI process before the format check rejects, AND leaves
    // multiple reallocated heap copies (read_to_string's growing
    // Vec<u8>) outside the Zeroizing wrapper's wipe path.
    let cap = crate::channels::matrix::MATRIX_RECOVERY_KEY_FILE_MAX_BYTES;
    use std::io::Read;
    if let Some(path) = key_file {
        let file = std::fs::File::open(path).map_err(|err| {
            format!(
                "failed to open Matrix recovery key file {}: {err}",
                path.display()
            )
        })?;
        let mut input = zeroize::Zeroizing::new(String::with_capacity(128));
        file.take(cap + 1)
            .read_to_string(&mut input)
            .map_err(|err| {
                format!(
                    "failed to read Matrix recovery key file {}: {err}",
                    path.display()
                )
            })?;
        if input.len() as u64 > cap {
            return Err(format!(
                "Matrix recovery key file {} exceeds {cap} bytes; refuse to read \
                 (a valid recovery key is well under this limit; check for a wrong path or stray content)",
                path.display(),
            )
            .into());
        }
        return Ok(input);
    }
    use std::io::IsTerminal;
    if stdin_requested || !std::io::stdin().is_terminal() {
        let mut input = zeroize::Zeroizing::new(String::with_capacity(128));
        std::io::stdin()
            .lock()
            .take(cap + 1)
            .read_to_string(&mut input)
            .map_err(|err| format!("failed to read Matrix recovery key from stdin: {err}"))?;
        if input.len() as u64 > cap {
            return Err(format!(
                "Matrix recovery key on stdin exceeds {cap} bytes; refuse to read \
                 (a valid recovery key is well under this limit; check the upstream producer)"
            )
            .into());
        }
        return Ok(input);
    }
    let key = rpassword::prompt_password("Matrix recovery key: ")?;
    if key.len() as u64 > cap {
        return Err(format!(
            "Matrix recovery key from prompt exceeds {cap} bytes; refuse to accept"
        )
        .into());
    }
    Ok(zeroize::Zeroizing::new(key))
}

fn validate_matrix_recovery_key_format(key: &str) -> Result<(), Box<dyn std::error::Error>> {
    let groups: Vec<&str> = key.split_ascii_whitespace().collect();
    let valid_group_count = groups.len() == 12;
    let valid_group_lengths = groups
        .iter()
        .enumerate()
        .all(|(index, group)| group.len() == 4 || (index == 11 && group.len() == 3));
    let valid_base58 = groups
        .iter()
        .flat_map(|group| group.bytes())
        .all(|b| matches!(b, b'1'..=b'9' | b'A'..=b'H' | b'J'..=b'N' | b'P'..=b'Z' | b'a'..=b'k' | b'm'..=b'z'));
    if valid_group_count && valid_group_lengths && valid_base58 {
        return Ok(());
    }
    Err(
        "Matrix recovery key must be 12 base58 groups of four characters \
         (the final group may be three characters)"
            .into(),
    )
}

fn cli_path_exists_strict(path: &Path, label: &str) -> Result<bool, Box<dyn std::error::Error>> {
    match std::fs::symlink_metadata(path) {
        Ok(_) => Ok(true),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
        Err(err) => Err(format!("failed to inspect {label} at {}: {err}", path.display()).into()),
    }
}

struct MatrixRecoveryRestoreCleanupFailure {
    message: String,
    audit_artifact: crate::logging::audit::MatrixRecoveryKeyRestoreCleanupArtifact,
}

fn matrix_recovery_cleanup_artifacts(
) -> Vec<crate::channels::matrix::MatrixRecoveryCleanupJournalArtifact> {
    use crate::channels::matrix::{
        MatrixRecoveryCleanupArtifactResult, MatrixRecoveryCleanupArtifactResultState,
        MatrixRecoveryCleanupArtifactRole, MatrixRecoveryCleanupJournalArtifact,
    };
    [
        (
            MatrixRecoveryCleanupArtifactRole::RotationMarker,
            "matrix/recovery_key.rotating",
        ),
        (
            MatrixRecoveryCleanupArtifactRole::MintingMarker,
            "matrix/recovery_key.minting",
        ),
        (
            MatrixRecoveryCleanupArtifactRole::PendingKey,
            "matrix/recovery_key.pending",
        ),
    ]
    .into_iter()
    .map(|(role, path)| MatrixRecoveryCleanupJournalArtifact {
        role,
        path: path.to_string(),
        expected_provenance: "stale_after_operator_restore".to_string(),
        result: MatrixRecoveryCleanupArtifactResult {
            state: MatrixRecoveryCleanupArtifactResultState::Pending,
            error_kind: None,
        },
    })
    .collect()
}

fn matrix_recovery_cleanup_artifact_path(
    state_dir: &Path,
    role: crate::channels::matrix::MatrixRecoveryCleanupArtifactRole,
) -> PathBuf {
    match role {
        crate::channels::matrix::MatrixRecoveryCleanupArtifactRole::RotationMarker => {
            matrix_recovery_rotating_marker_path_for_state_dir(state_dir)
        }
        crate::channels::matrix::MatrixRecoveryCleanupArtifactRole::MintingMarker => {
            matrix_recovery_minting_marker_path_for_state_dir(state_dir)
        }
        crate::channels::matrix::MatrixRecoveryCleanupArtifactRole::PendingKey => {
            matrix_recovery_pending_key_path_for_state_dir(state_dir)
        }
    }
}

fn matrix_recovery_cleanup_artifact_audit_label(
    role: crate::channels::matrix::MatrixRecoveryCleanupArtifactRole,
) -> crate::logging::audit::MatrixRecoveryKeyArtifactLabel {
    match role {
        crate::channels::matrix::MatrixRecoveryCleanupArtifactRole::RotationMarker => {
            crate::logging::audit::MatrixRecoveryKeyArtifactLabel::RotationMarker
        }
        crate::channels::matrix::MatrixRecoveryCleanupArtifactRole::MintingMarker => {
            crate::logging::audit::MatrixRecoveryKeyArtifactLabel::MintingMarker
        }
        crate::channels::matrix::MatrixRecoveryCleanupArtifactRole::PendingKey => {
            crate::logging::audit::MatrixRecoveryKeyArtifactLabel::PendingKey
        }
    }
}

fn matrix_recovery_cleanup_error_kind_name(
    kind: &crate::logging::audit::MatrixRecoveryKeyRestoreCleanupErrorKind,
) -> &'static str {
    match kind {
        crate::logging::audit::MatrixRecoveryKeyRestoreCleanupErrorKind::RemoveFailed => {
            "remove_failed"
        }
        crate::logging::audit::MatrixRecoveryKeyRestoreCleanupErrorKind::ParentSyncFailed => {
            "parent_sync_failed"
        }
    }
}

fn matrix_recovery_cleanup_audit_label_name(
    label: &crate::logging::audit::MatrixRecoveryKeyArtifactLabel,
) -> &'static str {
    match label {
        crate::logging::audit::MatrixRecoveryKeyArtifactLabel::RotationMarker => "rotation marker",
        crate::logging::audit::MatrixRecoveryKeyArtifactLabel::MintingMarker => "minting marker",
        crate::logging::audit::MatrixRecoveryKeyArtifactLabel::CurrentKey => "current key",
        crate::logging::audit::MatrixRecoveryKeyArtifactLabel::PendingKey => "pending file",
    }
}

fn write_matrix_recovery_cleanup_journal_durable(
    state_dir: &Path,
    journal: &crate::channels::matrix::MatrixRecoveryCleanupJournal,
) -> Result<(), Box<dyn std::error::Error>> {
    use std::io::Write;

    let path = crate::channels::matrix::matrix_recovery_cleanup_journal_path(state_dir);
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let tmp_path = crate::paths::atomic_tmp_path(&path, "matrix-recovery-cleanup");
    {
        // Route through the canonical helper for O_NOFOLLOW + O_EXCL +
        // 0o600. The prior inline form omitted both O_NOFOLLOW AND the
        // explicit 0o600 mode, so the journal would land under the
        // umask default (often 0o644 → world-readable).
        let mut file = crate::paths::create_atomic_tmp_owner_only(&tmp_path)?;
        let content = serde_json::to_vec_pretty(journal)?;
        if let Err(err) = (|| -> std::io::Result<()> {
            file.write_all(&content)?;
            file.write_all(b"\n")?;
            file.sync_all()
        })() {
            let _ = std::fs::remove_file(&tmp_path);
            return Err(err.into());
        }
    }
    if let Err(err) = std::fs::rename(&tmp_path, &path) {
        let _ = std::fs::remove_file(&tmp_path);
        return Err(err.into());
    }
    crate::paths::sync_parent_dir_blocking(&path)?;
    Ok(())
}

fn load_matrix_recovery_cleanup_journal(
    state_dir: &Path,
) -> Result<Option<crate::channels::matrix::MatrixRecoveryCleanupJournal>, Box<dyn std::error::Error>>
{
    // Cap at 16 KiB matching the daemon-side
    // `MATRIX_RECOVERY_CLEANUP_JOURNAL_MAX_BYTES`. The CLI runs in
    // the daemon-down window after a recovery-key restore — a same-
    // uid attacker who pre-plants a multi-GB file at this path could
    // OOM the operator's `cara matrix recovery-key restore` mid-flow.
    const CAP: u64 = 16 * 1024;
    let path = crate::channels::matrix::matrix_recovery_cleanup_journal_path(state_dir);
    use std::io::Read;
    // O_NOFOLLOW + O_NONBLOCK: the daemon-side reader for this same
    // artifact class (`read_capped_marker_or_journal` in matrix.rs)
    // uses O_NOFOLLOW via `open_owner_only_secret_file_for_read`. CLI
    // parity. The post-open `is_file()` check below catches FIFO
    // dirent type but only AFTER open(2) returns; without O_NONBLOCK
    // a FIFO planted at the journal path in the daemon-down window
    // hangs open(2) until the attacker writes EOF.
    let mut open_opts = std::fs::OpenOptions::new();
    open_opts.read(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        open_opts.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
    }
    let file = match open_opts.open(&path) {
        Ok(file) => file,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(err) => return Err(err.into()),
    };
    let metadata = file.metadata()?;
    // file-type check after the held-fd metadata read: refuses FIFO /
    // socket / block / char devices. A same-uid attacker who plants a
    // FIFO at the journal path in the daemon-down window would
    // otherwise hang the CLI's `read_to_end` until the attacker writes
    // EOF — denial of service. Daemon-side
    // `open_owner_only_secret_file_for_read` enforces the same check.
    if !metadata.is_file() {
        return Err(format!(
            "Matrix recovery-key cleanup journal at {} is not a regular file; refusing to read",
            path.display()
        )
        .into());
    }
    if metadata.len() > CAP {
        return Err(format!(
            "Matrix recovery-key cleanup journal at {} exceeds {} bytes; refusing to read",
            path.display(),
            CAP
        )
        .into());
    }
    let mut content = Vec::new();
    file.take(CAP + 1).read_to_end(&mut content)?;
    if content.len() as u64 > CAP {
        return Err(format!(
            "Matrix recovery-key cleanup journal at {} exceeds {} bytes (post-read)",
            path.display(),
            CAP
        )
        .into());
    }
    let journal = serde_json::from_slice::<crate::channels::matrix::MatrixRecoveryCleanupJournal>(
        content.trim_ascii(),
    )
    .map_err(|err| {
        format!(
            "Matrix recovery-key cleanup journal at {} is corrupt: {err}",
            path.display()
        )
    })?;
    if journal.version != crate::channels::matrix::MATRIX_RECOVERY_CLEANUP_JOURNAL_VERSION {
        return Err(format!(
            "Matrix recovery-key cleanup journal at {} has unsupported version {}; expected {}. \
             This typically indicates a downgrade after a newer binary wrote the journal. \
             Recovery: either run the newer binary once to let cleanup complete (preferred), \
             or manually inspect matrix/recovery_key.{{pending,minting,rotating}} artifacts and \
             remove them along with this journal file before retrying.",
            path.display(),
            journal.version,
            crate::channels::matrix::MATRIX_RECOVERY_CLEANUP_JOURNAL_VERSION
        )
        .into());
    }
    Ok(Some(journal))
}

fn remove_matrix_recovery_cleanup_journal(
    state_dir: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
    let path = crate::channels::matrix::matrix_recovery_cleanup_journal_path(state_dir);
    match std::fs::remove_file(&path) {
        Ok(()) => crate::paths::sync_parent_dir_blocking(&path)?,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
        Err(err) => return Err(err.into()),
    }
    Ok(())
}

/// Write the recovery-cleanup journal in `Started` phase before the
/// operator-supplied recovery key is persisted. The journal anchors the
/// restore intent so that a crash between key write and artifact cleanup
/// is recoverable: the daemon's startup probe refuses to boot on an
/// unresolved journal, and a re-run of `cara matrix recovery-key restore`
/// resumes the existing journal via the cleanup function below.
///
/// Idempotent: if a journal already exists (from a prior crashed restore)
/// it is left as-is; the cleanup function will pick it up and either
/// resume removal or finish a `Completed` journal.
fn anchor_matrix_recovery_cleanup_journal_for_restore(
    state_dir: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
    use crate::channels::matrix::{
        MatrixRecoveryCleanupJournal, MatrixRecoveryCleanupJournalPhase,
    };
    if load_matrix_recovery_cleanup_journal(state_dir)?.is_some() {
        return Ok(());
    }
    let journal = MatrixRecoveryCleanupJournal {
        version: crate::channels::matrix::MATRIX_RECOVERY_CLEANUP_JOURNAL_VERSION,
        phase: MatrixRecoveryCleanupJournalPhase::Started,
        artifacts: matrix_recovery_cleanup_artifacts(),
    };
    write_matrix_recovery_cleanup_journal_durable(state_dir, &journal)?;
    let artifact_labels: Vec<_> = journal
        .artifacts
        .iter()
        .map(|artifact| matrix_recovery_cleanup_artifact_audit_label(artifact.role))
        .collect();
    if let Err(audit_err) = crate::logging::audit::audit_blocking(
        state_dir.to_path_buf(),
        crate::logging::audit::AuditEvent::MatrixRecoveryKeyRestoreCleanupAnchored {
            artifacts: artifact_labels,
        },
    ) {
        tracing::warn!(
            error = %audit_err,
            "matrix recovery key cleanup-journal anchor audit emission failed"
        );
    }
    Ok(())
}

fn cleanup_matrix_recovery_pending_key_after_restore(
    state_dir: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
    use crate::channels::matrix::{
        MatrixRecoveryCleanupArtifactResult, MatrixRecoveryCleanupArtifactResultState,
        MatrixRecoveryCleanupJournal, MatrixRecoveryCleanupJournalPhase,
    };

    let mut journal = match load_matrix_recovery_cleanup_journal(state_dir)? {
        Some(journal) => journal,
        None => MatrixRecoveryCleanupJournal {
            version: crate::channels::matrix::MATRIX_RECOVERY_CLEANUP_JOURNAL_VERSION,
            phase: MatrixRecoveryCleanupJournalPhase::Started,
            artifacts: matrix_recovery_cleanup_artifacts(),
        },
    };
    if journal.phase == MatrixRecoveryCleanupJournalPhase::Completed {
        remove_matrix_recovery_cleanup_journal(state_dir)?;
        return Ok(());
    }
    if journal.artifacts.is_empty() {
        journal.artifacts = matrix_recovery_cleanup_artifacts();
    }
    write_matrix_recovery_cleanup_journal_durable(state_dir, &journal)?;

    let mut failures = Vec::new();
    for index in 0..journal.artifacts.len() {
        let role = journal.artifacts[index].role;
        let path = matrix_recovery_cleanup_artifact_path(state_dir, role);
        let audit_label = matrix_recovery_cleanup_artifact_audit_label(role);
        match cleanup_matrix_recovery_restore_artifact(&path, audit_label) {
            Ok(state) => {
                journal.artifacts[index].result = MatrixRecoveryCleanupArtifactResult {
                    state,
                    error_kind: None,
                };
            }
            Err(err) => {
                journal.artifacts[index].result = MatrixRecoveryCleanupArtifactResult {
                    state: MatrixRecoveryCleanupArtifactResultState::Failed,
                    error_kind: Some(
                        matrix_recovery_cleanup_error_kind_name(&err.audit_artifact.error_kind)
                            .to_string(),
                    ),
                };
                failures.push(err);
            }
        }
        write_matrix_recovery_cleanup_journal_durable(state_dir, &journal)?;
    }
    if failures.is_empty() {
        journal.phase = MatrixRecoveryCleanupJournalPhase::Completed;
        write_matrix_recovery_cleanup_journal_durable(state_dir, &journal)?;
        remove_matrix_recovery_cleanup_journal(state_dir)?;
        Ok(())
    } else {
        let audit_failure = crate::logging::audit::audit_blocking(
            state_dir.to_path_buf(),
            crate::logging::audit::AuditEvent::MatrixRecoveryKeyRestoreCleanupFailed {
                artifacts: failures
                    .iter()
                    .map(|failure| failure.audit_artifact.clone())
                    .collect(),
            },
        )
        .err();
        Err(format!(
            "Matrix recovery key was restored, but stale recovery-key cleanup failed: {}{}",
            failures
                .iter()
                .map(|failure| failure.message.as_str())
                .collect::<Vec<_>>()
                .join("; "),
            audit_failure
                .map(|err| format!("; audit write failed: {err}"))
                .unwrap_or_default()
        )
        .into())
    }
}

fn cleanup_matrix_recovery_restore_artifact(
    path: &Path,
    audit_label: crate::logging::audit::MatrixRecoveryKeyArtifactLabel,
) -> Result<
    crate::channels::matrix::MatrixRecoveryCleanupArtifactResultState,
    MatrixRecoveryRestoreCleanupFailure,
> {
    match std::fs::remove_file(path) {
        Ok(()) => {
            let label = matrix_recovery_cleanup_audit_label_name(&audit_label);
            crate::paths::sync_parent_dir_blocking(path).map_err(|err| {
                MatrixRecoveryRestoreCleanupFailure {
                    message: format!("failed to sync Matrix recovery-key {label} cleanup: {err}"),
                    audit_artifact: crate::logging::audit::MatrixRecoveryKeyRestoreCleanupArtifact {
                        label: audit_label,
                        error_kind: crate::logging::audit::MatrixRecoveryKeyRestoreCleanupErrorKind::ParentSyncFailed,
                    },
                }
            })?;
            tracing::info!(
                path = %path.display(),
                label,
                "removed stale Matrix recovery-key artifact after restore"
            );
            Ok(crate::channels::matrix::MatrixRecoveryCleanupArtifactResultState::Removed)
        }
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
            Ok(crate::channels::matrix::MatrixRecoveryCleanupArtifactResultState::NotFound)
        }
        Err(err) => {
            let label = matrix_recovery_cleanup_audit_label_name(&audit_label);
            tracing::warn!(
                path = %path.display(),
                label,
                error = %err,
                "failed to remove stale Matrix recovery-key artifact after restore"
            );
            Err(MatrixRecoveryRestoreCleanupFailure {
                message: format!("failed to remove Matrix recovery-key {label}: {err}"),
                audit_artifact: crate::logging::audit::MatrixRecoveryKeyRestoreCleanupArtifact {
                    label: audit_label,
                    error_kind: crate::logging::audit::MatrixRecoveryKeyRestoreCleanupErrorKind::RemoveFailed,
                },
            })
        }
    }
}

fn handle_matrix_rekey_store(new: bool) -> Result<(), Box<dyn std::error::Error>> {
    if !new {
        return Err("matrix rekey-store currently requires --new".into());
    }
    let cfg = config::load_config()?;
    let matrix_config = match crate::channels::matrix::resolve_matrix_config(&cfg)? {
        crate::channels::matrix::MatrixConfigResolve::Configured(config) => config,
        crate::channels::matrix::MatrixConfigResolve::Disabled => {
            return Err("matrix rekey-store requires matrix.enabled=true".into())
        }
        crate::channels::matrix::MatrixConfigResolve::Missing => {
            return Err("matrix rekey-store requires Matrix configuration".into())
        }
    };
    let crate::channels::matrix::MatrixSecurity::Encrypted { passphrase_source } =
        &matrix_config.security
    else {
        return Err("matrix rekey-store requires matrix.encrypted=true".into());
    };
    if !matches!(
        passphrase_source,
        crate::channels::matrix::PassphraseSource::DeriveFromConfigPassword
    ) {
        return Err(
            "matrix rekey-store only rekeys stores derived from CARAPACE_CONFIG_PASSWORD; \
             rotate explicit MATRIX_STORE_PASSPHRASE/matrix.storePassphrase outside Carapace"
                .into(),
        );
    }
    let state_dir = crate::server::ws::resolve_state_dir();
    let passphrase_path = crate::channels::matrix::matrix_store_passphrase_file_path(&state_dir);
    let pending_passphrase_path = matrix_store_pending_passphrase_file_path(&state_dir);
    let rekey_marker_path = matrix_store_rekey_marker_path(&state_dir);
    let rekey_pid = std::process::id();
    tracing::warn!(
        audit_event = "matrix_store_rekey_start",
        state_dir = %state_dir.display(),
        pid = rekey_pid,
        "matrix store rekey requested"
    );
    // SECURITY: companion durable audit so an incident-response
    // query against `audit.jsonl` shows the rekey was attempted,
    // not just the tracing log (which rotates). AUDIT_LOG is not
    // initialized in CLI processes, so audit_durable_for_state_dir
    // falls through to audit_blocking which writes directly to
    // state_dir/audit.jsonl with 0o600 enforced on the file.
    if let Err(err) = crate::logging::audit::audit_durable_for_state_dir(
        state_dir.clone(),
        crate::logging::audit::AuditEvent::MatrixStoreRekeyStart { pid: rekey_pid },
    ) {
        tracing::warn!(
            error = %err,
            "failed to write matrix_store_rekey_start audit event; tracing-warn is the only forensic signal"
        );
    }
    // The DLQ is encrypted under a key derived from
    // (CARAPACE_CONFIG_PASSWORD, installation_id). After SQLite
    // store advance the new pinned passphrase replaces the
    // CARAPACE_CONFIG_PASSWORD-derived one, so old DLQ records
    // would become permanently undecryptable without an in-rekey
    // rotation. The actual re-encryption happens AFTER pending /
    // marker write but BEFORE SQLite advance (below), so a
    // rotation failure aborts the rekey before any persistent
    // change.
    // Refuse to rekey while the daemon is running. SQLite default
    // busy timeout returns SQLITE_BUSY immediately on a concurrent
    // open; mid-rotation BUSY can leave stores partially rotated and
    // produce a confusing failure that the operator may not realise
    // is "the daemon is still holding these files." The advisory
    // lock + control-socket probe is best-effort but catches the
    // common case where the operator forgets to stop the daemon.
    let _running_daemon_guard = ensure_no_running_daemon_for_rekey(&state_dir)
        .map_err(|err| -> Box<dyn std::error::Error> { err.into() })?;
    if cli_path_exists_strict(&passphrase_path, "Matrix store passphrase")? {
        cleanup_stale_matrix_rekey_files(&pending_passphrase_path, &rekey_marker_path)?;
        return Err(format!(
            "Matrix store passphrase is already pinned at {}; refusing to overwrite it",
            passphrase_path.display()
        )
        .into());
    }
    if recover_interrupted_matrix_store_rekey(
        &state_dir,
        &matrix_config,
        &passphrase_path,
        &pending_passphrase_path,
        &rekey_marker_path,
    )? {
        println!(
            "Finalized interrupted Matrix store rekey; new store passphrase pinned at {}",
            passphrase_path.display()
        );
        return Ok(());
    }
    // resolve_matrix_store_passphrase already returns Zeroizing.
    let old_passphrase =
        crate::channels::matrix::resolve_matrix_store_passphrase(&state_dir, &matrix_config)?
            .ok_or("encrypted Matrix store did not resolve a passphrase")?;
    // Zeroize the random byte buffer once we've hex-encoded it; both
    // the bytes and the encoded String must be wiped on drop because
    // either form is sufficient to decrypt the Matrix SQLite store.
    let mut bytes = zeroize::Zeroizing::new([0u8; 32]);
    fill(bytes.as_mut())?;
    let new_passphrase = zeroize::Zeroizing::new(hex::encode(*bytes));
    write_owner_only_cli_secret_no_replace(&pending_passphrase_path, &new_passphrase)?;
    if let Err(err) = write_owner_only_cli_secret_no_replace(&rekey_marker_path, "rekeying") {
        if let Err(cleanup_err) = std::fs::remove_file(&pending_passphrase_path) {
            return Err(format!(
                "failed to write Matrix store rekey marker at {}: {err}; \
                 additionally, removing the pending passphrase at {} failed: {cleanup_err}. \
                 Remove the pending passphrase manually before retrying.",
                rekey_marker_path.display(),
                pending_passphrase_path.display()
            )
            .into());
        }
        crate::paths::sync_parent_dir_best_effort_blocking(&pending_passphrase_path);
        return Err(format!(
            "failed to write Matrix store rekey marker at {}: {err}",
            rekey_marker_path.display()
        )
        .into());
    }
    // Re-encrypt the inbound DLQ under the new passphrase BEFORE
    // SQLite advance. The DLQ AEAD key derives from the resolved
    // store passphrase; once SQLite is on the NEW key (via the
    // pending passphrase file promotion below) the daemon can no
    // longer derive the OLD DLQ key. Rotating BEFORE SQLite advance
    // means a rotation failure aborts the rekey transaction with
    // OLD ciphertext still on disk; rotation success carries a
    // backup path the caller restores if SQLite advance fails.
    let dlq_outcome = match crate::channels::matrix::rotate_matrix_inbound_dlq_for_rekey(
        &state_dir,
        &matrix_config,
        &old_passphrase,
        &new_passphrase,
    ) {
        Ok(outcome) => outcome,
        Err(err) => {
            if let Err(cleanup_err) =
                cleanup_stale_matrix_rekey_files(&pending_passphrase_path, &rekey_marker_path)
            {
                return Err(format!(
                    "Matrix DLQ rotation during rekey failed: {err}; additionally, \
                     cleanup of pending passphrase and rekey marker failed: {cleanup_err}"
                )
                .into());
            }
            return Err(format!("Matrix DLQ rotation during rekey failed: {err}").into());
        }
    };
    let total_stores = match advance_matrix_sqlite_store_ciphers(
        &state_dir,
        &old_passphrase,
        &new_passphrase,
    ) {
        Ok(MatrixRekeyAdvance::Completed {
            rotated,
            already_new,
        }) => rotated.len() + already_new.len(),
        Ok(MatrixRekeyAdvance::Failed {
            error,
            rolled_back,
            rollback_failed,
        }) => {
            // Restore the OLD DLQ ciphertext on SQLite-advance
            // rollback so the post-failure state is internally
            // consistent (SQLite back on OLD key, DLQ back on
            // OLD key). Best-effort: a restore failure is
            // surfaced in the error message but does not mask
            // the original SQLite failure.
            let dlq_restore_msg = restore_dlq_backup_after_rekey_rollback(&state_dir, &dlq_outcome);
            let mut err = format_matrix_rekey_failure(
                &error,
                &rolled_back,
                &rollback_failed,
                &pending_passphrase_path,
                &rekey_marker_path,
            );
            if let Some(suffix) = dlq_restore_msg {
                err = format!("{err}; {suffix}").into();
            }
            return Err(err);
        }
        Err(err) => {
            // Detection-time error before any UPDATE landed; clean up.
            let dlq_restore_msg = restore_dlq_backup_after_rekey_rollback(&state_dir, &dlq_outcome);
            if let Err(cleanup_err) =
                cleanup_stale_matrix_rekey_files(&pending_passphrase_path, &rekey_marker_path)
            {
                let suffix = dlq_restore_msg
                    .map(|s| format!("; {s}"))
                    .unwrap_or_default();
                return Err(format!(
                        "Matrix store rekey failed before any cipher rotation: {err}; \
                     additionally, cleanup of pending passphrase and marker failed: {cleanup_err}{suffix}"
                    )
                    .into());
            }
            if let Some(suffix) = dlq_restore_msg {
                return Err(format!("{err}; {suffix}").into());
            }
            return Err(err);
        }
    };
    if let Err(err) =
        promote_owner_only_cli_secret_no_replace(&pending_passphrase_path, &passphrase_path)
    {
        return Err(format!(
            "Matrix store was rekeyed but finalizing the new passphrase from {} to {} failed: {err}. \
             The pending passphrase and rekey marker remain in place; rerun `cara matrix rekey-store --new` \
             before restarting the daemon or changing CARAPACE_CONFIG_PASSWORD.",
            pending_passphrase_path.display(),
            passphrase_path.display()
        )
        .into());
    }
    // SQLite advance and passphrase promotion both succeeded. Only
    // now is it safe to remove the OLD-keyed DLQ backup; deleting it
    // before `store_passphrase.pending` is promoted would make an
    // interrupted finalization unrecoverable.
    cleanup_dlq_backup_after_rekey_success(&state_dir, &dlq_outcome);
    cleanup_stale_matrix_rekey_files_strict(&pending_passphrase_path, &rekey_marker_path)?;
    let complete_pid = std::process::id();
    tracing::warn!(
        audit_event = "matrix_store_rekey_complete",
        state_dir = %state_dir.display(),
        sqlite_store_count = total_stores,
        pid = complete_pid,
        "matrix store rekey completed"
    );
    // Pair the start-event audit with a durable complete-event so
    // forensic queries can correlate the two and detect crashed
    // rekeys (start without complete).
    if let Err(err) = crate::logging::audit::audit_durable_for_state_dir(
        state_dir.clone(),
        crate::logging::audit::AuditEvent::MatrixStoreRekeyComplete {
            sqlite_store_count: total_stores,
            pid: complete_pid,
            recovered: false,
        },
    ) {
        tracing::warn!(
            error = %err,
            "failed to write matrix_store_rekey_complete audit event; tracing-warn is the only forensic signal"
        );
    }
    println!(
        "Matrix store rekeyed across {total_stores} SQLite store(s); new store passphrase pinned at {}",
        passphrase_path.display()
    );
    Ok(())
}

/// On a failed SQLite advance after the DLQ has already been
/// rotated, restore the OLD ciphertext (atomic rename of the
/// `.pre-rekey` backup over the live path) so the post-failure
/// state is internally consistent. Returns an operator-actionable
/// message suffix on success or a best-effort failure description
/// on rename failure. `None` is returned for `Skipped` outcomes.
///
/// Takes `state_dir` so the live path can be derived via the
/// canonical `matrix_inbound_dlq_path` helper rather than reverse-
/// engineering it from `backup_path` with a fragile `with_extension`
/// chain. Path derivation by extension manipulation silently breaks
/// if the backup filename convention changes (e.g. suffix moves to
/// `.pre-rekey-v2` or live file moves to a sibling directory) — and
/// this code path determines which ciphertext the operator is left
/// with after a rollback, so a wrong destination is silently
/// destructive.
fn restore_dlq_backup_after_rekey_rollback(
    state_dir: &Path,
    outcome: &crate::channels::matrix::MatrixDlqRekeyOutcome,
) -> Option<String> {
    match outcome {
        crate::channels::matrix::MatrixDlqRekeyOutcome::Skipped => None,
        crate::channels::matrix::MatrixDlqRekeyOutcome::Rotated {
            decoded_count,
            backup_path,
        } => {
            let live_path = crate::channels::matrix::matrix_inbound_dlq_path(state_dir);
            match crate::channels::matrix::restore_matrix_inbound_dlq_backup(
                backup_path,
                &live_path,
            ) {
                Ok(()) => Some(format!(
                    "Matrix inbound DLQ ({decoded_count} record(s)) restored from \
                     {} to {}",
                    backup_path.display(),
                    live_path.display()
                )),
                Err(err) => Some(format!(
                    "Matrix inbound DLQ restore from {} to {} FAILED: {err}. \
                     The rotated NEW-keyed DLQ at {} is unrecoverable under the \
                     OLD passphrase; recover the OLD ciphertext from {} manually \
                     and rerun rekey",
                    backup_path.display(),
                    live_path.display(),
                    live_path.display(),
                    backup_path.display()
                )),
            }
        }
    }
}

/// Remove the DLQ backup created by `rotate_matrix_inbound_dlq_for_rekey`
/// after a successful SQLite advance. Best-effort; a failure is
/// warn-logged but does not fail the rekey since the live DLQ
/// already carries the NEW-keyed contents.
///
/// fsyncs the parent dir after a successful unlink so a crash
/// immediately after cannot resurrect the backup. A resurrected
/// backup would be picked up by `recover_matrix_inbound_dlq_rekey`
/// as live-rekey-in-progress evidence; the daemon's recovery probe
/// then takes the wrong path on the next start.
fn cleanup_dlq_backup_after_rekey_success(
    state_dir: &Path,
    outcome: &crate::channels::matrix::MatrixDlqRekeyOutcome,
) {
    let crate::channels::matrix::MatrixDlqRekeyOutcome::Rotated { backup_path, .. } = outcome
    else {
        return;
    };
    match std::fs::remove_file(backup_path) {
        Ok(()) => {
            crate::paths::sync_parent_dir_best_effort_blocking(backup_path);
        }
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
        Err(err) => {
            let live_path = crate::channels::matrix::matrix_inbound_dlq_path(state_dir);
            tracing::warn!(
                audit_event = "matrix_dlq_rekey_backup_cleanup_failed",
                backup_path = %backup_path.display(),
                live_path = %live_path.display(),
                error = %err,
                "failed to remove DLQ rekey backup; live DLQ carries the new-keyed contents — remove the backup manually"
            );
            // SECURITY: a tracing-warn is easy to miss. The
            // orphaned backup is load-bearing — on next daemon
            // start, recover_matrix_inbound_dlq_rekey treats it
            // as evidence of an interrupted rekey and may roll
            // the live DLQ contents back to the OLD key,
            // corrupting any records appended after rekey-success.
            // Operator MUST remove the backup before restart;
            // emit a durable audit so they have a grep-able
            // signal in addition to the tracing-warn.
            if let Err(audit_err) = crate::logging::audit::audit_durable_for_state_dir(
                state_dir.to_path_buf(),
                crate::logging::audit::AuditEvent::MatrixDlqRekeyBackupCleanupFailed {
                    backup_path: crate::logging::audit::truncate_audit_free_text_field(
                        &backup_path.display().to_string(),
                        crate::logging::audit::AUDIT_FREE_TEXT_FIELD_MAX_BYTES,
                    ),
                    live_path: crate::logging::audit::truncate_audit_free_text_field(
                        &live_path.display().to_string(),
                        crate::logging::audit::AUDIT_FREE_TEXT_FIELD_MAX_BYTES,
                    ),
                    error: crate::logging::audit::truncate_audit_free_text_field(
                        &err.to_string(),
                        crate::logging::audit::AUDIT_FREE_TEXT_FIELD_MAX_BYTES,
                    ),
                },
            ) {
                tracing::warn!(
                    error = %audit_err,
                    "failed to write matrix_dlq_rekey_backup_cleanup_failed audit event; tracing-warn is the only forensic signal"
                );
            }
        }
    }
}

fn format_matrix_rekey_failure(
    error: &str,
    rolled_back: &[PathBuf],
    rollback_failed: &[(PathBuf, String)],
    pending_passphrase_path: &Path,
    rekey_marker_path: &Path,
) -> Box<dyn std::error::Error> {
    if rollback_failed.is_empty() {
        format!(
            "Matrix store rekey failed: {error}. Rolled back {} previously rotated store(s); \
             pending passphrase and rekey marker have been preserved at {} and {} for the operator. \
             Before removing them, restore or intentionally archive any Matrix inbound DLQ backup at \
             `matrix/inbound_dlq.jsonl.pre-rekey`; removing the markers first can leave the next \
             rekey attempt without the old-keyed DLQ recovery source. Remove them with `rm` before \
             retrying only after that backup state is settled and the daemon will not be restarted \
             with the old passphrase.",
            rolled_back.len(),
            pending_passphrase_path.display(),
            rekey_marker_path.display()
        )
        .into()
    } else {
        let detail = rollback_failed
            .iter()
            .map(|(path, err)| format!("{}: {err}", path.display()))
            .collect::<Vec<_>>()
            .join("; ");
        format!(
            "Matrix store rekey failed: {error}. Rolled back {rolled_count} store(s), but rollback ALSO FAILED for {failed_count}: {detail}. \
             Pending passphrase at {pending} and rekey marker at {marker} have been preserved. \
             Inspect each Matrix SQLite store manually before retrying — some stores may already be using the pending passphrase \
             while others remain on the original passphrase. Also restore or intentionally archive any Matrix inbound DLQ backup at \
             `matrix/inbound_dlq.jsonl.pre-rekey` before removing the preserved marker files.",
            rolled_count = rolled_back.len(),
            failed_count = rollback_failed.len(),
            detail = detail,
            pending = pending_passphrase_path.display(),
            marker = rekey_marker_path.display(),
        )
        .into()
    }
}

/// Detect a running carapace daemon before rekey. The daemon holds
/// the Matrix SQLite stores open via the SDK; concurrent rusqlite
/// connections from this CLI hit SQLITE_BUSY and partial-rotation
/// can produce a confusing operator-recovery state.
///
/// Best-effort detection via the daemon's PID file. If the file
/// exists AND points at a live process, refuse the rekey with an
/// operator-actionable error. Returns an opaque guard whose only
/// purpose is to be held for the duration of the rekey (no resource
/// is bound today; reserved for a future flock-based exclusion
/// without changing call sites).
fn ensure_no_running_daemon_for_rekey(state_dir: &Path) -> Result<RekeyDaemonGuard, String> {
    ensure_no_running_daemon_for_matrix_secret_mutation(state_dir, "cara matrix rekey-store --new")
}

fn ensure_no_running_daemon_for_matrix_secret_mutation(
    state_dir: &Path,
    command: &str,
) -> Result<RekeyDaemonGuard, String> {
    // Kernel-enforced advisory lock first — ground truth for "is
    // anyone (daemon or other CLI) actively touching the Matrix
    // store right now?". The daemon holds the same lock for its
    // lifetime via `DaemonPidGuard::install`, so a successful
    // try_acquire here is also a successful "no daemon running"
    // check. This closes the round-21 TOCTOU window between the
    // PID-file probe and the SQLite rotation: the daemon cannot be
    // launched mid-rekey because its own startup `try_acquire`
    // would fail until we drop this lock.
    let rekey_lock_path = matrix_rekey_lock_path(state_dir);
    if let Some(parent) = rekey_lock_path.parent() {
        if let Err(err) = std::fs::create_dir_all(parent) {
            return Err(format!(
                "failed to create state dir for Matrix rekey lock at {}: {err}",
                parent.display()
            ));
        }
    }
    let lock = match crate::sessions::file_lock::FileLock::try_acquire(&rekey_lock_path) {
        Ok(Some(lock)) => lock,
        Ok(None) => {
            // Lock held — likely the daemon, possibly another rekey
            // CLI. Both cases produce the same operator-actionable
            // message: stop the carapace daemon (or wait for the
            // other rekey to finish), then retry.
            return Err(format!(
                "Matrix rekey lock at {} is held by another process — likely the carapace daemon \
                 (which acquires this lock for its lifetime to prevent concurrent store \
                 mutation), or another Matrix secret maintenance invocation. Stop the daemon \
                 (or wait for the other rekey to finish), then retry.",
                rekey_lock_path.display()
            ));
        }
        Err(err) => {
            return Err(format!(
                "failed to acquire Matrix rekey lock at {}: {err}",
                rekey_lock_path.display()
            ));
        }
    };

    // PID-file probe stays as belt-and-suspenders even though the
    // flock above is the ground-truth check. Operators who lost the
    // lock file (e.g. `rm` on the state dir) will still get a
    // "daemon may be running" hint from this branch instead of
    // racing into rotation against a possibly-live daemon. NOTE:
    // every error path BELOW must include the `_lock` in
    // `RekeyDaemonGuard` so it's released when the guard drops.
    let pid_path = state_dir.join("daemon.pid");
    // O_NOFOLLOW + 4 KiB cap: the pid file is at most a few decimal
    // digits; an unbounded `read_to_string` against a planted symlink
    // (to e.g. /dev/zero) would OOM the CLI. Same threat shape as the
    // post-Batch-67 audit flagged on the other CLI state-dir reads.
    let pid_content = match read_small_cli_state_file_no_follow(&pid_path, 4096) {
        Ok(Some(s)) => s,
        Ok(None) => {
            return Ok(RekeyDaemonGuard { _lock: lock });
        }
        Err(err) => {
            return Err(format!(
                "failed to read daemon PID file at {}: {err}",
                pid_path.display()
            ));
        }
    };
    let trimmed = pid_content.trim();
    if trimmed.is_empty() {
        return Ok(RekeyDaemonGuard { _lock: lock });
    }
    // Parse as u32 first (matches what `std::process::id()` writes —
    // and matches Windows PID width, which is u32 with valid values
    // up to 4_294_967_295 well past i32::MAX). Then narrow to i32 for
    // the Unix `kill(pid, 0)` probe which takes a `libc::pid_t`. A
    // u32 PID > i32::MAX would previously have failed `parse::<i32>`
    // and silently fallen through to "no daemon" — letting rekey
    // proceed against an actually-live high-PID daemon on Windows.
    let Ok(pid_u32) = trimmed.parse::<u32>() else {
        // Garbage in the PID file — likely a stale write from a
        // previous run. Don't block on it; the flock above is the
        // real check.
        return Ok(RekeyDaemonGuard { _lock: lock });
    };
    let pid: i32 = match i32::try_from(pid_u32) {
        Ok(narrowed) => narrowed,
        Err(_) => {
            // PID > i32::MAX. On Windows the OpenProcess probe
            // expects u32, so cast directly. On Unix, pid_t is i32
            // and PIDs above i32::MAX cannot exist — treat as
            // garbage.
            #[cfg(windows)]
            {
                return if rekey_pid_is_alive_windows_u32(pid_u32) {
                    Err(format!(
                        "carapace daemon appears to be running (pid {pid_u32}, recorded at {}). \
                         Stop the daemon before running `{command}`.",
                        pid_path.display()
                    ))
                } else {
                    Ok(RekeyDaemonGuard { _lock: lock })
                };
            }
            #[cfg(not(windows))]
            {
                return Ok(RekeyDaemonGuard { _lock: lock });
            }
        }
    };
    // `kill(0, 0)` signals the caller's process group; `kill(-1, 0)`
    // signals every reachable process. Both typically return 0
    // (alive), making a corrupt PID file (zero, negative) refuse the
    // rekey forever. Treat `pid <= 1` as garbage — pid 1 is init,
    // not the carapace daemon.
    if pid <= 1 {
        return Ok(RekeyDaemonGuard { _lock: lock });
    }
    if rekey_pid_is_alive(pid) {
        return Err(format!(
            "carapace daemon appears to be running (pid {pid}, recorded at {}). \
             Stop the daemon before running `{command}` — the daemon holds Matrix \
             secret storage open and concurrent mutation can leave local state requiring \
             manual recovery.",
            pid_path.display()
        ));
    }
    Ok(RekeyDaemonGuard { _lock: lock })
}

/// Path of the kernel-enforced rekey lock. Matches the daemon-side
/// `DaemonPidGuard::install` so both processes contend on the same
/// inode. Lives at `state_dir/.matrix-rekey.lock`; the leading dot
/// hides it from casual `ls`. The actual lock-sentinel file ends in
/// `.lock` per `FileLock`'s convention so the on-disk file is
/// `state_dir/.matrix-rekey.lock.lock`.
pub(crate) fn matrix_rekey_lock_path(state_dir: &Path) -> std::path::PathBuf {
    state_dir.join(".matrix-rekey.lock")
}

/// Held for the duration of `cara matrix rekey-store --new`. Ties
/// together (a) the PID-file liveness probe (best-effort, can be
/// stale) and (b) a kernel-enforced exclusive flock on
/// `state_dir/.matrix-rekey.lock` (ground-truth for "is anyone else
/// touching the Matrix store right now?"). The daemon acquires the
/// SAME flock for its lifetime via `DaemonPidGuard::install`, so:
/// - rekey CLI vs running daemon: flock acquisition fails → CLI
///   refuses with a clear "stop the daemon first" message,
///   regardless of whether the PID file is stale.
/// - two concurrent rekey CLI invocations: only one acquires the
///   flock; the loser refuses.
/// - daemon launch DURING a rekey: daemon's `DaemonPidGuard::install`
///   try_acquire fails → daemon refuses to start with a clear
///   "rekey in progress" message.
///
/// The flock is released when the rekey CLI returns (or panics —
/// flock releases on file-descriptor close, including via stack
/// unwind). Drop ordering: lock first, PID guard second; doesn't
/// matter because they're independent files.
struct RekeyDaemonGuard {
    _lock: crate::sessions::file_lock::FileLock,
}

#[cfg(unix)]
fn rekey_pid_is_alive(pid: i32) -> bool {
    // `kill(pid, 0)` is the canonical liveness probe on Unix: signal
    // 0 doesn't deliver anything. The return value alone is
    // insufficient — kill returns -1 with EPERM if the process exists
    // but the caller lacks permission to signal it (e.g., the daemon
    // runs as a different user). Treating EPERM as "process is dead"
    // would let the rekey proceed against a running daemon owned by
    // another user, producing partial cipher rotation under
    // SQLITE_BUSY.
    //
    // Treat the process as alive on:
    //   - kill(pid, 0) == 0          (caller can signal)
    //   - kill(pid, 0) == -1, EPERM  (process exists, signal denied)
    // Treat as dead on ESRCH OR on unusual errnos that don't carry a
    // process-exists signal (EINVAL, ENOSYS, EACCES from a seccomp
    // filter). The flock acquisition that runs BEFORE this probe
    // already proved no other process holds the rekey sentinel, so
    // the PID-file probe is a belt-and-suspenders check; degrading
    // to "probably dead" + warn-log on unusual errnos keeps
    // operators on container hosts with restrictive seccomp profiles
    // from hitting an apparent dead-end ("stop the daemon" — but
    // there's no daemon).
    // SAFETY: libc::kill is unsafe; we pass a benign signal (0).
    let rc = unsafe { libc::kill(pid, 0) };
    if rc == 0 {
        return true;
    }
    let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
    if errno == libc::EPERM {
        return true;
    }
    if errno != libc::ESRCH {
        tracing::warn!(
            pid,
            errno,
            "rekey PID-file probe returned unusual errno; treating as dead \
             since the flock acquisition above proved no other process \
             contends. If a daemon IS running and rekey-store proceeds \
             anyway, the daemon's flock would have already failed and \
             this code path would not have been reached."
        );
    }
    false
}

#[cfg(windows)]
fn rekey_pid_is_alive_windows_u32(pid: u32) -> bool {
    // u32-direct probe used when a parsed PID is >i32::MAX (legal on
    // Windows where PID width is u32). Same OpenProcess semantics as
    // `rekey_pid_is_alive`; that wrapper narrows from i32, this one
    // accepts u32 directly so PIDs in the upper half of the range
    // are still queryable.
    if pid <= 1 {
        return false;
    }
    use windows_sys::Win32::Foundation::{CloseHandle, ERROR_INVALID_PARAMETER};
    use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};

    // SAFETY: pure FFI probe; any returned handle is closed below.
    let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
    if !handle.is_null() {
        // SAFETY: handle came from the OpenProcess call above.
        unsafe {
            CloseHandle(handle);
        }
        return true;
    }
    let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
    errno != ERROR_INVALID_PARAMETER as i32
}

#[cfg(windows)]
fn rekey_pid_is_alive(pid: i32) -> bool {
    // Without a real liveness probe on Windows, an unclean shutdown
    // would leave a stale `daemon.pid` behind and `cara matrix
    // rekey-store --new` would refuse forever — operator dead-end
    // requiring manual file cleanup. `OpenProcess` with
    // PROCESS_QUERY_LIMITED_INFORMATION is the canonical Windows
    // probe: it succeeds for live processes (we close the handle
    // immediately), fails with ERROR_INVALID_PARAMETER for a
    // non-existent PID, and fails with ERROR_ACCESS_DENIED when the
    // process exists but the caller lacks rights — the security-
    // load-bearing case that matches Unix `kill(pid, 0)` returning
    // EPERM. Treat that as alive so a daemon owned by another user
    // still blocks the rekey.
    //
    // `pid <= 1` is treated as garbage on both platforms: PID 0 and 1
    // are reserved (System Idle Process and System on Windows; init
    // on Unix) — neither will ever be the carapace daemon, and a
    // corrupt PID file containing one of those values would otherwise
    // make `OpenProcess(0)`/`OpenProcess(1)` succeed and pin the
    // operator forever. Mirrors the Unix branch in
    // `ensure_no_running_daemon_for_rekey`.
    if pid <= 1 {
        return false;
    }
    use windows_sys::Win32::Foundation::{CloseHandle, ERROR_INVALID_PARAMETER};
    use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};

    // SAFETY: OpenProcess is FFI but pure — passes a u32 PID. Any
    // returned handle is closed immediately; the FFI surface returns
    // a null pointer on failure rather than an error sentinel.
    let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid as u32) };
    if !handle.is_null() {
        // SAFETY: handle came from the OpenProcess call above and is
        // not used elsewhere; closing here is sound.
        unsafe {
            CloseHandle(handle);
        }
        return true;
    }
    let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
    // ERROR_INVALID_PARAMETER (87) — no such PID; treat as dead.
    // ERROR_ACCESS_DENIED (5) and everything else — process likely
    // exists with denied access, OR the system is in an unusual
    // state (handle table exhaustion etc.). Conservatively report
    // alive so the operator gets the "stop the daemon" hint rather
    // than letting the rekey proceed against an actually-live
    // daemon owned by another user (the Windows analogue of the
    // Unix EPERM contract).
    errno != ERROR_INVALID_PARAMETER as i32
}

#[cfg(not(any(unix, windows)))]
fn rekey_pid_is_alive(_pid: i32) -> bool {
    // Targets without `kill(pid, 0)` or `OpenProcess`. Trust the PID
    // file's existence; the rekey CLI is operator-driven and the
    // worst case is a refused rekey on a stale PID file (recovered
    // by `rm daemon.pid`).
    true
}

// These two helpers are pub(crate) over in `crate::channels::matrix` so
// the daemon's `resolve_matrix_store_passphrase` can detect an
// interrupted rekey on startup. Re-export them here under the local
// names to avoid touching every CLI call site.
use crate::channels::matrix::{
    matrix_store_pending_passphrase_file_path, matrix_store_rekey_marker_path,
};

fn recover_interrupted_matrix_store_rekey(
    state_dir: &Path,
    matrix_config: &crate::channels::matrix::MatrixConfig,
    passphrase_path: &Path,
    pending_passphrase_path: &Path,
    rekey_marker_path: &Path,
) -> Result<bool, Box<dyn std::error::Error>> {
    let pending_passphrase_exists =
        cli_path_exists_strict(pending_passphrase_path, "Matrix store pending passphrase")?;
    let rekey_marker_exists =
        cli_path_exists_strict(rekey_marker_path, "Matrix store rekey marker")?;
    if !pending_passphrase_exists && !rekey_marker_exists {
        return Ok(false);
    }
    if !pending_passphrase_exists {
        // "marker exists, pending passphrase missing" used to be a
        // hard-error operator dead-end — but the marker without a
        // pending passphrase is just stale crud (the rekey command
        // either crashed before writing pending, or someone manually
        // deleted pending). Without a pending passphrase there is
        // nothing to advance any store toward, so the only sensible
        // recovery is to drop the marker and let the outer rekey
        // flow generate a fresh pending and start over. The stores
        // are still on the OLD cipher (the rekey never landed an
        // UPDATE), so dropping the marker is safe.
        if let Err(err) = std::fs::remove_file(rekey_marker_path) {
            return Err(format!(
                "Matrix store rekey marker exists at {} but pending passphrase {} is missing; \
                 attempted to clean up the stale marker but the removal failed: {err}. \
                 Remove the marker manually and re-run `cara matrix rekey-store --new`.",
                rekey_marker_path.display(),
                pending_passphrase_path.display()
            )
            .into());
        }
        crate::paths::sync_parent_dir_best_effort_blocking(rekey_marker_path);
        eprintln!(
            "Cleaned up stale Matrix store rekey marker at {} (pending passphrase was missing). \
             Continuing with a fresh rekey attempt.",
            rekey_marker_path.display()
        );
        return Ok(false);
    }

    let pending_passphrase = read_non_empty_cli_secret(pending_passphrase_path)?;
    // Recovery cannot call `resolve_matrix_store_passphrase` here:
    // that function returns `MatrixError::StartupFailed` whenever the
    // (pending || marker) pattern is on disk — which is precisely the
    // precondition for entering recovery. Derive the OLD passphrase
    // directly from CARAPACE_CONFIG_PASSWORD + installation_id via the
    // pure-HKDF helper that bypasses the daemon's fail-closed gate.
    // Callers must guarantee this is only invoked after the early
    // returns above (no recovery → no derivation), otherwise the
    // gate is duplicated for nothing. `Explicit` passphrase configs
    // never reach this line because the rekey CLI rejects them with
    // a different error path far above.
    let old_passphrase = zeroize::Zeroizing::new(
        crate::channels::matrix::derive_matrix_store_passphrase_from_config_password(state_dir)
            .map_err(|err| -> Box<dyn std::error::Error> {
                format!(
                    "interrupted Matrix store rekey could not be advanced: \
                     failed to derive the old store passphrase from \
                     CARAPACE_CONFIG_PASSWORD + installation_id: {err}. \
                     Restore the value of CARAPACE_CONFIG_PASSWORD that was \
                     in effect when `cara matrix rekey-store --new` was first \
                     started, then rerun this command."
                )
                .into()
            })?,
    );
    let dlq_outcome = crate::channels::matrix::recover_matrix_inbound_dlq_rekey(
        state_dir,
        matrix_config,
        &old_passphrase,
        &pending_passphrase,
    )
    .map_err(|err| -> Box<dyn std::error::Error> {
        format!("interrupted Matrix store rekey could not recover inbound DLQ state: {err}").into()
    })?;
    // Advance any stores still on the old cipher to the pending one.
    // `advance_matrix_sqlite_store_ciphers` is idempotent: stores already
    // on the pending cipher are tolerated, stores on the old cipher are
    // rotated, and stores that import with neither passphrase produce a
    // detection-time error before any UPDATE lands.
    match advance_matrix_sqlite_store_ciphers(state_dir, &old_passphrase, &pending_passphrase) {
        Ok(MatrixRekeyAdvance::Completed {
            rotated,
            already_new,
        }) => {
            let total_stores = rotated.len() + already_new.len();
            promote_owner_only_cli_secret_no_replace(pending_passphrase_path, passphrase_path)?;
            cleanup_dlq_backup_after_rekey_success(state_dir, &dlq_outcome);
            cleanup_stale_matrix_rekey_files_strict(pending_passphrase_path, rekey_marker_path)?;
            let recovery_pid = std::process::id();
            tracing::warn!(
                audit_event = "matrix_store_rekey_complete",
                state_dir = %state_dir.display(),
                sqlite_store_count = total_stores,
                pid = recovery_pid,
                recovered = true,
                "interrupted matrix store rekey completed during recovery"
            );
            // Same durable-audit requirement as the normal-flow
            // completion above. The `recovered: true` flag
            // distinguishes this from a fresh start→complete pair.
            if let Err(err) = crate::logging::audit::audit_durable_for_state_dir(
                state_dir.to_path_buf(),
                crate::logging::audit::AuditEvent::MatrixStoreRekeyComplete {
                    sqlite_store_count: total_stores,
                    pid: recovery_pid,
                    recovered: true,
                },
            ) {
                tracing::warn!(
                    error = %err,
                    "failed to write matrix_store_rekey_complete audit event (recovery flow); tracing-warn is the only forensic signal"
                );
            }
            Ok(true)
        }
        Ok(MatrixRekeyAdvance::Failed {
            error,
            rolled_back,
            rollback_failed,
        }) => {
            let dlq_restore_msg = restore_dlq_backup_after_rekey_rollback(state_dir, &dlq_outcome);
            let mut err = format_matrix_rekey_failure(
                &format!("interrupted Matrix store rekey could not be advanced: {error}"),
                &rolled_back,
                &rollback_failed,
                pending_passphrase_path,
                rekey_marker_path,
            )
            .to_string();
            if let Some(suffix) = dlq_restore_msg {
                err = format!("{err}; {suffix}");
            }
            Err(err.into())
        }
        Err(detection_err) => {
            // Detection-time error (advance returned `Err` before any
            // UPDATE landed). The most common cause during recovery is
            // that `CARAPACE_CONFIG_PASSWORD` was changed between the
            // original `--new` run and the recovery start: the
            // `old_passphrase` we just derived no longer matches the
            // store's actual cipher, so the advance can't classify any
            // store. Surface this as an operator-actionable hint
            // instead of leaving them with a bare "accepts neither"
            // message.
            let dlq_restore_msg = restore_dlq_backup_after_rekey_rollback(state_dir, &dlq_outcome)
                .map(|suffix| format!(" {suffix}"))
                .unwrap_or_default();
            Err(format!(
                "interrupted Matrix store rekey could not be advanced: {detection_err}. \
                 If you changed CARAPACE_CONFIG_PASSWORD since starting `cara matrix rekey-store --new`, \
                 restore the previous value (or set MATRIX_STORE_PASSPHRASE to the original derived value) \
                 and rerun. The pending passphrase at {} and rekey marker at {} have NOT been removed.{dlq_restore_msg}",
                pending_passphrase_path.display(),
                rekey_marker_path.display(),
            )
            .into())
        }
    }
}

fn read_non_empty_cli_secret(
    path: &Path,
) -> Result<zeroize::Zeroizing<String>, Box<dyn std::error::Error>> {
    // Wrap the raw file buffer in `Zeroizing` BEFORE trimming so the
    // original allocation (which may include a trailing newline that
    // is NOT in the trimmed copy) is wiped on drop. Returning a plain
    // `String` would leave the un-trimmed source in heap memory until
    // the allocator reuses the buffer.
    //
    // O_NOFOLLOW + 64 KiB cap: a planted symlink to /dev/zero or a
    // multi-GB file under the same uid would OOM the CLI's rekey
    // path. The pending-passphrase file is at most a few hundred
    // bytes; the cap is generous to keep the helper consistent with
    // other CLI state-dir reads.
    let raw = match read_small_cli_state_file_no_follow(path, 64 * 1024)? {
        Some(content) => zeroize::Zeroizing::new(content),
        None => return Err(format!("secret file {} is empty", path.display()).into()),
    };
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        return Err(format!("secret file {} is empty", path.display()).into());
    }
    Ok(zeroize::Zeroizing::new(trimmed.to_string()))
}

/// Open a small CLI state-dir file with `O_NOFOLLOW` + a size cap,
/// returning the bytes as `String`. Returns `Ok(None)` for
/// `NotFound` so callers can branch on missing files; any other
/// error is surfaced. Mirrors the daemon-side
/// `read_recovery_key_file_to_string_bounded_blocking` shape but is
/// CLI-scoped and refuses symlinks unconditionally (the CLI does not
/// have the daemon's documented operator-tooling escape hatch).
fn read_small_cli_state_file_no_follow(
    path: &Path,
    cap_bytes: u64,
) -> Result<Option<String>, std::io::Error> {
    use std::io::Read;
    let mut open_opts = std::fs::OpenOptions::new();
    open_opts.read(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        // O_NOFOLLOW + O_NONBLOCK: post-open is_file() refuses FIFO
        // dirents AFTER open(2) returns. A same-uid attacker who
        // plants a FIFO at the daemon.pid / device-identity / similar
        // CLI-state path in the daemon-down window otherwise hangs
        // open(2) indefinitely; O_NONBLOCK lets the post-check fire.
        open_opts.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
    }
    let file = match open_opts.open(path) {
        Ok(file) => file,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(err) => return Err(err),
    };
    let metadata = file.metadata()?;
    if !metadata.is_file() {
        return Err(std::io::Error::other(format!(
            "refusing to read {}: path is not a regular file",
            path.display()
        )));
    }
    if metadata.len() > cap_bytes {
        return Err(std::io::Error::other(format!(
            "refusing to read {}: file exceeds {cap_bytes} bytes",
            path.display()
        )));
    }
    let mut buf = String::new();
    file.take(cap_bytes + 1).read_to_string(&mut buf)?;
    if buf.len() as u64 > cap_bytes {
        return Err(std::io::Error::other(format!(
            "refusing to read {}: file exceeds {cap_bytes} bytes (post-read)",
            path.display()
        )));
    }
    Ok(Some(buf))
}

fn cleanup_stale_matrix_rekey_files(
    pending_passphrase_path: &Path,
    rekey_marker_path: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
    match std::fs::remove_file(pending_passphrase_path) {
        Ok(()) => crate::paths::sync_parent_dir_best_effort_blocking(pending_passphrase_path),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
        Err(err) => return Err(err.into()),
    }
    match std::fs::remove_file(rekey_marker_path) {
        Ok(()) => crate::paths::sync_parent_dir_best_effort_blocking(rekey_marker_path),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
        Err(err) => return Err(err.into()),
    }
    Ok(())
}

fn cleanup_stale_matrix_rekey_files_strict(
    pending_passphrase_path: &Path,
    rekey_marker_path: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
    for path in [pending_passphrase_path, rekey_marker_path] {
        match std::fs::remove_file(path) {
            Ok(()) => crate::paths::sync_parent_dir_blocking(path)
                .map_err(|err| -> Box<dyn std::error::Error> { err.into() })?,
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
            Err(err) => return Err(err.into()),
        }
    }
    Ok(())
}

fn matrix_sqlite_store_paths(state_dir: &Path) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
    let matrix_dir = state_dir.join("matrix");
    let mut paths = Vec::new();
    for path in [
        matrix_dir.join("matrix-sdk-state.sqlite3"),
        matrix_dir.join("matrix-sdk-crypto.sqlite3"),
        matrix_dir
            .join("cache")
            .join("matrix-sdk-event-cache.sqlite3"),
        matrix_dir.join("cache").join("matrix-sdk-media.sqlite3"),
    ] {
        if cli_path_exists_strict(&path, "Matrix SQLite store")? {
            paths.push(path);
        }
    }
    Ok(paths)
}

/// Per-store cipher state, used to drive the rekey advance idempotently.
///
/// On disk a cipher record is encrypted with exactly one passphrase
/// (`old` or `new`). We classify by which passphrase imports cleanly:
///
/// - `OldOnly` — store still on the original passphrase; the rekey
///   advance must rotate this store.
/// - `NewOnly` — store already on the new passphrase, either because a
///   prior interrupted rekey advanced it or because the operator re-ran
///   the command after a crash.
/// - `Neither` — corrupted record or the wrong `old` passphrase passed
///   in; the advance fails fast before any UPDATE lands.
///
/// Per-store probe data captured during the rekey advance's first
/// pass. `cipher_blob` holds a `matrix-sdk-store-encryption` ciphertext
/// — combined with either the old or new passphrase, this is
/// sufficient to derive the Matrix SQLite store key. The hand-rolled
/// `Debug` elides the blob (length only), and `Drop` zeroes the bytes
/// so a stray `tracing::debug!(?probe, ...)` cannot leak ciphertext
/// through `RedactingWriter`. Mirrors the same hygiene applied to
/// `MatrixInboundDlqRecord`.
struct MatrixStoreCipherProbe {
    path: PathBuf,
    cipher_blob: Vec<u8>,
    importable_with_old: bool,
    importable_with_new: bool,
}

impl std::fmt::Debug for MatrixStoreCipherProbe {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("MatrixStoreCipherProbe")
            .field("path", &self.path)
            .field(
                "cipher_blob",
                &format_args!("<elided {} bytes>", self.cipher_blob.len()),
            )
            .field("importable_with_old", &self.importable_with_old)
            .field("importable_with_new", &self.importable_with_new)
            .finish()
    }
}

impl Drop for MatrixStoreCipherProbe {
    fn drop(&mut self) {
        use zeroize::Zeroize;
        self.cipher_blob.zeroize();
    }
}

#[derive(Debug)]
enum MatrixRekeyAdvance {
    Completed {
        rotated: Vec<PathBuf>,
        already_new: Vec<PathBuf>,
    },
    Failed {
        error: String,
        rolled_back: Vec<PathBuf>,
        rollback_failed: Vec<(PathBuf, String)>,
    },
}

fn detect_matrix_store_cipher_state(
    path: &Path,
    old_passphrase: &str,
    new_passphrase: &str,
) -> Result<MatrixStoreCipherProbe, Box<dyn std::error::Error>> {
    use matrix_sdk_store_encryption::StoreCipher;
    use rusqlite::OptionalExtension;

    let conn = rusqlite::Connection::open(path)
        .map_err(|err| format!("open Matrix SQLite store {}: {err}", path.display()))?;
    let cipher_blob: Vec<u8> = conn
        .query_row("SELECT value FROM kv WHERE key = 'cipher'", [], |row| {
            row.get(0)
        })
        .optional()
        .map_err(|err| format!("read Matrix SQLite cipher {}: {err}", path.display()))?
        .ok_or_else(|| {
            format!(
                "Matrix SQLite store {} has no cipher record",
                path.display()
            )
        })?;
    let importable_with_old = StoreCipher::import(old_passphrase, &cipher_blob).is_ok();
    let importable_with_new = StoreCipher::import(new_passphrase, &cipher_blob).is_ok();
    Ok(MatrixStoreCipherProbe {
        path: path.to_path_buf(),
        cipher_blob,
        importable_with_old,
        importable_with_new,
    })
}

fn write_matrix_store_cipher_blob(path: &Path, blob: &[u8]) -> Result<(), String> {
    use rusqlite::params;
    let conn = rusqlite::Connection::open(path)
        .map_err(|err| format!("open Matrix SQLite store {}: {err}", path.display()))?;
    conn.execute(
        "UPDATE kv SET value = ?1 WHERE key = 'cipher'",
        params![blob],
    )
    .map_err(|err| format!("update Matrix SQLite cipher {}: {err}", path.display()))?;
    // Force a WAL checkpoint + truncate so the cipher rotation lands
    // in the main database file before this function returns. Without
    // this the auto-commit fsync writes the WAL but the main file is
    // unchanged; a power loss between commit and a future checkpoint
    // could revert the rotation. The caller deletes the pending
    // passphrase on success — there is no recovery path for a
    // post-cleanup revert, so the durability contract has to be tight
    // here.
    conn.pragma_update(None, "wal_checkpoint", "TRUNCATE")
        .map_err(|err| {
            format!(
                "WAL checkpoint after cipher rotation {}: {err}",
                path.display()
            )
        })?;
    drop(conn);
    // Fsync the parent directory so the WAL-truncate's metadata
    // changes (.wal/.shm size or removal) are durable.
    crate::paths::sync_parent_dir_blocking(path)
        .map_err(|err| format!("fsync parent dir for {}: {err}", path.display()))?;
    Ok(())
}

/// Idempotent per-store rekey driver.
///
/// Detects the cipher state of every Matrix SQLite store, then rotates
/// any store still on the `old` passphrase to the `new` one. Tolerates
/// stores already on the new passphrase (used by the recovery path
/// after a crash mid-rotation). On per-store rotate failure, attempts
/// to roll back stores that were just rotated and reports both the
/// rolled-back set and any rollback failures separately.
///
/// The caller is responsible for the surrounding transaction: write
/// pending passphrase + marker before calling, promote pending →
/// final on `Completed`, decide whether to clean up on `Failed` based
/// on whether `rollback_failed` is empty.
fn advance_matrix_sqlite_store_ciphers(
    state_dir: &Path,
    old_passphrase: &str,
    new_passphrase: &str,
) -> Result<MatrixRekeyAdvance, Box<dyn std::error::Error>> {
    use matrix_sdk_store_encryption::StoreCipher;

    let paths = matrix_sqlite_store_paths(state_dir)?;
    if paths.is_empty() {
        return Err(format!(
            "no Matrix SQLite stores found under {}",
            state_dir.join("matrix").display()
        )
        .into());
    }

    // First pass: classify every store. Detection-time errors (corrupt
    // records, wrong passphrases) abort BEFORE any UPDATE runs so the
    // operator can retry without partial-rotation cleanup.
    let mut probes = Vec::with_capacity(paths.len());
    for path in paths {
        let probe = detect_matrix_store_cipher_state(&path, old_passphrase, new_passphrase)?;
        if !probe.importable_with_old && !probe.importable_with_new {
            return Err(format!(
                "Matrix SQLite store {} accepts neither the current nor the pending passphrase; \
                 the cipher record is corrupt or one of the passphrases is wrong. \
                 No UPDATEs have been issued.",
                probe.path.display()
            )
            .into());
        }
        probes.push(probe);
    }

    // Second pass: rotate any old-only store to the new passphrase.
    let mut rotated = Vec::new();
    let mut already_new = Vec::new();
    for probe in &probes {
        if probe.importable_with_new {
            already_new.push(probe.path.clone());
            continue;
        }
        // Old-only: re-import with old, export with new, UPDATE.
        let cipher = match StoreCipher::import(old_passphrase, &probe.cipher_blob) {
            Ok(cipher) => cipher,
            Err(err) => {
                let (rb_ok, rb_failed) = roll_back_rotated_stores(&rotated, &probes);
                return Ok(MatrixRekeyAdvance::Failed {
                    error: format!(
                        "decrypt Matrix SQLite cipher {}: {err}",
                        probe.path.display()
                    ),
                    rolled_back: rb_ok,
                    rollback_failed: rb_failed,
                });
            }
        };
        let new_blob = match cipher.export(new_passphrase) {
            Ok(blob) => blob,
            Err(err) => {
                let (rb_ok, rb_failed) = roll_back_rotated_stores(&rotated, &probes);
                return Ok(MatrixRekeyAdvance::Failed {
                    error: format!(
                        "encrypt Matrix SQLite cipher {}: {err}",
                        probe.path.display()
                    ),
                    rolled_back: rb_ok,
                    rollback_failed: rb_failed,
                });
            }
        };
        match write_matrix_store_cipher_blob(&probe.path, &new_blob) {
            Ok(()) => rotated.push(probe.path.clone()),
            Err(err) => {
                let (rb_ok, rb_failed) = roll_back_rotated_stores(&rotated, &probes);
                return Ok(MatrixRekeyAdvance::Failed {
                    error: err,
                    rolled_back: rb_ok,
                    rollback_failed: rb_failed,
                });
            }
        }
    }

    Ok(MatrixRekeyAdvance::Completed {
        rotated,
        already_new,
    })
}

/// Rollback driver: for each store we just rotated, re-write the
/// original cipher blob recorded at detection time. Surfaces per-store
/// rollback failures so the caller can refuse cleanup and direct the
/// operator at concrete files. Never silences errors.
fn roll_back_rotated_stores(
    rotated: &[PathBuf],
    probes: &[MatrixStoreCipherProbe],
) -> (Vec<PathBuf>, Vec<(PathBuf, String)>) {
    let mut rolled_back = Vec::new();
    let mut failed = Vec::new();
    for path in rotated {
        let Some(probe) = probes.iter().find(|p| p.path == *path) else {
            failed.push((
                path.clone(),
                "internal error: no detection probe for rotated path".to_string(),
            ));
            continue;
        };
        match write_matrix_store_cipher_blob(&probe.path, &probe.cipher_blob) {
            Ok(()) => rolled_back.push(path.clone()),
            Err(err) => failed.push((path.clone(), err)),
        }
    }
    (rolled_back, failed)
}

// Canonical recovery-key path helpers live in
// `src/channels/matrix.rs` as `pub(crate)` so CLI and daemon agree on
// every recovery_key{,.pending,.minting,.rotating} filename via a
// single source of truth. Earlier this file duplicated the four
// builders with `_for_state_dir` suffixes; a rename on either side
// would silently desynchronize the two. Re-export aliases here only
// so the existing call sites need no rewrite; new code should call
// `crate::channels::matrix::matrix_recovery_*_path` directly.
use crate::channels::matrix::{
    matrix_recovery_key_path as matrix_recovery_key_path_for_state_dir,
    matrix_recovery_minting_marker_path as matrix_recovery_minting_marker_path_for_state_dir,
    matrix_recovery_pending_key_path as matrix_recovery_pending_key_path_for_state_dir,
    matrix_recovery_rotating_marker_path as matrix_recovery_rotating_marker_path_for_state_dir,
};

#[cfg(unix)]
fn write_owner_only_cli_secret_no_replace(
    path: &PathBuf,
    content: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    use std::io::Write;

    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    if cli_path_exists_strict(path, "secret file")? {
        return Err(format!(
            "refusing to overwrite existing secret at {}; move it aside first",
            path.display()
        )
        .into());
    }
    // Write to a temp file in the same directory, fsync, then rename
    // into place. A direct create+truncate at the final path would
    // destroy the existing master recovery key on a crash or disk
    // error mid-write.
    let tmp_path = cli_secret_temp_path(path);
    {
        // Route through the canonical helper for O_NOFOLLOW + O_EXCL +
        // 0o600. The master recovery secret is the highest-stakes
        // payload that hits the CLI write path; the second-line guard
        // matters here even when O_EXCL alone is correct today.
        let mut file = crate::paths::create_atomic_tmp_owner_only(&tmp_path)?;
        let result = (|| -> std::io::Result<()> {
            file.write_all(content.as_bytes())?;
            file.write_all(b"\n")?;
            file.sync_all()
        })();
        if let Err(err) = result {
            let _ = std::fs::remove_file(&tmp_path);
            return Err(err.into());
        }
    }
    if let Err(err) = std::fs::hard_link(&tmp_path, path) {
        let _ = std::fs::remove_file(&tmp_path);
        return Err(err.into());
    }
    let _ = std::fs::remove_file(&tmp_path);
    crate::paths::sync_parent_dir_blocking(path)?;
    Ok(())
}

#[cfg(not(unix))]
fn write_owner_only_cli_secret_no_replace(
    path: &PathBuf,
    content: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    // SECURITY: same atomic-no-replace contract as the Unix branch.
    // Round 22 fixed the sibling `promote_owner_only_cli_secret_no_replace`
    // to use `std::fs::hard_link`; this `write_*` peer was missed.
    // The previous `path.exists()` + `std::fs::rename` pattern silently
    // overwrote on Windows because std-fs-rename maps to
    // `MoveFileExW(MOVEFILE_REPLACE_EXISTING)`. Reachable from
    // `cara matrix recovery-key restore` (which is NOT under the
    // rekey lock), so two concurrent restores could overwrite the
    // freshly-minted recovery key.
    //
    // tmp + hard_link is portable: on Windows NTFS supports hard
    // links and `CreateHardLinkW` returns ERROR_ALREADY_EXISTS for
    // a pre-existing destination — the atomic no-replace contract.
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    if cli_path_exists_strict(path, "secret file")? {
        return Err(format!(
            "refusing to overwrite existing secret at {}; move it aside first",
            path.display()
        )
        .into());
    }
    let tmp_path = cli_secret_temp_path(path);
    {
        use std::io::Write;
        let mut file = std::fs::OpenOptions::new()
            .create_new(true)
            .write(true)
            .open(&tmp_path)?;
        let result = (|| -> std::io::Result<()> {
            file.write_all(content.as_bytes())?;
            file.write_all(b"\n")?;
            file.sync_all()
        })();
        if let Err(err) = result {
            let _ = std::fs::remove_file(&tmp_path);
            return Err(err.into());
        }
    }
    let link_result = std::fs::hard_link(&tmp_path, path);
    let _ = std::fs::remove_file(&tmp_path);
    if let Err(err) = link_result {
        if err.kind() == std::io::ErrorKind::Unsupported {
            return Err(format!(
                "filesystem at {} does not support hard links \
                 (e.g. FAT32, exFAT, or ReFS-without-hardlinks); \
                 move CARAPACE_STATE_DIR to an NTFS volume and retry",
                path.display()
            )
            .into());
        }
        return Err(format!(
            "link secret into place at {} (from {}): {err}",
            path.display(),
            tmp_path.display()
        )
        .into());
    }
    crate::paths::sync_parent_dir_blocking(path)?;
    Ok(())
}

fn promote_owner_only_cli_secret_no_replace(
    src: &Path,
    dst: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
    // SECURITY: this function is the atomic-no-replace promote step
    // for the Matrix store passphrase (`cli/mod.rs` rekey path) and
    // similar owner-only CLI secrets. It MUST refuse to overwrite an
    // existing destination — two concurrent `cara matrix rekey-store`
    // invocations both pass an `exists()` precheck, but only one
    // `hard_link` call can succeed (the other returns EEXIST /
    // ERROR_ALREADY_EXISTS). The previous Windows path used
    // `dst.exists()` + `std::fs::rename`; on Windows std-fs-rename
    // maps to `MoveFileExW(MOVEFILE_REPLACE_EXISTING)` which silently
    // replaced concurrent writers — silent passphrase overwrite =
    // data loss for the encrypted Matrix store. NTFS supports hard
    // links, so the Unix idiom is portable. Unsupported filesystems
    // (FAT32, ReFS-with-disabled-hardlinks) surface a clear error
    // rather than silently losing data.
    if cli_path_exists_strict(dst, "secret file")? {
        return Err(format!(
            "refusing to overwrite existing secret at {}; move it aside first",
            dst.display()
        )
        .into());
    }
    if let Err(err) = std::fs::hard_link(src, dst) {
        return Err(format!(
            "link secret into place at {} (from {}): {err}",
            dst.display(),
            src.display()
        )
        .into());
    }
    // Destination dirent flush is on the success path — propagate
    // errors. Source-side removal (cleanup of the pending file) is
    // best-effort: the link has already committed by then.
    crate::paths::sync_parent_dir_blocking(dst)?;
    std::fs::remove_file(src)?;
    crate::paths::sync_parent_dir_best_effort_blocking(src);
    Ok(())
}

fn cli_secret_temp_path(path: &Path) -> PathBuf {
    crate::paths::atomic_tmp_path(path, "cli-secret")
}

struct TaskCreateOptions {
    payload: String,
    next_run_at_ms: Option<u64>,
    max_attempts: Option<u32>,
    max_total_runtime_ms: Option<u64>,
    max_turns: Option<u32>,
    max_run_timeout_seconds: Option<u32>,
}

struct TaskUpdateOptions {
    payload: Option<String>,
    max_attempts: Option<u32>,
    max_total_runtime_ms: Option<u64>,
    max_turns: Option<u32>,
    max_run_timeout_seconds: Option<u32>,
    reason: Option<String>,
}

async fn handle_task_create(
    host: &str,
    port: Option<u16>,
    options: TaskCreateOptions,
) -> Result<(), Box<dyn std::error::Error>> {
    let parsed_payload: Value = serde_json::from_str(&options.payload)
        .map_err(|e| format!("invalid --payload JSON (expected CronPayload object): {e}"))?;
    let mut body = serde_json::Map::new();
    body.insert("payload".to_string(), parsed_payload);
    body.insert(
        "nextRunAtMs".to_string(),
        options.next_run_at_ms.map_or(Value::Null, Value::from),
    );

    let mut policy = serde_json::Map::new();
    if let Some(max_attempts) = options.max_attempts {
        policy.insert("maxAttempts".to_string(), Value::from(max_attempts));
    }
    if let Some(max_total_runtime_ms) = options.max_total_runtime_ms {
        policy.insert(
            "maxTotalRuntimeMs".to_string(),
            Value::from(max_total_runtime_ms),
        );
    }
    if let Some(max_turns) = options.max_turns {
        policy.insert("maxTurns".to_string(), Value::from(max_turns));
    }
    if let Some(max_run_timeout_seconds) = options.max_run_timeout_seconds {
        policy.insert(
            "maxRunTimeoutSeconds".to_string(),
            Value::from(max_run_timeout_seconds),
        );
    }
    if !policy.is_empty() {
        body.insert("policy".to_string(), Value::Object(policy));
    }
    let body = Value::Object(body);

    let response = send_control_request(
        host,
        resolve_port(port),
        reqwest::Method::POST,
        "/control/tasks",
        &[],
        Some(body),
    )
    .await?;
    print_pretty_json(&response)
}

async fn handle_task_list(
    host: &str,
    port: Option<u16>,
    state: Option<String>,
    limit: Option<usize>,
) -> Result<(), Box<dyn std::error::Error>> {
    let mut query: Vec<(&str, String)> = Vec::new();
    if let Some(state) = state {
        let normalized = state.trim();
        if !normalized.is_empty() {
            query.push(("state", normalized.to_string()));
        }
    }
    if let Some(limit) = limit {
        query.push(("limit", limit.to_string()));
    }

    let response = send_control_request(
        host,
        resolve_port(port),
        reqwest::Method::GET,
        "/control/tasks",
        &query,
        None,
    )
    .await?;
    print_pretty_json(&response)
}

async fn handle_task_get(
    host: &str,
    port: Option<u16>,
    id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let task_id = normalize_task_id(id)?;
    let path = format!("/control/tasks/{task_id}");
    let response = send_control_request(
        host,
        resolve_port(port),
        reqwest::Method::GET,
        &path,
        &[],
        None,
    )
    .await?;
    print_pretty_json(&response)
}

async fn handle_task_cancel(
    host: &str,
    port: Option<u16>,
    id: &str,
    reason: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
    let task_id = normalize_task_id(id)?;
    let path = format!("/control/tasks/{task_id}/cancel");
    let body = reason
        .as_deref()
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(|trimmed| serde_json::json!({ "reason": trimmed }));
    let response = send_control_request(
        host,
        resolve_port(port),
        reqwest::Method::POST,
        &path,
        &[],
        body,
    )
    .await?;
    print_pretty_json(&response)
}

async fn handle_task_retry(
    host: &str,
    port: Option<u16>,
    id: &str,
    delay_ms: Option<u64>,
    reason: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
    let task_id = normalize_task_id(id)?;
    let path = format!("/control/tasks/{task_id}/retry");
    let mut body = serde_json::Map::new();
    if let Some(delay_ms) = delay_ms {
        body.insert("delayMs".to_string(), Value::from(delay_ms));
    }
    if let Some(reason) = reason {
        let trimmed = reason.trim();
        if !trimmed.is_empty() {
            body.insert("reason".to_string(), Value::from(trimmed));
        }
    }
    let payload = if body.is_empty() {
        None
    } else {
        Some(Value::Object(body))
    };
    let response = send_control_request(
        host,
        resolve_port(port),
        reqwest::Method::POST,
        &path,
        &[],
        payload,
    )
    .await?;
    print_pretty_json(&response)
}

async fn handle_task_resume(
    host: &str,
    port: Option<u16>,
    id: &str,
    delay_ms: Option<u64>,
    reason: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
    let task_id = normalize_task_id(id)?;
    let path = format!("/control/tasks/{task_id}/resume");
    let mut body = serde_json::Map::new();
    if let Some(delay_ms) = delay_ms {
        body.insert("delayMs".to_string(), Value::from(delay_ms));
    }
    if let Some(reason) = reason {
        let trimmed = reason.trim();
        if !trimmed.is_empty() {
            body.insert("reason".to_string(), Value::from(trimmed));
        }
    }
    let payload = if body.is_empty() {
        None
    } else {
        Some(Value::Object(body))
    };
    let response = send_control_request(
        host,
        resolve_port(port),
        reqwest::Method::POST,
        &path,
        &[],
        payload,
    )
    .await?;
    print_pretty_json(&response)
}

async fn handle_task_update(
    host: &str,
    port: Option<u16>,
    id: &str,
    options: TaskUpdateOptions,
) -> Result<(), Box<dyn std::error::Error>> {
    let task_id = normalize_task_id(id)?;
    let path = format!("/control/tasks/{task_id}");
    let mut body = serde_json::Map::new();

    if let Some(payload) = options.payload {
        let parsed_payload: Value = serde_json::from_str(&payload)
            .map_err(|e| format!("invalid --payload JSON (expected CronPayload object): {e}"))?;
        body.insert("payload".to_string(), parsed_payload);
    }

    let mut policy = serde_json::Map::new();
    if let Some(max_attempts) = options.max_attempts {
        policy.insert("maxAttempts".to_string(), Value::from(max_attempts));
    }
    if let Some(max_total_runtime_ms) = options.max_total_runtime_ms {
        policy.insert(
            "maxTotalRuntimeMs".to_string(),
            Value::from(max_total_runtime_ms),
        );
    }
    if let Some(max_turns) = options.max_turns {
        policy.insert("maxTurns".to_string(), Value::from(max_turns));
    }
    if let Some(max_run_timeout_seconds) = options.max_run_timeout_seconds {
        policy.insert(
            "maxRunTimeoutSeconds".to_string(),
            Value::from(max_run_timeout_seconds),
        );
    }
    if !policy.is_empty() {
        body.insert("policy".to_string(), Value::Object(policy));
    }

    if let Some(reason) = options.reason {
        let trimmed = reason.trim();
        if !trimmed.is_empty() {
            body.insert("reason".to_string(), Value::from(trimmed));
        }
    }

    if body.is_empty() {
        return Err("task update requires at least one of: --payload, --max-attempts, --max-total-runtime-ms, --max-turns, --max-run-timeout-seconds, --reason".into());
    }

    let response = send_control_request(
        host,
        resolve_port(port),
        reqwest::Method::PATCH,
        &path,
        &[],
        Some(Value::Object(body)),
    )
    .await?;
    print_pretty_json(&response)
}

fn normalize_task_id(id: &str) -> Result<String, Box<dyn std::error::Error>> {
    let trimmed = id.trim();
    if trimmed.is_empty() {
        return Err("task id cannot be empty".into());
    }
    Ok(trimmed.to_string())
}

async fn send_control_request(
    host: &str,
    port: u16,
    method: reqwest::Method,
    path: &str,
    query: &[(&str, String)],
    body: Option<Value>,
) -> Result<Value, Box<dyn std::error::Error>> {
    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(90))
        .build()?;
    let GatewayAuth { token, password } = resolve_gateway_auth().await;
    let auth = GatewayAuth { token, password };
    let url = build_control_url(host, port, path, query)?;
    send_control_request_with_client_and_auth(&client, &auth, method, url, body).await
}

fn build_control_url(
    host: &str,
    port: u16,
    path: &str,
    query: &[(&str, String)],
) -> Result<Url, Box<dyn std::error::Error>> {
    let mut url = Url::parse(&format!("http://{}:{}{}", host, port, path))
        .map_err(|e| format!("failed to build control URL: {e}"))?;
    if !query.is_empty() {
        let mut pairs = url.query_pairs_mut();
        for (key, value) in query {
            pairs.append_pair(key, value);
        }
    }
    Ok(url)
}

async fn send_control_request_with_client_and_auth(
    client: &reqwest::Client,
    auth: &GatewayAuth,
    method: reqwest::Method,
    url: Url,
    body: Option<Value>,
) -> Result<Value, Box<dyn std::error::Error>> {
    // Refuse to send a bearer credential over plaintext HTTP to a
    // non-loopback host. `build_control_url` constructs `http://` URLs
    // unconditionally; if the operator points the CLI at a remote
    // gateway via `--host`, the token would otherwise traverse the
    // network in cleartext. Loopback (127.0.0.1, ::1) is the only
    // address class where this is acceptable; for remote control,
    // route through an SSH tunnel and target localhost.
    let has_credential = auth.token.is_some() || auth.password.is_some();
    let host_str = url.host_str().unwrap_or("");
    if has_credential && url.scheme() == "http" && !crate::net_util::is_loopback_host(host_str) {
        return Err(format!(
            "refusing to send gateway bearer credential over plaintext HTTP to non-loopback host '{}'. \
             For remote control, tunnel to localhost (e.g. `ssh -L 18789:127.0.0.1:18789 host`) and \
             target the loopback port instead.",
            host_str
        )
        .into());
    }
    let request_url = url.clone();
    // SECURITY: HTTP-scheme requests reach this point only when one
    // of (a) the caller supplied no credential, or (b) the target is
    // a loopback address — both gated by the conditional refusal
    // above. The carapace daemon binds 127.0.0.1:18789 over HTTP by
    // default; forcing HTTPS for control-plane traffic would require
    // operators to provision a self-signed certificate for localhost
    // and is therefore deliberately not enforced at this sink.
    let mut request = client.request(method, url);
    if let Some(token) = auth.token.as_deref() {
        request = request.bearer_auth(token);
    } else if let Some(password) = auth.password.as_deref() {
        request = request.bearer_auth(password);
    }
    if let Some(body) = body {
        request = request.json(&body);
    }

    let response = request.send().await.map_err(|e| {
        // SECURITY: the explicit `{request_url}` is intentional
        // operator-visible context; the implicit URL via `{e}` is
        // the redundant copy `reqwest::Error::Display` embeds.
        // Strip the implicit one via `without_url()` so userinfo
        // / query-string secrets don't appear twice (once
        // sanitized via request_url, once raw via the error).
        format!(
            "failed to send control request ({request_url}): {}",
            e.without_url()
        )
    })?;
    let status = response.status();
    // Cap control responses at 1 MiB — they carry status JSON / error
    // payloads, never bulk data.
    let bytes = crate::net_util::read_response_body_bytes_capped(response, 1024 * 1024).await?;
    if !status.is_success() {
        let error = extract_control_error_message(&bytes);
        // Surface operator-actionable hints alongside the bare HTTP
        // status — but ONLY for matrix-verification endpoints. The
        // 404/409/410/504 hint copy is verification-flow-specific
        // ("run `cara matrix verifications`"); applying it to
        // unrelated CLI calls (cara task get, cara config set, etc)
        // misroutes the operator. Scope by URL path.
        let is_matrix_verification_endpoint =
            request_url.path().contains("/control/matrix/verifications");
        let hint = if is_matrix_verification_endpoint {
            match status.as_u16() {
                404 => Some(
                    "the flow id may be a typo or already pruned — \
                     run `cara matrix verifications` and copy the flow id exactly.",
                ),
                409 => Some(
                    "the flow hasn't advanced far enough yet — \
                     wait for the peer to respond, then retry. \
                     `cara matrix verifications` shows the current state.",
                ),
                410 => Some(
                    "the flow is in a terminal state (cancelled / done / mismatched) — \
                     retrying issues the same SDK request and earns the same rejection. \
                     Start a new flow with `cara matrix verify <user>`.",
                ),
                504 => Some(
                    "the SDK request timed out; the action may have landed before the timeout fired — \
                     re-run `cara matrix verifications` to see whether the flow advanced.",
                ),
                _ => None,
            }
        } else {
            None
        };
        let formatted = match hint {
            Some(hint) => {
                format!("control request failed (HTTP {status}): {error}\n  → {hint}")
            }
            None => format!("control request failed (HTTP {status}): {error}"),
        };
        return Err(formatted.into());
    }

    if bytes.is_empty() {
        return Ok(serde_json::json!({ "ok": true }));
    }

    serde_json::from_slice(&bytes)
        .map_err(|e| format!("failed to parse control response as JSON: {e}").into())
}

fn extract_control_error_message(body: &[u8]) -> String {
    // SECURITY: the daemon's error JSON `error`/`message` fields can
    // carry homeserver- / plugin- / model-influenced bytes (e.g. a
    // hostile Matrix homeserver returns ANSI-laced error.message).
    // Strip terminal-control / bidi / zero-width chars before the
    // extracted string flows into the operator-visible Display chain
    // (`send_control_request` formats it into a Box<dyn Error> that
    // many CLI handlers eprintln via Display, bypassing Debug
    // escaping). Mirrors the strip applied at the chat REPL surface.
    let strip = |s: String| -> String {
        crate::logging::redact::strip_terminal_unsafe_chars(&s).into_owned()
    };
    if body.is_empty() {
        return "empty response body".to_string();
    }
    if let Ok(value) = serde_json::from_slice::<Value>(body) {
        if let Some(error) = value.get("error").and_then(|v| v.as_str()) {
            return strip(error.to_string());
        }
        if let Some(message) = value.get("message").and_then(|v| v.as_str()) {
            return strip(message.to_string());
        }
        return strip(value.to_string());
    }
    let text = strip(String::from_utf8_lossy(body).trim().to_string());
    if text.is_empty() {
        "response body unavailable".to_string()
    } else {
        text
    }
}

fn terminal_safe_pretty_json(value: &Value) -> Result<String, serde_json::Error> {
    // SECURITY: every CLI JSON-output site exposes peer-, plugin-,
    // model-, or homeserver-influenced fields to the operator's
    // terminal (Matrix homeserver replies, task payload/reason
    // written by any channel/agent flow, plugin manifest fields).
    // `serde_json::to_string_pretty` escapes only JSON-mandated
    // controls (U+0000-U+001F, `"`, `\`) — NOT bidi (U+202A-U+202E),
    // zero-width (U+200B-U+200D), BOM (U+FEFF), or other Cf-class
    // formatting chars. Strip every string in the tree through
    // `strip_terminal_unsafe_chars` before serialization so a
    // hostile field cannot rewrite the operator's terminal or visually
    // swap displayed JSON values.
    let mut owned = value.clone();
    strip_terminal_unsafe_chars_in_json(&mut owned);
    serde_json::to_string_pretty(&owned)
}

fn write_pretty_json<W: std::io::Write + ?Sized>(
    value: &Value,
    out: &mut W,
) -> Result<(), Box<dyn std::error::Error>> {
    writeln!(out, "{}", terminal_safe_pretty_json(value)?)?;
    Ok(())
}

fn print_pretty_json(value: &Value) -> Result<(), Box<dyn std::error::Error>> {
    let mut stdout = std::io::stdout();
    write_pretty_json(value, &mut stdout)?;
    Ok(())
}

/// Recursively strip terminal-unsafe chars (ANSI / bidi / zero-width
/// / Cf / BOM) from every string value in a JSON tree.
///
/// SECURITY: `serde_json::to_string_pretty` escapes only JSON-mandated
/// controls (U+0000–U+001F, `"`, `\`); it does NOT escape U+200B-
/// U+200F, U+202A-U+202E, U+2066-U+2069, or U+FEFF. A hostile /
/// MITM-attacked homeserver returning `displayName` / `userId` /
/// `deviceId` with bidi overrides will appear visually swapped in
/// an operator's `cara matrix devices` / `verifications` output —
/// the operator's last-line defense against approving the wrong
/// device. Strip-on-the-way-out closes that surface for matrix-side
/// JSON prints without affecting other callers.
fn strip_terminal_unsafe_chars_in_json(value: &mut Value) {
    match value {
        Value::String(s) => {
            let stripped = crate::logging::redact::strip_terminal_unsafe_chars(s);
            if let std::borrow::Cow::Owned(owned) = stripped {
                *s = owned;
            }
        }
        Value::Array(items) => {
            for item in items.iter_mut() {
                strip_terminal_unsafe_chars_in_json(item);
            }
        }
        Value::Object(map) => {
            for (_key, v) in map.iter_mut() {
                strip_terminal_unsafe_chars_in_json(v);
            }
        }
        _ => {}
    }
}

/// Run the `logs` subcommand -- fetch recent logs from a running instance.
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct LogsTailEntry {
    timestamp: u64,
    level: LogLevel,
    target: String,
    message: String,
}

#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct LogsTailResponse {
    #[serde(default)]
    entries: Vec<LogsTailEntry>,
    #[serde(default)]
    truncated: bool,
}

#[derive(Debug, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct PluginStatusEntry {
    name: String,
    #[serde(default)]
    plugin_id: Option<String>,
    #[serde(default)]
    enabled: Option<bool>,
    #[serde(default)]
    install_id: Option<Value>,
    #[serde(default)]
    requested_at: Option<u64>,
    #[serde(default)]
    source: Option<String>,
    #[serde(default)]
    state: Option<String>,
    #[serde(default)]
    reason: Option<String>,
}

#[derive(Debug, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct PluginsStatusResponse {
    plugins_enabled: bool,
    configured_plugin_path_count: usize,
    restart_required_for_changes: bool,
    activation_error_count: usize,
    #[serde(default)]
    plugins: Vec<PluginStatusEntry>,
}

#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct PluginBinEntry {
    name: String,
}

#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct PluginsBinsResponse {
    #[serde(default)]
    bins: Vec<PluginBinEntry>,
}

#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct PluginActivationSummary {
    state: String,
    #[serde(default)]
    message: Option<String>,
}

#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct PluginMutationResponse {
    name: String,
    #[serde(default)]
    version: Option<String>,
    activation: PluginActivationSummary,
}

struct PluginStatusCliOptions {
    connection: WsConnectionArgs,
    json_output: bool,
    name: Option<String>,
    plugin_id: Option<String>,
    source: Option<PluginSourceSelection>,
    state: Option<PluginStateSelection>,
    only_failed: bool,
    strict: bool,
}

pub(crate) type WsStream =
    tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
pub(crate) type WsRead = futures_util::stream::SplitStream<WsStream>;
pub(crate) type WsWrite = futures_util::stream::SplitSink<WsStream, Message>;

#[derive(Debug)]
struct InsecureCertVerifier;

impl ServerCertVerifier for InsecureCertVerifier {
    fn verify_server_cert(
        &self,
        _end_entity: &CertificateDer<'_>,
        _intermediates: &[CertificateDer<'_>],
        _server_name: &ServerName<'_>,
        _ocsp_response: &[u8],
        _now: UnixTime,
    ) -> Result<ServerCertVerified, RustlsError> {
        Ok(ServerCertVerified::assertion())
    }

    fn verify_tls12_signature(
        &self,
        _message: &[u8],
        _cert: &CertificateDer<'_>,
        _dss: &DigitallySignedStruct,
    ) -> Result<HandshakeSignatureValid, RustlsError> {
        Ok(HandshakeSignatureValid::assertion())
    }

    fn verify_tls13_signature(
        &self,
        _message: &[u8],
        _cert: &CertificateDer<'_>,
        _dss: &DigitallySignedStruct,
    ) -> Result<HandshakeSignatureValid, RustlsError> {
        Ok(HandshakeSignatureValid::assertion())
    }

    fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
        vec![
            SignatureScheme::ECDSA_NISTP256_SHA256,
            SignatureScheme::ECDSA_NISTP384_SHA384,
            SignatureScheme::ECDSA_NISTP521_SHA512,
            SignatureScheme::RSA_PSS_SHA256,
            SignatureScheme::RSA_PSS_SHA384,
            SignatureScheme::RSA_PSS_SHA512,
            SignatureScheme::RSA_PKCS1_SHA256,
            SignatureScheme::RSA_PKCS1_SHA384,
            SignatureScheme::RSA_PKCS1_SHA512,
            SignatureScheme::ED25519,
            SignatureScheme::ED448,
        ]
    }
}

pub(crate) async fn connect_ws(
    ws_url: &str,
    trust_invalid: bool,
) -> Result<WsStream, Box<dyn std::error::Error>> {
    if trust_invalid && ws_url.starts_with("wss://") {
        let config = rustls::ClientConfig::builder()
            .dangerous()
            .with_custom_certificate_verifier(std::sync::Arc::new(InsecureCertVerifier))
            .with_no_client_auth();
        let connector = Connector::Rustls(std::sync::Arc::new(config));
        let (stream, _) =
            connect_async_tls_with_config(ws_url, None, false, Some(connector)).await?;
        Ok(stream)
    } else {
        let (stream, _) = connect_async(ws_url).await?;
        Ok(stream)
    }
}

pub(crate) struct GatewayAuth {
    pub(crate) token: Option<String>,
    pub(crate) password: Option<String>,
}

pub(crate) async fn resolve_gateway_auth() -> GatewayAuth {
    let token_env = config::read_config_env("CARAPACE_GATEWAY_TOKEN").and_then(|v| {
        let token = v.trim().to_string();
        if token.is_empty() {
            None
        } else {
            Some(token)
        }
    });
    let password_env = config::read_config_env("CARAPACE_GATEWAY_PASSWORD").and_then(|v| {
        let password = v.trim().to_string();
        if password.is_empty() {
            None
        } else {
            Some(password)
        }
    });

    let mut token_cfg = None;
    let mut password_cfg = None;
    if let Ok(cfg) = config::load_config() {
        if let Some(token) = cfg
            .get("gateway")
            .and_then(|v| v.get("auth"))
            .and_then(|v| v.get("token"))
            .and_then(|v| v.as_str())
        {
            let token = token.trim().to_string();
            if !token.is_empty() {
                token_cfg = Some(token);
            }
        }
        if let Some(password) = cfg
            .get("gateway")
            .and_then(|v| v.get("auth"))
            .and_then(|v| v.get("password"))
            .and_then(|v| v.as_str())
        {
            let password = password.trim().to_string();
            if !password.is_empty() {
                password_cfg = Some(password);
            }
        }
    }

    let mut token_creds = None;
    let mut password_creds = None;
    let state_dir = resolve_state_dir();
    if let Ok(mut creds) = credentials::read_gateway_auth(state_dir).await {
        token_creds = std::mem::take(&mut creds.token).and_then(|v| {
            let token = v.trim().to_string();
            if token.is_empty() {
                None
            } else {
                Some(token)
            }
        });
        password_creds = std::mem::take(&mut creds.password).and_then(|v| {
            let password = v.trim().to_string();
            if password.is_empty() {
                None
            } else {
                Some(password)
            }
        });
    }

    GatewayAuth {
        token: token_env.or(token_cfg).or(token_creds),
        password: password_env.or(password_cfg).or(password_creds),
    }
}

#[derive(Debug, serde::Serialize, serde::Deserialize, Zeroize, ZeroizeOnDrop)]
pub(crate) struct StoredDeviceIdentity {
    device_id: String,
    public_key: String,
    secret_key: String,
}

const DEVICE_IDENTITY_FILENAME: &str = "device-identity.json";
const CONNECT_CHALLENGE_TIMEOUT: Duration = Duration::from_secs(30);

fn device_identity_path(state_dir: &Path) -> PathBuf {
    state_dir.join(DEVICE_IDENTITY_FILENAME)
}

fn strict_device_identity_mode() -> bool {
    env_flag_enabled("CARAPACE_DEVICE_IDENTITY_STRICT")
}

fn env_flag_enabled(name: &str) -> bool {
    config::read_config_env(name)
        .map(|value| {
            matches!(
                value.trim().to_lowercase().as_str(),
                "1" | "true" | "yes" | "on"
            )
        })
        .unwrap_or(false)
}

pub(crate) async fn load_or_create_device_identity(
    state_dir: &Path,
) -> Result<StoredDeviceIdentity, Box<dyn std::error::Error>> {
    std::fs::create_dir_all(state_dir)?;
    let identity_path = device_identity_path(state_dir);
    let strict = strict_device_identity_mode();

    match credentials::read_device_identity(state_dir.to_path_buf()).await {
        Ok(Some(data)) => {
            let identity: StoredDeviceIdentity = serde_json::from_str(&data)?;
            validate_device_identity(&identity)?;
            if identity_path.exists() {
                if let Err(err) = std::fs::remove_file(&identity_path) {
                    eprintln!(
                        "Warning: failed to remove file-backed device identity: {}",
                        err
                    );
                }
            }
            return Ok(identity);
        }
        Ok(None) => {}
        Err(err) => {
            if strict && should_fallback_to_device_identity_file(&err) {
                return Err(format!(
                    "credential store unavailable ({err}); strict device identity mode enabled"
                )
                .into());
            }
            if !should_fallback_to_device_identity_file(&err) {
                return Err(err.into());
            }
            warn_device_identity_file_fallback(&err);
        }
    }

    if identity_path.exists() {
        if strict {
            return Err(format!(
                "file-backed device identity present at {}; strict device identity mode enabled",
                identity_path.display()
            )
            .into());
        }
        // O_NOFOLLOW + 64 KiB cap via the shared CLI helper. The
        // device_identity file is a small JSON blob with an Ed25519
        // signing keypair; a same-uid attacker who plants a symlink at
        // this path during the daemon-down window before keyring
        // promotion completes would otherwise have the CLI read
        // attacker-chosen identity bytes. The strict-mode branch above
        // already refused; this non-strict fallback path now also
        // refuses symlinks unconditionally.
        let data = match read_small_cli_state_file_no_follow(&identity_path, 64 * 1024)? {
            Some(content) => content,
            None => {
                return Err(format!(
                    "device identity file at {} vanished between probe and read",
                    identity_path.display()
                )
                .into());
            }
        };
        let identity: StoredDeviceIdentity = serde_json::from_str(&data)?;
        validate_device_identity(&identity)?;
        if let Err(err) = credentials::write_device_identity(
            state_dir.to_path_buf(),
            &serde_json::to_string(&identity)?,
        )
        .await
        {
            if strict && should_fallback_to_device_identity_file(&err) {
                return Err(format!(
                    "credential store unavailable ({err}); strict device identity mode enabled"
                )
                .into());
            }
            if !should_fallback_to_device_identity_file(&err) {
                return Err(err.into());
            }
            warn_device_identity_file_fallback(&err);
            write_device_identity_file(&identity_path, &identity)?;
            return Ok(identity);
        }
        let _ = std::fs::remove_file(&identity_path);
        return Ok(identity);
    }

    let identity = generate_device_identity()?;
    if let Err(err) = credentials::write_device_identity(
        state_dir.to_path_buf(),
        &serde_json::to_string(&identity)?,
    )
    .await
    {
        if strict && should_fallback_to_device_identity_file(&err) {
            return Err(format!(
                "credential store unavailable ({err}); strict device identity mode enabled"
            )
            .into());
        }
        if !should_fallback_to_device_identity_file(&err) {
            return Err(err.into());
        }
        warn_device_identity_file_fallback(&err);
        write_device_identity_file(&identity_path, &identity)?;
    }
    Ok(identity)
}

fn should_fallback_to_device_identity_file(err: &credentials::CredentialError) -> bool {
    matches!(
        err,
        credentials::CredentialError::StoreUnavailable(_)
            | credentials::CredentialError::StoreLocked
            | credentials::CredentialError::AccessDenied
    )
}

fn warn_device_identity_file_fallback(err: &credentials::CredentialError) {
    match err {
        credentials::CredentialError::StoreLocked => {
            eprintln!("Warning: credential store is locked; using file-backed device identity.");
        }
        credentials::CredentialError::AccessDenied => {
            eprintln!(
                "Warning: credential store access denied; using file-backed device identity."
            );
        }
        credentials::CredentialError::StoreUnavailable(_) => {
            eprintln!("Warning: credential store unavailable; using file-backed device identity.");
        }
        _ => {
            eprintln!("Warning: credential store error; using file-backed device identity.");
        }
    }
}

fn write_device_identity_file(
    path: &Path,
    identity: &StoredDeviceIdentity,
) -> Result<(), Box<dyn std::error::Error>> {
    let contents = serde_json::to_string_pretty(identity)?;
    #[cfg(unix)]
    {
        use std::fs::OpenOptions;
        use std::io::Write;
        use std::os::unix::fs::OpenOptionsExt;
        // O_NOFOLLOW second-line guard: this file holds the Ed25519
        // device-pairing signing keypair. A same-uid attacker who
        // plants a symlink at `path` between the strict-mode probe
        // and the non-strict fallback open would otherwise truncate
        // the symlink's target file (the prior `truncate(true)` open
        // followed symlinks). Mirrors the Batch 65 / Batch 72 CLI
        // O_NOFOLLOW sweep — pulled in-scope by the cross-cutting
        // branch-touched-seam review.
        // O_NONBLOCK added in B109: `O_WRONLY | O_CREAT | O_TRUNC`
        // on a planted FIFO with no reader hangs open(2)
        // indefinitely; O_NONBLOCK + post-open is_file() refusal
        // closes the FIFO-hang class for this device-identity
        // write path.
        let mut file = OpenOptions::new()
            .create(true)
            .truncate(true)
            .write(true)
            .mode(0o600)
            .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
            .open(path)?;
        let metadata = file.metadata()?;
        if !metadata.is_file() {
            return Err(std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                "device identity path is not a regular file",
            )
            .into());
        }
        file.write_all(contents.as_bytes())?;
    }
    #[cfg(not(unix))]
    {
        std::fs::write(path, contents)?;
    }
    Ok(())
}

fn validate_device_identity(
    identity: &StoredDeviceIdentity,
) -> Result<(), Box<dyn std::error::Error>> {
    let signing_key = signing_key_from_identity(identity)?;
    let public_key = signing_key.verifying_key().to_bytes();
    let public_key_b64 = encode_base64_url(&public_key);
    if public_key_b64 != identity.public_key {
        return Err("device identity public key mismatch".into());
    }
    let derived_id = derive_device_id(&public_key);
    if derived_id != identity.device_id {
        return Err("device identity mismatch".into());
    }
    Ok(())
}

fn generate_device_identity() -> Result<StoredDeviceIdentity, Box<dyn std::error::Error>> {
    let mut seed = [0u8; 32];
    fill(&mut seed)?;
    let signing_key = SigningKey::from_bytes(&seed);
    let public_key = signing_key.verifying_key().to_bytes();
    let device_id = derive_device_id(&public_key);
    let secret_key = encode_base64_url(&seed);
    seed.zeroize();
    Ok(StoredDeviceIdentity {
        device_id,
        public_key: encode_base64_url(&public_key),
        secret_key,
    })
}

fn signing_key_from_identity(
    identity: &StoredDeviceIdentity,
) -> Result<SigningKey, Box<dyn std::error::Error>> {
    let secret_raw = Zeroizing::new(decode_base64_any(&identity.secret_key)?);
    if secret_raw.len() != 32 {
        return Err("device identity secret key invalid".into());
    }
    let mut secret = [0u8; 32];
    secret.copy_from_slice(&secret_raw);
    let signing_key = SigningKey::from_bytes(&secret);
    secret.zeroize();
    Ok(signing_key)
}

pub(crate) fn build_device_identity_for_connect(
    identity: &StoredDeviceIdentity,
    client_id: &str,
    client_mode: &str,
    role: &str,
    scopes: &[String],
    token: Option<&str>,
    nonce: Option<&str>,
) -> Result<Value, Box<dyn std::error::Error>> {
    let signed_at_ms = current_time_ms();
    let payload = build_device_auth_payload(DeviceAuthPayload {
        device_id: identity.device_id.clone(),
        client_id: client_id.to_string(),
        client_mode: client_mode.to_string(),
        role: role.to_string(),
        scopes: scopes.to_vec(),
        signed_at_ms,
        token: token.map(|value| value.to_string()),
        nonce: nonce.map(|value| value.to_string()),
    });
    let signature = sign_device_payload(identity, &payload)?;
    let mut device = serde_json::json!({
        "id": identity.device_id.clone(),
        "publicKey": identity.public_key.clone(),
        "signature": signature,
        "signedAt": signed_at_ms,
    });
    if let Some(nonce) = nonce {
        device["nonce"] = Value::String(nonce.to_string());
    }
    Ok(device)
}

fn sign_device_payload(
    identity: &StoredDeviceIdentity,
    payload: &str,
) -> Result<String, Box<dyn std::error::Error>> {
    let signing_key = signing_key_from_identity(identity)?;
    let signature = signing_key.sign(payload.as_bytes());
    Ok(encode_base64_url(&signature.to_bytes()))
}

struct DeviceAuthPayload {
    device_id: String,
    client_id: String,
    client_mode: String,
    role: String,
    scopes: Vec<String>,
    signed_at_ms: i64,
    token: Option<String>,
    nonce: Option<String>,
}

fn build_device_auth_payload(params: DeviceAuthPayload) -> String {
    let version = if params.nonce.is_some() { "v2" } else { "v1" };
    let scopes = params.scopes.join(",");
    let token = params.token.unwrap_or_default();
    let mut base = vec![
        version.to_string(),
        params.device_id,
        params.client_id,
        params.client_mode,
        params.role,
        scopes,
        params.signed_at_ms.to_string(),
        token,
    ];
    if let Some(nonce) = params.nonce {
        base.push(nonce);
    }
    base.join("|")
}

fn encode_base64_url(bytes: &[u8]) -> String {
    URL_SAFE_NO_PAD.encode(bytes)
}

fn decode_base64_any(input: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
    if let Ok(bytes) = URL_SAFE_NO_PAD.decode(input.as_bytes()) {
        return Ok(bytes);
    }
    STANDARD
        .decode(input.as_bytes())
        .map_err(|_| "base64 decode failed".into())
}

fn derive_device_id(public_key: &[u8]) -> String {
    let digest = Sha256::digest(public_key);
    hex::encode(digest)
}

fn current_time_ms() -> i64 {
    crate::time::unix_now_ms_i64()
}

fn ws_url_from_http(url: &Url) -> Result<String, Box<dyn std::error::Error>> {
    let scheme = match url.scheme() {
        "https" => "wss",
        "http" => "ws",
        _ => return Err("invalid URL scheme".into()),
    };
    let host = match url.host() {
        Some(Host::Domain(name)) => name.to_string(),
        Some(Host::Ipv4(addr)) => addr.to_string(),
        Some(Host::Ipv6(addr)) => format!("[{}]", addr),
        None => return Err("missing host in gateway URL".into()),
    };
    let port = url
        .port_or_known_default()
        .ok_or("missing port in gateway URL")?;
    Ok(format!("{scheme}://{host}:{port}/ws"))
}

/// Render a gateway URL as a redacted `scheme://host:port` string.
///
/// SECURITY: `Url::as_str()` round-trips userinfo (`user:pass@`),
/// querystrings (`?token=...`), and fragments. The `cara pair` flow
/// must never println or persist these — userinfo is a credential,
/// query strings commonly carry single-use bootstrap tokens, and the
/// only state the pairing flow actually consumes downstream is the
/// scheme/host/port triple (see `ws_url_from_http` and the
/// `gateway_url` field in `pairing.json`, which is informational and
/// never re-parsed for anything but display).
fn redacted_gateway_url(url: &Url) -> Result<String, Box<dyn std::error::Error>> {
    let scheme = url.scheme();
    if scheme != "http" && scheme != "https" {
        return Err("invalid URL scheme".into());
    }
    let host = match url.host() {
        Some(Host::Domain(name)) => name.to_string(),
        Some(Host::Ipv4(addr)) => addr.to_string(),
        Some(Host::Ipv6(addr)) => format!("[{}]", addr),
        None => return Err("missing host in gateway URL".into()),
    };
    let port = url
        .port_or_known_default()
        .ok_or("missing port in gateway URL")?;
    Ok(format!("{scheme}://{host}:{port}"))
}

pub(crate) async fn read_ws_json(
    reader: &mut WsRead,
    writer: &mut WsWrite,
) -> Result<Value, Box<dyn std::error::Error>> {
    while let Some(msg) = reader.next().await {
        match msg? {
            Message::Text(text) => return Ok(serde_json::from_str(&text)?),
            Message::Binary(bytes) => {
                if let Ok(text) = String::from_utf8(bytes.to_vec()) {
                    return Ok(serde_json::from_str(&text)?);
                }
            }
            Message::Ping(data) => {
                writer.send(Message::Pong(data)).await?;
            }
            Message::Pong(_) => {}
            Message::Close(frame) => {
                let reason = frame
                    .as_ref()
                    .map(|f| f.reason.to_string())
                    .unwrap_or_default();
                let msg = if reason.is_empty() {
                    "WebSocket closed".to_string()
                } else {
                    format!("WebSocket closed: {}", reason)
                };
                return Err(msg.into());
            }
            _ => {}
        }
    }

    Err("WebSocket closed".into())
}

pub(crate) async fn await_connect_challenge(
    reader: &mut WsRead,
    writer: &mut WsWrite,
) -> Result<String, Box<dyn std::error::Error>> {
    let deadline = tokio::time::Instant::now() + CONNECT_CHALLENGE_TIMEOUT;
    tokio::time::timeout_at(deadline, async {
        loop {
            let frame = read_ws_json(reader, writer).await?;
            let frame_type = frame.get("type").and_then(|v| v.as_str()).unwrap_or("");
            if frame_type == "event" {
                let event = frame.get("event").and_then(|v| v.as_str()).unwrap_or("");
                if event == "connect.challenge" {
                    let nonce = frame
                        .get("payload")
                        .and_then(|v| v.get("nonce"))
                        .and_then(|v| v.as_str())
                        .ok_or("connect.challenge missing nonce")?;
                    return Ok(nonce.to_string());
                }
                continue;
            }
            if frame_type == "res" {
                let message = frame
                    .get("error")
                    .and_then(|v| v.get("message"))
                    .and_then(|v| v.as_str())
                    .unwrap_or("unexpected response before connect");
                return Err(message.to_string().into());
            }
        }
    })
    .await
    .map_err(|_| -> Box<dyn std::error::Error> { "connect.challenge timed out".into() })?
}

pub(crate) async fn await_ws_response(
    reader: &mut WsRead,
    writer: &mut WsWrite,
    request_id: &str,
) -> Result<Value, Box<dyn std::error::Error>> {
    loop {
        let frame = read_ws_json(reader, writer).await?;
        let frame_type = frame.get("type").and_then(|v| v.as_str()).unwrap_or("");
        if frame_type == "event" {
            continue;
        }
        if frame_type != "res" {
            continue;
        }
        let id = frame.get("id").and_then(|v| v.as_str()).unwrap_or("");
        if id != request_id {
            continue;
        }
        let ok = frame.get("ok").and_then(|v| v.as_bool()).unwrap_or(false);
        if ok {
            return Ok(frame.get("payload").cloned().unwrap_or(Value::Null));
        }
        let message = frame
            .get("error")
            .and_then(|v| v.get("message"))
            .and_then(|v| v.as_str())
            .unwrap_or("request failed");
        return Err(message.to_string().into());
    }
}

#[derive(Debug)]
pub(crate) struct WsError {
    pub(crate) code: Option<String>,
    pub(crate) message: String,
    pub(crate) details: Option<Value>,
}

impl std::fmt::Display for WsError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.message)
    }
}

impl std::error::Error for WsError {}

pub(crate) async fn await_ws_response_with_error(
    reader: &mut WsRead,
    writer: &mut WsWrite,
    request_id: &str,
) -> Result<Value, WsError> {
    loop {
        let frame = match read_ws_json(reader, writer).await {
            Ok(frame) => frame,
            Err(err) => {
                return Err(WsError {
                    code: None,
                    message: err.to_string(),
                    details: None,
                });
            }
        };
        let frame_type = frame.get("type").and_then(|v| v.as_str()).unwrap_or("");
        if frame_type == "event" {
            continue;
        }
        if frame_type != "res" {
            continue;
        }
        let id = frame.get("id").and_then(|v| v.as_str()).unwrap_or("");
        if id != request_id {
            continue;
        }
        let ok = frame.get("ok").and_then(|v| v.as_bool()).unwrap_or(false);
        if ok {
            return Ok(frame.get("payload").cloned().unwrap_or(Value::Null));
        }
        let error = frame.get("error").cloned().unwrap_or(Value::Null);
        let code = error
            .get("code")
            .and_then(|v| v.as_str())
            .map(|v| v.to_string());
        // SECURITY: daemon WS error.message can carry homeserver- /
        // plugin- / model-influenced bytes; strip terminal-control /
        // bidi / zero-width before stamping into WsError so every
        // downstream eprintln/Display sink is safe by default.
        let message = crate::logging::redact::strip_terminal_unsafe_chars(
            error
                .get("message")
                .and_then(|v| v.as_str())
                .unwrap_or("request failed"),
        )
        .into_owned();
        let details = error.get("details").cloned();
        return Err(WsError {
            code,
            message,
            details,
        });
    }
}

fn extract_pairing_request_id(details: &Value) -> Option<String> {
    if let Some(id) = details.get("requestId").and_then(|value| value.as_str()) {
        return Some(id.to_string());
    }
    details
        .get("details")
        .and_then(|value| value.get("requestId"))
        .and_then(|value| value.as_str())
        .map(|id| id.to_string())
}

fn cli_error(message: impl Into<String>) -> Box<dyn std::error::Error> {
    std::io::Error::other(message.into()).into()
}

fn validate_cli_ws_transport(
    host: &str,
    tls: bool,
    trust: bool,
    allow_plaintext: bool,
) -> Result<(), Box<dyn std::error::Error>> {
    let is_loopback = crate::net_util::is_loopback_host(host);
    if !is_loopback && !tls && !allow_plaintext {
        return Err(cli_error(
            "remote WebSocket commands require TLS or explicit plaintext opt-in; use --tls or pass --allow-plaintext",
        ));
    }
    if !is_loopback && !tls && allow_plaintext {
        eprintln!(
            "Warning: using plaintext WebSocket to remote host; credentials will be sent unencrypted."
        );
    }
    if trust && !tls {
        eprintln!("Warning: --trust has no effect without --tls.");
    }
    Ok(())
}

async fn connect_cli_ws_authenticated(
    connection: &WsConnectionArgs,
    role: &str,
    scopes: &[&str],
) -> Result<(WsWrite, WsRead), Box<dyn std::error::Error>> {
    validate_cli_ws_transport(
        &connection.host,
        connection.tls,
        connection.trust,
        connection.allow_plaintext,
    )?;

    let port = resolve_port(connection.port);
    let auth = resolve_gateway_auth().await;
    if auth.token.is_none() && auth.password.is_none() {
        eprintln!("No gateway auth credentials found.");
        eprintln!("Attempting local-direct connection (if enabled)...");
    }

    let state_dir = resolve_state_dir();
    let device_identity = load_or_create_device_identity(&state_dir).await?;
    let ws_url = {
        // Scheme picked from the operator's --tls flag. The plaintext
        // branch is opt-in via --allow-plaintext earlier in connection
        // setup; building the URL via a `scheme` variable keeps generic
        // insecure-websocket scanners from flagging the intentional
        // branch.
        let scheme = if connection.tls { "wss" } else { "ws" };
        format!("{scheme}://{}:{}/ws", connection.host, port)
    };
    let ws_stream = connect_ws(&ws_url, connection.trust).await.map_err(|err| {
        cli_error(format!(
            "could not connect to carapace at {}:{}: {}",
            connection.host, port, err
        ))
    })?;
    let (mut ws_write, mut ws_read) = ws_stream.split();

    let nonce = await_connect_challenge(&mut ws_read, &mut ws_write).await?;
    let scope_values: Vec<String> = scopes.iter().map(|scope| (*scope).to_string()).collect();
    let mut connect_params = serde_json::json!({
        "minProtocol": 3,
        "maxProtocol": 3,
        "client": {
            "id": "cli",
            "version": env!("CARGO_PKG_VERSION"),
            "platform": std::env::consts::OS,
            "mode": "cli"
        },
        "role": role,
        "scopes": scope_values.clone()
    });
    let GatewayAuth { token, password } = auth;
    let token_for_signature = token.clone();
    if let Some(token) = token {
        connect_params["auth"] = serde_json::json!({ "token": token });
    } else if let Some(password) = password {
        connect_params["auth"] = serde_json::json!({ "password": password });
    }
    connect_params["device"] = build_device_identity_for_connect(
        &device_identity,
        "cli",
        "cli",
        role,
        &scope_values,
        token_for_signature.as_deref(),
        Some(&nonce),
    )?;

    let connect_frame = serde_json::json!({
        "type": "req",
        "id": "connect-1",
        "method": "connect",
        "params": connect_params
    });
    ws_write
        .send(Message::Text(serde_json::to_string(&connect_frame)?.into()))
        .await?;

    if let Err(err) = await_ws_response_with_error(&mut ws_read, &mut ws_write, "connect-1").await {
        let message = if err.code.as_deref() == Some("not_paired")
            && err.message.contains("pairing required")
        {
            let mut message =
                "device pairing required for this CLI; approve the request in the control UI and retry".to_string();
            if let Some(details) = err.details.as_ref() {
                if let Some(request_id) = extract_pairing_request_id(details) {
                    message.push_str(&format!(" (request ID: {request_id})"));
                }
            }
            message
        } else if err.message.contains("device identity required") {
            format!(
                "{}; this gateway requires a paired device for WebSocket access. Set gateway.auth.token for local CLI access or use the control UI",
                err.message
            )
        } else {
            err.message.clone()
        };
        return Err(cli_error(format!("WebSocket connect failed: {message}")));
    }

    Ok((ws_write, ws_read))
}

async fn send_cli_ws_request(
    connection: &WsConnectionArgs,
    role: &str,
    scopes: &[&str],
    method: &str,
    params: Value,
) -> Result<Value, Box<dyn std::error::Error>> {
    let (mut ws_write, mut ws_read) =
        connect_cli_ws_authenticated(connection, role, scopes).await?;
    let request_id = format!("cli-{}", method.replace('.', "-"));
    let frame = serde_json::json!({
        "type": "req",
        "id": request_id,
        "method": method,
        "params": params,
    });
    ws_write
        .send(Message::Text(serde_json::to_string(&frame)?.into()))
        .await?;
    await_ws_response_with_error(&mut ws_read, &mut ws_write, &request_id)
        .await
        .map_err(|err| cli_error(format!("{method} failed: {}", err.message)))
}

fn plugin_source_label(source: PluginSourceSelection) -> &'static str {
    match source {
        PluginSourceSelection::Managed => "managed",
        PluginSourceSelection::Config => "config",
    }
}

fn plugin_state_label(state: PluginStateSelection) -> &'static str {
    match state {
        PluginStateSelection::Active => "active",
        PluginStateSelection::Disabled => "disabled",
        PluginStateSelection::Ignored => "ignored",
        PluginStateSelection::Failed => "failed",
    }
}

fn plugin_entry_matches_filters(
    entry: &PluginStatusEntry,
    name: Option<&str>,
    plugin_id: Option<&str>,
    source: Option<PluginSourceSelection>,
    state: Option<PluginStateSelection>,
    only_failed: bool,
) -> bool {
    if let Some(name) = name {
        if entry.name != name {
            return false;
        }
    }
    if let Some(plugin_id) = plugin_id {
        if entry.plugin_id.as_deref() != Some(plugin_id) {
            return false;
        }
    }
    if let Some(source) = source {
        if entry.source.as_deref() != Some(plugin_source_label(source)) {
            return false;
        }
    }
    if let Some(state) = state {
        if entry.state.as_deref() != Some(plugin_state_label(state)) {
            return false;
        }
    }
    if only_failed && entry.state.as_deref() != Some("failed") {
        return false;
    }
    true
}

fn maybe_fail_strict_plugin_status(
    response: &PluginsStatusResponse,
) -> Result<(), Box<dyn std::error::Error>> {
    if response.activation_error_count > 0 {
        return Err(cli_error(format!(
            "plugin activation errors reported: {}",
            response.activation_error_count
        )));
    }
    if response.plugins.is_empty() {
        return Err(cli_error("no matching plugins found"));
    }
    let non_active = response
        .plugins
        .iter()
        .filter(|entry| entry.state.as_deref() != Some("active"))
        .map(|entry| entry.name.as_str())
        .collect::<Vec<_>>();
    if !non_active.is_empty() {
        return Err(cli_error(format!(
            "one or more matching plugins are not active: {}",
            non_active.join(", ")
        )));
    }
    Ok(())
}

fn print_plugin_status_human(response: &PluginsStatusResponse) {
    println!("Plugin status");
    println!("=============");
    println!("  Plugins enabled: {}", response.plugins_enabled);
    println!(
        "  Configured plugin paths: {}",
        response.configured_plugin_path_count
    );
    println!(
        "  Restart required for changes: {}",
        response.restart_required_for_changes
    );
    println!("  Activation errors: {}", response.activation_error_count);
    println!();

    if response.plugins.is_empty() {
        println!("No matching plugins found.");
        return;
    }

    // SECURITY: plugin status entries echo fields originating from
    // plugin manifests / activation runtime (name, state, source,
    // plugin_id, reason). Strip terminal-control chars before
    // printing to operator terminal — same threat model as the
    // chat REPL strip.
    let strip = crate::logging::redact::strip_terminal_unsafe_chars;
    for entry in &response.plugins {
        let state = entry.state.as_deref().unwrap_or("unknown");
        let source = entry.source.as_deref().unwrap_or("-");
        let enabled = entry.enabled.unwrap_or(true);
        let plugin_id = entry.plugin_id.as_deref().unwrap_or("-");
        println!(
            "{} [{}] {} (enabled: {}, pluginId: {})",
            strip(&entry.name),
            strip(source),
            strip(state),
            enabled,
            strip(plugin_id)
        );
        if let Some(reason) = entry.reason.as_deref().filter(|reason| !reason.is_empty()) {
            println!("  reason: {}", strip(reason));
        }
    }
}

fn print_plugin_bins_human(response: &PluginsBinsResponse) {
    if response.bins.is_empty() {
        println!("No managed plugin binaries found.");
        return;
    }
    let mut names = response
        .bins
        .iter()
        .map(|entry| entry.name.as_str())
        .collect::<Vec<_>>();
    names.sort_unstable();
    // SECURITY: plugin bin names originate from plugin manifests /
    // staged-file basenames — strip terminal-control chars before
    // printing to operator terminal.
    let strip = crate::logging::redact::strip_terminal_unsafe_chars;
    for name in names {
        println!("{}", strip(name));
    }
}

fn validate_local_plugin_artifact(file_label: &str, bytes: &[u8]) -> Result<(), String> {
    crate::plugins::loader::validate_plugin_component_bytes(file_label, bytes)
        .map_err(|error| format!("invalid plugin component '{}': {}", file_label, error))
}

async fn read_and_validate_local_plugin_artifact(
    file: &Path,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
    let metadata = tokio::fs::metadata(file).await.map_err(|err| {
        cli_error(format!(
            "failed to stat plugin file '{}': {}",
            file.display(),
            err
        ))
    })?;
    if !metadata.is_file() {
        return Err(cli_error(format!(
            "plugin file '{}' is not a regular file",
            file.display()
        )));
    }
    if metadata.len() > crate::plugins::MAX_MANAGED_PLUGIN_ARTIFACT_BYTES {
        return Err(cli_error(format!(
            "plugin file '{}' exceeds maximum size ({} bytes > {} bytes)",
            file.display(),
            metadata.len(),
            crate::plugins::MAX_MANAGED_PLUGIN_ARTIFACT_BYTES
        )));
    }

    let bytes = tokio::fs::read(file).await.map_err(|err| {
        cli_error(format!(
            "failed to read plugin file '{}': {}",
            file.display(),
            err
        ))
    })?;
    let file_label = file.display().to_string();
    tokio::task::spawn_blocking(move || {
        validate_local_plugin_artifact(&file_label, &bytes)?;
        Ok::<Vec<u8>, String>(bytes)
    })
    .await
    .map_err(|err| cli_error(format!("plugin validation task failed: {}", err)))?
    .map_err(cli_error)
}

#[derive(Debug)]
struct ManagedPluginFileTransaction {
    dest: PathBuf,
    backup: Option<PathBuf>,
    lock: PathBuf,
    // Tracks what `Drop` should finish if the future is cancelled mid-commit/rollback.
    drop_action: ManagedPluginFileTransactionDropAction,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ManagedPluginFileTransactionDropAction {
    RollbackArtifact,
    ReleaseLockOnly,
    CommitCleanup,
    Completed,
}

#[derive(Debug)]
struct PendingPluginFileTransactionLock {
    path: Option<PathBuf>,
}

impl PendingPluginFileTransactionLock {
    fn new(path: PathBuf) -> Self {
        Self { path: Some(path) }
    }

    async fn release_with_context(mut self, message: String) -> String {
        match self.path.take() {
            Some(path) => release_plugin_file_transaction_lock_with_context(&path, message).await,
            None => message,
        }
    }

    fn into_path(mut self) -> PathBuf {
        self.path
            .take()
            .expect("pending plugin transaction lock already released")
    }
}

impl Drop for PendingPluginFileTransactionLock {
    fn drop(&mut self) {
        let Some(path) = self.path.take() else {
            return;
        };
        run_blocking_cleanup(move || {
            match release_plugin_file_transaction_lock_blocking(&path) {
                Ok(()) => eprintln!(
                    "Warning: PendingPluginFileTransactionLock dropped before handoff; synchronously removed staging lock '{}' after an interrupted or cancelled run",
                    path.display()
                ),
                Err(err) => eprintln!(
                    "Warning: PendingPluginFileTransactionLock dropped before handoff and failed to remove staging lock '{}': {}",
                    path.display(),
                    err
                ),
            }
        });
    }
}

impl ManagedPluginFileTransaction {
    async fn commit(mut self) -> Result<(), Box<dyn std::error::Error>> {
        self.drop_action = ManagedPluginFileTransactionDropAction::CommitCleanup;
        let mut failures = Vec::new();
        if let Some(backup) = self.backup.as_deref() {
            match tokio::fs::remove_file(backup).await {
                Ok(()) => self.backup = None,
                Err(err) if err.kind() == std::io::ErrorKind::NotFound => self.backup = None,
                Err(err) => {
                    failures.push(format!(
                        "plugin request succeeded, but failed to remove staging backup '{}': {}; remove or recover that backup before the next local `--file` plugin mutation",
                        backup.display(), err
                    ));
                }
            }
        }
        if let Err(err) = release_plugin_file_transaction_lock(&self.lock).await {
            failures.push(append_lock_release_failure(
                "plugin request succeeded".to_string(),
                &self.lock,
                &err,
            ));
        }
        self.drop_action = ManagedPluginFileTransactionDropAction::Completed;
        if failures.is_empty() {
            Ok(())
        } else {
            Err(cli_error(failures.join("; ")))
        }
    }

    async fn rollback(mut self) -> Result<String, Box<dyn std::error::Error>> {
        let rollback_result = match self.backup.as_deref() {
            Some(backup) => match restore_previous_plugin_artifact(backup, &self.dest).await {
                Ok(()) => Ok(format!(
                    "restored previous local managed artifact at '{}'",
                    self.dest.display()
                )),
                Err(err) => Err(err),
            },
            None => match tokio::fs::remove_file(&self.dest).await {
                Ok(()) => Ok(format!(
                    "removed staged local managed artifact at '{}'",
                    self.dest.display()
                )),
                Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(format!(
                    "no staged local managed artifact remained at '{}'",
                    self.dest.display()
                )),
                Err(err) => Err(cli_error(format!(
                    "failed to remove staged plugin artifact '{}' during rollback: {}",
                    self.dest.display(),
                    err
                ))),
            },
        };
        if rollback_result.is_ok() {
            self.backup = None;
            self.drop_action = ManagedPluginFileTransactionDropAction::ReleaseLockOnly;
        }
        let lock_release_result = release_plugin_file_transaction_lock(&self.lock).await;
        self.drop_action = ManagedPluginFileTransactionDropAction::Completed;
        match (rollback_result, lock_release_result) {
            (Ok(note), Ok(())) => Ok(note),
            (Ok(note), Err(lock_err)) => Err(cli_error(append_lock_release_failure(
                note, &self.lock, &lock_err,
            ))),
            (Err(err), Ok(())) => Err(err),
            (Err(err), Err(lock_err)) => Err(cli_error(append_lock_release_failure(
                err.to_string(),
                &self.lock,
                &lock_err,
            ))),
        }
    }
}

impl Drop for ManagedPluginFileTransaction {
    fn drop(&mut self) {
        let drop_action = std::mem::replace(
            &mut self.drop_action,
            ManagedPluginFileTransactionDropAction::Completed,
        );
        if drop_action == ManagedPluginFileTransactionDropAction::Completed {
            return;
        }
        let dest = self.dest.clone();
        let backup = self.backup.take();
        let lock = self.lock.clone();
        match drop_action {
            ManagedPluginFileTransactionDropAction::RollbackArtifact => {
                let message = format!(
                    "ManagedPluginFileTransaction dropped before rollback finished; attempting synchronous cleanup for staged artifact '{}' and any matching `.cli-lock` / `.cli-backup` files after an interrupted or cancelled run",
                    self.dest.display()
                );
                eprintln!("Warning: {message}");
                run_blocking_cleanup(move || {
                    if let Err(err) = rollback_managed_plugin_file_transaction_blocking(
                        &dest,
                        backup.as_deref(),
                        &lock,
                    ) {
                        eprintln!("Warning: dropped transaction cleanup failed: {err}");
                    }
                });
            }
            ManagedPluginFileTransactionDropAction::ReleaseLockOnly => {
                let message = format!(
                    "ManagedPluginFileTransaction dropped after artifact rollback but before staging lock release for '{}'; attempting synchronous lock cleanup after an interrupted or cancelled run",
                    self.dest.display()
                );
                eprintln!("Warning: {message}");
                run_blocking_cleanup(move || {
                    if let Err(err) = release_plugin_file_transaction_lock_blocking(&lock) {
                        eprintln!(
                            "Warning: dropped transaction cleanup failed to remove staging lock '{}': {}",
                            lock.display(),
                            err
                        );
                    }
                });
            }
            ManagedPluginFileTransactionDropAction::CommitCleanup => {
                let message = format!(
                    "ManagedPluginFileTransaction dropped while commit cleanup was still removing `.cli-backup` / `.cli-lock` sidecars for '{}'; attempting synchronous cleanup after an interrupted or cancelled run",
                    self.dest.display()
                );
                eprintln!("Warning: {message}");
                run_blocking_cleanup(move || {
                    if let Err(err) =
                        finish_managed_plugin_file_commit_cleanup_blocking(backup.as_deref(), &lock)
                    {
                        eprintln!("Warning: dropped transaction cleanup failed: {err}");
                    }
                });
            }
            ManagedPluginFileTransactionDropAction::Completed => {}
        }
    }
}

fn plugin_cli_sidecar_path(
    dest: &Path,
    suffix: &str,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
    let file_name = dest.file_name().ok_or_else(|| {
        cli_error(format!(
            "managed plugin destination '{}' has no file name",
            dest.display()
        ))
    })?;
    let mut sidecar_name = file_name.to_os_string();
    sidecar_name.push(suffix);
    Ok(dest.with_file_name(sidecar_name))
}

fn plugin_cli_backup_path(dest: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
    plugin_cli_sidecar_path(dest, ".cli-backup")
}

fn plugin_cli_lock_path(dest: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
    plugin_cli_sidecar_path(dest, ".cli-lock")
}

fn plugin_cli_staged_path(dest: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
    plugin_cli_sidecar_path(dest, ".cli-staged")
}

/// Cap on the .cli-lock sidecar size when read for PID-liveness
/// probe. PID strings are at most ~20 bytes (u32 max + newline);
/// 256 bytes is generous headroom matching the daemon-side sweep.
const PLUGIN_CLI_LOCK_PID_PROBE_MAX_BYTES: u64 = 256;

/// Age threshold for reaping zero-byte CLI lock sentinels. Matches
/// the daemon-side `PLUGIN_CLI_LOCK_STALE_REAP_AGE_MS` (B129) —
/// 60 s is far past any legitimate acquire window.
const PLUGIN_CLI_LOCK_STALE_REAP_AGE_MS_CLI: u64 = 60_000;

/// Try the lock-open once; on `AlreadyExists`, run a one-shot
/// stale-sweep against the existing sentinel and retry exactly
/// once. Returns the open File handle on success.
async fn open_plugin_file_transaction_lock_with_retry(
    lock: &Path,
) -> Result<std::fs::File, Box<dyn std::error::Error>> {
    fn open_lock_create_new(lock: &Path) -> std::io::Result<std::fs::File> {
        let mut options = std::fs::OpenOptions::new();
        options.write(true).create_new(true);
        #[cfg(unix)]
        {
            use std::os::unix::fs::OpenOptionsExt;
            options.custom_flags(libc::O_NOFOLLOW);
        }
        options.open(lock)
    }
    let lock_for_open = lock.to_path_buf();
    let first = tokio::task::spawn_blocking(move || open_lock_create_new(&lock_for_open))
        .await
        .map_err(|e| cli_error(format!("spawn_blocking failed: {e}")))?;
    match first {
        Ok(file) => Ok(file),
        Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
            let lock_for_sweep = lock.to_path_buf();
            let reaped = tokio::task::spawn_blocking(move || {
                try_reap_stale_plugin_cli_lock(&lock_for_sweep)
            })
            .await
            .map_err(|e| cli_error(format!("spawn_blocking failed: {e}")))?;
            if !reaped {
                return Err(cli_error(format!(
                    "refusing to stage plugin file because staging lock '{}' already exists; another local plugin mutation may still be in progress, or the lock may be stale from a previous interrupted run. Verify that no other `cara plugins install --file` or `cara plugins update --file` command is still running, inspect the PID recorded in the lock file if needed, and then remove the lock file and retry. The PID in the lock file may have been recycled if the original process crashed.",
                    lock.display()
                )));
            }
            // Reaped a dead-PID sentinel — try once more.
            let lock_for_retry = lock.to_path_buf();
            tokio::task::spawn_blocking(move || open_lock_create_new(&lock_for_retry))
                .await
                .map_err(|e| cli_error(format!("spawn_blocking failed: {e}")))?
                .map_err(|err| {
                    cli_error(format!(
                        "failed to create staging lock '{}' after reaping stale sentinel: {}",
                        lock.display(),
                        err
                    ))
                })
        }
        Err(err) => Err(cli_error(format!(
            "failed to create staging lock '{}': {}",
            lock.display(),
            err
        ))),
    }
}

/// Probe the existing `.cli-lock` sentinel: read its PID, check
/// liveness, reap if dead. Returns `true` if a dead-PID (or aged
/// zero-byte) sentinel was removed; `false` if the sentinel is
/// alive / unreadable / not stale-enough. Mirrors the daemon-side
/// logic at `server::ws::handlers::plugins::sweep_stale_plugin_cli_locks`.
fn try_reap_stale_plugin_cli_lock(lock: &Path) -> bool {
    let bytes = match crate::paths::read_to_vec_no_hang_no_follow_capped(
        lock,
        PLUGIN_CLI_LOCK_PID_PROBE_MAX_BYTES,
    ) {
        Ok(Some(bytes)) => bytes,
        // NotFound between AlreadyExists and now means another writer
        // already removed it; treat as reaped so the retry can race.
        Ok(None) => return true,
        Err(_) => return false,
    };
    let pid_text = match std::str::from_utf8(&bytes) {
        Ok(text) => text.trim(),
        Err(_) => return false,
    };
    if pid_text.is_empty() {
        // Zero-byte sentinel — only reap if older than the age
        // threshold (same discipline as B129 daemon sweep).
        let stale = std::fs::metadata(lock)
            .and_then(|m| m.modified())
            .ok()
            .and_then(|mtime| mtime.elapsed().ok())
            .map(|elapsed| elapsed.as_millis() as u64 >= PLUGIN_CLI_LOCK_STALE_REAP_AGE_MS_CLI)
            .unwrap_or(false);
        if !stale {
            return false;
        }
        match std::fs::remove_file(lock) {
            Ok(()) => return true,
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return true,
            Err(_) => return false,
        }
    }
    let pid = match pid_text.parse::<i32>() {
        Ok(pid) => pid,
        Err(_) => return false,
    };
    if rekey_pid_is_alive(pid) {
        return false;
    }
    match std::fs::remove_file(lock) {
        Ok(()) => true,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => true,
        Err(_) => false,
    }
}

async fn acquire_plugin_file_transaction_lock(
    lock: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
    let std_file = open_plugin_file_transaction_lock_with_retry(lock).await?;
    let mut file = tokio::fs::File::from_std(std_file);
    let pid = std::process::id().to_string();
    if let Err(err) = file.write_all(pid.as_bytes()).await {
        return Err(cleanup_failed_plugin_file_transaction_lock_init(
            file,
            lock,
            format!(
                "failed to record staging lock owner in '{}': {}",
                lock.display(),
                err
            ),
        )
        .await);
    }
    if let Err(err) = file.sync_data().await {
        return Err(cleanup_failed_plugin_file_transaction_lock_init(
            file,
            lock,
            format!(
                "failed to persist staging lock owner in '{}': {}",
                lock.display(),
                err
            ),
        )
        .await);
    }
    Ok(())
}

async fn cleanup_failed_plugin_file_transaction_lock_init(
    file: tokio::fs::File,
    lock: &Path,
    message: String,
) -> Box<dyn std::error::Error> {
    drop(file);
    let mut message = message;
    match tokio::fs::remove_file(lock).await {
        Ok(()) => {}
        Err(cleanup_err) if cleanup_err.kind() == std::io::ErrorKind::NotFound => {}
        Err(cleanup_err) => {
            message = format!(
                "{message}; additionally failed to remove staging lock '{}': {}",
                lock.display(),
                cleanup_err
            );
        }
    }
    cli_error(message)
}

async fn release_plugin_file_transaction_lock(lock: &Path) -> std::io::Result<()> {
    match tokio::fs::remove_file(lock).await {
        Ok(()) => Ok(()),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(err) => Err(err),
    }
}

fn release_plugin_file_transaction_lock_blocking(lock: &Path) -> std::io::Result<()> {
    match std::fs::remove_file(lock) {
        Ok(()) => Ok(()),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(err) => Err(err),
    }
}

fn append_lock_release_failure(
    message: String,
    lock: &Path,
    lock_err: &dyn std::fmt::Display,
) -> String {
    format!(
        "{message}; additionally failed to remove staging lock '{}': {}; remove that lock file before the next local `--file` plugin mutation",
        lock.display(), lock_err
    )
}

async fn release_plugin_file_transaction_lock_with_context(lock: &Path, message: String) -> String {
    match release_plugin_file_transaction_lock(lock).await {
        Ok(()) => message,
        Err(lock_err) => append_lock_release_failure(message, lock, &lock_err),
    }
}

fn finish_managed_plugin_file_commit_cleanup_blocking(
    backup: Option<&Path>,
    lock: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
    let mut failures = Vec::new();
    if let Some(backup) = backup {
        match std::fs::remove_file(backup) {
            Ok(()) => {}
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
            Err(err) => failures.push(format!(
                "plugin request succeeded, but failed to remove staging backup '{}': {}; remove or recover that backup before the next local `--file` plugin mutation",
                backup.display(), err
            )),
        }
    }
    if let Err(err) = release_plugin_file_transaction_lock_blocking(lock) {
        failures.push(append_lock_release_failure(
            "plugin request succeeded".to_string(),
            lock,
            &err,
        ));
    }
    if failures.is_empty() {
        Ok(())
    } else {
        Err(cli_error(failures.join("; ")))
    }
}

#[cfg(not(windows))]
async fn replace_plugin_artifact_with_backup(backup: &Path, dest: &Path) -> std::io::Result<()> {
    tokio::fs::rename(backup, dest).await
}

#[cfg(not(windows))]
fn replace_plugin_artifact_with_backup_blocking(backup: &Path, dest: &Path) -> std::io::Result<()> {
    std::fs::rename(backup, dest)
}

#[cfg(windows)]
async fn replace_plugin_artifact_with_backup(backup: &Path, dest: &Path) -> std::io::Result<()> {
    let backup = backup.to_path_buf();
    let dest = dest.to_path_buf();
    tokio::task::spawn_blocking(move || replace_plugin_artifact_with_backup_windows(&backup, &dest))
        .await
        .map_err(|err| std::io::Error::other(format!("rollback replace task failed: {}", err)))?
}

#[cfg(windows)]
fn replace_plugin_artifact_with_backup_blocking(backup: &Path, dest: &Path) -> std::io::Result<()> {
    replace_plugin_artifact_with_backup_windows(backup, dest)
}

#[cfg(windows)]
fn replace_plugin_artifact_with_backup_windows(backup: &Path, dest: &Path) -> std::io::Result<()> {
    use std::os::windows::ffi::OsStrExt;
    use windows_sys::Win32::Storage::FileSystem::{
        MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
    };

    let backup_wide: Vec<u16> = backup
        .as_os_str()
        .encode_wide()
        .chain(std::iter::once(0))
        .collect();
    let dest_wide: Vec<u16> = dest
        .as_os_str()
        .encode_wide()
        .chain(std::iter::once(0))
        .collect();
    let flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH;
    // SAFETY: `backup_wide` and `dest_wide` are owned, NUL-terminated UTF-16
    // buffers that stay alive for the duration of this call, so the pointers are
    // valid and satisfy `MoveFileExW`'s requirements.
    let result = unsafe { MoveFileExW(backup_wide.as_ptr(), dest_wide.as_ptr(), flags) };
    if result == 0 {
        Err(std::io::Error::last_os_error())
    } else {
        Ok(())
    }
}

async fn restore_previous_plugin_artifact(
    backup: &Path,
    dest: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
    #[cfg(test)]
    if should_fail_restore_previous_plugin_artifact(dest) {
        return Err(cli_error(format!(
            "failed to restore previous plugin artifact from '{}' to '{}': injected restore failure",
            backup.display(),
            dest.display()
        )));
    }
    replace_plugin_artifact_with_backup(backup, dest)
        .await
        .map_err(|err| {
            cli_error(format!(
                "failed to restore previous plugin artifact from '{}' to '{}': {}",
                backup.display(),
                dest.display(),
                err
            ))
        })?;

    Ok(())
}

fn restore_previous_plugin_artifact_blocking(
    backup: &Path,
    dest: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
    replace_plugin_artifact_with_backup_blocking(backup, dest).map_err(|err| {
        cli_error(format!(
            "failed to restore previous plugin artifact from '{}' to '{}': {}",
            backup.display(),
            dest.display(),
            err
        ))
    })?;

    Ok(())
}

async fn cleanup_partially_staged_plugin_artifact(
    dest: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
    #[cfg(test)]
    if should_fail_staged_plugin_cleanup(dest) {
        return Err(cli_error(format!(
            "failed to remove partially staged plugin artifact '{}': injected staged plugin cleanup failure",
            dest.display()
        )));
    }
    match tokio::fs::remove_file(dest).await {
        Ok(()) => Ok(()),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(err) => Err(cli_error(format!(
            "failed to remove partially staged plugin artifact '{}': {}",
            dest.display(),
            err
        ))),
    }
}

fn cleanup_partially_staged_plugin_artifact_blocking(
    dest: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
    match std::fs::remove_file(dest) {
        Ok(()) => Ok(()),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(err) => Err(cli_error(format!(
            "failed to remove partially staged plugin artifact '{}': {}",
            dest.display(),
            err
        ))),
    }
}

fn rollback_managed_plugin_file_transaction_blocking(
    dest: &Path,
    backup: Option<&Path>,
    lock: &Path,
) -> Result<String, Box<dyn std::error::Error>> {
    let rollback_result = match backup {
        Some(backup) => match restore_previous_plugin_artifact_blocking(backup, dest) {
            Ok(()) => Ok(format!(
                "restored previous local managed artifact at '{}'",
                dest.display()
            )),
            Err(err) => Err(err),
        },
        None => match cleanup_partially_staged_plugin_artifact_blocking(dest) {
            Ok(()) => Ok(format!(
                "removed staged local managed artifact at '{}'",
                dest.display()
            )),
            Err(err) => Err(err),
        },
    };
    let lock_release_result = release_plugin_file_transaction_lock_blocking(lock);
    match (rollback_result, lock_release_result) {
        (Ok(note), Ok(())) => Ok(note),
        (Ok(note), Err(lock_err)) => Err(cli_error(append_lock_release_failure(
            note, lock, &lock_err,
        ))),
        (Err(err), Ok(())) => Err(err),
        (Err(err), Err(lock_err)) => Err(cli_error(append_lock_release_failure(
            err.to_string(),
            lock,
            &lock_err,
        ))),
    }
}

#[cfg(test)]
fn should_fail_staged_plugin_write(dest: &Path) -> bool {
    crate::config::read_process_env_os("CARAPACE_TEST_FAIL_STAGE_PLUGIN_WRITE_DEST")
        .map(PathBuf::from)
        .as_deref()
        == Some(dest)
}

/// Reject a path that is a symlink. This narrows the TOCTOU window between
/// a prior `symlink_metadata` check and a subsequent mutation. It is not
/// race-proof on its own but combined with `O_NOFOLLOW` on opens it
/// provides defense-in-depth against local symlink-swap attacks.
fn reject_if_symlink(path: &Path, label: &str) -> Result<(), String> {
    match path.symlink_metadata() {
        Ok(m) if m.file_type().is_symlink() => Err(format!(
            "{label} '{}' is a symlink; refusing to proceed",
            path.display()
        )),
        Ok(_) => Ok(()),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(e) => Err(format!(
            "failed to verify {label} '{}' is not a symlink: {e}",
            path.display()
        )),
    }
}

async fn write_staged_plugin_artifact(dest: &Path, bytes: &[u8]) -> std::io::Result<()> {
    #[cfg(test)]
    if should_fail_staged_plugin_write(dest) {
        return Err(std::io::Error::other(
            "injected staged plugin write failure",
        ));
    }
    // create_new(true) provides O_CREAT|O_EXCL (fails if path exists).
    // On Unix, O_NOFOLLOW prevents following symlinks — if an attacker
    // places a symlink between the cleanup check and this open, the open
    // fails with ELOOP instead of creating a file through the symlink.
    //
    // Uses std::fs for the open (to access OpenOptionsExt::custom_flags),
    // then wraps in tokio::fs::File for async write.
    let dest = dest.to_path_buf();
    let std_file = tokio::task::spawn_blocking({
        move || {
            let mut options = std::fs::OpenOptions::new();
            options.write(true).create_new(true);
            #[cfg(unix)]
            {
                use std::os::unix::fs::OpenOptionsExt;
                options.custom_flags(libc::O_NOFOLLOW);
            }
            options.open(&dest)
        }
    })
    .await
    .map_err(|e| std::io::Error::other(format!("spawn_blocking failed: {e}")))??;

    let mut file = tokio::fs::File::from_std(std_file);
    file.write_all(bytes).await?;
    file.sync_data().await
}

#[cfg(test)]
fn should_fail_staged_plugin_cleanup(dest: &Path) -> bool {
    crate::config::read_process_env_os("CARAPACE_TEST_FAIL_STAGE_PLUGIN_CLEANUP_DEST")
        .map(PathBuf::from)
        .as_deref()
        == Some(dest)
}

#[cfg(test)]
fn should_fail_staged_plugin_rename_dest(dest: &Path) -> bool {
    crate::config::read_process_env_os("CARAPACE_TEST_FAIL_STAGE_PLUGIN_RENAME_DEST")
        .map(PathBuf::from)
        .as_deref()
        == Some(dest)
}

#[cfg(test)]
fn should_fail_restore_previous_plugin_artifact(dest: &Path) -> bool {
    crate::config::read_process_env_os("CARAPACE_TEST_FAIL_RESTORE_PLUGIN_DEST")
        .map(PathBuf::from)
        .as_deref()
        == Some(dest)
}

async fn finalize_staged_plugin_artifact(staged: &Path, dest: &Path) -> std::io::Result<()> {
    #[cfg(test)]
    if should_fail_staged_plugin_rename_dest(dest) {
        return Err(std::io::Error::other(
            "injected staged plugin rename failure",
        ));
    }
    tokio::fs::rename(staged, dest).await
}

async fn stage_plugin_file_into_managed_dir(
    connection: &WsConnectionArgs,
    name: &str,
    file: &Path,
) -> Result<ManagedPluginFileTransaction, Box<dyn std::error::Error>> {
    if !crate::net_util::is_loopback_host(&connection.host) {
        return Err(cli_error(
            "--file is only supported for loopback targets; use --url for remote plugin management",
        ));
    }
    crate::plugins::validate_managed_plugin_name(name).map_err(cli_error)?;

    let bytes = read_and_validate_local_plugin_artifact(file).await?;

    let managed_plugins_dir = resolve_state_dir().join("plugins");
    tokio::fs::create_dir_all(&managed_plugins_dir)
        .await
        .map_err(|err| {
            cli_error(format!(
                "failed to create managed plugins directory '{}': {}",
                managed_plugins_dir.display(),
                err
            ))
        })?;
    let dest = managed_plugins_dir.join(format!("{}.wasm", name));
    let backup = plugin_cli_backup_path(&dest)?;
    let lock = plugin_cli_lock_path(&dest)?;
    let staged = plugin_cli_staged_path(&dest)?;
    acquire_plugin_file_transaction_lock(&lock).await?;
    let pending_lock = PendingPluginFileTransactionLock::new(lock);
    let had_existing = match async {
        let existing_metadata = match tokio::fs::symlink_metadata(&dest).await {
            Ok(metadata) => Some(metadata),
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
            Err(err) => {
                return Err(format!(
                    "failed to inspect managed plugin destination '{}': {}",
                    dest.display(),
                    err
                ));
            }
        };
        if let Some(metadata) = existing_metadata.as_ref() {
            if !metadata.is_file() {
                return Err(format!(
                    "managed plugin destination '{}' is not a regular file",
                    dest.display()
                ));
            }
        }
        // SECURITY: symlink-resistant staging for loopback-only --file workflow.
        match tokio::fs::symlink_metadata(&backup).await {
            Ok(_) => {
                return Err(format!(
                    "refusing to stage plugin file because rollback backup '{}' already exists; remove or recover it first",
                    backup.display()
                ));
            }
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
            Err(err) => {
                return Err(format!(
                    "failed to inspect staging backup '{}': {}",
                    backup.display(),
                    err
                ));
            }
        }
        match tokio::fs::symlink_metadata(&staged).await {
            Ok(_) => {
                cleanup_partially_staged_plugin_artifact(&staged)
                    .await
                    .map_err(|err| {
                        format!(
                            "failed to remove stale staged plugin artifact '{}': {}",
                            staged.display(),
                            err
                        )
                    })?;
            }
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
            Err(err) => {
                return Err(format!(
                    "failed to inspect staged plugin artifact '{}': {}",
                    staged.display(),
                    err
                ));
            }
        }
        let had_existing = existing_metadata.is_some();
        if had_existing {
            // Tighten the TOCTOU window: re-check dest is not a symlink
            // immediately before the rename. Combined with the preflight
            // check above, this narrows the attack surface.
            reject_if_symlink(&dest, "managed plugin destination")?;
            tokio::fs::rename(&dest, &backup).await.map_err(|err| {
                format!(
                    "failed to move existing managed plugin artifact '{}' to staging backup '{}': {}",
                    dest.display(),
                    backup.display(),
                    err
                )
            })?;
        }
        if let Err(err) = write_staged_plugin_artifact(&staged, &bytes).await {
            let mut message = format!(
                "failed to stage plugin file into temporary artifact '{}': {}",
                staged.display(),
                err
            );
            if let Err(cleanup_err) = cleanup_partially_staged_plugin_artifact(&staged).await {
                message = format!(
                    "{message}; additionally failed to remove partial staged file: {}",
                    cleanup_err
                );
            }
            if had_existing {
                let restore_result = restore_previous_plugin_artifact(&backup, &dest).await;
                return Err(match restore_result {
                    Ok(()) => format!(
                        "{message}; restored previous local managed artifact at '{}'",
                        dest.display()
                    ),
                    Err(restore_err) => {
                        format!("{message}; rollback also failed: {}", restore_err)
                    }
                });
            }
            return Err(message);
        }
        reject_if_symlink(&staged, "staged plugin artifact")?;
        if let Err(err) = finalize_staged_plugin_artifact(&staged, &dest).await {
            let mut message = format!(
                "failed to finalize staged plugin artifact from '{}' to '{}': {}",
                staged.display(),
                dest.display(),
                err
            );
            if let Err(cleanup_err) = cleanup_partially_staged_plugin_artifact(&staged).await {
                message = format!(
                    "{message}; additionally failed to remove partial staged file: {}",
                    cleanup_err
                );
            }
            if had_existing {
                let restore_result = restore_previous_plugin_artifact(&backup, &dest).await;
                return Err(match restore_result {
                    Ok(()) => format!(
                        "{message}; restored previous local managed artifact at '{}'",
                        dest.display()
                    ),
                    Err(restore_err) => {
                        format!("{message}; rollback also failed: {}", restore_err)
                    }
                });
            }
            return Err(message);
        }
        Ok::<bool, String>(had_existing)
    }
    .await
    {
        Ok(had_existing) => had_existing,
        Err(message) => {
            let message = pending_lock.release_with_context(message).await;
            return Err(cli_error(message));
        }
    };
    let lock = pending_lock.into_path();
    Ok(ManagedPluginFileTransaction {
        dest,
        backup: had_existing.then_some(backup),
        lock,
        drop_action: ManagedPluginFileTransactionDropAction::RollbackArtifact,
    })
}

async fn finalize_plugin_file_mutation<T, F>(
    staged_file: Option<ManagedPluginFileTransaction>,
    result_future: F,
) -> Result<T, Box<dyn std::error::Error>>
where
    F: std::future::Future<Output = Result<T, Box<dyn std::error::Error>>>,
{
    let result = result_future.await;
    match (staged_file, result) {
        (Some(transaction), Ok(value)) => {
            transaction.commit().await?;
            Ok(value)
        }
        (Some(transaction), Err(err)) => {
            let original = err.to_string();
            match transaction.rollback().await {
                Ok(rollback_note) => Err(cli_error(format!("{original}; {rollback_note}"))),
                Err(rollback_err) => Err(cli_error(format!(
                    "{original}; rollback also failed: {}",
                    rollback_err
                ))),
            }
        }
        (None, result) => result,
    }
}

fn plugin_mutation_params(
    name: &str,
    url: Option<&str>,
    version: Option<&str>,
    publisher_key: Option<&str>,
    signature: Option<&str>,
) -> Value {
    let mut params = serde_json::Map::new();
    params.insert("name".to_string(), Value::String(name.to_string()));
    if let Some(url) = url {
        params.insert("url".to_string(), Value::String(url.to_string()));
    }
    if let Some(version) = version.filter(|value| !value.trim().is_empty()) {
        params.insert(
            "version".to_string(),
            Value::String(version.trim().to_string()),
        );
    }
    if let Some(publisher_key) = publisher_key.filter(|value| !value.trim().is_empty()) {
        params.insert(
            "publisherKey".to_string(),
            Value::String(publisher_key.trim().to_string()),
        );
    }
    if let Some(signature) = signature.filter(|value| !value.trim().is_empty()) {
        params.insert(
            "signature".to_string(),
            Value::String(signature.trim().to_string()),
        );
    }
    Value::Object(params)
}

async fn handle_plugins_status_cli(
    options: PluginStatusCliOptions,
) -> Result<(), Box<dyn std::error::Error>> {
    let payload = send_cli_ws_request(
        &options.connection,
        "operator",
        &["operator.read"],
        "plugins.status",
        json!({}),
    )
    .await?;
    let mut response: PluginsStatusResponse = serde_json::from_value(payload)?;
    response.plugins.retain(|entry| {
        plugin_entry_matches_filters(
            entry,
            options.name.as_deref(),
            options.plugin_id.as_deref(),
            options.source,
            options.state,
            options.only_failed,
        )
    });

    if options.json_output {
        print_pretty_json(&serde_json::to_value(&response)?)?;
    } else {
        print_plugin_status_human(&response);
    }

    if options.strict {
        maybe_fail_strict_plugin_status(&response)?;
    }
    Ok(())
}

async fn handle_plugins_bins_cli(
    connection: WsConnectionArgs,
    json_output: bool,
) -> Result<(), Box<dyn std::error::Error>> {
    let payload = send_cli_ws_request(
        &connection,
        "operator",
        &["operator.read"],
        "plugins.bins",
        json!({}),
    )
    .await?;
    if json_output {
        print_pretty_json(&payload)?;
        return Ok(());
    }
    let response: PluginsBinsResponse = serde_json::from_value(payload)?;
    print_plugin_bins_human(&response);
    Ok(())
}

async fn handle_plugins_install_cli(
    options: PluginMutationArgs,
) -> Result<(), Box<dyn std::error::Error>> {
    let staged_file = if let Some(file) = options.file.as_deref() {
        Some(stage_plugin_file_into_managed_dir(&options.connection, &options.name, file).await?)
    } else {
        None
    };
    let payload = finalize_plugin_file_mutation(
        staged_file,
        send_cli_ws_request(
            &options.connection,
            "operator",
            &["operator.admin"],
            "plugins.install",
            plugin_mutation_params(
                &options.name,
                options.url.as_deref(),
                options.version.as_deref(),
                options.publisher_key.as_deref(),
                options.signature.as_deref(),
            ),
        ),
    )
    .await?;
    if options.json {
        print_pretty_json(&payload)?;
        return Ok(());
    }
    let response: PluginMutationResponse = serde_json::from_value(payload)?;
    let strip = crate::logging::redact::strip_terminal_unsafe_chars;
    println!("Plugin install requested");
    println!("  Name: {}", strip(&response.name));
    if let Some(version) = response.version.as_deref() {
        println!("  Version: {}", strip(version));
    }
    if let Some(message) = response.activation.message.as_deref() {
        // SECURITY: activation.{state,message} originate from the
        // plugin manifest / runtime, partially plugin-author-
        // controlled. Strip terminal-control chars before printing
        // to operator terminal — same threat model as the chat
        // REPL strip.
        println!(
            "  Activation: {} ({})",
            strip(&response.activation.state),
            strip(message)
        );
    } else {
        println!("  Activation: {}", strip(&response.activation.state));
    }
    Ok(())
}

async fn handle_plugins_update_cli(
    options: PluginMutationArgs,
) -> Result<(), Box<dyn std::error::Error>> {
    let staged_file = if let Some(file) = options.file.as_deref() {
        Some(stage_plugin_file_into_managed_dir(&options.connection, &options.name, file).await?)
    } else {
        None
    };
    let payload = finalize_plugin_file_mutation(
        staged_file,
        send_cli_ws_request(
            &options.connection,
            "operator",
            &["operator.admin"],
            "plugins.update",
            plugin_mutation_params(
                &options.name,
                options.url.as_deref(),
                options.version.as_deref(),
                options.publisher_key.as_deref(),
                options.signature.as_deref(),
            ),
        ),
    )
    .await?;
    if options.json {
        print_pretty_json(&payload)?;
        return Ok(());
    }
    let response: PluginMutationResponse = serde_json::from_value(payload)?;
    let strip = crate::logging::redact::strip_terminal_unsafe_chars;
    println!("Plugin update requested");
    println!("  Name: {}", strip(&response.name));
    if let Some(version) = response.version.as_deref() {
        println!("  Version: {}", strip(version));
    }
    if let Some(message) = response.activation.message.as_deref() {
        // SECURITY: activation.{state,message} originate from the
        // plugin manifest / runtime, partially plugin-author-
        // controlled. Strip terminal-control chars before printing
        // to operator terminal — same threat model as the chat
        // REPL strip.
        println!(
            "  Activation: {} ({})",
            strip(&response.activation.state),
            strip(message)
        );
    } else {
        println!("  Activation: {}", strip(&response.activation.state));
    }
    Ok(())
}

pub async fn handle_plugins(command: PluginsCommand) -> Result<(), Box<dyn std::error::Error>> {
    match command {
        PluginsCommand::Status {
            json,
            name,
            plugin_id,
            source,
            state,
            only_failed,
            strict,
            connection,
        } => {
            handle_plugins_status_cli(PluginStatusCliOptions {
                connection,
                json_output: json,
                name,
                plugin_id,
                source,
                state,
                only_failed,
                strict,
            })
            .await
        }
        PluginsCommand::Bins { json, connection } => {
            handle_plugins_bins_cli(connection, json).await
        }
        PluginsCommand::Install(args) => handle_plugins_install_cli(args).await,
        PluginsCommand::Update(args) => handle_plugins_update_cli(args).await,
    }
}

pub async fn handle_logs(
    host: &str,
    port: Option<u16>,
    lines: usize,
    tls: bool,
    trust: bool,
    allow_plaintext: bool,
) -> Result<(), Box<dyn std::error::Error>> {
    let connection = WsConnectionArgs {
        port,
        host: host.to_string(),
        tls,
        trust,
        allow_plaintext,
    };
    let payload = send_cli_ws_request(
        &connection,
        "operator",
        &["operator.read"],
        "logs.tail",
        json!({ "limit": lines }),
    )
    .await?;

    let response: LogsTailResponse = serde_json::from_value(payload)?;
    // SECURITY: daemon log fields (target, message) may carry
    // attacker-influenced bytes — channel message text, hostile
    // homeserver headers, plugin error paths, model responses —
    // anything that the daemon ever logs. `cara logs tail` is the
    // canonical operator-facing surface for inspecting daemon logs,
    // so a hostile log line containing ANSI cursor-up + clear-line /
    // bidi / zero-width sequences could paint fake operator prompts,
    // hide subsequent log lines via scrollback rewrites, or swap
    // perceived contents. The daemon's `RedactingMakeWriter` already
    // strips control chars at log-WRITE time, but operators may run
    // `cara logs tail` against a non-current-host daemon (different
    // version, or a `--host` that points at a hostile or proxied
    // endpoint), so re-strip on the read side as defense-in-depth.
    let strip = crate::logging::redact::strip_terminal_unsafe_chars;
    for entry in response.entries {
        println!(
            "{} [{}] {}: {}",
            format_timestamp(entry.timestamp),
            entry.level,
            strip(&entry.target),
            strip(&entry.message)
        );
    }
    if response.truncated {
        eprintln!("(log output truncated; reduce scope or increase --lines)");
    }

    Ok(())
}

/// Run the `version` subcommand.
pub fn handle_version() {
    println!("cara {}", env!("CARGO_PKG_VERSION"));
    println!("  Build date: {}", env!("CARAPACE_BUILD_DATE"));
    println!("  Git commit: {}", env!("CARAPACE_GIT_HASH"));
    println!(
        "  Platform:   {} ({})",
        std::env::consts::OS,
        std::env::consts::ARCH
    );
}

// ---------------------------------------------------------------------------
// Backup / Restore / Reset handlers
// ---------------------------------------------------------------------------

use std::io::Read as IoRead;
use std::path::{Component, Path, PathBuf};

pub(crate) fn resolve_state_dir() -> PathBuf {
    crate::paths::resolve_state_dir()
}

/// Resolve the memory store directory.
fn resolve_memory_dir() -> PathBuf {
    dirs::home_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .join(".config")
        .join("carapace")
        .join("memory")
}

/// Name of the marker file inside backup archives to identify them as
/// carapace backups.
const BACKUP_MARKER: &str = ".carapace-backup";

/// Run the `backup` subcommand.
pub fn handle_backup(output: Option<&str>, force: bool) -> Result<(), Box<dyn std::error::Error>> {
    let state_dir = resolve_state_dir();
    let config_path = config::get_config_path();
    let memory_dir = resolve_memory_dir();

    // Refuse to backup while the daemon is running. The session-
    // history mutators (sessions/store.rs:2997, 3048, 3319, 3444)
    // each take per-history FileLock during writes; the tar stream
    // here does NOT take those locks, so a write that interleaves
    // with our read produces a torn last record. Worse: HMAC sidecar
    // + history JSONL are tar'd via independent opens, so the
    // restored snapshot can have a sidecar referencing post-flush
    // bytes while the JSONL is mid-flush — permanent integrity
    // failure on restore.
    //
    // The daemon's `DaemonPidGuard::install` holds the matrix-rekey
    // flock for its lifetime, so `ensure_no_running_daemon_for_matrix_secret_mutation`
    // is the correct "is a daemon running?" check despite the
    // matrix-specific name. Reuses the existing `RekeyDaemonGuard`
    // RAII so the lock releases on function exit.
    let _running_daemon_guard =
        ensure_no_running_daemon_for_matrix_secret_mutation(&state_dir, "cara backup")
            .map_err(|err| format!("refusing to back up state while daemon is running: {err}"))?;

    // Determine output path.
    let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
    let default_name = format!("carapace-backup-{}.tar.gz", timestamp);
    let output_path = PathBuf::from(output.unwrap_or(&default_name));

    // SECURITY: refuse to clobber a pre-existing output file unless the
    // operator explicitly opted in via `--force`. The prior `File::create`
    // unconditionally truncated whatever was already there — an operator
    // typo (`cara backup -o ~/.ssh/known_hosts`) silently overwrote the
    // target with the tar.gz stream. Mirrors `cara restore`'s `--force`
    // pattern: explicit operator intent required for any destructive
    // path collision.
    // SECURITY: even with `--force`, refuse to follow symlinks
    // at the output path. Without `O_NOFOLLOW`, a TOCTOU between the
    // operator's first run (which sees "refusing to overwrite") and
    // re-running with `--force` would let a same-uid attacker plant a
    // symlink that the second invocation follows — overwriting an
    // arbitrary daemon-writable file with the tar.gz stream. The
    // create-new branch also benefits from O_NOFOLLOW for symmetry
    // (a planted symlink at the output path would otherwise pass
    // `create_new` because the symlink dirent doesn't exist before
    // we create it).
    let mut open_opts = std::fs::OpenOptions::new();
    open_opts.write(true);
    if force {
        open_opts.create(true).truncate(true);
    } else {
        open_opts.create_new(true);
    }
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        open_opts.custom_flags(libc::O_NOFOLLOW);
    }
    let file = match open_opts.open(&output_path) {
        Ok(file) => file,
        Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
            return Err(format!(
                "refusing to overwrite existing file {} (pass --force to overwrite)",
                output_path.display()
            )
            .into());
        }
        Err(err) => {
            // Unix `O_NOFOLLOW` returns ELOOP when the path is a
            // symlink. Surface a clearer message in that case so the
            // operator does not chase the kernel error.
            #[cfg(unix)]
            if err.raw_os_error() == Some(libc::ELOOP) {
                return Err(format!(
                    "refusing to follow symlink at output path {} (cara backup writes only to regular files; remove the symlink and re-run)",
                    output_path.display()
                )
                .into());
            }
            return Err(err.into());
        }
    };
    let enc = flate2::write::GzEncoder::new(file, flate2::Compression::default());
    let mut archive = tar::Builder::new(enc);

    // Write marker file so we can validate on restore.
    let marker_content = format!("carapace-backup v1\ncreated={}\n", timestamp);
    let marker_bytes = marker_content.as_bytes();
    let mut header = tar::Header::new_gnu();
    header.set_size(marker_bytes.len() as u64);
    header.set_mode(0o644);
    header.set_cksum();
    archive.append_data(&mut header, BACKUP_MARKER, marker_bytes)?;

    let mut included_sections: Vec<&str> = Vec::new();

    // Sessions directory.
    let sessions_dir = state_dir.join("sessions");
    if sessions_dir.is_dir() {
        archive.append_dir_all("sessions", &sessions_dir)?;
        included_sections.push("sessions");
    }

    // Config file.
    if config_path.exists() {
        let name = config_path
            .file_name()
            .unwrap_or_else(|| std::ffi::OsStr::new("carapace.json"));
        archive.append_path_with_name(&config_path, Path::new("config").join(name))?;
        included_sections.push("config");
    }

    // Memory store directory.
    if memory_dir.is_dir() {
        archive.append_dir_all("memory", &memory_dir)?;
        included_sections.push("memory");
    }

    // Cron data directory.
    let cron_dir = state_dir.join("cron");
    if cron_dir.is_dir() {
        archive.append_dir_all("cron", &cron_dir)?;
        included_sections.push("cron");
    }

    // Durable task queue/state directory.
    let tasks_dir = state_dir.join("tasks");
    if tasks_dir.is_dir() {
        archive.append_dir_all("tasks", &tasks_dir)?;
        included_sections.push("tasks");
    }

    // Usage data file.
    let usage_path = state_dir.join("usage.json");
    if usage_path.exists() {
        archive.append_path_with_name(&usage_path, "usage/usage.json")?;
        included_sections.push("usage");
    }

    // Finalize the archive.
    let enc = archive.into_inner()?;
    enc.finish()?;

    // Report results.
    let metadata = std::fs::metadata(&output_path)?;
    let size = metadata.len();
    let human_size = format_file_size(size);

    println!("Backup created successfully");
    println!("  Path: {}", output_path.display());
    println!("  Size: {} ({})", human_size, size);
    println!(
        "  Included: {}",
        if included_sections.is_empty() {
            "(empty)".to_string()
        } else {
            included_sections.join(", ")
        }
    );

    Ok(())
}

/// Run the `restore` subcommand.
pub fn handle_restore(archive_path: &str, force: bool) -> Result<(), Box<dyn std::error::Error>> {
    let archive_path = PathBuf::from(archive_path);
    if !archive_path.exists() {
        eprintln!("Backup file not found: {}", archive_path.display());
        std::process::exit(1);
    }

    let sections_found = validate_backup_file(&archive_path)?;

    // Prompt for confirmation unless --force is given.
    if !force {
        eprintln!(
            "This will overwrite existing data with the contents of: {}",
            archive_path.display()
        );
        eprintln!(
            "Sections to restore: {}",
            if sections_found.is_empty() {
                "(none)".to_string()
            } else {
                sections_found.join(", ")
            }
        );
        eprintln!("Pass --force to proceed without this prompt.");
        std::process::exit(1);
    }

    let (restored, restored_sessions) = restore_files_from_tar(&archive_path)?;

    println!("Restore completed successfully");
    println!(
        "  Restored: {}",
        if restored.is_empty() {
            "(nothing)".to_string()
        } else {
            restored.join(", ")
        }
    );
    if restored_sessions > 0 {
        println!("  Sessions: {}", restored_sessions);
    }

    Ok(())
}

/// Validate a backup archive by checking for the marker file and discovering sections.
fn validate_backup_file(archive_path: &PathBuf) -> Result<Vec<String>, Box<dyn std::error::Error>> {
    let file = std::fs::File::open(archive_path)?;
    let dec = flate2::read::GzDecoder::new(file);
    let mut archive = tar::Archive::new(dec);

    let mut found_marker = false;
    let mut sections_found: Vec<String> = Vec::new();

    for entry_result in archive.entries()? {
        let entry = entry_result?;
        let path = entry.path()?;
        if !is_safe_archive_path(&path) {
            return Err(std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                format!("invalid path in backup: {}", path.display()),
            )
            .into());
        }
        let path_str = path.to_string_lossy().to_string();

        if path_str == BACKUP_MARKER {
            found_marker = true;
        } else {
            let section = if path_str.starts_with("sessions/") {
                Some("sessions")
            } else if path_str.starts_with("config/") {
                Some("config")
            } else if path_str.starts_with("memory/") {
                Some("memory")
            } else if path_str.starts_with("cron/") {
                Some("cron")
            } else if path_str == "tasks" || path_str.starts_with("tasks/") {
                Some("tasks")
            } else if path_str.starts_with("usage/") {
                Some("usage")
            } else {
                None
            };
            if let Some(s) = section {
                if !sections_found.contains(&s.to_string()) {
                    sections_found.push(s.to_string());
                }
            }
        }
    }

    if !found_marker {
        eprintln!("Invalid backup: archive does not contain a carapace backup marker.");
        eprintln!("The file may be corrupt or was not created by `cara backup`.");
        std::process::exit(1);
    }

    Ok(sections_found)
}

/// Extract files from a validated backup archive into the appropriate locations.
fn restore_files_from_tar(
    archive_path: &PathBuf,
) -> Result<(Vec<String>, usize), Box<dyn std::error::Error>> {
    let state_dir = resolve_state_dir();
    let config_path = config::get_config_path();
    let memory_dir = resolve_memory_dir();

    // Refuse to restore while the daemon is running. The daemon's
    // in-memory caches (HMAC rolling state, session message-count,
    // task-queue snapshot) reference pre-restore state — replacing
    // the on-disk files underneath would break every subsequent
    // append's integrity contract. See handle_backup for the
    // matching guard rationale.
    let _running_daemon_guard =
        ensure_no_running_daemon_for_matrix_secret_mutation(&state_dir, "cara backup-restore")
            .map_err(|err| format!("refusing to restore state while daemon is running: {err}"))?;

    // SECURITY: `extract_entry` already opens the
    // FINAL component with `O_NOFOLLOW`, but the intermediate path
    // components are still resolved normally by `OpenOptions::open`.
    // A same-uid attacker who plants `state_dir/sessions -> ~victim/.ssh`
    // before `cara restore --force` runs would have archive bytes for
    // `sessions/foo` written to `~victim/.ssh/foo` because the symlink
    // is followed at the parent-resolution step, then `O_NOFOLLOW`
    // refuses ONLY a symlink at `foo` itself. Pre-check each section
    // root with `symlink_metadata` and refuse the whole restore if
    // any is a symlink — operator must remove it before retrying.
    let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
    let sections_to_check: Vec<(&str, PathBuf)> = vec![
        ("sessions", state_dir.join("sessions")),
        ("config", config_dir.to_path_buf()),
        ("memory", memory_dir.clone()),
        ("cron", state_dir.join("cron")),
        ("tasks", state_dir.join("tasks")),
    ];
    for (label, candidate) in sections_to_check {
        match std::fs::symlink_metadata(&candidate) {
            Ok(meta) if meta.file_type().is_symlink() => {
                return Err(format!(
                    "refusing to restore: section root {} ({}) is a symlink. \
                     `cara restore --force` extracts under each section root, and following \
                     the symlink would write archive bytes into the symlink target's directory. \
                     Remove the symlink and re-run.",
                    label,
                    candidate.display()
                )
                .into());
            }
            Ok(_) | Err(_) => {}
        }
    }

    let file = std::fs::File::open(archive_path)?;
    let dec = flate2::read::GzDecoder::new(file);
    let mut archive = tar::Archive::new(dec);

    let mut restored: Vec<String> = Vec::new();
    let mut restored_sessions: usize = 0;

    for entry_result in archive.entries()? {
        let mut entry = entry_result?;
        let path = entry.path()?.to_path_buf();
        if !is_safe_archive_path(&path) {
            return Err(std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                format!("invalid path in backup: {}", path.display()),
            )
            .into());
        }
        let path_str = path.to_string_lossy().to_string();

        if path_str == BACKUP_MARKER {
            continue;
        }

        if path_str.starts_with("sessions/") {
            let rel = path.strip_prefix("sessions").unwrap_or(&path);
            let target = state_dir.join("sessions").join(rel);
            extract_entry(&mut entry, &target)?;
            if path_str.ends_with(".json") && !path_str.ends_with(".jsonl") {
                restored_sessions += 1;
            }
            if !restored.contains(&"sessions".to_string()) {
                restored.push("sessions".to_string());
            }
        } else if path_str.starts_with("config/") {
            let rel = path.strip_prefix("config").unwrap_or(&path);
            let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
            let target = config_dir.join(rel);
            extract_entry(&mut entry, &target)?;
            if !restored.contains(&"config".to_string()) {
                restored.push("config".to_string());
            }
        } else if path_str.starts_with("memory/") {
            let rel = path.strip_prefix("memory").unwrap_or(&path);
            let target = memory_dir.join(rel);
            extract_entry(&mut entry, &target)?;
            if !restored.contains(&"memory".to_string()) {
                restored.push("memory".to_string());
            }
        } else if path_str.starts_with("cron/") {
            let rel = path.strip_prefix("cron").unwrap_or(&path);
            let target = state_dir.join("cron").join(rel);
            extract_entry(&mut entry, &target)?;
            if !restored.contains(&"cron".to_string()) {
                restored.push("cron".to_string());
            }
        } else if path_str == "tasks" || path_str.starts_with("tasks/") {
            let rel = path.strip_prefix("tasks").unwrap_or(&path);
            let target = state_dir.join("tasks").join(rel);
            extract_entry(&mut entry, &target)?;
            if !restored.contains(&"tasks".to_string()) {
                restored.push("tasks".to_string());
            }
        } else if path_str.starts_with("usage/") {
            let rel = path.strip_prefix("usage").unwrap_or(&path);
            let target = state_dir.join(rel);
            extract_entry(&mut entry, &target)?;
            if !restored.contains(&"usage".to_string()) {
                restored.push("usage".to_string());
            }
        }
    }

    Ok((restored, restored_sessions))
}

fn is_safe_archive_path(path: &Path) -> bool {
    for component in path.components() {
        match component {
            Component::Normal(_) | Component::CurDir => {}
            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return false,
        }
    }
    true
}

/// Extract a single tar entry to a target path, creating parent directories.
///
/// SECURITY: `std::fs::write` opens with `O_CREAT|O_WRONLY|O_TRUNC`
/// and follows symlinks at the destination. A same-uid attacker who plants
/// a symlink at e.g. `state_dir/sessions/index.json -> ~/.ssh/authorized_keys`
/// before `cara restore --force` runs would have the archive bytes written
/// through the symlink to arbitrary files. Mirror `cara backup --force`'s
/// `O_NOFOLLOW` defense.
fn extract_entry(
    entry: &mut tar::Entry<'_, flate2::read::GzDecoder<std::fs::File>>,
    target: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
    let entry_type = entry.header().entry_type();
    if entry_type.is_dir() {
        std::fs::create_dir_all(target)?;
    } else if entry_type.is_file() {
        if let Some(parent) = target.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let mut buf = Vec::new();
        entry.read_to_end(&mut buf)?;
        let mut open_opts = std::fs::OpenOptions::new();
        open_opts.write(true).create(true).truncate(true);
        #[cfg(unix)]
        {
            use std::os::unix::fs::OpenOptionsExt;
            open_opts.custom_flags(libc::O_NOFOLLOW);
        }
        let mut file = match open_opts.open(target) {
            Ok(file) => file,
            Err(err) => {
                #[cfg(unix)]
                if err.raw_os_error() == Some(libc::ELOOP) {
                    return Err(format!(
                        "refusing to follow symlink at extract target {} (remove the symlink and re-run)",
                        target.display()
                    )
                    .into());
                }
                return Err(err.into());
            }
        };
        std::io::Write::write_all(&mut file, &buf)?;
    }
    Ok(())
}

/// Run the `reset` subcommand.
pub fn handle_reset(
    sessions: bool,
    cron: bool,
    usage: bool,
    memory: bool,
    all: bool,
    force: bool,
) -> Result<(), Box<dyn std::error::Error>> {
    let do_sessions = sessions || all;
    let do_cron = cron || all;
    let do_usage = usage || all;
    let do_memory = memory || all;

    if !do_sessions && !do_cron && !do_usage && !do_memory {
        eprintln!(
            "No data category specified. Use --sessions, --cron, --usage, --memory, or --all."
        );
        std::process::exit(1);
    }

    // Build a list of what will be deleted for the confirmation prompt.
    let mut categories: Vec<&str> = Vec::new();
    if do_sessions {
        categories.push("sessions");
    }
    if do_cron {
        categories.push("cron");
    }
    if do_usage {
        categories.push("usage");
    }
    if do_memory {
        categories.push("memory");
    }

    if !force {
        eprintln!("This will permanently delete: {}", categories.join(", "));
        eprintln!("Pass --force to proceed without this prompt.");
        std::process::exit(1);
    }

    let state_dir = resolve_state_dir();

    // Refuse to run against a live daemon. `remove_dir_all` against
    // `state_dir/sessions/` (or `cron`) while the daemon holds per-
    // history `FileLock`s and has writes in flight produces the same
    // shape of inconsistency that motivated the Batch 89 backup
    // guard: the daemon ends up writing to deleted inodes and
    // reading half-deleted directory trees until restart. The
    // matrix-rekey flock that `ensure_no_running_daemon_for_matrix_secret_mutation`
    // wraps is held for the daemon's lifetime via `DaemonPidGuard`
    // so it doubles as a general "is the daemon up" probe. `--force`
    // bypasses the destructive-confirmation prompt above but not
    // this safety — the operator should stop the daemon before
    // reset, not race it.
    let _running_daemon_guard =
        ensure_no_running_daemon_for_matrix_secret_mutation(&state_dir, "cara reset")
            .map_err(|err| -> Box<dyn std::error::Error> { err.into() })?;

    let mut deleted: Vec<String> = Vec::new();

    // SECURITY: `Path::is_dir` and `std::fs::remove_dir_all`
    // both follow symlinks. A same-uid attacker who plants
    // `state_dir/sessions -> ~/personal-photos/` before
    // `cara reset --sessions --force` runs would have the daemon
    // recursively delete the symlink target's contents. Refuse to
    // recurse if the category path itself is a symlink — operator
    // must remove the symlink before reset proceeds.
    fn reset_dir_if_present(
        path: &Path,
        category: &str,
        deleted: &mut Vec<String>,
    ) -> Result<(), Box<dyn std::error::Error>> {
        let meta = match std::fs::symlink_metadata(path) {
            Ok(m) => m,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                deleted.push(format!(
                    "{category} (directory not found, nothing to delete)"
                ));
                return Ok(());
            }
            Err(e) => return Err(e.into()),
        };
        if meta.file_type().is_symlink() {
            return Err(format!(
                "refusing to recurse into symlinked {category} directory at {} \
                 (remove the symlink and re-run; `cara reset --force` will not \
                 follow symlinks into unknown filesystems)",
                path.display()
            )
            .into());
        }
        if !meta.is_dir() {
            deleted.push(format!(
                "{category} (path exists but is not a directory; nothing to delete)"
            ));
            return Ok(());
        }
        std::fs::remove_dir_all(path)?;
        deleted.push(format!("{category} (directory removed)"));
        Ok(())
    }

    if do_sessions {
        let sessions_dir = state_dir.join("sessions");
        match std::fs::symlink_metadata(&sessions_dir) {
            Ok(meta) if meta.file_type().is_symlink() => {
                return Err(format!(
                    "refusing to recurse into symlinked sessions directory at {} \
                     (remove the symlink and re-run)",
                    sessions_dir.display()
                )
                .into());
            }
            Ok(meta) if meta.is_dir() => {
                let count = count_files_in_dir(&sessions_dir, "json");
                std::fs::remove_dir_all(&sessions_dir)?;
                deleted.push(format!("sessions ({} metadata files removed)", count));
            }
            Ok(_) => {
                deleted.push("sessions (path exists but is not a directory)".to_string());
            }
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                deleted.push("sessions (directory not found, nothing to delete)".to_string());
            }
            Err(e) => return Err(e.into()),
        }
    }

    if do_cron {
        reset_dir_if_present(&state_dir.join("cron"), "cron", &mut deleted)?;
    }

    if do_usage {
        let usage_path = state_dir.join("usage.json");
        if usage_path.exists() {
            std::fs::remove_file(&usage_path)?;
            deleted.push("usage (usage.json removed)".to_string());
        } else {
            deleted.push("usage (file not found, nothing to delete)".to_string());
        }
    }

    if do_memory {
        let memory_dir = resolve_memory_dir();
        // Same symlink defense as the sessions branch above.
        match std::fs::symlink_metadata(&memory_dir) {
            Ok(meta) if meta.file_type().is_symlink() => {
                return Err(format!(
                    "refusing to recurse into symlinked memory directory at {} \
                     (remove the symlink and re-run)",
                    memory_dir.display()
                )
                .into());
            }
            Ok(meta) if meta.is_dir() => {
                let count = count_files_in_dir(&memory_dir, "json");
                std::fs::remove_dir_all(&memory_dir)?;
                deleted.push(format!("memory ({} store files removed)", count));
            }
            Ok(_) => {
                deleted.push("memory (path exists but is not a directory)".to_string());
            }
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                deleted.push("memory (directory not found, nothing to delete)".to_string());
            }
            Err(e) => return Err(e.into()),
        }
    }

    println!("Reset completed");
    for item in &deleted {
        println!("  - {}", item);
    }

    Ok(())
}

// ---------------------------------------------------------------------------
// Setup / Pair / Update handlers
// ---------------------------------------------------------------------------

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SetupOutcome {
    LocalChat,
    Discord,
    Telegram,
    Matrix,
    Hooks,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SetupProviderChoice {
    Anthropic,
    OpenAi,
    Ollama,
    Gemini,
    Vertex,
    NearAi,
    Venice,
    Bedrock,
}

impl SetupProviderChoice {
    fn prompt_key(self) -> &'static str {
        match self {
            Self::Anthropic => "anthropic",
            Self::OpenAi => "openai",
            Self::Ollama => "ollama",
            Self::Gemini => "gemini",
            Self::Vertex => "vertex",
            Self::NearAi => "nearai",
            Self::Venice => "venice",
            Self::Bedrock => "bedrock",
        }
    }

    fn label(self) -> &'static str {
        match self {
            Self::Anthropic => "Anthropic",
            Self::OpenAi => "OpenAI",
            Self::Ollama => "Ollama",
            Self::Gemini => "Gemini",
            Self::Vertex => "Vertex",
            Self::NearAi => "NEAR AI Cloud",
            Self::Venice => "Venice",
            Self::Bedrock => "Bedrock",
        }
    }

    fn parse_prompt(raw: &str) -> Option<Self> {
        match raw.trim().to_lowercase().as_str() {
            "anthropic" | "claude" => Some(Self::Anthropic),
            "openai" | "gpt" => Some(Self::OpenAi),
            "ollama" => Some(Self::Ollama),
            "gemini" => Some(Self::Gemini),
            "vertex" => Some(Self::Vertex),
            "nearai" | "near-ai" | "near" => Some(Self::NearAi),
            "venice" => Some(Self::Venice),
            "bedrock" => Some(Self::Bedrock),
            _ => None,
        }
    }
}

impl SetupOutcome {
    fn prompt_key(self) -> &'static str {
        match self {
            Self::LocalChat => "local-chat",
            Self::Discord => "discord",
            Self::Telegram => "telegram",
            Self::Matrix => "matrix",
            Self::Hooks => "hooks",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum VerifyOutcome {
    LocalChat,
    Discord,
    Telegram,
    Matrix,
    Hooks,
    Autonomy,
}

impl VerifyOutcome {
    fn key(self) -> &'static str {
        match self {
            Self::LocalChat => "local-chat",
            Self::Discord => "discord",
            Self::Telegram => "telegram",
            Self::Matrix => "matrix",
            Self::Hooks => "hooks",
            Self::Autonomy => "autonomy",
        }
    }
}

impl From<SetupOutcome> for VerifyOutcome {
    fn from(value: SetupOutcome) -> Self {
        match value {
            SetupOutcome::LocalChat => Self::LocalChat,
            SetupOutcome::Discord => Self::Discord,
            SetupOutcome::Telegram => Self::Telegram,
            SetupOutcome::Matrix => Self::Matrix,
            SetupOutcome::Hooks => Self::Hooks,
        }
    }
}

impl VerifyOutcomeSelection {
    fn resolved(self, cfg: &Value) -> VerifyOutcome {
        match self {
            Self::Auto => infer_setup_outcome_from_config(cfg).into(),
            Self::LocalChat => VerifyOutcome::LocalChat,
            Self::Discord => VerifyOutcome::Discord,
            Self::Telegram => VerifyOutcome::Telegram,
            Self::Matrix => VerifyOutcome::Matrix,
            Self::Hooks => VerifyOutcome::Hooks,
            Self::Autonomy => VerifyOutcome::Autonomy,
        }
    }
}

fn parse_setup_outcome(raw: &str) -> Option<SetupOutcome> {
    match raw.trim().to_lowercase().as_str() {
        "local-chat" | "local" | "chat" | "assistant" => Some(SetupOutcome::LocalChat),
        "discord" => Some(SetupOutcome::Discord),
        "telegram" => Some(SetupOutcome::Telegram),
        "matrix" | "element" => Some(SetupOutcome::Matrix),
        "hooks" | "webhook" | "webhooks" => Some(SetupOutcome::Hooks),
        _ => None,
    }
}

#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum SetupProvider {
    #[value(name = "anthropic", alias = "claude")]
    Anthropic,
    #[value(name = "codex")]
    Codex,
    #[value(name = "openai", alias = "gpt")]
    OpenAi,
    #[value(name = "ollama")]
    Ollama,
    #[value(name = "gemini")]
    Gemini,
    #[value(name = "vertex")]
    Vertex,
    #[value(name = "nearai", alias = "near-ai", alias = "near")]
    NearAi,
    #[value(name = "venice")]
    Venice,
    #[value(name = "bedrock")]
    Bedrock,
}

impl SetupProvider {
    /// Wizard-display label. Diverges from `onboarding::setup::SetupProvider::label`
    /// for `Codex` — wizard prompts say "OpenAI" because Codex is the
    /// OpenAI subscription path; onboarding's label says "Codex" for log
    /// and config display where the auth-mode distinction matters.
    fn label(self) -> &'static str {
        match self {
            Self::Anthropic => "Anthropic",
            Self::Codex => "OpenAI",
            Self::OpenAi => "OpenAI",
            Self::Ollama => "Ollama",
            Self::Gemini => "Gemini",
            Self::Vertex => "Vertex",
            Self::NearAi => "NEAR AI Cloud",
            Self::Venice => "Venice",
            Self::Bedrock => "Bedrock",
        }
    }

    fn model_prompt_label(self) -> &'static str {
        match self {
            Self::Codex => "Codex",
            _ => self.label(),
        }
    }

    fn api_key_env_var_name(self) -> Option<&'static str> {
        match self {
            Self::Anthropic => Some("ANTHROPIC_API_KEY"),
            Self::Codex => None,
            Self::OpenAi => Some("OPENAI_API_KEY"),
            Self::Gemini => Some("GOOGLE_API_KEY"),
            Self::Vertex => None,
            Self::NearAi => Some("NEARAI_API_KEY"),
            Self::Venice => Some("VENICE_API_KEY"),
            Self::Ollama | Self::Bedrock => None,
        }
    }

    fn prompt_key(self) -> &'static str {
        crate::onboarding::setup::SetupProvider::from(self).prompt_key()
    }
}

#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum SetupAuthModeSelection {
    #[value(name = "oauth")]
    OAuth,
    #[value(name = "api-key")]
    ApiKey,
    #[value(name = "setup-token")]
    SetupToken,
}

impl From<SetupProvider> for crate::onboarding::setup::SetupProvider {
    fn from(value: SetupProvider) -> Self {
        match value {
            SetupProvider::Anthropic => Self::Anthropic,
            SetupProvider::Codex => Self::Codex,
            SetupProvider::OpenAi => Self::OpenAi,
            SetupProvider::Ollama => Self::Ollama,
            SetupProvider::Gemini => Self::Gemini,
            SetupProvider::Vertex => Self::Vertex,
            SetupProvider::NearAi => Self::NearAi,
            SetupProvider::Venice => Self::Venice,
            SetupProvider::Bedrock => Self::Bedrock,
        }
    }
}

impl From<crate::onboarding::setup::SetupProvider> for SetupProvider {
    fn from(value: crate::onboarding::setup::SetupProvider) -> Self {
        match value {
            crate::onboarding::setup::SetupProvider::Anthropic => Self::Anthropic,
            crate::onboarding::setup::SetupProvider::Codex => Self::Codex,
            crate::onboarding::setup::SetupProvider::OpenAi => Self::OpenAi,
            crate::onboarding::setup::SetupProvider::Ollama => Self::Ollama,
            crate::onboarding::setup::SetupProvider::Gemini => Self::Gemini,
            crate::onboarding::setup::SetupProvider::Vertex => Self::Vertex,
            crate::onboarding::setup::SetupProvider::NearAi => Self::NearAi,
            crate::onboarding::setup::SetupProvider::Venice => Self::Venice,
            crate::onboarding::setup::SetupProvider::Bedrock => Self::Bedrock,
        }
    }
}

impl From<SetupAuthModeSelection> for crate::onboarding::setup::SetupAuthMode {
    fn from(value: SetupAuthModeSelection) -> Self {
        match value {
            SetupAuthModeSelection::OAuth => Self::OAuth,
            SetupAuthModeSelection::ApiKey => Self::ApiKey,
            SetupAuthModeSelection::SetupToken => Self::SetupToken,
        }
    }
}

type ProviderSetupResult = crate::onboarding::setup::SetupFlowResult;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ModelProviderRoute {
    Anthropic,
    Codex,
    OpenAi,
    Ollama,
    Gemini,
    Vertex,
    NearAi,
    Bedrock,
    Venice,
}

fn env_var_present(key: &str) -> bool {
    config::read_config_env(key)
        .map(|value| !value.trim().is_empty())
        .unwrap_or(false)
}

fn env_var_value(key: &str) -> Option<String> {
    config::read_config_env(key)
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
}

#[cfg(test)]
fn detect_setup_provider_env_hints() -> Vec<SetupProvider> {
    let mut providers = Vec::new();

    if env_var_present("ANTHROPIC_API_KEY")
        || (env_var_present("ANTHROPIC_SETUP_TOKEN") && env_var_present("CARAPACE_CONFIG_PASSWORD"))
    {
        providers.push(SetupProvider::Anthropic);
    }
    if env_var_present("OPENAI_API_KEY") {
        providers.push(SetupProvider::OpenAi);
    }
    if env_var_present("CARAPACE_CONFIG_PASSWORD")
        && env_var_present("OPENAI_OAUTH_CLIENT_ID")
        && env_var_present("OPENAI_OAUTH_CLIENT_SECRET")
    {
        providers.push(SetupProvider::Codex);
    }
    if env_var_present("OLLAMA_BASE_URL") {
        providers.push(SetupProvider::Ollama);
    }
    if env_var_present("GOOGLE_API_KEY") {
        providers.push(SetupProvider::Gemini);
    }
    if env_var_present("VERTEX_PROJECT_ID") {
        providers.push(SetupProvider::Vertex);
    }
    if env_var_present("NEARAI_API_KEY") {
        providers.push(SetupProvider::NearAi);
    }
    if env_var_present("VENICE_API_KEY") {
        providers.push(SetupProvider::Venice);
    }

    let bedrock_region = env_var_present("AWS_REGION") || env_var_present("AWS_DEFAULT_REGION");
    let bedrock_access_key = env_var_present("AWS_ACCESS_KEY_ID");
    let bedrock_secret_key = env_var_present("AWS_SECRET_ACCESS_KEY");
    if bedrock_region && bedrock_access_key && bedrock_secret_key {
        providers.push(SetupProvider::Bedrock);
    }

    providers
}

fn detect_setup_provider_choice_env_hints() -> Vec<SetupProviderChoice> {
    let mut choices = Vec::new();

    if env_var_present("ANTHROPIC_API_KEY")
        || (env_var_present("ANTHROPIC_SETUP_TOKEN") && env_var_present("CARAPACE_CONFIG_PASSWORD"))
    {
        choices.push(SetupProviderChoice::Anthropic);
    }
    if env_var_present("OPENAI_API_KEY")
        || (env_var_present("CARAPACE_CONFIG_PASSWORD")
            && env_var_present("OPENAI_OAUTH_CLIENT_ID")
            && env_var_present("OPENAI_OAUTH_CLIENT_SECRET"))
    {
        choices.push(SetupProviderChoice::OpenAi);
    }
    if env_var_present("OLLAMA_BASE_URL") {
        choices.push(SetupProviderChoice::Ollama);
    }
    if env_var_present("GOOGLE_API_KEY") {
        choices.push(SetupProviderChoice::Gemini);
    }
    if env_var_present("VERTEX_PROJECT_ID") {
        choices.push(SetupProviderChoice::Vertex);
    }
    if env_var_present("NEARAI_API_KEY") {
        choices.push(SetupProviderChoice::NearAi);
    }
    if env_var_present("VENICE_API_KEY") {
        choices.push(SetupProviderChoice::Venice);
    }

    let bedrock_region = env_var_present("AWS_REGION") || env_var_present("AWS_DEFAULT_REGION");
    let bedrock_access_key = env_var_present("AWS_ACCESS_KEY_ID");
    let bedrock_secret_key = env_var_present("AWS_SECRET_ACCESS_KEY");
    if bedrock_region && bedrock_access_key && bedrock_secret_key {
        choices.push(SetupProviderChoice::Bedrock);
    }

    choices
}

fn default_setup_provider_choice(provider_hints: &[SetupProviderChoice]) -> SetupProviderChoice {
    if let [provider] = provider_hints {
        *provider
    } else {
        provider_hints
            .first()
            .copied()
            .unwrap_or(SetupProviderChoice::Anthropic)
    }
}

fn referenced_env_vars(value: &str) -> Vec<String> {
    crate::config::env_var_references_in_string(value)
}

fn first_missing_env_var(value: &str) -> Option<String> {
    referenced_env_vars(value)
        .into_iter()
        .find(|env_var| !env_var_present(env_var))
}

fn config_string(cfg: &Value, path: &[&str]) -> Option<String> {
    let mut current = cfg;
    for key in path {
        current = current.get(*key)?;
    }
    current
        .as_str()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
}

fn config_value_is_usable(value: &str) -> bool {
    referenced_env_vars(value)
        .into_iter()
        .all(|env_var| env_var_present(&env_var))
}

fn config_path_has_usable_value(cfg: &Value, path: &[&str]) -> bool {
    config_string(cfg, path)
        .map(|value| config_value_is_usable(&value))
        .unwrap_or(false)
}

fn unresolved_placeholder_env_var(cfg: &Value, path: &[&str]) -> Option<String> {
    config_string(cfg, path).and_then(|value| first_missing_env_var(&value))
}

fn ollama_configured_for_guidance(cfg: &Value) -> bool {
    if env_var_present("OLLAMA_BASE_URL") {
        return true;
    }

    match cfg.get("providers").and_then(|value| value.get("ollama")) {
        Some(ollama_cfg) => {
            if let Some(base_url) = ollama_cfg.get("baseUrl").and_then(|value| value.as_str()) {
                config_value_is_usable(base_url.trim()) && !base_url.trim().is_empty()
            } else {
                true
            }
        }
        None => false,
    }
}

fn usable_provider_labels(cfg: &Value) -> Vec<&'static str> {
    let mut labels = Vec::new();
    if env_var_present("ANTHROPIC_API_KEY")
        || config_path_has_usable_value(cfg, &["anthropic", "apiKey"])
        || (config_path_has_usable_value(cfg, &["anthropic", "authProfile"])
            && env_var_present("CARAPACE_CONFIG_PASSWORD"))
    {
        labels.push("Anthropic");
    }
    if env_var_present("OPENAI_API_KEY") || config_path_has_usable_value(cfg, &["openai", "apiKey"])
    {
        labels.push("OpenAI");
    }
    if config_path_has_usable_value(cfg, &["codex", "authProfile"])
        && env_var_present("CARAPACE_CONFIG_PASSWORD")
    {
        labels.push("Codex");
    }
    if ollama_configured_for_guidance(cfg) {
        labels.push("Ollama");
    }
    if env_var_present("GOOGLE_API_KEY") || config_path_has_usable_value(cfg, &["google", "apiKey"])
    {
        labels.push("Gemini");
    }
    let vertex_project = env_var_present("VERTEX_PROJECT_ID")
        || config_path_has_usable_value(cfg, &["vertex", "projectId"]);
    let vertex_location = env_var_present("VERTEX_LOCATION")
        || config_path_has_usable_value(cfg, &["vertex", "location"]);
    let vertex_model =
        env_var_present("VERTEX_MODEL") || config_path_has_usable_value(cfg, &["vertex", "model"]);
    let vertex_route = local_chat_model(cfg);
    if crate::agent::vertex::is_vertex_model(&vertex_route)
        && vertex_project
        && vertex_location
        && (!matches!(vertex_route.as_str(), "vertex:default") || vertex_model)
    {
        labels.push("Vertex");
    }
    if env_var_present("NEARAI_API_KEY") || config_path_has_usable_value(cfg, &["nearai", "apiKey"])
    {
        labels.push("NEAR AI Cloud");
    }
    if env_var_present("VENICE_API_KEY") || config_path_has_usable_value(cfg, &["venice", "apiKey"])
    {
        labels.push("Venice");
    }
    let bedrock_enabled = cfg
        .get("bedrock")
        .and_then(|value| value.get("enabled"))
        .and_then(|value| value.as_bool())
        != Some(false);
    let bedrock_region = env_var_present("AWS_REGION")
        || env_var_present("AWS_DEFAULT_REGION")
        || config_path_has_usable_value(cfg, &["bedrock", "region"]);
    let bedrock_access_key = env_var_present("AWS_ACCESS_KEY_ID")
        || config_path_has_usable_value(cfg, &["bedrock", "accessKeyId"]);
    let bedrock_secret_key = env_var_present("AWS_SECRET_ACCESS_KEY")
        || config_path_has_usable_value(cfg, &["bedrock", "secretAccessKey"]);
    if bedrock_enabled && bedrock_region && bedrock_access_key && bedrock_secret_key {
        labels.push("Bedrock");
    }
    labels
}

fn no_provider_configured_guidance() -> String {
    "configure a provider for the selected model, or rerun `cara setup --force`, then retry `cara verify --outcome local-chat`"
        .to_string()
}

fn missing_placeholder_guidance(env_var: &str) -> String {
    format!(
        "set `${env_var}` in the same shell you use for `cara start` and `cara verify`, or rerun `cara setup --force` to write the key into config, then retry `cara verify --outcome local-chat`"
    )
}

fn provider_route_fallback_guidance(
    cfg: &Value,
    provider_label: &str,
    config_hint: Option<&str>,
) -> String {
    let usable = usable_provider_labels(cfg);
    if usable.is_empty() {
        return no_provider_configured_guidance();
    }

    match config_hint {
        Some(hint) => format!(
            "the selected model currently routes to {provider_label}; configure {provider_label} ({hint}) or switch `agents.defaults.model` to one of the other usable providers ({}), then retry `cara verify --outcome local-chat`",
            usable.join(", ")
        ),
        None => format!(
            "the selected model currently routes to {provider_label}; configure {provider_label} or switch `agents.defaults.model` to one of the other usable providers ({}), then retry `cara verify --outcome local-chat`",
            usable.join(", ")
        ),
    }
}

fn single_credential_provider_guidance(
    cfg: &Value,
    provider_label: &str,
    primary_env_var_name: &str,
    config_path: &[&str],
    configured_message: &str,
    config_hint: Option<&str>,
) -> String {
    if env_var_present(primary_env_var_name) {
        return configured_message.to_string();
    }

    if let Some(configured) = config_string(cfg, config_path) {
        if let Some(env_var) = first_missing_env_var(&configured) {
            return missing_placeholder_guidance(&env_var);
        }
        return configured_message.to_string();
    }

    provider_route_fallback_guidance(cfg, provider_label, config_hint)
}

fn local_chat_model(cfg: &Value) -> String {
    config_string(cfg, &["agents", "defaults", "model"]).unwrap_or_default()
}

fn local_chat_provider_route(model: &str) -> Option<ModelProviderRoute> {
    if crate::agent::ollama::is_ollama_model(model) {
        Some(ModelProviderRoute::Ollama)
    } else if crate::agent::nearai::is_nearai_model(model) {
        Some(ModelProviderRoute::NearAi)
    } else if crate::agent::venice::is_venice_model(model) {
        Some(ModelProviderRoute::Venice)
    } else if crate::agent::gemini::is_gemini_model(model) {
        Some(ModelProviderRoute::Gemini)
    } else if crate::agent::vertex::is_vertex_model(model) {
        Some(ModelProviderRoute::Vertex)
    } else if crate::agent::codex::is_codex_model(model) {
        Some(ModelProviderRoute::Codex)
    } else if crate::agent::openai::is_openai_model(model) {
        Some(ModelProviderRoute::OpenAi)
    } else if crate::agent::bedrock::is_bedrock_model(model) {
        Some(ModelProviderRoute::Bedrock)
    } else if crate::agent::anthropic::is_anthropic_model(model) {
        Some(ModelProviderRoute::Anthropic)
    } else {
        None
    }
}

fn provider_api_key_guidance(cfg: &Value, provider: SetupProvider, config_path: &[&str]) -> String {
    let configured_message = format!(
        "check {} API key/model and retry `cara verify --outcome local-chat`",
        provider.label()
    );
    let Some(primary_env_var_name) = provider.api_key_env_var_name() else {
        return provider_route_fallback_guidance(cfg, provider.label(), None);
    };
    single_credential_provider_guidance(
        cfg,
        provider.label(),
        primary_env_var_name,
        config_path,
        &configured_message,
        None,
    )
}

fn anthropic_provider_guidance(cfg: &Value) -> String {
    if let Some(env_var) = unresolved_placeholder_env_var(cfg, &["anthropic", "apiKey"]) {
        return missing_placeholder_guidance(&env_var);
    }

    let api_key_configured = SetupProvider::Anthropic
        .api_key_env_var_name()
        .map(env_var_present)
        .unwrap_or(false)
        || config_path_has_usable_value(cfg, &["anthropic", "apiKey"]);
    if api_key_configured {
        let mut guidance =
            provider_api_key_guidance(cfg, SetupProvider::Anthropic, &["anthropic", "apiKey"]);
        if config_path_has_usable_value(cfg, &["anthropic", "authProfile"]) {
            guidance.push_str(
                " Note: both `anthropic.apiKey` and `anthropic.authProfile` are configured; the API key configuration will be used.",
            );
        }
        return guidance;
    }

    if config_path_has_usable_value(cfg, &["anthropic", "authProfile"]) {
        if env_var_present("CARAPACE_CONFIG_PASSWORD") {
            return "check Anthropic auth profile/model and retry `cara verify --outcome local-chat`"
                .to_string();
        }
        return "set `CARAPACE_CONFIG_PASSWORD` in the same shell you use for `cara start` and `cara verify`, then retry `cara verify --outcome local-chat`".to_string();
    }

    provider_api_key_guidance(cfg, SetupProvider::Anthropic, &["anthropic", "apiKey"])
}

fn bedrock_provider_guidance(cfg: &Value) -> String {
    let region = env_var_present("AWS_REGION")
        || env_var_present("AWS_DEFAULT_REGION")
        || config_path_has_usable_value(cfg, &["bedrock", "region"]);
    let access_key = env_var_present("AWS_ACCESS_KEY_ID")
        || config_path_has_usable_value(cfg, &["bedrock", "accessKeyId"]);
    let secret_key = env_var_present("AWS_SECRET_ACCESS_KEY")
        || config_path_has_usable_value(cfg, &["bedrock", "secretAccessKey"]);

    if region && access_key && secret_key {
        return "check AWS Bedrock region/credentials and selected model, then retry `cara verify --outcome local-chat`"
            .to_string();
    }

    if let Some(env_var) = unresolved_placeholder_env_var(cfg, &["bedrock", "region"])
        .or_else(|| unresolved_placeholder_env_var(cfg, &["bedrock", "accessKeyId"]))
        .or_else(|| unresolved_placeholder_env_var(cfg, &["bedrock", "secretAccessKey"]))
    {
        return missing_placeholder_guidance(&env_var);
    }

    provider_route_fallback_guidance(
        cfg,
        "Bedrock",
        Some("`AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` or `bedrock.*`"),
    )
}

fn vertex_provider_guidance(cfg: &Value) -> String {
    let project = env_var_present("VERTEX_PROJECT_ID")
        || config_path_has_usable_value(cfg, &["vertex", "projectId"]);
    let location = env_var_present("VERTEX_LOCATION")
        || config_path_has_usable_value(cfg, &["vertex", "location"]);
    let local_route = local_chat_model(cfg);
    let requires_default_model = crate::agent::vertex::is_vertex_model(&local_route)
        && crate::agent::vertex::strip_vertex_prefix(&local_route) == "default";
    let default_model =
        env_var_present("VERTEX_MODEL") || config_path_has_usable_value(cfg, &["vertex", "model"]);

    if project && location && requires_default_model && !default_model {
        return "set `VERTEX_MODEL` in the same shell you use for `cara start` and `cara verify`, or configure `vertex.model`, then retry `cara verify --outcome local-chat`"
            .to_string();
    }

    if project && location && (!requires_default_model || default_model) {
        return "check Vertex auth, project, location, and selected model, then retry `cara verify --outcome local-chat`"
            .to_string();
    }

    if let Some(env_var) = unresolved_placeholder_env_var(cfg, &["vertex", "projectId"])
        .or_else(|| unresolved_placeholder_env_var(cfg, &["vertex", "location"]))
        .or_else(|| unresolved_placeholder_env_var(cfg, &["vertex", "model"]))
    {
        return missing_placeholder_guidance(&env_var);
    }

    provider_route_fallback_guidance(
        cfg,
        "Vertex",
        Some("`VERTEX_PROJECT_ID`, `VERTEX_LOCATION`, `VERTEX_MODEL` or `vertex.*`"),
    )
}

fn codex_provider_guidance(cfg: &Value) -> String {
    if config_path_has_usable_value(cfg, &["codex", "authProfile"]) {
        if env_var_present("CARAPACE_CONFIG_PASSWORD") {
            return "check Codex auth profile/model and retry `cara verify --outcome local-chat`"
                .to_string();
        }
        return "set `CARAPACE_CONFIG_PASSWORD` in the same shell you use for `cara start` and `cara verify`, then retry `cara verify --outcome local-chat`".to_string();
    }

    provider_route_fallback_guidance(
        cfg,
        "Codex",
        Some("`codex.authProfile` plus `CARAPACE_CONFIG_PASSWORD` (or rerun `cara setup --provider codex`)"),
    )
}

fn local_chat_verify_next_step(cfg: &Value) -> String {
    let model = local_chat_model(cfg);
    let Some(route) = local_chat_provider_route(&model) else {
        if model.is_empty() {
            return "set `agents.defaults.model` to a provider:model value \
                 (for example `provider:<model-id>`; replace `<model-id>` with your chosen model), then retry \
                 `cara verify --outcome local-chat`"
                .to_string();
        }
        let suggestion = crate::model_names::prefix_bare_model(&model);
        return if suggestion != model {
            format!(
                "`agents.defaults.model` = \"{model}\" needs a provider prefix; \
                 use `{suggestion}` instead, then retry `cara verify --outcome local-chat`"
            )
        } else {
            format!(
                "`agents.defaults.model` = \"{model}\" uses an unrecognized provider; \
                 configure a provider for the selected model, or rerun `cara setup --force`, \
                 then retry `cara verify --outcome local-chat`"
            )
        };
    };
    match route {
        ModelProviderRoute::Anthropic => anthropic_provider_guidance(cfg),
        ModelProviderRoute::Codex => codex_provider_guidance(cfg),
        ModelProviderRoute::OpenAi => {
            provider_api_key_guidance(cfg, SetupProvider::OpenAi, &["openai", "apiKey"])
        }
        ModelProviderRoute::Gemini => single_credential_provider_guidance(
            cfg,
            "Gemini",
            "GOOGLE_API_KEY",
            &["google", "apiKey"],
            "check Gemini API key/model and retry `cara verify --outcome local-chat`",
            Some("`GOOGLE_API_KEY` or `google.apiKey`"),
        ),
        ModelProviderRoute::Vertex => vertex_provider_guidance(cfg),
        ModelProviderRoute::NearAi => single_credential_provider_guidance(
            cfg,
            "NEAR AI Cloud",
            "NEARAI_API_KEY",
            &["nearai", "apiKey"],
            "check NEAR AI Cloud API key/model and retry `cara verify --outcome local-chat`",
            Some("`NEARAI_API_KEY` or `nearai.apiKey`"),
        ),
        ModelProviderRoute::Venice => single_credential_provider_guidance(
            cfg,
            "Venice",
            "VENICE_API_KEY",
            &["venice", "apiKey"],
            "check Venice API key/model and retry `cara verify --outcome local-chat`",
            Some("`VENICE_API_KEY` or `venice.apiKey`"),
        ),
        ModelProviderRoute::Ollama => {
            if ollama_configured_for_guidance(cfg) {
                "check Ollama server reachability/base URL and selected model, then retry `cara verify --outcome local-chat`"
                    .to_string()
            } else {
                provider_route_fallback_guidance(
                    cfg,
                    "Ollama",
                    Some("`OLLAMA_BASE_URL` or `providers.ollama.baseUrl`"),
                )
            }
        }
        ModelProviderRoute::Bedrock => bedrock_provider_guidance(cfg),
    }
}

fn verify_failure_follow_up_url(outcome: VerifyOutcome) -> &'static str {
    match outcome {
        VerifyOutcome::Discord => "https://getcara.io/cookbook/discord-assistant.html",
        VerifyOutcome::Telegram => "https://getcara.io/cookbook/telegram-webhook-assistant.html",
        VerifyOutcome::Matrix
        | VerifyOutcome::Hooks
        | VerifyOutcome::LocalChat
        | VerifyOutcome::Autonomy => "https://getcara.io/help.html#guided-setup-help",
    }
}

#[cfg(test)]
#[derive(Debug, Clone, PartialEq, Eq)]
struct BedrockValidationCall {
    region: String,
    access_key: String,
    secret_key: String,
    session_token: Option<String>,
    default_model: String,
}

#[cfg(test)]
#[derive(Debug, Clone, Default)]
struct SetupInteractiveTestHarness {
    force_interactive: Option<bool>,
    visible_inputs: std::collections::VecDeque<String>,
    hidden_inputs: std::collections::VecDeque<String>,
    provider_validation_results: std::collections::VecDeque<Result<(), String>>,
    bedrock_validation_results:
        std::collections::VecDeque<Result<Vec<crate::onboarding::setup::SetupCheck>, String>>,
    channel_validation_results: std::collections::VecDeque<Result<(), String>>,
    visible_prompt_count: usize,
    hidden_prompt_count: usize,
    provider_validation_calls: usize,
    bedrock_validation_calls: Vec<BedrockValidationCall>,
    channel_validation_calls: usize,
}

#[cfg(test)]
static SETUP_INTERACTIVE_TEST_HARNESS: std::sync::LazyLock<
    std::sync::Mutex<Option<SetupInteractiveTestHarness>>,
> = std::sync::LazyLock::new(|| std::sync::Mutex::new(None));
// Test harness state is process-global; harness-using tests must hold a `ScopedEnv`.

#[cfg(test)]
fn set_setup_interactive_test_harness(harness: SetupInteractiveTestHarness) {
    let mut slot = SETUP_INTERACTIVE_TEST_HARNESS
        .lock()
        .unwrap_or_else(|poisoned| poisoned.into_inner());
    assert!(slot.is_none(), "setup test harness already installed");
    *slot = Some(harness);
}

#[cfg(test)]
fn clear_setup_interactive_test_harness() {
    let mut slot = SETUP_INTERACTIVE_TEST_HARNESS
        .lock()
        .unwrap_or_else(|poisoned| poisoned.into_inner());
    *slot = None;
}

#[cfg(test)]
fn setup_interactive_test_harness_snapshot() -> Option<SetupInteractiveTestHarness> {
    let slot = SETUP_INTERACTIVE_TEST_HARNESS
        .lock()
        .unwrap_or_else(|poisoned| poisoned.into_inner());
    slot.clone()
}

#[cfg(test)]
fn setup_interactive_test_harness_override_interactive() -> Option<bool> {
    let slot = SETUP_INTERACTIVE_TEST_HARNESS
        .lock()
        .unwrap_or_else(|poisoned| poisoned.into_inner());
    slot.as_ref().and_then(|state| state.force_interactive)
}

#[cfg(test)]
fn setup_interactive_test_harness_take_prompt_input(prompt: &str, hidden: bool) -> Option<String> {
    let mut slot = SETUP_INTERACTIVE_TEST_HARNESS
        .lock()
        .unwrap_or_else(|poisoned| poisoned.into_inner());
    let state = slot.as_mut()?;
    let value = if hidden {
        state.hidden_prompt_count = state.hidden_prompt_count.saturating_add(1);
        state.hidden_inputs.pop_front()
    } else {
        state.visible_prompt_count = state.visible_prompt_count.saturating_add(1);
        state.visible_inputs.pop_front()
    };
    if value.is_some() {
        return value;
    }
    drop(slot);
    if hidden {
        panic!("missing scripted hidden input for prompt: {prompt}");
    }
    panic!("missing scripted visible input for prompt: {prompt}");
}

#[cfg(test)]
fn setup_interactive_test_harness_take_provider_validation_result() -> Option<Result<(), String>> {
    let mut slot = SETUP_INTERACTIVE_TEST_HARNESS
        .lock()
        .unwrap_or_else(|poisoned| poisoned.into_inner());
    let state = slot.as_mut()?;
    state.provider_validation_calls = state.provider_validation_calls.saturating_add(1);
    let result = state.provider_validation_results.pop_front();
    if result.is_some() {
        return result;
    }
    drop(slot);
    panic!("missing scripted provider validation result");
}

#[cfg(test)]
fn setup_interactive_test_harness_take_bedrock_validation_result(
    region: &str,
    access_key: &str,
    secret_key: &str,
    session_token: Option<&str>,
    default_model: &str,
) -> Option<Result<Vec<crate::onboarding::setup::SetupCheck>, String>> {
    let mut slot = SETUP_INTERACTIVE_TEST_HARNESS
        .lock()
        .unwrap_or_else(|poisoned| poisoned.into_inner());
    let state = slot.as_mut()?;
    state.bedrock_validation_calls.push(BedrockValidationCall {
        region: region.to_string(),
        access_key: access_key.to_string(),
        secret_key: secret_key.to_string(),
        session_token: session_token.map(str::to_string),
        default_model: default_model.to_string(),
    });
    let result = state.bedrock_validation_results.pop_front();
    if result.is_some() {
        return result;
    }
    drop(slot);
    panic!("missing scripted Bedrock validation result");
}

#[cfg(test)]
fn setup_interactive_test_harness_take_channel_validation_result() -> Option<Result<(), String>> {
    let mut slot = SETUP_INTERACTIVE_TEST_HARNESS
        .lock()
        .unwrap_or_else(|poisoned| poisoned.into_inner());
    let state = slot.as_mut()?;
    state.channel_validation_calls = state.channel_validation_calls.saturating_add(1);
    let result = state.channel_validation_results.pop_front();
    if result.is_some() {
        return result;
    }
    drop(slot);
    panic!("missing scripted channel validation result");
}

fn stdin_is_interactive() -> bool {
    #[cfg(test)]
    {
        // Keep tests deterministic: default to non-interactive unless explicitly forced.
        setup_interactive_test_harness_override_interactive().unwrap_or(false)
    }

    #[cfg(not(test))]
    {
        std::io::stdin().is_terminal()
    }
}

fn prompt_setup_outcome() -> Result<SetupOutcome, Box<dyn std::error::Error>> {
    loop {
        let selection = prompt_with_default(
            "Pick your first-run outcome (fastest path: local-chat/discord/telegram/matrix/hooks)",
            SetupOutcome::LocalChat.prompt_key(),
        )?;
        if let Some(outcome) = parse_setup_outcome(&selection) {
            return Ok(outcome);
        }
        eprintln!("Please choose one of: local-chat, discord, telegram, matrix, hooks.");
    }
}

fn prompt_optional_value_from_env(
    env_var: &str,
    label: &str,
    value_label: &str,
    hide_sensitive_input: bool,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
    let env_value = config::read_config_env(env_var).filter(|v| !v.trim().is_empty());
    if let Some(value) = env_value {
        let use_env = prompt_yes_no(&format!("Use {label} from ${env_var}?"), true)?;
        if use_env {
            return Ok(Some(value.trim().to_string()));
        }
    }

    let entered = prompt_sensitive_line(value_label, hide_sensitive_input, true)?;
    if entered.is_empty() {
        Ok(None)
    } else {
        Ok(Some(entered))
    }
}

fn print_setup_outcome_next_steps(outcome: SetupOutcome, port: u16, hooks_enabled: bool) {
    println!();
    let verify_command = format!(
        "cara verify --outcome {} --port {port}",
        outcome.prompt_key()
    );
    match outcome {
        SetupOutcome::LocalChat => {
            println!("First-run outcome: local assistant chat");
            println!("Next step: run `{verify_command}` once the service is up.");
            println!("Then open chat with `cara chat --port {port}`.");
            println!(
                "Need step-by-step help? {}",
                verify_failure_follow_up_url(VerifyOutcome::LocalChat)
            );
        }
        SetupOutcome::Discord => {
            println!("First-run outcome: Discord assistant");
            println!("Next step: run `{verify_command}`.");
            println!("For full send-path verification, rerun with `--discord-to <channel_id>`.");
            println!("Docs: https://getcara.io/cookbook/discord-assistant.html");
            println!("Repo docs path: docs/cookbook/discord-assistant.md");
        }
        SetupOutcome::Telegram => {
            println!("First-run outcome: Telegram assistant");
            println!("Next step: run `{verify_command}`.");
            println!("For full send-path verification, rerun with `--telegram-to <chat_id>`.");
            println!("Docs: https://getcara.io/cookbook/telegram-webhook-assistant.html");
            println!("Repo docs path: docs/cookbook/telegram-webhook-assistant.md");
        }
        SetupOutcome::Matrix => {
            println!("First-run outcome: Matrix / Element assistant");
            println!("Next step: run `{verify_command}`.");
            println!("For send-path verification context, rerun with `--matrix-to <room_id>`.");
            println!("Repo docs path: docs/channels.md#matrix");
        }
        SetupOutcome::Hooks => {
            println!("First-run outcome: hooks automation");
            println!("Next step: run `{verify_command}`.");
            if hooks_enabled {
                println!(
                    "After verification, send a test hook to http://127.0.0.1:{port}/hooks/wake"
                );
                println!(
                    "Example: curl -X POST -H 'Authorization: Bearer <CARAPACE_HOOKS_TOKEN>' \\"
                );
                println!(
                    "  -H 'Content-Type: application/json' http://127.0.0.1:{port}/hooks/wake \\"
                );
                println!("  -d '{{\"text\":\"wake\"}}'");
            } else {
                println!(
                    "Hooks outcome selected, but hooks are disabled. Enable `gateway.hooks.enabled` to use it."
                );
            }
        }
    }
}

fn print_setup_assessment_summary(assessment: &crate::onboarding::setup::SetupAssessment) {
    println!();
    println!("Provider setup summary");
    println!("----------------------");
    println!("Provider: {}", assessment.provider.label());
    if let Some(auth_mode) = assessment.auth_mode {
        println!("Auth mode: {}", auth_mode.label());
    }
    if let Some(profile_name) = assessment.profile_name.as_deref() {
        match assessment.email.as_deref() {
            Some(email) => println!("Profile: {profile_name} ({email})"),
            None => println!("Profile: {profile_name}"),
        }
    }
    println!("Status: {}", assessment.status.label());
    println!("{}", assessment.summary);
    for check in &assessment.checks {
        let status = match check.status {
            crate::onboarding::setup::SetupCheckStatus::Pass => "PASS",
            crate::onboarding::setup::SetupCheckStatus::Fail => "FAIL",
            crate::onboarding::setup::SetupCheckStatus::Skip => "SKIP",
        };
        println!("[{status}] {}: {}", check.name, check.detail);
        if let Some(remediation) = check.remediation.as_deref() {
            println!("      next step: {remediation}");
        }
    }
}

fn prompt_line(prompt: &str) -> Result<String, Box<dyn std::error::Error>> {
    #[cfg(test)]
    if let Some(scripted) = setup_interactive_test_harness_take_prompt_input(prompt, false) {
        return Ok(scripted.trim().to_string());
    }

    use std::io::{self, IsTerminal, Write};

    print!("{}", prompt);
    io::stdout().flush()?;
    let mut input = String::new();
    let bytes_read = io::stdin().read_line(&mut input)?;
    // SECURITY: refuse to silently default the prompt when stdin is
    // closed AND not a TTY (cron, systemd, `</dev/null`, CI). The
    // prior behavior returned `Ok("")` on EOF, and `prompt_yes_no`
    // then treated an empty line as "accept the default" — silently
    // executing destructive confirmations (e.g., `cara import`) on
    // the operator's behalf. EOF on a piped script with content
    // (`echo y | cara …`) is fine; this only fires when there was
    // nothing to read.
    if bytes_read == 0 && !io::stdin().is_terminal() {
        return Err("stdin closed and not a TTY; refusing to silently default the prompt".into());
    }
    Ok(input.trim().to_string())
}

fn prompt_hidden_line(prompt: &str) -> Result<String, Box<dyn std::error::Error>> {
    #[cfg(test)]
    if let Some(scripted) = setup_interactive_test_harness_take_prompt_input(prompt, true) {
        return Ok(scripted.trim().to_string());
    }

    use std::io::IsTerminal;
    let input = rpassword::prompt_password(prompt)?;
    let trimmed = input.trim();
    // SECURITY: rpassword falls back to a plain stdin read when
    // not on a TTY. On immediate EOF it returns `Ok("")`, which the
    // caller then treats as "user entered blank" and may continue with
    // an empty secret. Mirror `prompt_line`'s EOF guard: refuse to
    // return an empty string when stdin is not a TTY (operator likely
    // piping `echo "" | cara …` by accident).
    if trimmed.is_empty() && !std::io::stdin().is_terminal() {
        return Err(
            "stdin closed and not a TTY; refusing to silently default the hidden-input prompt"
                .into(),
        );
    }
    Ok(trimmed.to_string())
}

fn sensitive_prompt_text(label: &str, hide_sensitive_input: bool, allow_blank: bool) -> String {
    let visibility_hint = if hide_sensitive_input {
        "input hidden; pasted text will not be shown"
    } else {
        "input visible (WARNING: secrets will be shown on screen)"
    };
    let blank_hint = if allow_blank {
        ", leave blank to skip for now"
    } else {
        ""
    };

    format!("Enter {label} ({visibility_hint}{blank_hint}): ")
}

fn prompt_sensitive_line_with<FHidden, FVisible>(
    prompt: &str,
    hide_sensitive_input: bool,
    prompt_hidden: FHidden,
    prompt_visible: FVisible,
) -> Result<String, Box<dyn std::error::Error>>
where
    FHidden: FnOnce(&str) -> Result<String, Box<dyn std::error::Error>>,
    FVisible: FnOnce(&str) -> Result<String, Box<dyn std::error::Error>>,
{
    if hide_sensitive_input {
        prompt_hidden(prompt)
    } else {
        prompt_visible(prompt)
    }
}

fn prompt_sensitive_line(
    label: &str,
    hide_sensitive_input: bool,
    allow_blank: bool,
) -> Result<String, Box<dyn std::error::Error>> {
    let prompt = sensitive_prompt_text(label, hide_sensitive_input, allow_blank);
    prompt_sensitive_line_with(
        &prompt,
        hide_sensitive_input,
        prompt_hidden_line,
        prompt_line,
    )
}

fn prompt_with_default(prompt: &str, default: &str) -> Result<String, Box<dyn std::error::Error>> {
    let line = prompt_line(&format!("{prompt} [{default}]: "))?;
    if line.is_empty() {
        Ok(default.to_string())
    } else {
        Ok(line)
    }
}

fn prompt_yes_no(prompt: &str, default_yes: bool) -> Result<bool, Box<dyn std::error::Error>> {
    let suffix = if default_yes { "Y/n" } else { "y/N" };
    let line = prompt_line(&format!("{prompt} ({suffix}): "))?;
    if line.is_empty() {
        return Ok(default_yes);
    }
    match line.trim().to_lowercase().as_str() {
        "y" | "yes" => Ok(true),
        "n" | "no" => Ok(false),
        _ => Ok(default_yes),
    }
}

fn prompt_yes_no_required(prompt: &str) -> Result<bool, Box<dyn std::error::Error>> {
    loop {
        let line = prompt_line(&format!("{prompt} (y/n): "))?;
        match line.trim().to_lowercase().as_str() {
            "y" | "yes" => return Ok(true),
            "n" | "no" => return Ok(false),
            _ => eprintln!("Please enter y or n."),
        }
    }
}

fn prompt_choice(
    prompt: &str,
    default: &str,
    accepted: &[&str],
) -> Result<String, Box<dyn std::error::Error>> {
    loop {
        let selection = prompt_with_default(prompt, default)?;
        let normalized = selection.trim().to_lowercase();
        if accepted
            .iter()
            .any(|value| normalized.as_str() == value.to_lowercase())
        {
            return Ok(normalized);
        }
        eprintln!("Please enter one of: {}", accepted.join(", "));
    }
}

fn prompt_port(default_port: u16) -> Result<u16, Box<dyn std::error::Error>> {
    loop {
        let raw = prompt_with_default("Gateway port", &default_port.to_string())?;
        match raw.parse::<u16>() {
            Ok(0) => eprintln!("Port must be between 1 and 65535."),
            Ok(port) => return Ok(port),
            Err(_) => eprintln!("Please enter a valid TCP port (1-65535)."),
        }
    }
}

fn generate_hex_secret(byte_len: usize) -> Result<String, Box<dyn std::error::Error>> {
    Ok(crate::crypto::generate_hex_secret(byte_len)?)
}

fn prompt_custom_secret(
    kind: &str,
    hide_sensitive_input: bool,
) -> Result<String, Box<dyn std::error::Error>> {
    const MIN_SECRET_LENGTH: usize = 10;

    loop {
        let entered = prompt_sensitive_line(kind, hide_sensitive_input, false)?;
        if entered.is_empty() {
            eprintln!("{} cannot be empty.", kind);
            continue;
        }

        if entered.chars().count() < MIN_SECRET_LENGTH {
            eprintln!(
                "{} must be at least {} characters long.",
                kind, MIN_SECRET_LENGTH
            );
            continue;
        }

        return Ok(entered);
    }
}

async fn validate_provider_credentials(provider: &str, api_key: &str) -> Result<(), String> {
    #[cfg(test)]
    if let Some(result) = setup_interactive_test_harness_take_provider_validation_result() {
        return result;
    }

    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(8))
        .build()
        .map_err(|e| format!("failed to build validation client: {e}"))?;

    let response = match provider {
        "openai" => client
            .get("https://api.openai.com/v1/models")
            .bearer_auth(api_key)
            .send()
            .await
            .map_err(|e| {
                // SECURITY: `reqwest::Error` Display embeds the request
                // URL chain. The endpoint itself is a constant, but
                // an operator-configured proxy with `HTTP_PROXY=
                // http://user:pass@proxy:8080` would surface those
                // credentials via the Display path. Strip via
                // `without_url()` to match the rule applied at every
                // other reqwest error site in the tree.
                format!("OpenAI credential check failed: {}", e.without_url())
            })?,
        "anthropic" => client
            .get("https://api.anthropic.com/v1/models")
            .header("x-api-key", api_key)
            .header("anthropic-version", "2023-06-01")
            .send()
            .await
            .map_err(|e| format!("Anthropic credential check failed: {}", e.without_url()))?,
        other => return Err(format!("unsupported provider for validation: {other}")),
    };

    if response.status().is_success() {
        return Ok(());
    }

    let status = response.status();
    let body = crate::net_util::read_response_body_text_capped(
        response,
        crate::net_util::MAX_RESPONSE_BODY_BYTES,
    )
    .await
    .unwrap_or_default();
    let has_body = !body.trim().is_empty();
    let mut message = format!("{provider} credential check failed (HTTP {status}).");
    if has_body {
        message.push_str(
            " The provider returned an error message that is hidden because it may contain sensitive information.",
        );
    }
    Err(message)
}

async fn validate_provider_credentials_owned(
    provider: String,
    api_key: String,
) -> Result<(), String> {
    validate_provider_credentials(&provider, &api_key).await
}

fn map_channel_validation_error(
    channel_name: &str,
    err: crate::channels::ChannelAuthError,
) -> String {
    let detail = err.message();
    let mut message = if err.is_auth() {
        format!("{channel_name} credential check failed.")
    } else {
        format!("{channel_name} credential check hit a transient error.")
    };

    if !detail.trim().is_empty() {
        message.push_str(" Details are hidden because they may contain sensitive information.");
    }

    message
}

fn prompt_and_configure_bot_channel(
    config: &mut Value,
    channel_key: &str,
    channel_label: &str,
    env_var: &str,
    hide_sensitive_input: bool,
) -> Result<(), Box<dyn std::error::Error>> {
    let token_label = format!("{channel_label} bot token");
    let channel_token =
        prompt_optional_value_from_env(env_var, &token_label, &token_label, hide_sensitive_input)?;
    if let Some(token) = channel_token {
        println!("{channel_label} token captured.");
        validate_channel_credentials_interactive(channel_key, &token)?;
        // SECURITY: when CARAPACE_CONFIG_PASSWORD is set, snapshot it
        // and encrypt the bot token inline so the candidate config
        // carries an enc:v2: envelope by the time `persist_config_file`
        // -> `seal_config_secrets` runs. Closes the TOCTOU window
        // where the password env var could vanish between this point
        // and the seal layer's independent `config_password()` read
        // (test pollution, container hot-reload, operator unset),
        // which otherwise causes `seal_config_secrets` to early-
        // return Ok(()) and leave the plaintext bot token on disk.
        // When the password is unset, fall through to plaintext: that
        // matches the documented unencrypted first-run setup contract
        // (mirrors `gateway.auth.token` first-run handling).
        let stored_token = if let Some(password) = config::config_password() {
            if config::secrets::is_encrypted(&token) {
                token
            } else {
                let store = config::secrets::SecretStore::new(password.as_ref())
                    .map_err(|err| format!("failed to initialize config secret store: {err}"))?;
                store
                    .encrypt(&token)
                    .map_err(|err| format!("failed to encrypt {channel_key}.botToken: {err}"))?
            }
        } else {
            token
        };
        config[channel_key] = serde_json::json!({
            "enabled": true,
            "botToken": stored_token
        });
    } else {
        println!("No {channel_label} token entered; skipping credential validation.");
        println!(
            "Skipped {channel_label} token. You can configure it later in `{channel_key}.botToken`."
        );
    }
    Ok(())
}

fn parse_csv_prompt_values(raw: &str) -> Vec<String> {
    raw.split(',')
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
        .collect()
}

fn prompt_and_configure_matrix_channel(
    config: &mut Value,
    hide_sensitive_input: bool,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
    let homeserver_url = prompt_optional_value_from_env(
        "MATRIX_HOMESERVER_URL",
        "Matrix homeserver URL",
        "Matrix homeserver URL",
        false,
    )?;
    let user_id = prompt_optional_value_from_env(
        "MATRIX_USER_ID",
        "Matrix user ID",
        "Matrix user ID",
        false,
    )?;
    let (Some(homeserver_url), Some(user_id)) = (homeserver_url, user_id) else {
        println!("Matrix homeserver URL and user ID are required; skipping Matrix channel setup.");
        return Ok(None);
    };

    let default_credential = if config::read_config_env("MATRIX_ACCESS_TOKEN").is_some() {
        "access-token"
    } else {
        "password"
    };
    let credential_mode = prompt_choice(
        "Matrix credential mode (password/access-token)",
        default_credential,
        &["password", "access-token"],
    )?;

    let mut matrix = serde_json::Map::new();
    matrix.insert("enabled".to_string(), Value::Bool(true));
    matrix.insert("homeserverUrl".to_string(), Value::String(homeserver_url));
    matrix.insert("userId".to_string(), Value::String(user_id));
    matrix.insert("encrypted".to_string(), Value::Bool(true));

    if credential_mode == "access-token" {
        let access_token = prompt_optional_value_from_env(
            "MATRIX_ACCESS_TOKEN",
            "Matrix access token",
            "Matrix access token",
            hide_sensitive_input,
        )?;
        let device_id = prompt_optional_value_from_env(
            "MATRIX_DEVICE_ID",
            "Matrix device ID",
            "Matrix device ID",
            false,
        )?;
        // The runtime requires deviceId whenever accessToken is set
        // (silent fall-through to password login would churn the bot's
        // device identity on every restart). Reject the partial config
        // here rather than letting setup write a config that the daemon
        // will refuse to load.
        match (access_token, device_id) {
            (Some(token), Some(device_id)) => {
                matrix.insert("accessToken".to_string(), Value::String(token));
                matrix.insert("deviceId".to_string(), Value::String(device_id));
            }
            (Some(_), None) => {
                println!(
                    "Matrix access-token mode also requires a device ID. \
                     Set MATRIX_DEVICE_ID or rerun setup and provide one when prompted."
                );
                return Ok(None);
            }
            (None, _) => {
                println!("Matrix access token not provided; skipping Matrix channel setup.");
                return Ok(None);
            }
        }
    } else if let Some(password) = prompt_optional_value_from_env(
        "MATRIX_PASSWORD",
        "Matrix password",
        "Matrix password",
        hide_sensitive_input,
    )? {
        matrix.insert("password".to_string(), Value::String(password));
    } else {
        println!("Matrix password not provided; skipping Matrix channel setup.");
        return Ok(None);
    }

    let encrypted = prompt_yes_no("Enable Matrix encrypted-room support?", true)?;
    matrix.insert("encrypted".to_string(), Value::Bool(encrypted));
    if encrypted {
        if let Some(store_passphrase) = prompt_optional_value_from_env(
            "MATRIX_STORE_PASSPHRASE",
            "Matrix encrypted-store passphrase",
            "Matrix encrypted-store passphrase",
            hide_sensitive_input,
        )? {
            matrix.insert(
                "storePassphrase".to_string(),
                Value::String(store_passphrase),
            );
        }
    }

    let allow_users = prompt_line(
        "Optional Matrix auto-join allowUsers MXIDs, comma-separated (leave blank to reject all): ",
    )?;
    let allow_server_names =
        prompt_line("Optional Matrix auto-join allowServerNames, comma-separated: ")?;
    let allow_users = parse_csv_prompt_values(&allow_users);
    let allow_server_names = parse_csv_prompt_values(&allow_server_names);
    if !allow_users.is_empty() || !allow_server_names.is_empty() {
        matrix.insert(
            "autoJoin".to_string(),
            serde_json::json!({
                "allowUsers": allow_users,
                "allowServerNames": allow_server_names,
            }),
        );
    }

    let matrix_secret_keys = ["accessToken", "password", "storePassphrase"];
    let contains_matrix_secret = matrix_secret_keys
        .iter()
        .any(|key| matrix.get(*key).and_then(Value::as_str).is_some());
    if contains_matrix_secret {
        let Some(password) = config::config_password() else {
            return Err(
                "CARAPACE_CONFIG_PASSWORD is required before setup can write Matrix secrets to config"
                    .into(),
            );
        };
        // SECURITY: snapshot the config password ONCE here and encrypt
        // the matrix secrets inline so the candidate config carries
        // `enc:v2:` envelopes by the time `persist_config_file` ->
        // `seal_config_secrets` runs. Without this, a transient unset
        // of CARAPACE_CONFIG_PASSWORD between the refusal check above
        // and the seal layer's own independent `config_password()`
        // read (test pollution, container hot-reload, operator unset)
        // makes `seal_config_secrets` early-return Ok(()) silently —
        // leaving plaintext access_token / password / storePassphrase
        // on disk while the wizard prints "Config written" as if
        // sealed. `validate_locked_secret_preservation` does not
        // catch first-run setup because `existing_raw` has no
        // enc:v2: value at these paths; the downgrade guard cannot
        // fire. Pre-encrypting here makes `seal_config_secrets`'s
        // `is_encrypted()` guard skip these slots so the re-seal
        // pass becomes a no-op regardless of env-var state.
        let store = config::secrets::SecretStore::new(password.as_ref())
            .map_err(|err| format!("failed to initialize config secret store: {err}"))?;
        for key in matrix_secret_keys.iter() {
            let plaintext = match matrix.get(*key).and_then(Value::as_str) {
                Some(value) if !config::secrets::is_encrypted(value) => value.to_string(),
                _ => continue,
            };
            let encrypted = store
                .encrypt(&plaintext)
                .map_err(|err| format!("failed to encrypt matrix.{key}: {err}"))?;
            matrix.insert((*key).to_string(), Value::String(encrypted));
        }
    }

    config["matrix"] = Value::Object(matrix);
    let destination =
        prompt_line("Optional: Matrix room ID for send-path verify (leave blank to skip): ")?;
    Ok(normalize_optional_input(Some(destination)))
}

fn operator_ssrf_config_from_cli_config(cfg: &Value) -> crate::plugins::capabilities::SsrfConfig {
    crate::plugins::capabilities::SsrfConfig {
        allow_tailscale: cfg
            .pointer("/plugins/sandbox/allow_tailscale")
            .and_then(Value::as_bool)
            .unwrap_or(false),
    }
}

async fn validate_channel_credentials(
    channel: &str,
    token: &str,
    ssrf_config: crate::plugins::capabilities::SsrfConfig,
) -> Result<(), String> {
    #[cfg(test)]
    if let Some(result) = setup_interactive_test_harness_take_channel_validation_result() {
        return result;
    }

    match channel {
        "discord" => {
            let token = token.to_string();
            tokio::task::spawn_blocking(move || {
                DiscordChannel::new(DISCORD_DEFAULT_API_BASE_URL.to_string(), token, ssrf_config)
                    .validate()
                    .map_err(|err| map_channel_validation_error("Discord", err))
            })
            .await
            .map_err(|e| format!("Discord credential check task failed: {e}"))?
        }
        "telegram" => {
            let token = token.to_string();
            tokio::task::spawn_blocking(move || {
                TelegramChannel::new(
                    TELEGRAM_DEFAULT_API_BASE_URL.to_string(),
                    token,
                    ssrf_config,
                )
                .validate()
                .map_err(|err| map_channel_validation_error("Telegram", err))
            })
            .await
            .map_err(|e| format!("Telegram credential check task failed: {e}"))?
        }
        other => Err(format!("unsupported channel for validation: {other}")),
    }
}

async fn validate_channel_credentials_owned(channel: String, token: String) -> Result<(), String> {
    validate_channel_credentials(
        &channel,
        &token,
        setup_channel_credential_validation_ssrf_config(),
    )
    .await
}

fn setup_channel_credential_validation_ssrf_config() -> crate::plugins::capabilities::SsrfConfig {
    // Interactive setup validates today's built-in Discord/Telegram SaaS
    // endpoints only. It deliberately does not inherit operator plugin SSRF
    // policy until channel base-URL overrides exist and can be validated as
    // part of the setup config being written.
    crate::plugins::capabilities::SsrfConfig::validation_only()
}

fn verify_channel_credential_validation_ssrf_config(
    cfg: &Value,
) -> crate::plugins::capabilities::SsrfConfig {
    // `cara verify` checks the already-written operator configuration, so it
    // uses the operator's SSRF policy instead of setup's validation-only mode.
    operator_ssrf_config_from_cli_config(cfg)
}

fn setup_rerun_command(
    provider: SetupProvider,
    requested_auth_mode: Option<SetupAuthModeSelection>,
    resolved_model: Option<&str>,
) -> Result<String, SetupCommandModelError> {
    let mut command = crate::onboarding::setup::SetupProvider::from(provider)
        .setup_command(setup_provider_auth_mode_hint(provider, requested_auth_mode));
    if let Some(model) = resolved_model {
        command = setup_command_with_resolved_model(command, model)?;
    }
    Ok(command)
}

fn setup_rerun_command_for_remediation(
    provider: SetupProvider,
    requested_auth_mode: Option<SetupAuthModeSelection>,
    resolved_model: Option<&str>,
) -> String {
    match setup_rerun_command(provider, requested_auth_mode, resolved_model) {
        Ok(command) => command,
        Err(err) => {
            tracing::error!(
                provider = provider.prompt_key(),
                error = %err,
                "setup remediation command model injection failed; using placeholder command"
            );
            crate::onboarding::setup::SetupProvider::from(provider)
                .setup_command(setup_provider_auth_mode_hint(provider, requested_auth_mode))
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct SetupCommandModelError;

impl std::fmt::Display for SetupCommandModelError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "setup remediation command rendering requires a validated model id and a simple setup command template"
        )
    }
}

impl std::error::Error for SetupCommandModelError {}

fn setup_command_with_resolved_model(
    command: String,
    model: &str,
) -> Result<String, SetupCommandModelError> {
    // Callers pass a `ValidatedSetupModel` or an equivalent route-derived
    // value, so reaching the placeholder fallback here would hide an invariant
    // violation from the setup flow that produced the remediation command.
    let model = model.trim();
    if model.is_empty() || !crate::onboarding::setup::model_id_is_command_token_safe(model) {
        return Err(SetupCommandModelError);
    }
    crate::onboarding::setup::setup_command_with_model_argument(command, model)
        .map_err(|_| SetupCommandModelError)
}

fn validate_provider_credentials_interactive(
    provider: SetupProvider,
    requested_auth_mode: Option<SetupAuthModeSelection>,
    api_key: &str,
    resolved_model: Option<&str>,
) -> Result<crate::onboarding::setup::SetupCheck, Box<dyn std::error::Error>> {
    if provider == SetupProvider::Anthropic
        && requested_auth_mode == Some(SetupAuthModeSelection::SetupToken)
    {
        return Ok(crate::onboarding::setup::SetupCheck::validation_skip(
            "Live provider validation",
            "Anthropic setup-token live validation was skipped because setup-tokens do not use the API-key validation probe."
                .to_string(),
            Some(
                "run `cara verify --outcome local-chat` after setup to exercise the configured Anthropic setup-token path"
                    .to_string(),
            ),
            None,
        ));
    }

    let validate_now = prompt_yes_no("Validate provider credentials now?", true)?;
    if !validate_now {
        return Ok(crate::onboarding::setup::SetupCheck::validation_skip(
            "Live provider validation",
            format!("{} credential validation was skipped", provider.label()),
            Some(
                "run `cara verify` after setup to exercise the configured provider path"
                    .to_string(),
            ),
            None,
        ));
    }

    let provider_key = provider.prompt_key().to_string();
    let api_key = api_key.to_string();
    println!("Checking {} credentials...", provider.label());
    match run_sync_blocking_send(validate_provider_credentials_owned(provider_key, api_key))
        .map_err(|err| format!("credential validation runtime failed: {err}"))
    {
        Ok(()) => {
            println!("Credential check succeeded.");
            Ok(crate::onboarding::setup::SetupCheck::validation_pass(
                "Live provider validation",
                format!("{} credential validation succeeded", provider.label()),
                None,
            ))
        }
        Err(err) => {
            eprintln!("Credential check failed: {}", err);
            if prompt_yes_no("Continue setup and write config anyway?", false)? {
                let rerun_command = setup_rerun_command_for_remediation(
                    provider,
                    requested_auth_mode,
                    resolved_model,
                );
                let rerun_reference =
                    crate::onboarding::setup::setup_command_reference(&rerun_command);
                Ok(crate::onboarding::setup::SetupCheck::validation_fail(
                    "Live provider validation",
                    err,
                    format!(
                        "fix the credential and rerun {rerun_reference} or run `cara verify` after updating config"
                    ),
                    None,
                ))
            } else {
                Err("setup aborted after credential validation failure".into())
            }
        }
    }
}

fn validate_bedrock_credentials_interactive(
    region: &str,
    access_key: &str,
    secret_key: &str,
    session_token: Option<&str>,
    default_model: &str,
) -> Result<Vec<crate::onboarding::setup::SetupCheck>, Box<dyn std::error::Error>> {
    let validate_now = prompt_yes_no("Validate Bedrock credentials now?", true)?;
    if !validate_now {
        return Ok(vec![crate::onboarding::setup::SetupCheck::validation_skip(
            "Live Bedrock validation",
            "Bedrock credential validation was skipped".to_string(),
            Some(
                "run `cara verify` after setup to exercise the configured provider path"
                    .to_string(),
            ),
            None,
        )]);
    }

    #[cfg(test)]
    if let Some(result) = setup_interactive_test_harness_take_bedrock_validation_result(
        region,
        access_key,
        secret_key,
        session_token,
        default_model,
    ) {
        return result.map_err(|err| std::io::Error::other(err).into());
    }

    let mut checks = Vec::new();
    checks.push(crate::onboarding::bedrock::validate_region(region));

    let region = region.to_string();
    let access_key = access_key.to_string();
    let secret_key = secret_key.to_string();
    let session_token = session_token.map(|s| s.to_string());
    let default_model = default_model.to_string();

    println!("Checking Bedrock credentials...");
    let (cred_check, models_json) = run_sync_blocking_send(async move {
        Ok::<_, String>(
            crate::onboarding::bedrock::validate_bedrock_credentials(
                &region,
                &access_key,
                &secret_key,
                session_token.as_deref(),
            )
            .await,
        )
    })
    .map_err(|err| format!("credential validation runtime failed: {err}"))?;

    match cred_check.status {
        crate::onboarding::setup::SetupCheckStatus::Pass => {
            println!("Credential check succeeded.");
        }
        crate::onboarding::setup::SetupCheckStatus::Fail => {
            eprintln!("Credential check failed: {}", cred_check.detail);
            if let Some(ref remediation) = cred_check.remediation {
                eprintln!("  Remediation: {}", remediation);
            }
            if !prompt_yes_no("Continue setup and write config anyway?", false)? {
                return Err("setup aborted after credential validation failure".into());
            }
        }
        _ => {}
    }
    checks.push(cred_check);

    if let Some(ref models) = models_json {
        let model_check = crate::onboarding::bedrock::check_model_access(&default_model, models);
        match model_check.status {
            crate::onboarding::setup::SetupCheckStatus::Pass => {
                println!("Model access check succeeded.");
            }
            crate::onboarding::setup::SetupCheckStatus::Fail => {
                eprintln!("Model access: {}", model_check.detail);
                if let Some(ref remediation) = model_check.remediation {
                    eprintln!("  Remediation: {}", remediation);
                }
            }
            _ => {}
        }
        checks.push(model_check);
    }

    Ok(checks)
}

fn vertex_validation_failure_remediation(
    err: &crate::agent::vertex::VertexSetupValidationError,
    rerun_command: &str,
) -> String {
    match err {
        crate::agent::vertex::VertexSetupValidationError::InvalidProjectId => {
            format!("enter a valid GCP project ID and rerun `{rerun_command}`")
        }
        crate::agent::vertex::VertexSetupValidationError::InvalidLocation => {
            format!("enter a valid GCP location such as `us-central1` and rerun `{rerun_command}`")
        }
        crate::agent::vertex::VertexSetupValidationError::MissingDefaultModel => {
            format!("set `vertex.model` for the default Vertex route, then rerun `{rerun_command}`")
        }
        crate::agent::vertex::VertexSetupValidationError::UnsupportedModel => {
            format!("configure a supported Vertex model for the selected route, then rerun `{rerun_command}`")
        }
        crate::agent::vertex::VertexSetupValidationError::ClientInit(_) => {
            format!("check local HTTP client and TLS runtime availability, then rerun `{rerun_command}`")
        }
        crate::agent::vertex::VertexSetupValidationError::AuthUnavailable => {
            format!("run `gcloud auth application-default login` or use a metadata-backed Google Cloud service account, then rerun `{rerun_command}`")
        }
        crate::agent::vertex::VertexSetupValidationError::AccessDenied => {
            format!("check Vertex IAM/API access for the configured project, location, and model, then rerun `{rerun_command}`")
        }
        crate::agent::vertex::VertexSetupValidationError::ProbeRejected => {
            "check the Vertex project, location, and model values; if they look correct, this may indicate a malformed Vertex validation request in Carapace"
                .to_string()
        }
        crate::agent::vertex::VertexSetupValidationError::Unavailable => {
            format!("check the Vertex project ID, location, and model name, then rerun `{rerun_command}`")
        }
        crate::agent::vertex::VertexSetupValidationError::Rejected => {
            format!("check the Vertex project, location, model, and provider access, then rerun `{rerun_command}`")
        }
        crate::agent::vertex::VertexSetupValidationError::RateLimited => {
            format!(
                "retry after the current Vertex AI rate limit window, then rerun `{rerun_command}`"
            )
        }
        crate::agent::vertex::VertexSetupValidationError::Transport => {
            format!("retry if Vertex AI is temporarily unavailable; otherwise check network connectivity and rerun `{rerun_command}`")
        }
    }
}

fn validate_vertex_provider_interactive(
    config: &serde_json::Value,
    input: &crate::onboarding::vertex::VertexSetupInput,
) -> Result<crate::onboarding::setup::SetupCheck, Box<dyn std::error::Error>> {
    let validate_now = prompt_yes_no("Validate Vertex configuration now?", true)?;
    if !validate_now {
        return Ok(crate::onboarding::setup::SetupCheck::validation_skip(
            "Live provider validation",
            "Vertex live validation was skipped",
            Some(
                "run `cara verify` after setup to exercise the configured Vertex path".to_string(),
            ),
            None,
        ));
    }

    let route_model = input.route_model()?;
    let rerun_command =
        setup_rerun_command_for_remediation(SetupProvider::Vertex, None, Some(&route_model));

    #[cfg(test)]
    if let Some(result) = setup_interactive_test_harness_take_provider_validation_result() {
        return match result {
            Ok(()) => Ok(crate::onboarding::setup::SetupCheck::validation_pass(
                "Live provider validation",
                "Vertex auth, project, location, and model access validated",
                None,
            )),
            Err(detail) => {
                eprintln!("Credential check failed: {detail}");
                if prompt_yes_no("Continue setup and write config anyway?", false)? {
                    Ok(crate::onboarding::setup::SetupCheck::validation_fail(
                        "Live provider validation",
                        detail,
                        format!(
                            "check Vertex auth, project, location, and model access, then rerun `{rerun_command}`"
                        ),
                        None,
                    ))
                } else {
                    Err("setup aborted after provider configuration validation failure".into())
                }
            }
        };
    }

    println!("Checking Vertex configuration...");
    let gcloud_timeout_ms =
        crate::agent::vertex::resolve_gcloud_token_timeout_ms_from_config(config);
    match run_sync_blocking_send(crate::agent::vertex::validate_vertex_setup_with_timeout(
        input.project_id.clone(),
        input.location.clone(),
        route_model,
        input.default_model(),
        Some(gcloud_timeout_ms),
    )) {
        Ok(()) => {
            println!("Credential check succeeded.");
            Ok(crate::onboarding::setup::SetupCheck::validation_pass(
                "Live provider validation",
                "Vertex auth, project, location, and model access validated",
                None,
            ))
        }
        Err(crate::runtime_bridge::BridgeError::Inner(err)) => {
            let detail = err.to_string();
            eprintln!("Credential check failed: {detail}");
            if prompt_yes_no("Continue setup and write config anyway?", false)? {
                Ok(crate::onboarding::setup::SetupCheck::validation_fail(
                    "Live provider validation",
                    detail,
                    vertex_validation_failure_remediation(&err, &rerun_command),
                    None,
                ))
            } else {
                Err("setup aborted after provider configuration validation failure".into())
            }
        }
        Err(err) => {
            let detail = format!("Vertex validation runtime failed: {err}");
            eprintln!("Credential check failed: {detail}");
            if prompt_yes_no("Continue setup and write config anyway?", false)? {
                Ok(crate::onboarding::setup::SetupCheck::validation_fail(
                    "Live provider validation",
                    detail,
                    format!("check local runtime availability and rerun `{rerun_command}`"),
                    None,
                ))
            } else {
                Err("setup aborted after provider configuration validation failure".into())
            }
        }
    }
}

fn validate_channel_credentials_interactive(
    channel: &str,
    token: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let validate_now = prompt_yes_no(&format!("Validate {channel} credentials now?"), true)?;
    if !validate_now {
        return Ok(());
    }

    let channel = channel.to_string();
    let token = token.to_string();
    println!("Checking {} credentials...", channel);
    match run_sync_blocking_send(validate_channel_credentials_owned(channel, token))
        .map_err(|err| format!("credential validation runtime failed: {err}"))
    {
        Ok(()) => {
            println!("Credential check succeeded.");
            Ok(())
        }
        Err(err) => {
            eprintln!("Credential check failed: {}", err);
            if prompt_yes_no("Continue setup and write config anyway?", false)? {
                Ok(())
            } else {
                Err("setup aborted after credential validation failure".into())
            }
        }
    }
}

#[derive(Debug)]
struct VerifyCheckResult {
    name: String,
    status: VerifyCheckStatus,
    detail: String,
    next_step: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum VerifyCheckStatus {
    Pass,
    Fail,
    Skip,
}

impl VerifyCheckResult {
    fn pass(name: impl Into<String>, detail: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            status: VerifyCheckStatus::Pass,
            detail: detail.into(),
            next_step: None,
        }
    }

    fn fail(
        name: impl Into<String>,
        detail: impl Into<String>,
        next_step: impl Into<String>,
    ) -> Self {
        Self {
            name: name.into(),
            status: VerifyCheckStatus::Fail,
            detail: detail.into(),
            next_step: Some(next_step.into()),
        }
    }

    fn skip(
        name: impl Into<String>,
        detail: impl Into<String>,
        next_step: impl Into<String>,
    ) -> Self {
        Self {
            name: name.into(),
            status: VerifyCheckStatus::Skip,
            detail: detail.into(),
            next_step: Some(next_step.into()),
        }
    }
}

// Keep this list in sync when adding channels that support token placeholders.
const VERIFY_ALLOWED_ENV_PLACEHOLDER_KEYS: &[&str] = &[
    "DISCORD_BOT_TOKEN",
    "TELEGRAM_BOT_TOKEN",
    "MATRIX_ACCESS_TOKEN",
    "MATRIX_PASSWORD",
    "MATRIX_STORE_PASSPHRASE",
    "CARAPACE_HOOKS_TOKEN",
];
static WARNED_VERIFY_PLACEHOLDER_KEYS: LazyLock<Mutex<HashSet<String>>> =
    LazyLock::new(|| Mutex::new(HashSet::new()));

fn is_allowed_verify_placeholder_key(key: &str) -> bool {
    VERIFY_ALLOWED_ENV_PLACEHOLDER_KEYS.contains(&key)
}

fn warn_unsupported_verify_placeholder_key(key: &str) {
    if cfg!(test) {
        return;
    }
    if key.len() > 64 {
        return;
    }
    let mut warned = WARNED_VERIFY_PLACEHOLDER_KEYS
        .lock()
        .expect("verify placeholder warning lock poisoned");
    if !warned.insert(key.to_string()) {
        return;
    }
    eprintln!(
        "Warning: unsupported token placeholder `${{{key}}}` ignored during verification. \
Use one of: {}",
        VERIFY_ALLOWED_ENV_PLACEHOLDER_KEYS.join(", ")
    );
}

fn normalize_optional_input(input: Option<String>) -> Option<String> {
    input
        .map(|value| value.trim().to_string())
        .filter(|trimmed| !trimmed.is_empty())
}

fn summarize_destination_for_display(raw: &str) -> String {
    // SECURITY: use the canonical strip_terminal_unsafe_chars
    // sanitizer rather than `is_control()`. Unicode `is_control()`
    // is Cc-only — it catches ANSI ESC and C1 controls but does NOT
    // cover bidi format chars (U+202A-U+202E, U+2066-U+2069) or
    // zero-width chars (U+200B-U+200D, U+FEFF) which are the
    // dominant operator-paste / hostile-destination spoof vectors.
    // strip_terminal_unsafe_chars covers both classes.
    let sanitized: String = crate::logging::redact::strip_terminal_unsafe_chars(raw)
        .chars()
        .take(120)
        .collect();
    if sanitized.is_empty() {
        "<redacted>".to_string()
    } else {
        sanitized
    }
}

fn resolve_env_placeholder(value: &str) -> Option<String> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        return None;
    }
    let Some(key) = trimmed.strip_prefix("${").and_then(|s| s.strip_suffix('}')) else {
        return Some(trimmed.to_string());
    };
    let key = key.trim();
    if key.is_empty() {
        return None;
    }
    if !is_allowed_verify_placeholder_key(key) {
        warn_unsupported_verify_placeholder_key(key);
        return None;
    }
    config::read_config_env(key)
        .map(|env_value| env_value.trim().to_string())
        .filter(|normalized| !normalized.is_empty())
}

fn resolve_channel_bot_token(cfg: &Value, channel_key: &str, env_var: &str) -> Option<String> {
    config::read_config_env(env_var)
        .map(|value| value.trim().to_string())
        .filter(|normalized| !normalized.is_empty())
        .or_else(|| resolve_channel_bot_token_from_config(cfg, channel_key))
}

fn resolve_channel_bot_token_from_config(cfg: &Value, channel_key: &str) -> Option<String> {
    cfg.get(channel_key)
        .and_then(|v| v.get("botToken"))
        .and_then(|v| v.as_str())
        .and_then(resolve_env_placeholder)
}

fn channel_outcome_configured(cfg: &Value, channel_key: &str) -> bool {
    resolve_channel_bot_token_from_config(cfg, channel_key).is_some()
}

fn resolve_hooks_token(cfg: &Value) -> Option<String> {
    config::read_config_env("CARAPACE_HOOKS_TOKEN")
        .map(|value| value.trim().to_string())
        .filter(|normalized| !normalized.is_empty())
        .or_else(|| {
            cfg.get("gateway")
                .and_then(|v| v.get("hooks"))
                .and_then(|v| v.get("token"))
                .and_then(|v| v.as_str())
                .and_then(resolve_env_placeholder)
        })
}

fn infer_setup_outcome_from_config(cfg: &Value) -> SetupOutcome {
    // Priority is intentional: configured channels first, then hooks, then local chat.
    if channel_outcome_configured(cfg, "discord") {
        return SetupOutcome::Discord;
    }
    if channel_outcome_configured(cfg, "telegram") {
        return SetupOutcome::Telegram;
    }
    if matches!(
        crate::channels::matrix::resolve_matrix_config(cfg),
        Ok(crate::channels::matrix::MatrixConfigResolve::Configured(_))
    ) {
        return SetupOutcome::Matrix;
    }
    let hooks_enabled = cfg
        .get("gateway")
        .and_then(|v| v.get("hooks"))
        .and_then(|v| v.get("enabled"))
        .and_then(|v| v.as_bool())
        .unwrap_or(false);
    if hooks_enabled {
        return SetupOutcome::Hooks;
    }
    SetupOutcome::LocalChat
}

fn print_verify_summary(outcome: VerifyOutcome, port: u16, checks: &[VerifyCheckResult]) {
    println!();
    println!("Outcome verification summary");
    println!("----------------------------");
    println!("Outcome: {}", outcome.key());
    println!("Gateway port: {}", port);
    println!();
    for check in checks {
        let status = match check.status {
            VerifyCheckStatus::Pass => "PASS",
            VerifyCheckStatus::Fail => "FAIL",
            VerifyCheckStatus::Skip => "SKIP",
        };
        println!("[{}] {}: {}", status, check.name, check.detail);
        if let Some(next_step) = check.next_step.as_deref() {
            println!("      next step: {}", next_step);
        }
    }
}

async fn verify_channel_send_path(
    channel: VerifyOutcome,
    token: String,
    destination: String,
    ssrf_config: crate::plugins::capabilities::SsrfConfig,
) -> Result<(), String> {
    let message = format!(
        "Carapace verify ping ({})",
        chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
    );
    tokio::task::spawn_blocking(move || {
        use crate::plugins::{ChannelPluginInstance, OutboundContext};
        let outbound = OutboundContext {
            to: destination,
            text: message,
            media_url: None,
            gif_playback: false,
            reply_to_id: None,
            thread_id: None,
            account_id: None,
        };

        let delivery = match channel {
            VerifyOutcome::Discord => {
                let channel_impl = DiscordChannel::new(
                    DISCORD_DEFAULT_API_BASE_URL.to_string(),
                    token,
                    ssrf_config,
                );
                channel_impl.send_text(outbound)
            }
            VerifyOutcome::Telegram => {
                let channel_impl = TelegramChannel::new(
                    TELEGRAM_DEFAULT_API_BASE_URL.to_string(),
                    token,
                    ssrf_config,
                );
                channel_impl.send_text(outbound)
            }
            _ => return Err("unsupported channel send-path verification target".to_string()),
        }
        .map_err(|e| format!("send invocation failed: {e}"))?;

        if delivery.ok {
            Ok(())
        } else {
            Err(sanitize_channel_delivery_error(
                delivery
                    .error
                    .unwrap_or_else(|| "send path rejected request".to_string()),
            ))
        }
    })
    .await
    .map_err(|e| format!("send-path verification task failed: {e}"))?
}

fn sanitize_channel_delivery_error(raw_error: String) -> String {
    let trimmed = raw_error.trim();
    if trimmed.is_empty() {
        return "send path rejected request".to_string();
    }
    "send path rejected request (provider details hidden for safety)".to_string()
}

fn summarize_http_failure_body(status: reqwest::StatusCode, body: &str) -> String {
    let _ = status;
    let _ = body;
    "(body hidden for safety)".to_string()
}

async fn verify_local_chat_outcome(
    port: u16,
    cfg: &Value,
    checks: &mut Vec<VerifyCheckResult>,
) -> Result<(), String> {
    let mut setup_server_handle = match chat::ensure_local_gateway_running(port).await {
        Ok(handle) => {
            checks.push(VerifyCheckResult::pass(
                "Gateway reachability",
                format!("service is reachable at 127.0.0.1:{port}"),
            ));
            handle
        }
        Err(err) => {
            checks.push(VerifyCheckResult::fail(
                "Gateway reachability",
                err.to_string(),
                format!(
                    "start the service (`cara start --port {port}`) and retry `cara verify --outcome local-chat --port {port}`"
                ),
            ));
            return Err("outcome verification failed".to_string());
        }
    };

    let roundtrip_result = chat::verify_chat_roundtrip(
        port,
        "Reply with exactly: verification-ok",
        Duration::from_secs(45),
    )
    .await;
    if let Some(handle) = setup_server_handle.take() {
        handle.shutdown("cli-shutdown").await;
    }

    match roundtrip_result {
        Ok(()) => checks.push(VerifyCheckResult::pass(
            "Local chat roundtrip",
            "non-interactive chat send reached final state",
        )),
        Err(err) => {
            checks.push(VerifyCheckResult::fail(
                "Local chat roundtrip",
                err,
                local_chat_verify_next_step(cfg),
            ));
            return Err("outcome verification failed".to_string());
        }
    }
    Ok(())
}

async fn verify_hooks_outcome(
    port: u16,
    cfg: &Value,
    checks: &mut Vec<VerifyCheckResult>,
) -> Result<(), String> {
    let mut setup_server_handle = match chat::ensure_local_gateway_running(port).await {
        Ok(handle) => {
            checks.push(VerifyCheckResult::pass(
                "Gateway reachability",
                format!("service is reachable at 127.0.0.1:{port}"),
            ));
            handle
        }
        Err(err) => {
            checks.push(VerifyCheckResult::fail(
                "Gateway reachability",
                err.to_string(),
                format!(
                    "start the service (`cara start --port {port}`) and retry `cara verify --outcome hooks --port {port}`"
                ),
            ));
            return Err("outcome verification failed".to_string());
        }
    };

    let hooks_enabled = cfg
        .get("gateway")
        .and_then(|v| v.get("hooks"))
        .and_then(|v| v.get("enabled"))
        .and_then(|v| v.as_bool())
        .unwrap_or(false);
    if !hooks_enabled {
        checks.push(VerifyCheckResult::fail(
            "Hooks enabled",
            "gateway.hooks.enabled is false",
            "enable `gateway.hooks.enabled` and configure `gateway.hooks.token`, then retry",
        ));
        if let Some(handle) = setup_server_handle.take() {
            handle.shutdown("cli-shutdown").await;
        }
        return Err("outcome verification failed".to_string());
    } else {
        checks.push(VerifyCheckResult::pass(
            "Hooks enabled",
            "gateway.hooks.enabled is true",
        ));
    }

    let hooks_token = resolve_hooks_token(cfg);
    let hooks_token = if let Some(token) = hooks_token {
        checks.push(VerifyCheckResult::pass(
            "Hooks token",
            "hooks token resolved from config/environment",
        ));
        token
    } else {
        checks.push(VerifyCheckResult::fail(
            "Hooks token",
            "no hooks token configured",
            "set `gateway.hooks.token` (or CARAPACE_HOOKS_TOKEN) and retry",
        ));
        if let Some(handle) = setup_server_handle.take() {
            handle.shutdown("cli-shutdown").await;
        }
        return Err("outcome verification failed".to_string());
    };

    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(10))
        .build()
        .map_err(|e| format!("failed to create hooks verification client: {e}"))?;
    let wake_url = format!("http://127.0.0.1:{port}/hooks/wake");
    println!("Sending signed hooks wake request (this triggers real hooks processing)...");
    let wake_result = client
        .post(&wake_url)
        .header("Authorization", format!("Bearer {hooks_token}"))
        .json(&serde_json::json!({ "text": "verify-hook" }))
        .send()
        .await;
    if let Some(handle) = setup_server_handle.take() {
        handle.shutdown("cli-shutdown").await;
    }

    match wake_result {
        Ok(resp) if resp.status().is_success() => checks.push(VerifyCheckResult::pass(
            "Signed /hooks/wake",
            "received success response from /hooks/wake",
        )),
        Ok(resp) => {
            let status = resp.status();
            let body = crate::net_util::read_response_body_text_capped(
                resp,
                crate::net_util::MAX_RESPONSE_BODY_BYTES,
            )
            .await
            .unwrap_or_default();
            let safe_body = summarize_http_failure_body(status, &body);
            checks.push(VerifyCheckResult::fail(
                "Signed /hooks/wake",
                format!("request failed with HTTP {status}: {safe_body}"),
                format!(
                    "verify hooks auth token and retry `cara verify --outcome hooks --port {port}`"
                ),
            ));
        }
        Err(_err) => checks.push(VerifyCheckResult::fail(
            "Signed /hooks/wake",
            "network error sending request",
            format!("confirm gateway is reachable on port {port} and retry"),
        )),
    }

    Ok(())
}

async fn verify_channel_outcome(
    outcome: VerifyOutcome,
    cfg: &Value,
    discord_to: Option<String>,
    telegram_to: Option<String>,
    checks: &mut Vec<VerifyCheckResult>,
) -> Result<(), String> {
    let (channel_label, channel_key, env_var, destination, destination_flag) = match outcome {
        VerifyOutcome::Discord => (
            "Discord",
            "discord",
            "DISCORD_BOT_TOKEN",
            discord_to,
            "--discord-to <channel_id>",
        ),
        VerifyOutcome::Telegram => (
            "Telegram",
            "telegram",
            "TELEGRAM_BOT_TOKEN",
            telegram_to,
            "--telegram-to <chat_id>",
        ),
        other => {
            return Err(format!(
                "unsupported channel outcome for verification: {other:?}"
            ))
        }
    };

    let token = resolve_channel_bot_token(cfg, channel_key, env_var);
    let token = if let Some(token) = token {
        checks.push(VerifyCheckResult::pass(
            format!("{channel_label} credentials"),
            format!("{channel_label} token is configured"),
        ));
        token
    } else {
        checks.push(VerifyCheckResult::fail(
            format!("{channel_label} credentials"),
            format!("{channel_label} token is not configured"),
            format!("set `{channel_key}.botToken` or `{env_var}`, then retry `cara verify --outcome {channel_key}`"),
        ));
        return Err("outcome verification failed".to_string());
    };

    let ssrf_config = verify_channel_credential_validation_ssrf_config(cfg);
    match validate_channel_credentials(channel_key, &token, ssrf_config.clone()).await {
        Ok(()) => {
            checks.push(VerifyCheckResult::pass(
                format!("{channel_label} token check"),
                "credential validation succeeded",
            ));

            if let Some(destination) = destination {
                let destination_display = summarize_destination_for_display(&destination);
                println!(
                    "Sending verification ping to {channel_label} destination {destination_display}..."
                );
                match verify_channel_send_path(outcome, token, destination, ssrf_config).await {
                    Ok(()) => checks.push(VerifyCheckResult::pass(
                        format!("{channel_label} send path"),
                        "test message delivery succeeded",
                    )),
                    Err(err) => checks.push(VerifyCheckResult::fail(
                        format!("{channel_label} send path"),
                        err,
                        format!(
                            "confirm destination and bot permissions, then retry with `{destination_flag}`"
                        ),
                    )),
                }
            } else {
                checks.push(VerifyCheckResult::skip(
                    format!("{channel_label} send path"),
                    "destination id not provided; send-path test skipped",
                    format!("rerun with `{destination_flag}` to verify end-to-end delivery"),
                ));
            }
        }
        Err(err) => {
            checks.push(VerifyCheckResult::fail(
                format!("{channel_label} token check"),
                err,
                format!("update `{channel_key}.botToken` and retry `cara verify --outcome {channel_key}`"),
            ));
            checks.push(VerifyCheckResult::skip(
                format!("{channel_label} send path"),
                "not attempted because credential validation failed",
                format!(
                    "fix `{channel_key}` credentials first, then rerun with `{destination_flag}`"
                ),
            ));
        }
    }
    Ok(())
}

// The const below is the canonical source of truth for the wire-guard
// script's CLI-partition leg. Outside the unit test that pins its
// invariants nothing in the Rust runtime consumes it, so silence
// dead-code on release builds where the test cfg isn't active.
#[allow(dead_code)]
/// Matrix kinds that `verify_matrix_outcome` deliberately does NOT route
/// at the CLI boundary. Each entry carries a one-line justification
/// naming the layer that surfaces the kind to the operator before the
/// CLI's runtime-status polling ever has a chance to observe it
/// (configuration resolver, request DTO validation, request-scoped HTTP
/// responses, etc.).
///
/// The wire-guard script (`scripts/check-matrix-wire-guards.sh`)
/// asserts that this table plus the actual `Some("...")` arms of
/// `verify_matrix_outcome` partition `MatrixError::kind()` exactly:
/// no overlap, no gaps. Removing an entry here without also routing
/// the kind through `verify_matrix_outcome` fails CI; conversely,
/// routing a kind through `verify_matrix_outcome` that is also listed
/// here is rejected as a contradiction.
///
/// Keep entries sorted alphabetically by kind — the wire-guard
/// validator and the `test_matrix_cli_verifier_exceptions_invariants`
/// unit test both pin this for review-determinism.
pub(crate) const MATRIX_CLI_VERIFIER_EXCEPTIONS: &[(&str, &str)] = &[
    (
        "allowlist-too-large",
        "configuration resolver reports this before runtime status polling",
    ),
    (
        "device-not-found",
        "verification subcommands surface this as request-scoped 404",
    ),
    (
        "invalid-bool",
        "configuration resolver reports this before runtime status polling",
    ),
    (
        "invalid-config-root",
        "configuration resolver reports this before runtime status polling",
    ),
    (
        "invalid-length",
        "configuration resolver reports this before runtime status polling",
    ),
    (
        "invalid-string",
        "configuration resolver reports this before runtime status polling",
    ),
    (
        "invalid-string-array",
        "configuration resolver reports this before runtime status polling",
    ),
    (
        "invalid-url",
        "configuration resolver reports this before runtime status polling",
    ),
    (
        "invalid-user-id",
        "request DTO validation surfaces this before runtime readiness polling",
    ),
    (
        "missing-credentials",
        "configuration resolver reports this before runtime status polling",
    ),
    (
        "missing-device-id-for-token-restore",
        "configuration resolver reports this before runtime status polling",
    ),
    (
        "missing-homeserver-url",
        "configuration resolver reports this before runtime status polling",
    ),
    (
        "missing-user-id",
        "configuration resolver reports this before runtime status polling",
    ),
    (
        "room-not-found",
        "send-test surfaces this as request-scoped 404",
    ),
    (
        "send-terminal",
        "send-test surfaces this as request-scoped permanent 422",
    ),
    (
        "unsupported-room",
        "send-test surfaces this as request-scoped 422",
    ),
    (
        "user-identity-not-found",
        "verification subcommands surface this as request-scoped 404",
    ),
    (
        "verification",
        "verification subcommands surface this at the action boundary",
    ),
    (
        "verification-cancelled",
        "verification subcommands surface this as request-scoped 410",
    ),
    (
        "verification-flow-not-found",
        "verification subcommands surface this as request-scoped 404",
    ),
    (
        "verification-flow-not-ready",
        "verification subcommands surface this as request-scoped 409",
    ),
    (
        "verification-timeout",
        "verification subcommands surface this as request-scoped 504",
    ),
];

async fn verify_matrix_outcome(
    port: u16,
    cfg: &Value,
    matrix_to: Option<String>,
    checks: &mut Vec<VerifyCheckResult>,
) -> Result<(), String> {
    let matrix_config = match crate::channels::matrix::resolve_matrix_config(cfg) {
        Ok(crate::channels::matrix::MatrixConfigResolve::Configured(config)) => {
            checks.push(VerifyCheckResult::pass(
                "Matrix configuration",
                format!("Matrix user {} is configured", config.user_id),
            ));
            config
        }
        Ok(crate::channels::matrix::MatrixConfigResolve::Disabled) => {
            checks.push(VerifyCheckResult::fail(
                "Matrix configuration",
                "matrix.enabled is false",
                "enable matrix.enabled and rerun `cara verify --outcome matrix`",
            ));
            return Err("outcome verification failed".to_string());
        }
        Ok(crate::channels::matrix::MatrixConfigResolve::Missing) => {
            checks.push(VerifyCheckResult::fail(
                "Matrix configuration",
                "matrix config is missing",
                "add matrix homeserver/user credentials and rerun `cara verify --outcome matrix`",
            ));
            return Err("outcome verification failed".to_string());
        }
        Err(err) => {
            checks.push(VerifyCheckResult::fail(
                "Matrix configuration",
                err.to_string(),
                "fix matrix config and rerun `cara verify --outcome matrix`",
            ));
            return Err("outcome verification failed".to_string());
        }
    };

    // The MatrixSecurity sum type is constructed at config-resolve time:
    // - Encrypted{Explicit(_)} = operator supplied matrix.storePassphrase
    //   or MATRIX_STORE_PASSPHRASE; secret already available
    // - Encrypted{DeriveFromConfigPassword} = will derive via HKDF over
    //   CARAPACE_CONFIG_PASSWORD; verify the env var is set
    // - Unencrypted = no store secret needed
    if matrix_config.encrypted() {
        let state_dir = crate::server::ws::resolve_state_dir();
        if let Err(err) =
            crate::channels::matrix::resolve_matrix_store_passphrase(&state_dir, &matrix_config)
        {
            // Detect specific failure shapes so the next-step is
            // actionable instead of generic. The interrupted-rekey
            // case has its own recovery command; the missing-secret
            // case has its own remediation; the explicit "store
            // passphrase doesn't match" mode points at the recovery
            // key. Generic "set a store secret" misleads operators
            // who just had a power failure mid-rekey.
            // Match on typed `MatrixError` variants for the next-step
            // selection instead of substring-matching the Display text:
            // a future copy-edit of the error message would silently
            // break the substring detection.
            let next_step = if matches!(
                err,
                crate::channels::matrix::MatrixError::InterruptedRekey(_)
            ) {
                "stop any running daemon, then re-run `cara matrix rekey-store --new` to advance \
                 or roll back the in-flight rotation before starting the daemon"
            } else if matches!(
                err,
                crate::channels::matrix::MatrixError::MissingStoreSecret
            ) {
                "set CARAPACE_CONFIG_PASSWORD (or matrix.storePassphrase / MATRIX_STORE_PASSPHRASE) \
                 and rerun `cara verify --outcome matrix`"
            } else if matches!(
                err,
                crate::channels::matrix::MatrixError::EncryptedStorePassphraseMismatch { .. }
            ) {
                "the encrypted store rejected the resolved passphrase. Check whether \
                 CARAPACE_CONFIG_PASSWORD changed since last successful start, OR look for an \
                 interrupted rekey at `{state_dir}/matrix/store_passphrase.{pending,rekeying}`. \
                 See docs/channels.md#matrix-store-rekey-lifecycle for the recovery procedure"
            } else {
                "fix the Matrix store secret (see error above) and rerun \
                 `cara verify --outcome matrix`"
            };
            let err_text = err.to_string();
            checks.push(VerifyCheckResult::fail(
                "Matrix encrypted store",
                err_text,
                next_step,
            ));
            return Err("outcome verification failed".to_string());
        }
    }
    checks.push(VerifyCheckResult::pass(
        "Matrix encrypted store",
        if matrix_config.encrypted() {
            "encrypted Matrix store secret is available"
        } else {
            "matrix.encrypted=false; only unencrypted rooms are supported"
        },
    ));

    let mut setup_server_handle = match chat::ensure_local_gateway_running(port).await {
        Ok(handle) => {
            checks.push(VerifyCheckResult::pass(
                "Gateway reachability",
                format!("service is reachable at 127.0.0.1:{port}"),
            ));
            handle
        }
        Err(err) => {
            checks.push(VerifyCheckResult::fail(
                "Gateway reachability",
                err.to_string(),
                format!(
                    "start the service (`cara start --port {port}`) and retry `cara verify --outcome matrix --port {port}`"
                ),
            ));
            return Err("outcome verification failed".to_string());
        }
    };

    let result = async {
        match wait_for_matrix_runtime_ready(port, Duration::from_secs(30)).await {
            Ok(()) => checks.push(VerifyCheckResult::pass(
                "Matrix runtime registration",
                "matrix channel is connected",
            )),
            Err(failure) => {
                // Route per-variant operator hints when the runtime
                // surfaced a typed `MatrixError` discriminator on
                // `extra.lastErrorKind`. Without this, the CLI sees
                // only the redacted Display string and ships a
                // generic "fix Matrix runtime startup" hint —
                // defeating typed-routing fixes upstream
                // (whoami's `AuthTokenRevoked` preservation, the
                // password-login `matrix_sync_terminal_error` peel,
                // the `EncryptedStorePassphraseMismatch` heuristic).
                // Match on the stable kebab-case kind, NOT the
                // Display message.
                let next_step = match failure.kind.as_deref() {
                    Some("auth-token-revoked") => {
                        "the homeserver rejected the access token (revoked, deactivated, locked, \
                         or suspended). For accessToken-configured deployments: mint a new token, \
                         then either (a) edit `matrix.accessToken` and `matrix.deviceId` directly \
                         in your carapace.json5 and restart the daemon, or (b) set \
                         `MATRIX_ACCESS_TOKEN` / `MATRIX_DEVICE_ID` in the environment and \
                         restart. `cara config set` rejects writes to these paths because they \
                         are protected against runtime mutation (identity-linked; churning them \
                         creates new Matrix devices). For password-configured deployments verify \
                         the password is correct and restart"
                    }
                    Some("encrypted-store-passphrase-mismatch") => {
                        "the encrypted Matrix store rejected the resolved passphrase. Check \
                         whether CARAPACE_CONFIG_PASSWORD changed since last successful start, \
                         OR look for an interrupted rekey at \
                         {state_dir}/matrix/store_passphrase.{pending,rekeying}. \
                         See docs/channels.md#matrix-store-rekey-lifecycle for the recovery procedure"
                    }
                    Some("interrupted-rekey") => {
                        "stop any running daemon, then re-run `cara matrix rekey-store --new` \
                         to advance or roll back the in-flight rotation before starting the daemon"
                    }
                    Some("missing-store-secret") => {
                        "set CARAPACE_CONFIG_PASSWORD (or matrix.storePassphrase / \
                         MATRIX_STORE_PASSPHRASE) and rerun `cara verify --outcome matrix`"
                    }
                    Some("auth-session-user-mismatch") => {
                        "the restored access token belongs to a different user than \
                         matrix.userId. Check matrix.userId against the token's owner, or \
                         rotate the access token to one issued for the configured user"
                    }
                    Some("auth-session-device-mismatch") => {
                        "the restored access token belongs to a different device than \
                         matrix.deviceId. Check matrix.deviceId against the device the token \
                         was issued for"
                    }
                    Some("auth-session-missing-device-id") => {
                        "the homeserver did not return a device id for the restored token \
                         (homeserver bug). File an issue with your homeserver software and \
                         try a fresh token"
                    }
                    Some("clock") => {
                        "the host system clock is not advancing or is out of sync. Verify \
                         `timedatectl status` (Linux) / `systemsetup -getusingnetworktime` \
                         (macOS) shows a healthy NTP source, then restart the daemon"
                    }
                    Some("client-build") => {
                        "the Matrix SDK client failed to construct (typically a filesystem \
                         issue under the matrix store directory or a corrupt token cache). \
                         Check write permissions on the configured state directory and \
                         inspect the runtime log message above for the underlying error"
                    }
                    Some("recovery-key-restore-failed") => {
                        "Matrix recovery-key restore failed. Verify the recovery key in \
                         Element, restore the current key with \
                         `cara matrix recovery-key restore --key-file <file>` or `--stdin`, \
                         then restart the daemon. When the control API includes \
                         `detail.reason`, use it to narrow the action: `wrong-key` means \
                         re-provision the Element recovery key, `empty-key-file` means the \
                         local key file is empty, `server-not-configured` means homeserver \
                         recovery/key backup is not enabled, and `transport-error` means \
                         retry after fixing homeserver reachability. `sdk-io` is ambiguous \
                         SDK-owned I/O; inspect both homeserver reachability and local Matrix \
                         store health. `concurrent-request` means another SDK recovery request \
                         is already in flight; wait for it to finish and retry once. \
                         `unpickling-failed` points at local Matrix crypto-store corruption; \
                         preserve the state directory for forensics before clearing local Matrix \
                         state and re-establishing the session"
                    }
                    Some("cross-signing-bootstrap-failed") => {
                        "Matrix cross-signing bootstrap failed. Verify the homeserver account \
                         can complete UIA, matrix.password / MATRIX_PASSWORD is present when \
                         UIA is required, and inspect the runtime log for the homeserver error"
                    }
                    Some("encrypted-state-io") => {
                        "carapace could not read, write, or protect local Matrix encrypted-state \
                         files. Verify ownership, permissions, disk space, and parent-directory \
                         fsync support under the configured state directory"
                    }
                    Some("recovery-state-probe-failed") => {
                        "Matrix recovery-state probing or mutation failed. Verify homeserver \
                         reachability, inspect Element's recovery status for this account, and \
                         retry after resolving any server-side recovery/key-backup issue"
                    }
                    Some("recovery-state-io") => {
                        "carapace could not read, write, or durably clean up Matrix recovery \
                         state files. Verify state-directory ownership, permissions, disk \
                         space, and fsync support before restarting"
                    }
                    Some("recovery-config-precondition") => {
                        "Matrix recovery-key operation cannot run with the current matrix \
                         configuration. Set matrix.encrypted=true, restore or rotate the \
                         recovery key as needed, then restart the daemon"
                    }
                    Some("recovery-key-promotion-refused") => {
                        "carapace refused to promote a pending Matrix recovery key because the \
                         rotation marker could not prove key ownership. Inspect the audit log, \
                         confirm the current recovery key, then remove stale recovery_key.rotating \
                         and recovery_key.pending artifacts only after that confirmation"
                    }
                    Some("auth") => {
                        "Matrix authentication failed for an unspecified reason. Verify \
                         matrix.homeserverUrl is reachable, that the access token / password \
                         and matrix.userId / matrix.deviceId are current, and inspect the \
                         runtime log message above for the homeserver response"
                    }
                    Some("auth-probe") => {
                        "Matrix token validation hit a transient whoami retry budget without a \
                         terminal auth response. Verify homeserver reachability and retry after \
                         the control-plane retry window; if it persists, inspect the runtime log \
                         for the homeserver transport error"
                    }
                    Some("installation-id") => {
                        "carapace could not read or create the Matrix installation id file \
                         under the state directory. Verify the state directory is writable \
                         and not on a filesystem that disallows file creation, then restart \
                         the daemon"
                    }
                    Some("sync-loop-give-up") => {
                        "Matrix has been unable to complete a successful sync for at least \
                         24 hours and the daemon has slowed retries to once per hour. The \
                         underlying cause is a sustained sync failure (homeserver \
                         unreachable, DNS misconfigured, account state, or transient outage). \
                         Verify matrix.homeserverUrl is reachable from this host, check the \
                         account state on the homeserver, and inspect the runtime log for \
                         the underlying transient error. The give-up state clears \
                         automatically on the next successful sync"
                    }
                    Some("startup-failed") => {
                        "Matrix runtime startup failed for an unspecified reason. Inspect \
                         the runtime log message above for the underlying cause and follow \
                         the relevant recovery procedure"
                    }
                    Some("token-persistence") => {
                        "carapace could not write the restored Matrix session token to disk. \
                         Verify the state directory is writable and inspect the runtime log \
                         for the underlying I/O error"
                    }
                    Some("store-key-derivation") => {
                        "deriving the encrypted Matrix store key failed. Verify \
                         CARAPACE_CONFIG_PASSWORD is set when matrix.encrypted=true and that \
                         the installation id file under the state directory is readable"
                    }
                    Some("command-queue-full") => {
                        "the Matrix runtime command queue is full — the actor is not draining \
                         commands fast enough. Inspect the runtime log for any blocked \
                         operation (slow homeserver, hung sync) and consider restarting the \
                         daemon"
                    }
                    Some("session-history-corrupt") => {
                        "protected session history failed closed during Matrix inbound dispatch \
                         or DLQ replay. Repair or restore the affected session history before \
                         replaying Matrix events"
                    }
                    Some("legacy-dlq-envelope-refused") => {
                        "matrix.inboundDlq.legacyEnvelopePolicy=refuse blocked a legacy v1 \
                         inbound DLQ envelope. Keep the record for forensic review, temporarily \
                         set the policy back to accept to drain it, or quarantine/drop the record \
                         deliberately before retrying"
                    }
                    Some("dlq-crypto") => {
                        "Matrix inbound DLQ cryptographic processing failed. Check Matrix store \
                         key history, matrix.encrypted toggle history, interrupted rekey state, \
                         and encrypted DLQ write failures; if encrypted records were written before \
                         matrix.encrypted=false, toggle matrix.encrypted back to true to drain them, \
                         otherwise follow the Matrix store rekey-recovery procedure before replaying the DLQ"
                    }
                    Some("dlq-io") => {
                        "Matrix inbound DLQ file I/O failed. Check disk space, state-directory \
                         ownership/permissions, symlink/hardlink interference, and filesystem \
                         fsync support before retrying"
                    }
                    Some("dlq-serialization") => {
                        "Matrix inbound DLQ record encoding or parsing failed. Quarantine or \
                         repair malformed DLQ records after preserving them for forensic review"
                    }
                    Some("dlq-dispatch-failure") => {
                        "Matrix inbound DLQ replay reached the agent dispatch pipeline but \
                         records still failed. Repair the downstream agent/session path, then \
                         rerun replay or restart for the next maintenance tick"
                    }
                    Some("dlq-cap-saturation") => {
                        "Matrix inbound DLQ is at its configured cap. Drain or repair the \
                         downstream agent pipeline, then replay/quarantine records deliberately \
                         before allowing more inbound Matrix traffic"
                    }
                    Some("sync-failed") => {
                        "Matrix sync is failing transiently. Verify homeserver reachability, DNS, \
                         and account state, then inspect the runtime log for the redacted SDK \
                         error if the failure persists"
                    }
                    Some("send-failed") => {
                        "Matrix outbound send failed transiently. Retry after the reported \
                         retry window if present, and inspect room reachability or homeserver \
                         rate-limit logs if it persists"
                    }
                    Some("not-connected") => {
                        "the Matrix runtime is not connected. Start or restart the daemon and \
                         wait for the Matrix channel to reach Connected before retrying"
                    }
                    _ => "fix Matrix runtime startup and rerun `cara verify --outcome matrix`",
                };
                checks.push(VerifyCheckResult::fail(
                    "Matrix runtime registration",
                    failure.observation,
                    next_step,
                ));
                return Err("outcome verification failed".to_string());
            }
        }

        match send_control_request(
            "127.0.0.1",
            port,
            reqwest::Method::GET,
            "/control/matrix/verifications",
            &[],
            None,
        )
        .await
        {
            Ok(_) => checks.push(VerifyCheckResult::pass(
                "Matrix verification API",
                "daemon verification endpoint is reachable",
            )),
            Err(err) => {
                // The runtime-registration check above already passed
                // (Connected). A failure here is most often a control-
                // API auth problem rather than a runtime issue. Steer
                // operators at the most-likely cause first.
                let err_text = err.to_string();
                let next_step = if err_text.to_lowercase().contains("unauthorized")
                    || err_text.to_lowercase().contains("401")
                    || err_text.to_lowercase().contains("403")
                {
                    "verify gateway auth credentials (CARAPACE_GATEWAY_TOKEN or \
                     gateway.auth.token / gateway.auth.password) match the daemon's \
                     configuration, then rerun"
                } else {
                    "verify gateway auth credentials match the daemon's configuration, \
                     check `cara status`, then rerun. If `cara status` shows the \
                     daemon healthy, capture the error above for the operator log."
                };
                checks.push(VerifyCheckResult::fail(
                    "Matrix verification API",
                    err_text,
                    next_step,
                ));
                return Err("outcome verification failed".to_string());
            }
        }

        if let Some(room_id) = matrix_to {
            let room_display = summarize_destination_for_display(&room_id);
            println!("Sending verification ping to Matrix room {room_display}...");
            match send_control_request(
                "127.0.0.1",
                port,
                reqwest::Method::POST,
                "/control/matrix/send-test",
                &[],
                Some(json!({
                    "roomId": room_id,
                    "text": "Carapace Matrix verification ping"
                })),
            )
            .await
            {
                Ok(response) if response.get("ok").and_then(|v| v.as_bool()) == Some(true) => {
                    // `delivery.outcome` discriminates the tagged-sum
                    // wire format. `sent` carries `messageId`; `failed`
                    // carries `error` + `retryable`. The outer `ok`
                    // is now derived from `MatrixSendTestDelivery::ok()`
                    // (see `server/control.rs::MatrixSendTestResponse`),
                    // so `ok=true` ⇔ `outcome="sent"` by construction.
                    // We still inspect `outcome` to extract the
                    // delivery shape (messageId vs error/retryable),
                    // not to disambiguate success.
                    let delivery = response.get("delivery");
                    let outcome = delivery
                        .and_then(|v| v.get("outcome"))
                        .and_then(|v| v.as_str())
                        .unwrap_or("");
                    if outcome == "sent" {
                        let event_id = delivery
                            .and_then(|v| v.get("messageId"))
                            .and_then(|v| v.as_str())
                            .unwrap_or("no event id returned");
                        checks.push(VerifyCheckResult::pass(
                            "Matrix send path",
                            format!("test message delivery succeeded ({event_id})"),
                        ));
                    } else {
                        let detail = delivery
                            .and_then(|v| v.get("error"))
                            .and_then(|v| v.as_str())
                            .unwrap_or("Matrix send-test reported a failed delivery outcome")
                            .to_string();
                        checks.push(VerifyCheckResult::fail(
                            "Matrix send path",
                            detail,
                            "confirm the room id, bot membership, encryption support, and homeserver connectivity",
                        ));
                        return Err("outcome verification failed".to_string());
                    }
                }
                Ok(response) => {
                    let detail = response
                        .get("delivery")
                        .and_then(|v| v.get("error"))
                        .and_then(|v| v.as_str())
                        .unwrap_or("Matrix send-test returned ok=false")
                        .to_string();
                    checks.push(VerifyCheckResult::fail(
                        "Matrix send path",
                        detail,
                        "confirm the room id, bot membership, encryption support, and homeserver connectivity",
                    ));
                    return Err("outcome verification failed".to_string());
                }
                Err(err) => {
                    checks.push(VerifyCheckResult::fail(
                        "Matrix send path",
                        err.to_string(),
                        "confirm the room id, bot membership, encryption support, and homeserver connectivity",
                    ));
                    return Err("outcome verification failed".to_string());
                }
            }
        } else {
            checks.push(VerifyCheckResult::skip(
                "Matrix send path",
                "Matrix room id not provided; send-path test skipped",
                "rerun with `--matrix-to <room_id>` to verify end-to-end Matrix delivery",
            ));
        }
        Ok(())
    }
    .await;

    if let Some(handle) = setup_server_handle.take() {
        handle.shutdown("cli-shutdown").await;
    }
    result
}

/// Outcome of a runtime-readiness probe failure. Carries the
/// formatted observation (operator-visible message) plus the
/// optional typed kind read from `extra.lastErrorKind` so the
/// caller can route per-variant remediation hints without
/// substring-matching the redacted Display string.
struct MatrixRuntimeReadyFailure {
    observation: String,
    kind: Option<String>,
}

/// Extract the typed `lastErrorKind` from a `/control/channels` Matrix
/// channel entry. Pinned against `ChannelStatusItem`'s wire shape
/// (server/control.rs:299) — `extra` lives at the top level of each
/// channel entry; there is no `metadata` wrapper. A prior version of
/// this code read `matrix.metadata.extra.lastErrorKind`, which
/// permanently returned `None`.
fn matrix_runtime_ready_kind_from_channel(channel: &Value) -> Option<String> {
    channel
        .get("extra")
        .and_then(|value| value.get("lastErrorKind"))
        .and_then(|value| value.as_str())
        .map(String::from)
}
async fn wait_for_matrix_runtime_ready(
    port: u16,
    timeout: Duration,
) -> Result<(), MatrixRuntimeReadyFailure> {
    let deadline = tokio::time::Instant::now() + timeout;
    loop {
        let observation = match send_control_request(
            "127.0.0.1",
            port,
            reqwest::Method::GET,
            "/control/channels",
            &[],
            None,
        )
        .await
        {
            Ok(response) => {
                let matrix = response
                    .get("channels")
                    .and_then(|value| value.as_array())
                    .and_then(|channels| {
                        channels.iter().find(|channel| {
                            channel.get("id").and_then(|value| value.as_str()) == Some("matrix")
                        })
                    });
                if let Some(matrix) = matrix {
                    let status = matrix
                        .get("status")
                        .and_then(|value| value.as_str())
                        .unwrap_or("unknown");
                    if status == crate::channels::ChannelStatus::CONNECTED_WIRE {
                        return Ok(());
                    }
                    let last_error = matrix
                        .get("lastError")
                        .and_then(|value| value.as_str())
                        .unwrap_or("no runtime error was reported");
                    let kind = matrix_runtime_ready_kind_from_channel(matrix);
                    let observation = format!("matrix channel status `{status}`: {last_error}");
                    if status == "error" {
                        return Err(MatrixRuntimeReadyFailure { observation, kind });
                    }
                    observation
                } else {
                    "matrix channel is not registered in the running gateway".to_string()
                }
            }
            Err(err) => format!("control endpoint did not report Matrix readiness: {err}"),
        };
        if tokio::time::Instant::now() >= deadline {
            return Err(MatrixRuntimeReadyFailure {
                observation: format!(
                    "matrix channel did not become connected within {} seconds; last observation: {observation}",
                    timeout.as_secs()
                ),
                kind: None,
            });
        }
        tokio::time::sleep(Duration::from_secs(1)).await;
    }
}

async fn verify_autonomy_outcome(
    port: u16,
    cfg: &Value,
    checks: &mut Vec<VerifyCheckResult>,
) -> Result<(), String> {
    async fn shutdown_embedded_gateway(
        setup_server_handle: &mut Option<crate::server::startup::ServerHandle>,
    ) {
        if let Some(handle) = setup_server_handle.take() {
            handle.shutdown("cli-shutdown").await;
        }
    }

    let mut setup_server_handle = match chat::ensure_local_gateway_running(port).await {
        Ok(handle) => {
            checks.push(VerifyCheckResult::pass(
                "Gateway reachability",
                format!("service is reachable at 127.0.0.1:{port}"),
            ));
            handle
        }
        Err(err) => {
            checks.push(VerifyCheckResult::fail(
                "Gateway reachability",
                err.to_string(),
                format!(
                    "start the service (`cara start --port {port}`) and retry `cara verify --outcome autonomy --port {port}`"
                ),
            ));
            return Err("outcome verification failed".to_string());
        }
    };

    let verify_result = async {
        let control_client = match reqwest::Client::builder()
            .timeout(Duration::from_secs(15))
            .build()
        {
            Ok(client) => client,
            Err(err) => {
                checks.push(VerifyCheckResult::fail(
                    "Control client setup",
                    err.to_string(),
                    "fix local networking/runtime dependencies, then retry",
                ));
                return Err("outcome verification failed".to_string());
            }
        };
        // Resolve control-plane auth once and reuse for task create + polling.
        // This includes env/config/keychain fallback behavior.
        let mut control_auth = resolve_gateway_auth().await;
        if control_auth.token.is_none() && control_auth.password.is_none() {
            if let Some(token) = cfg
                .get("gateway")
                .and_then(|v| v.get("auth"))
                .and_then(|v| v.get("token"))
                .and_then(|v| v.as_str())
                .map(str::trim)
                .filter(|v| !v.is_empty())
            {
                control_auth.token = Some(token.to_string());
            } else if let Some(password) = cfg
                .get("gateway")
                .and_then(|v| v.get("auth"))
                .and_then(|v| v.get("password"))
                .and_then(|v| v.as_str())
                .map(str::trim)
                .filter(|v| !v.is_empty())
            {
                control_auth.password = Some(password.to_string());
            }
        }

        let policy_max_attempts: u32 = 1;
        let policy_max_total_runtime_ms: u64 = 60_000;
        let policy_max_turns: u32 = 1;
        let policy_max_run_timeout_seconds: u32 = 30;
        let create_body = serde_json::json!({
            "payload": {
                "kind": "agentTurn",
                "message": "verify-autonomy",
            },
            "policy": {
                "maxAttempts": policy_max_attempts,
                "maxTotalRuntimeMs": policy_max_total_runtime_ms,
                "maxTurns": policy_max_turns,
                "maxRunTimeoutSeconds": policy_max_run_timeout_seconds
            }
        });

        let create_url = match build_control_url("127.0.0.1", port, "/control/tasks", &[])
            .map_err(|error| error.to_string())
        {
            Ok(url) => url,
            Err(error) => {
                checks.push(VerifyCheckResult::fail(
                    "Task create",
                    format!("failed to build control URL: {error}"),
                    "confirm host/port configuration and retry verification",
                ));
                return Err("outcome verification failed".to_string());
            }
        };
        let create_response = match send_control_request_with_client_and_auth(
            &control_client,
            &control_auth,
            reqwest::Method::POST,
            create_url,
            Some(create_body),
        )
        .await
        .map_err(|err| err.to_string())
        {
            Ok(response) => response,
            Err(error_message) => {
                checks.push(VerifyCheckResult::fail(
                    "Task create",
                    error_message,
                    format!("verify control auth and task queue availability, then retry `cara verify --outcome autonomy --port {port}`"),
                ));
                return Err("outcome verification failed".to_string());
            }
        };

        let created_task = create_response
            .get("task")
            .and_then(|task| task.as_object())
            .cloned();
        let Some(created_task) = created_task else {
            checks.push(VerifyCheckResult::fail(
                "Task create",
                "task response missing task object",
                "retry verification; if this persists, inspect server logs",
            ));
            return Err("outcome verification failed".to_string());
        };

        let Some(task_id) = created_task
            .get("id")
            .and_then(|value| value.as_str())
            .map(ToString::to_string)
        else {
            checks.push(VerifyCheckResult::fail(
                "Task create",
                "task response missing task id",
                "retry verification; if this persists, inspect server logs",
            ));
            return Err("outcome verification failed".to_string());
        };

        checks.push(VerifyCheckResult::pass(
            "Task create",
            format!("created task `{task_id}`"),
        ));

        let mut max_attempts_seen = created_task
            .get("attempts")
            .and_then(|value| value.as_u64())
            .unwrap_or(0);
        // Polling window must be at least as long as task runtime policy budgets
        // (plus headroom) to avoid false negatives on healthy slow paths.
        let polling_timeout_secs = policy_max_total_runtime_ms
            .div_ceil(1000)
            .max(u64::from(policy_max_run_timeout_seconds))
            .saturating_add(10);
        let deadline = std::time::Instant::now() + Duration::from_secs(polling_timeout_secs);
        let mut terminal_task: Option<Value> = None;

        while std::time::Instant::now() < deadline {
            let path = format!("/control/tasks/{task_id}");
            let task_url = match build_control_url("127.0.0.1", port, &path, &[])
                .map_err(|error| error.to_string())
            {
                Ok(url) => url,
                Err(error) => {
                    checks.push(VerifyCheckResult::fail(
                        "Task polling",
                        format!("failed to build control URL: {error}"),
                        "confirm host/port configuration and retry verification",
                    ));
                    return Err("outcome verification failed".to_string());
                }
            };
            let task_response = match send_control_request_with_client_and_auth(
                &control_client,
                &control_auth,
                reqwest::Method::GET,
                task_url,
                None,
            )
            .await
            .map_err(|err| err.to_string())
            {
                Ok(response) => response,
                Err(error_message) => {
                    checks.push(VerifyCheckResult::fail(
                        "Task polling",
                        error_message,
                        "confirm service health and control auth, then retry",
                    ));
                    return Err("outcome verification failed".to_string());
                }
            };

            let task = task_response.get("task").cloned();
            if let Some(task) = task {
                let attempts = task
                    .get("attempts")
                    .and_then(|value| value.as_u64())
                    .unwrap_or(0);
                max_attempts_seen = max_attempts_seen.max(attempts);
                if matches!(
                    task.get("state").and_then(|value| value.as_str()),
                    Some("done" | "blocked" | "failed" | "cancelled")
                ) {
                    terminal_task = Some(task);
                    break;
                }
            }

            tokio::time::sleep(Duration::from_millis(250)).await;
        }

        if max_attempts_seen == 0 {
            checks.push(VerifyCheckResult::fail(
                "Task start proof",
                "task never reported attempts > 0",
                "inspect worker loop logs and retry verification",
            ));
            return Err("outcome verification failed".to_string());
        }
        checks.push(VerifyCheckResult::pass(
            "Task start proof",
            format!("task attempts observed: {max_attempts_seen}"),
        ));

        let Some(terminal_task) = terminal_task else {
            checks.push(VerifyCheckResult::fail(
                "Task terminal proof",
                "task did not reach terminal state before timeout",
                "inspect queue/worker logs and retry verification",
            ));
            return Err("outcome verification failed".to_string());
        };

        let state = terminal_task
            .get("state")
            .and_then(|value| value.as_str())
            .unwrap_or("unknown");
        match state {
            "done" => {
                let run_count = terminal_task
                    .get("runIds")
                    .and_then(|value| value.as_array())
                    .map_or(0, |runs| runs.len());
                checks.push(VerifyCheckResult::pass(
                    "Task terminal proof",
                    format!("task reached done state (run IDs: {run_count})"),
                ));
                Ok(())
            }
            "blocked" => {
                let blocked_reason = terminal_task
                    .get("blockedReason")
                    .and_then(|value| value.as_str())
                    .unwrap_or("unknown");
                checks.push(VerifyCheckResult::pass(
                    "Task terminal proof",
                    format!("task reached blocked state ({blocked_reason})"),
                ));
                Ok(())
            }
            "failed" => {
                let error = terminal_task
                    .get("lastError")
                    .and_then(|value| value.as_str())
                    .unwrap_or("unknown error");
                checks.push(VerifyCheckResult::fail(
                    "Task terminal proof",
                    format!("task failed: {error}"),
                    "check provider configuration or task policy and retry",
                ));
                Err("outcome verification failed".to_string())
            }
            "cancelled" => {
                checks.push(VerifyCheckResult::fail(
                    "Task terminal proof",
                    "task was cancelled unexpectedly during verification",
                    "retry verify; if this repeats, inspect control-plane mutations",
                ));
                Err("outcome verification failed".to_string())
            }
            other => {
                checks.push(VerifyCheckResult::fail(
                    "Task terminal proof",
                    format!("task reached unexpected state `{other}`"),
                    "inspect task state transitions and retry verification",
                ));
                Err("outcome verification failed".to_string())
            }
        }
    }
    .await;

    shutdown_embedded_gateway(&mut setup_server_handle).await;
    verify_result
}

async fn run_outcome_verifier(
    selection: VerifyOutcomeSelection,
    port: u16,
    discord_to: Option<String>,
    telegram_to: Option<String>,
    matrix_to: Option<String>,
    cfg: Value,
) -> Result<(), String> {
    let mut checks: Vec<VerifyCheckResult> = Vec::new();
    let outcome = selection.resolved(&cfg);
    let discord_to = normalize_optional_input(discord_to);
    let telegram_to = normalize_optional_input(telegram_to);
    let matrix_to = normalize_optional_input(matrix_to);

    let result = match outcome {
        VerifyOutcome::LocalChat => verify_local_chat_outcome(port, &cfg, &mut checks).await,
        VerifyOutcome::Hooks => verify_hooks_outcome(port, &cfg, &mut checks).await,
        VerifyOutcome::Matrix => verify_matrix_outcome(port, &cfg, matrix_to, &mut checks).await,
        VerifyOutcome::Discord | VerifyOutcome::Telegram => {
            verify_channel_outcome(outcome, &cfg, discord_to, telegram_to, &mut checks).await
        }
        VerifyOutcome::Autonomy => verify_autonomy_outcome(port, &cfg, &mut checks).await,
    };
    if let Err(err) = result {
        print_verify_summary(outcome, port, &checks);
        println!("Next help path: {}", verify_failure_follow_up_url(outcome));
        return Err(err);
    }

    print_verify_summary(outcome, port, &checks);
    let has_fail = checks
        .iter()
        .any(|check| check.status == VerifyCheckStatus::Fail);
    let has_pass = checks
        .iter()
        .any(|check| check.status == VerifyCheckStatus::Pass);
    let has_skip = checks
        .iter()
        .any(|check| check.status == VerifyCheckStatus::Skip);
    if !has_fail && has_pass {
        println!();
        if has_skip {
            println!("Outcome verification passed with skipped checks.");
        } else {
            println!("Outcome verification passed.");
        }
        Ok(())
    } else if !has_fail {
        Err("outcome verification incomplete (no checks passed)".to_string())
    } else {
        Err("outcome verification failed".to_string())
    }
}

pub async fn handle_verify(
    outcome: VerifyOutcomeSelection,
    port: Option<u16>,
    discord_to: Option<String>,
    telegram_to: Option<String>,
    matrix_to: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
    let cfg = config::load_config()
        .map_err(|e| format!("failed to load config: {e}. Run `cara setup` first."))?;
    let port = resolve_port(port);
    run_outcome_verifier(outcome, port, discord_to, telegram_to, matrix_to, cfg)
        .await
        .map_err(|err| err.into())
}

async fn run_setup_post_checks(
    port: u16,
    run_status: bool,
    launch_chat: bool,
) -> Result<(), String> {
    let mut setup_server_handle = if run_status || launch_chat {
        chat::ensure_local_gateway_running(port)
            .await
            .map_err(|e| format!("failed to start local gateway: {e}"))?
    } else {
        None
    };

    if run_status {
        let status_result = handle_status("127.0.0.1", Some(port), false)
            .await
            .map_err(|e| format!("status check failed: {e}"));
        if status_result.is_err() {
            if let Some(handle) = setup_server_handle.take() {
                handle.shutdown("cli-shutdown").await;
            }
        }
        status_result?;
    }

    let chat_result = if launch_chat {
        chat::run_chat_session(false, port)
            .await
            .map_err(|e| format!("chat session failed: {e}"))
    } else {
        Ok(())
    };

    if let Some(handle) = setup_server_handle {
        handle.shutdown("cli-shutdown").await;
    }

    chat_result
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct SetupConfigValue {
    config_value: String,
    effective_value: Option<String>,
}

fn env_placeholder(env_var: &str) -> String {
    format!("${{{env_var}}}")
}

fn first_present_env_var(env_vars: &[&'static str]) -> Option<(&'static str, String)> {
    for env_var in env_vars {
        if let Some(value) = env_var_value(env_var) {
            return Some((*env_var, value));
        }
    }
    None
}

fn prompt_setup_provider_interactive(
    requested_provider: Option<SetupProvider>,
) -> Result<SetupProvider, Box<dyn std::error::Error>> {
    if let Some(provider) = requested_provider {
        return Ok(provider);
    }

    let provider_hints = detect_setup_provider_choice_env_hints();
    if provider_hints.len() > 1 {
        let labels = provider_hints
            .iter()
            .map(|provider| provider.label())
            .collect::<Vec<_>>();
        eprintln!(
            "Detected multiple provider env hints: {}.",
            labels.join(", ")
        );
        eprintln!("Setup will only write the provider you choose.");
    }

    let default_provider = default_setup_provider_choice(&provider_hints).prompt_key();
    loop {
        let selection = prompt_with_default(
            "Select provider for first run (anthropic/openai/ollama/gemini/vertex/nearai/venice/bedrock)",
            default_provider,
        )?;
        if let Some(provider) = SetupProviderChoice::parse_prompt(&selection) {
            return match provider {
                SetupProviderChoice::Anthropic => Ok(SetupProvider::Anthropic),
                SetupProviderChoice::OpenAi => prompt_openai_setup_provider_variant(),
                SetupProviderChoice::Ollama => Ok(SetupProvider::Ollama),
                SetupProviderChoice::Gemini => Ok(SetupProvider::Gemini),
                SetupProviderChoice::Vertex => Ok(SetupProvider::Vertex),
                SetupProviderChoice::NearAi => Ok(SetupProvider::NearAi),
                SetupProviderChoice::Venice => Ok(SetupProvider::Venice),
                SetupProviderChoice::Bedrock => Ok(SetupProvider::Bedrock),
            };
        }
        eprintln!(
            "Please enter one of: anthropic, openai, ollama, gemini, vertex, nearai, venice, bedrock."
        );
    }
}

fn prompt_openai_setup_provider_variant() -> Result<SetupProvider, Box<dyn std::error::Error>> {
    let default_variant = if env_var_present("OPENAI_API_KEY") {
        "api-key"
    } else if env_var_present("CARAPACE_CONFIG_PASSWORD")
        && env_var_present("OPENAI_OAUTH_CLIENT_ID")
        && env_var_present("OPENAI_OAUTH_CLIENT_SECRET")
    {
        "subscription-sign-in"
    } else {
        "api-key"
    };

    loop {
        let selection = prompt_choice(
            "How should OpenAI authenticate? (api-key/subscription-sign-in)",
            default_variant,
            &["api-key", "subscription-sign-in"],
        )?;
        match selection.as_str() {
            "api-key" => return Ok(SetupProvider::OpenAi),
            "subscription-sign-in" => return Ok(SetupProvider::Codex),
            _ => eprintln!("Please choose either `api-key` or `subscription-sign-in`."),
        }
    }
}

fn setup_provider_auth_mode_hint(
    provider: SetupProvider,
    requested_auth_mode: Option<SetupAuthModeSelection>,
) -> Option<crate::onboarding::setup::SetupAuthMode> {
    match provider {
        SetupProvider::Anthropic | SetupProvider::Gemini => requested_auth_mode.map(Into::into),
        SetupProvider::Codex => Some(crate::onboarding::setup::SetupAuthMode::OAuth),
        SetupProvider::OpenAi | SetupProvider::NearAi | SetupProvider::Venice => {
            Some(crate::onboarding::setup::SetupAuthMode::ApiKey)
        }
        SetupProvider::Ollama => Some(crate::onboarding::setup::SetupAuthMode::BaseUrl),
        SetupProvider::Vertex => None,
        SetupProvider::Bedrock => Some(crate::onboarding::setup::SetupAuthMode::StaticCredentials),
    }
}

fn prompt_gemini_setup_auth_mode(
    requested_mode: Option<SetupAuthModeSelection>,
) -> Result<SetupAuthModeSelection, Box<dyn std::error::Error>> {
    if let Some(mode) = requested_mode {
        if mode == SetupAuthModeSelection::SetupToken {
            return Err(
                "`--auth-mode setup-token` is only valid with `--provider anthropic`.".into(),
            );
        }
        return Ok(mode);
    }

    loop {
        let selection = prompt_choice(
            "How should Gemini authenticate? (oauth/api-key)",
            "oauth",
            &["oauth", "api-key"],
        )?;
        match selection.as_str() {
            "oauth" => return Ok(SetupAuthModeSelection::OAuth),
            "api-key" => return Ok(SetupAuthModeSelection::ApiKey),
            _ => {
                eprintln!("Please choose either `oauth` or `api-key`.");
            }
        }
    }
}

fn prompt_anthropic_setup_auth_mode(
    requested_mode: Option<SetupAuthModeSelection>,
) -> Result<SetupAuthModeSelection, Box<dyn std::error::Error>> {
    if let Some(mode) = requested_mode {
        return match mode {
            SetupAuthModeSelection::ApiKey | SetupAuthModeSelection::SetupToken => Ok(mode),
            SetupAuthModeSelection::OAuth => {
                Err("`--auth-mode oauth` is only valid with `--provider gemini`.".into())
            }
        };
    }

    loop {
        let selection = prompt_choice(
            "How should Anthropic authenticate? (api-key/setup-token)",
            "api-key",
            &["api-key", "setup-token"],
        )?;
        match selection.as_str() {
            "api-key" => return Ok(SetupAuthModeSelection::ApiKey),
            "setup-token" => return Ok(SetupAuthModeSelection::SetupToken),
            _ => eprintln!("Please choose either `api-key` or `setup-token`."),
        }
    }
}

fn prompt_vertex_setup_route(
) -> Result<crate::onboarding::vertex::VertexModelRoute, Box<dyn std::error::Error>> {
    let selection = prompt_choice(
        "How should Vertex choose the model? (default-route/explicit-model)",
        "default-route",
        &["default-route", "explicit-model"],
    )?;
    match selection.as_str() {
        "default-route" => Ok(crate::onboarding::vertex::VertexModelRoute::Default),
        "explicit-model" => Ok(crate::onboarding::vertex::VertexModelRoute::Explicit),
        _ => Err("prompt_choice returned an unexpected Vertex route".into()),
    }
}

fn prompt_required_visible_env_backed_config_value(
    env_vars: &[&'static str],
    placeholder_env_var: &'static str,
    label: &str,
    default_value: Option<&str>,
) -> Result<SetupConfigValue, Box<dyn std::error::Error>> {
    if let Some((env_name, env_value)) = first_present_env_var(env_vars) {
        if prompt_yes_no(&format!("Use {label} from ${env_name}?"), true)? {
            return Ok(SetupConfigValue {
                config_value: env_placeholder(env_name),
                effective_value: Some(env_value),
            });
        }

        let entered = prompt_with_default(label, &env_value)?;
        let trimmed = entered.trim();
        let chosen = if trimmed.is_empty() {
            env_value
        } else {
            trimmed.to_string()
        };
        return Ok(SetupConfigValue {
            config_value: chosen.clone(),
            effective_value: Some(chosen),
        });
    }

    match default_value {
        Some(default) => {
            let entered = prompt_with_default(label, default)?;
            let trimmed = entered.trim();
            let chosen = if trimmed.is_empty() {
                default.to_string()
            } else {
                trimmed.to_string()
            };
            Ok(SetupConfigValue {
                config_value: chosen.clone(),
                effective_value: Some(chosen),
            })
        }
        None => {
            let entered = prompt_line(&format!(
                "Enter {label} (leave blank to use ${placeholder_env_var} later): "
            ))?;
            let trimmed = entered.trim();
            if trimmed.is_empty() {
                return Ok(SetupConfigValue {
                    config_value: env_placeholder(placeholder_env_var),
                    effective_value: None,
                });
            }
            Ok(SetupConfigValue {
                config_value: trimmed.to_string(),
                effective_value: Some(trimmed.to_string()),
            })
        }
    }
}

fn prompt_vertex_explicit_model_id() -> Result<String, Box<dyn std::error::Error>> {
    println!(
        "Paste the Vertex model ID from Vertex AI Model Garden, for example a Google model ID or `publishers/<publisher>/models/<model-id>`."
    );
    loop {
        let entered = if let Some(default_model) = env_var_value("VERTEX_MODEL") {
            prompt_with_default("Vertex model ID", &default_model)?
        } else {
            prompt_line("Vertex model ID: ")?
        };
        let normalized = crate::onboarding::vertex::normalize_vertex_model_id(&entered);
        if normalized.is_empty() || normalized.eq_ignore_ascii_case("default") {
            eprintln!("Enter a concrete Vertex model ID from Vertex AI Model Garden.");
            continue;
        }
        match validate_setup_model_input(&entered, SetupProvider::Vertex) {
            Ok(validated) => return Ok(extract_vertex_explicit_model_id(&validated)?.to_string()),
            Err(err) => {
                eprintln!("Invalid model: {err}");
                continue;
            }
        }
    }
}

fn print_missing_setup_value_notice(env_var: &str, label: &str) {
    println!(
        "No {label} provided. Set ${env_var} in the same shell you use for `cara start` and `cara verify`, or rerun `cara setup --force` later."
    );
}

fn prompt_required_secret_config_value(
    env_var: &'static str,
    label: &str,
    hide_sensitive_input: bool,
) -> Result<SetupConfigValue, Box<dyn std::error::Error>> {
    if let Some((env_name, env_value)) = first_present_env_var(&[env_var]) {
        if prompt_yes_no(&format!("Use {label} from ${env_name}?"), true)? {
            return Ok(SetupConfigValue {
                config_value: env_placeholder(env_name),
                effective_value: Some(env_value),
            });
        }
    }

    let entered = prompt_sensitive_line(label, hide_sensitive_input, true)?;
    if entered.is_empty() {
        Ok(SetupConfigValue {
            config_value: env_placeholder(env_var),
            effective_value: None,
        })
    } else {
        Ok(SetupConfigValue {
            config_value: entered.clone(),
            effective_value: Some(entered),
        })
    }
}

fn prompt_required_secret_value_from_env(
    env_var: &'static str,
    label: &str,
    hide_sensitive_input: bool,
) -> Result<String, Box<dyn std::error::Error>> {
    if let Some((env_name, env_value)) = first_present_env_var(&[env_var]) {
        if prompt_yes_no(&format!("Use {label} from ${env_name}?"), true)? {
            return Ok(env_value);
        }
    }

    loop {
        let entered = prompt_sensitive_line(label, hide_sensitive_input, false)?;
        let trimmed = entered.trim();
        if !trimmed.is_empty() {
            return Ok(trimmed.to_string());
        }
        eprintln!("{label} is required.");
    }
}

fn prompt_optional_secret_config_value(
    env_var: &'static str,
    label: &str,
    hide_sensitive_input: bool,
) -> Result<Option<SetupConfigValue>, Box<dyn std::error::Error>> {
    if let Some((env_name, env_value)) = first_present_env_var(&[env_var]) {
        if prompt_yes_no(&format!("Use {label} from ${env_name}?"), true)? {
            return Ok(Some(SetupConfigValue {
                config_value: env_placeholder(env_name),
                effective_value: Some(env_value),
            }));
        }
    }

    let entered = prompt_sensitive_line(label, hide_sensitive_input, true)?;
    if entered.is_empty() {
        Ok(None)
    } else {
        Ok(Some(SetupConfigValue {
            config_value: entered.clone(),
            effective_value: Some(entered),
        }))
    }
}

fn prompt_required_visible_config_value(
    env_vars: &[&'static str],
    label: &str,
    default_value: &str,
) -> Result<SetupConfigValue, Box<dyn std::error::Error>> {
    if let Some((env_name, env_value)) = first_present_env_var(env_vars) {
        if prompt_yes_no(&format!("Use {label} from ${env_name}?"), true)? {
            return Ok(SetupConfigValue {
                config_value: env_placeholder(env_name),
                effective_value: Some(env_value),
            });
        }

        let entered = prompt_with_default(label, &env_value)?;
        return Ok(SetupConfigValue {
            config_value: entered.clone(),
            effective_value: Some(entered),
        });
    }

    let entered = prompt_with_default(label, default_value)?;
    Ok(SetupConfigValue {
        config_value: entered.clone(),
        effective_value: Some(entered),
    })
}

fn is_setup_provider_prompt_key(prefix: &str) -> bool {
    setup_provider_from_prompt_key(prefix).is_some()
}

fn setup_provider_from_prompt_key(prefix: &str) -> Option<SetupProvider> {
    let normalized = prefix.trim().to_ascii_lowercase();
    crate::onboarding::setup::SetupProvider::all()
        .iter()
        .copied()
        .find(|provider| provider.prompt_key() == normalized.as_str())
        .map(SetupProvider::from)
}

fn setup_provider_prompt_key_hint() -> String {
    crate::onboarding::setup::SetupProvider::all()
        .iter()
        .map(|provider| format!("`{}:`", provider.prompt_key()))
        .collect::<Vec<_>>()
        .join(", ")
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct ValidatedSetupModel(String);

impl ValidatedSetupModel {
    fn parse(raw: &str, provider: SetupProvider) -> Result<Self, String> {
        validate_setup_model_input(raw, provider).map(Self)
    }

    fn as_str(&self) -> &str {
        self.0.as_str()
    }

    fn provider(&self) -> Option<SetupProvider> {
        self.0
            .split_once(':')
            .and_then(|(prefix, _)| setup_provider_from_prompt_key(prefix))
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct ResolvedSetupRequest {
    provider: Option<SetupProvider>,
    model: Option<ValidatedSetupModel>,
    bare_model_without_provider: Option<String>,
}

fn is_setup_model_placeholder(value: &str) -> bool {
    let value = value.trim();
    value.eq_ignore_ascii_case("<model-id>")
        || (value.starts_with('<') && value.ends_with('>') && value.len() >= 2)
}

fn setup_model_placeholder_error() -> String {
    "replace `<model-id>` or other angle-bracket placeholder text with a concrete model id"
        .to_string()
}

fn setup_model_input_has_dotted_prefix(raw: &str) -> bool {
    raw.trim()
        .split_once(':')
        .is_some_and(|(prefix, _)| prefix.contains('.'))
}

fn setup_input_looks_like_bedrock_native_id(raw: &str) -> bool {
    raw.split_once(':')
        .is_some_and(|(prefix, _)| prefix.contains('.'))
        && crate::model_names::prefix_bare_model(raw).starts_with("bedrock:")
}

fn setup_input_looks_like_bedrock_arn(raw: &str) -> bool {
    let mut parts = raw.trim().split(':');
    let Some(prefix) = parts.next() else {
        return false;
    };
    let Some(_partition) = parts.next() else {
        return false;
    };
    let Some(service) = parts.next() else {
        return false;
    };
    prefix.eq_ignore_ascii_case("arn")
        && (service.eq_ignore_ascii_case("bedrock")
            || service.eq_ignore_ascii_case("bedrock-runtime"))
}

fn setup_bedrock_arn_model_error() -> String {
    "Bedrock ARN model identifiers are not supported by setup `--model`; use a Bedrock native model ID such as `anthropic.claude-v1:0` or a canonical `bedrock:<model-id>` value"
        .to_string()
}

fn validate_setup_model_id_chars(model_id: &str) -> Result<(), String> {
    match crate::onboarding::setup::validate_model_id_command_token(model_id) {
        Ok(()) => Ok(()),
        Err(crate::onboarding::setup::ModelIdCommandTokenError::FlagLikeSegment) => {
            let model_id = terminal_safe_setup_input(model_id);
            Err(format!(
                "model id `{model_id}` must not start with `-` or contain a `:` segment starting with `-`"
            ))
        }
        Err(crate::onboarding::setup::ModelIdCommandTokenError::UnsupportedCharacter) => {
            let model_id = terminal_safe_setup_input(model_id);
            Err(format!(
                "model id `{model_id}` must contain only letters, numbers, `.`, `_`, `-`, `/`, `:`, or `@`"
            ))
        }
    }
}

fn validate_setup_model_id_colon_segments(model_id: &str) -> Result<(), String> {
    if model_id.split(':').any(str::is_empty) {
        let model_id = terminal_safe_setup_input(model_id);
        return Err(format!(
            "model id `{model_id}` must not contain empty `:` segments"
        ));
    }
    Ok(())
}

fn setup_provider_implied_by_model_input(
    raw: &str,
) -> Result<Option<(SetupProvider, ValidatedSetupModel)>, String> {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        return Err("model is required".to_string());
    }
    let Some((prefix, rest)) = trimmed.split_once(':') else {
        if trimmed.contains(char::is_whitespace) {
            let trimmed = terminal_safe_setup_input(trimmed);
            return Err(format!("model id `{trimmed}` must not contain whitespace"));
        }
        return Ok(None);
    };
    let prefix = prefix.trim().to_ascii_lowercase();
    let rest = rest.trim();
    if setup_input_looks_like_bedrock_arn(trimmed) {
        return Err(setup_bedrock_arn_model_error());
    }
    if setup_model_input_has_dotted_prefix(trimmed) {
        return Ok(None);
    }
    if prefix.contains(char::is_whitespace) {
        let prefix = terminal_safe_setup_input(&prefix);
        return Err(format!(
            "provider prefix `{prefix}` must not contain whitespace"
        ));
    }
    let Some(provider) = setup_provider_from_prompt_key(&prefix) else {
        let recognized = setup_provider_prompt_key_hint();
        let prefix = terminal_safe_setup_input(&prefix);
        let rest = terminal_safe_setup_input(rest);
        return Err(format!(
            "`{prefix}:{rest}` uses unrecognized provider prefix `{prefix}:`; recognized prefixes: {recognized}; rerun with `--provider <provider>` or enter a recognized `<provider>:<model-id>` model"
        ));
    };
    let model = ValidatedSetupModel::parse(trimmed, provider)?;
    Ok(Some((provider, model)))
}

fn resolve_setup_request(
    requested_provider: Option<SetupProvider>,
    requested_model: Option<&str>,
) -> Result<ResolvedSetupRequest, String> {
    if let Some(provider) = requested_provider {
        let model = requested_model
            .map(|raw_model| ValidatedSetupModel::parse(raw_model, provider))
            .transpose()?;
        return Ok(ResolvedSetupRequest {
            provider: Some(provider),
            model,
            bare_model_without_provider: None,
        });
    }

    let bare_model_without_provider = requested_model
        .map(str::trim)
        .filter(|model| !model.is_empty() && !model.contains(':'))
        .map(ToOwned::to_owned);
    let inferred = requested_model
        .map(setup_provider_implied_by_model_input)
        .transpose()?
        .flatten();
    let (provider, model) = match inferred {
        Some((provider, model)) => (Some(provider), Some(model)),
        None => (None, None),
    };
    Ok(ResolvedSetupRequest {
        provider,
        model,
        bare_model_without_provider,
    })
}

/// Validate that `raw` is a `provider:model` string for the supplied provider.
///
/// Accepts either the fully-qualified `<prefix>:<model>` form or a bare
/// `<model>` (auto-prefixes with the provider's canonical prefix). Returns
/// the normalized canonical string on success, or a user-facing error message.
///
/// Whitespace around the colon and on either side of the input is trimmed in
/// the returned string, so `openai: gpt-5.5` normalizes to `openai:gpt-5.5`.
/// Bedrock native IDs like `anthropic.claude-v1:0` contain a colon as part of
/// the model id; for Bedrock only, we treat any input whose pre-colon portion
/// contains a dot as bare (so `--provider bedrock --model
/// anthropic.claude-v1:0` -> `bedrock:anthropic.claude-v1:0`). Other
/// providers keep the standard prefix mismatch path so likely Bedrock IDs do
/// not get silently accepted under the wrong provider.
fn validate_setup_model_input(raw: &str, provider: SetupProvider) -> Result<String, String> {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        return Err("model is required".to_string());
    }
    if is_setup_model_placeholder(trimmed) {
        return Err(setup_model_placeholder_error());
    }
    let expected_prefix = provider.prompt_key();
    // INVARIANT: no provider's `prompt_key()` may contain a dot. The
    // `already_prefixed` check below uses dot-before-colon as the Bedrock-only
    // signal that the input is a Bedrock-style native id (e.g.
    // `anthropic.claude-v1:0`) rather than `<prompt_key>:<model>`. Tests catch
    // ordinary provider-registry changes; keep a structured release-mode error
    // here too so a bad registration cannot silently corrupt setup output.
    if expected_prefix.contains('.') {
        return Err(format!(
            "internal setup provider registration error: prompt key `{expected_prefix}` must not contain `.`"
        ));
    }
    let split_parts = trimmed.split_once(':');
    // Bedrock currently uses dotted native model/profile IDs before the first
    // colon. If AWS ever introduces a native ID namespace starting with
    // `bedrock.`, this heuristic needs review before setup advertises it.
    if let Some((native_prefix, native_rest)) =
        split_parts.filter(|(prefix, _)| provider == SetupProvider::Bedrock && prefix.contains('.'))
    {
        let native_prefix_raw = native_prefix.trim();
        let native_rest_raw = native_rest.trim();
        if native_prefix_raw.contains(char::is_whitespace) {
            let native_prefix_raw = terminal_safe_setup_input(native_prefix_raw);
            return Err(format!(
                "model id `{native_prefix_raw}` must not contain whitespace"
            ));
        }
        if native_rest.is_empty() {
            let native_prefix_raw = terminal_safe_setup_input(native_prefix_raw);
            return Err(format!("model id after `{native_prefix_raw}:` is required"));
        }
        if native_rest_raw.contains(':') {
            let native_model =
                terminal_safe_setup_input(&format!("{native_prefix_raw}:{native_rest_raw}"));
            return Err(format!(
                "Bedrock native model id `{native_model}` must contain exactly one colon"
            ));
        }
        if native_rest_raw.contains(char::is_whitespace) {
            let native_rest_raw = terminal_safe_setup_input(native_rest_raw);
            return Err(format!(
                "model id `{native_rest_raw}` must not contain whitespace"
            ));
        }
        validate_setup_model_id_chars(native_prefix_raw)?;
        validate_setup_model_id_chars(native_rest_raw)?;
        return Ok(format!(
            "{expected_prefix}:{native_prefix_raw}:{native_rest_raw}"
        ));
    }
    let prefixed_parts = split_parts;
    let Some((actual_prefix, rest)) = prefixed_parts else {
        if trimmed.contains(char::is_whitespace) {
            let trimmed = terminal_safe_setup_input(trimmed);
            return Err(format!("model id `{trimmed}` must not contain whitespace"));
        }
        if provider == SetupProvider::Vertex && trimmed.eq_ignore_ascii_case("default") {
            return Ok(VERTEX_DEFAULT_SENTINEL.to_string());
        }
        validate_setup_model_id_chars(trimmed)?;
        return Ok(format!("{expected_prefix}:{trimmed}"));
    };
    let actual_prefix = actual_prefix.trim().to_ascii_lowercase();
    let rest = rest.trim();
    if actual_prefix.contains(char::is_whitespace) {
        let actual_prefix = terminal_safe_setup_input(&actual_prefix);
        return Err(format!(
            "provider prefix `{actual_prefix}` must not contain whitespace"
        ));
    }
    if provider == SetupProvider::Bedrock && setup_input_looks_like_bedrock_arn(trimmed) {
        return Err(setup_bedrock_arn_model_error());
    }
    if !actual_prefix.eq_ignore_ascii_case(expected_prefix) {
        // Show the canonical form the user *meant* (whitespace stripped),
        // not the raw input. Keeps the error consistent with the form the
        // validator returns on success and avoids confusing the user with
        // spaces they didn't notice.
        let canonical_input = format!("{actual_prefix}:{rest}");
        if setup_input_looks_like_bedrock_native_id(&canonical_input) {
            let actual_prefix = terminal_safe_setup_input(&actual_prefix);
            let rest = terminal_safe_setup_input(rest);
            return Err(format!(
                "`{actual_prefix}:{rest}` looks like a Bedrock native model ID, but `--provider {expected_prefix}` is configured; use `--provider bedrock` for Bedrock native IDs or enter an `{expected_prefix}:<model-id>` model"
            ));
        }
        if !is_setup_provider_prompt_key(&actual_prefix) {
            let actual_prefix = terminal_safe_setup_input(&actual_prefix);
            let rest = terminal_safe_setup_input(rest);
            return Err(format!(
                "`{actual_prefix}:{rest}` uses unrecognized provider prefix `{actual_prefix}:`, but `--provider {expected_prefix}` is configured; enter an `{expected_prefix}:<model-id>` model"
            ));
        }
        let actual_prefix = terminal_safe_setup_input(&actual_prefix);
        let rest = terminal_safe_setup_input(rest);
        return Err(format!(
            "`{actual_prefix}:{rest}` uses the `{actual_prefix}:` provider prefix, but `--provider {expected_prefix}` is configured; pick one"
        ));
    }
    if rest.is_empty() {
        return Err(format!("model id after `{expected_prefix}:` is required"));
    }
    if is_setup_model_placeholder(rest) {
        return Err(setup_model_placeholder_error());
    }
    if provider == SetupProvider::Vertex && rest.eq_ignore_ascii_case("default") {
        return Ok(VERTEX_DEFAULT_SENTINEL.to_string());
    }
    if provider == SetupProvider::Codex && rest.eq_ignore_ascii_case("default") {
        return Ok(CODEX_DEFAULT_SENTINEL.to_string());
    }
    if provider == SetupProvider::Bedrock && rest.matches(':').count() > 1 {
        if setup_input_looks_like_bedrock_arn(rest) {
            return Err(setup_bedrock_arn_model_error());
        }
        let rest = terminal_safe_setup_input(rest);
        return Err(format!(
            "Bedrock model id `{rest}` must contain at most one native-model suffix colon"
        ));
    }
    if rest.contains(char::is_whitespace) {
        let rest = terminal_safe_setup_input(rest);
        return Err(format!("model id `{rest}` must not contain whitespace"));
    }
    validate_setup_model_id_chars(rest)?;
    if (provider == SetupProvider::Ollama || provider == SetupProvider::Bedrock)
        && rest.contains(':')
    {
        validate_setup_model_id_colon_segments(rest)?;
    }
    if rest
        .split_once(':')
        .is_some_and(|(nested_prefix, _)| nested_prefix.eq_ignore_ascii_case(expected_prefix))
    {
        let rest = terminal_safe_setup_input(rest);
        return Err(format!(
            "model id `{rest}` must not repeat the `{expected_prefix}:` provider prefix"
        ));
    }
    // Ollama tag syntax (`name:tag`) and Bedrock native IDs are the only
    // supported multi-colon setup model forms. Add any future provider with
    // colon-bearing native IDs to this allowlist.
    if provider != SetupProvider::Ollama && provider != SetupProvider::Bedrock && rest.contains(':')
    {
        let rest = terminal_safe_setup_input(rest);
        return Err(format!(
            "model id `{rest}` must not contain `:` for `{expected_prefix}:` models"
        ));
    }
    Ok(format!("{expected_prefix}:{rest}"))
}

/// Prompt the user for a model in `provider:<model>` form. Re-prompts on
/// invalid input. Used by interactive setup when `--model` was not supplied.
fn prompt_required_model(
    provider: SetupProvider,
) -> Result<ValidatedSetupModel, Box<dyn std::error::Error>> {
    let label = provider.model_prompt_label();
    let prefix = provider.prompt_key();
    println!();
    println!("Pick the default model for {label}.");
    println!(
        "Use the provider-native model ID from the provider docs, console, or local endpoint."
    );
    if provider == SetupProvider::Codex {
        println!(
            "Type `{CODEX_DEFAULT_SENTINEL}` (or just `default`) for the default Codex model, or an explicit Codex model in `codex:<model-id>` form."
        );
        println!("Bare Codex model IDs are auto-prefixed with `codex:`.");
    } else if provider == SetupProvider::Bedrock {
        println!("Enter `bedrock:<model-id>` form, or a bare AWS Bedrock model ID.");
        println!(
            "Dotted Bedrock native IDs like `anthropic.claude-v1:0` are accepted without the `bedrock:` prefix."
        );
    } else {
        println!(
            "Enter the full `{prefix}:<model-id>` form, or a bare `<model-id>` without `:` (auto-prefixed)."
        );
        println!(
            "If the provider-native model ID contains `:`, include the provider prefix explicitly."
        );
    }
    loop {
        let entered = prompt_line(&format!("{label} default model: "))?;
        match validate_setup_model_input(&entered, provider) {
            Ok(model) => return Ok(ValidatedSetupModel(model)),
            Err(err) => eprintln!("Invalid model: {err}"),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BareModelProviderInference {
    MatchesSelectedProvider,
    MatchesDifferentProvider,
    Unknown,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AmbiguousBareModelProviderInference {
    MatchesDifferentProvider,
    Unknown,
}

fn infer_bare_model_provider(raw: &str, provider: SetupProvider) -> BareModelProviderInference {
    let trimmed = raw.trim();
    // This is only a confirmation-default heuristic. Provider-specific model
    // validity remains owned by the provider docs/API. Most unknown names
    // default to a No confirmation rather than being treated as proof of a
    // match; Ollama's local model namespace is intentionally opaque, so the
    // selected Ollama provider is the only reliable signal for unknown bare IDs.
    let suggested = crate::model_names::prefix_bare_model(trimmed);
    let expected_prefix = format!("{}:", provider.prompt_key());
    if suggested.starts_with(&expected_prefix) {
        return BareModelProviderInference::MatchesSelectedProvider;
    }
    if suggested != trimmed {
        return BareModelProviderInference::MatchesDifferentProvider;
    }
    if matches!(provider, SetupProvider::Codex | SetupProvider::Vertex)
        && trimmed.eq_ignore_ascii_case("default")
    {
        return BareModelProviderInference::MatchesSelectedProvider;
    }
    if provider == SetupProvider::Ollama {
        return BareModelProviderInference::MatchesSelectedProvider;
    }
    BareModelProviderInference::Unknown
}

fn bare_model_confirmation_prompt(
    provider_inference: AmbiguousBareModelProviderInference,
    raw: &str,
    resolved_model: &str,
    provider: SetupProvider,
) -> String {
    let label = provider.model_prompt_label();
    let raw = terminal_safe_setup_input(raw.trim());
    match provider_inference {
        AmbiguousBareModelProviderInference::MatchesDifferentProvider => format!(
            "`{}` looks like a model from a different provider. Still use `{}` as the {} default model?",
            raw,
            resolved_model,
            label
        ),
        AmbiguousBareModelProviderInference::Unknown => format!(
            "Carapace cannot infer whether `{}` belongs to {}. Use `{}` as the {} default model?",
            raw,
            label,
            resolved_model,
            label
        ),
    }
}

fn confirm_ambiguous_bare_model(
    provider_inference: BareModelProviderInference,
    raw: &str,
    resolved_model: &str,
    provider: SetupProvider,
) -> Result<bool, Box<dyn std::error::Error>> {
    let raw_display = terminal_safe_setup_input(raw.trim());
    let provider_inference = match provider_inference {
        BareModelProviderInference::MatchesDifferentProvider => {
            eprintln!(
                "`{}` looks like a model from a different provider. Confirm it belongs to {} before accepting.",
                raw_display,
                provider.model_prompt_label()
            );
            AmbiguousBareModelProviderInference::MatchesDifferentProvider
        }
        BareModelProviderInference::Unknown => {
            eprintln!(
                "Carapace cannot infer whether `{}` belongs to {}. Confirm the provider-native model ID before accepting.",
                raw_display,
                provider.model_prompt_label()
            );
            AmbiguousBareModelProviderInference::Unknown
        }
        BareModelProviderInference::MatchesSelectedProvider => {
            return Ok(true);
        }
    };
    println!("Resolved default model: `{resolved_model}`.");
    let prompt = bare_model_confirmation_prompt(provider_inference, raw, resolved_model, provider);
    prompt_yes_no_required(&prompt)
}

fn prompt_optional_base_url_override(
    provider_label: &str,
    env_var: &'static str,
    default_url: &str,
) -> Result<Option<SetupConfigValue>, Box<dyn std::error::Error>> {
    if let Some((env_name, env_value)) = first_present_env_var(&[env_var]) {
        if prompt_yes_no(
            &format!("Use {provider_label} base URL from ${env_name}?"),
            true,
        )? {
            return Ok(Some(SetupConfigValue {
                config_value: env_placeholder(env_name),
                effective_value: Some(env_value),
            }));
        }
    }

    if !prompt_yes_no(
        &format!("Override default {provider_label} base URL?"),
        false,
    )? {
        return Ok(None);
    }

    let entered = prompt_with_default(&format!("{provider_label} base URL"), default_url)?;
    Ok(Some(SetupConfigValue {
        config_value: entered.clone(),
        effective_value: Some(entered),
    }))
}

fn render_setup_validation_failure(err: &crate::agent::AgentError) -> String {
    match err {
        crate::agent::AgentError::InvalidApiKey(_) => {
            "Provider configuration check failed: the supplied credential is invalid or incomplete."
                .to_string()
        }
        crate::agent::AgentError::InvalidBaseUrl(_) => {
            "Provider configuration check failed: the supplied base URL is invalid or unsupported."
                .to_string()
        }
        crate::agent::AgentError::Provider(_) => {
            "Provider configuration check failed: the provider rejected the configuration."
                .to_string()
        }
        _ => "Provider configuration check failed.".to_string(),
    }
}

fn prompt_oauth_client_value(
    env_var: &'static str,
    label: &str,
    hide_sensitive_input: bool,
) -> Result<String, Box<dyn std::error::Error>> {
    if let Some((env_name, env_value)) = first_present_env_var(&[env_var]) {
        if prompt_yes_no(&format!("Use {label} from ${env_name}?"), true)? {
            return Ok(env_value);
        }
    }

    loop {
        let entered = if hide_sensitive_input {
            prompt_sensitive_line(label, true, false)?
        } else {
            prompt_line(&format!("Enter {label}: "))?
        };
        if !entered.trim().is_empty() {
            return Ok(entered.trim().to_string());
        }
        eprintln!("{label} is required for OAuth sign-in.");
    }
}

fn configure_gemini_provider_interactive(
    config: &mut Value,
    hide_sensitive_input: bool,
    requested_auth_mode: Option<SetupAuthModeSelection>,
    resolved_model: &str,
) -> Result<ProviderSetupResult, Box<dyn std::error::Error>> {
    let auth_mode = prompt_gemini_setup_auth_mode(requested_auth_mode)?;
    let base_url = prompt_optional_base_url_override(
        "Gemini",
        "GOOGLE_API_BASE_URL",
        "https://generativelanguage.googleapis.com",
    )?;
    let mut result = ProviderSetupResult::default();

    match auth_mode {
        SetupAuthModeSelection::ApiKey => {
            let api_key = prompt_required_secret_config_value(
                "GOOGLE_API_KEY",
                "Gemini API key",
                hide_sensitive_input,
            )?;
            if api_key.effective_value.is_none() {
                print_missing_setup_value_notice("GOOGLE_API_KEY", "Gemini API key");
            }

            if let Some(key) = api_key.effective_value.clone() {
                let validation = crate::onboarding::gemini::validate_gemini_api_key_input(
                    &key,
                    base_url
                        .as_ref()
                        .and_then(|value| value.effective_value.as_deref()),
                );
                if let Err(err) = validation {
                    result.observed_checks.push(handle_setup_validation_failure(
                        SetupProvider::Gemini,
                        Some(SetupAuthModeSelection::ApiKey),
                        err,
                        Some(resolved_model),
                    )?);
                }
            }

            crate::onboarding::gemini::write_gemini_api_key_config(
                config,
                &api_key.config_value,
                base_url.as_ref().map(|value| value.config_value.as_str()),
            );
        }
        SetupAuthModeSelection::OAuth => {
            let client_id = prompt_oauth_client_value(
                "GOOGLE_OAUTH_CLIENT_ID",
                "Google OAuth client ID",
                false,
            )?;
            let client_secret = prompt_oauth_client_value(
                "GOOGLE_OAUTH_CLIENT_SECRET",
                "Google OAuth client secret",
                hide_sensitive_input,
            )?;

            if let Some(url) = base_url
                .as_ref()
                .and_then(|value| value.effective_value.as_deref())
            {
                if let Err(err) =
                    crate::onboarding::gemini::validate_gemini_base_url_input(Some(url))
                {
                    result.observed_checks.push(handle_setup_validation_failure(
                        SetupProvider::Gemini,
                        Some(SetupAuthModeSelection::OAuth),
                        err,
                        Some(resolved_model),
                    )?);
                }
            }

            let config_snapshot = config.clone();
            let completion =
                run_sync_blocking_send(crate::onboarding::gemini::run_cli_google_oauth(
                    config_snapshot,
                    Some(client_id.clone()),
                    Some(client_secret.clone()),
                ))
                .map_err(|err| format!("Gemini Google sign-in runtime failed: {err}"))?;

            let state_dir = resolve_state_dir();
            std::fs::create_dir_all(&state_dir)?;
            let profile_detail = match completion.auth_profile.email.as_deref() {
                Some(email) => format!("stored Gemini auth profile for {email}"),
                None => "stored Gemini auth profile".to_string(),
            };
            crate::onboarding::gemini::persist_cli_google_oauth(state_dir, config, completion)?;
            result
                .observed_checks
                .push(crate::onboarding::setup::SetupCheck::validation_pass(
                    "Live provider validation",
                    profile_detail,
                    None,
                ));

            if let Some(base_url) = base_url {
                config["google"]["baseUrl"] = serde_json::json!(base_url.config_value);
            }
        }
        SetupAuthModeSelection::SetupToken => {
            return Err(
                "`--auth-mode setup-token` is only valid with `--provider anthropic`.".into(),
            );
        }
    }

    Ok(result)
}

fn configure_codex_provider_interactive(
    config: &mut Value,
    hide_sensitive_input: bool,
) -> Result<ProviderSetupResult, Box<dyn std::error::Error>> {
    println!(
        "OpenAI subscription sign-in stores a refreshable auth profile. OpenAI API-key setup remains available through the OpenAI API-key path."
    );

    let client_id =
        prompt_oauth_client_value("OPENAI_OAUTH_CLIENT_ID", "OpenAI OAuth client ID", false)?;
    let client_secret = prompt_oauth_client_value(
        "OPENAI_OAUTH_CLIENT_SECRET",
        "OpenAI OAuth client secret",
        hide_sensitive_input,
    )?;

    let config_snapshot = config.clone();
    let completion = run_sync_blocking_send(crate::onboarding::codex::run_cli_openai_oauth(
        config_snapshot,
        Some(client_id.clone()),
        Some(client_secret.clone()),
    ))
    .map_err(|err| format!("Codex sign-in runtime failed: {err}"))?;

    let state_dir = resolve_state_dir();
    std::fs::create_dir_all(&state_dir)?;
    let profile_detail = match completion.auth_profile.email.as_deref() {
        Some(email) => format!("stored OpenAI auth profile for {email}"),
        None => "stored OpenAI auth profile".to_string(),
    };
    crate::onboarding::codex::persist_cli_openai_oauth(state_dir, config, completion)?;

    Ok(ProviderSetupResult {
        observed_checks: vec![crate::onboarding::setup::SetupCheck::validation_pass(
            "Live provider validation",
            profile_detail,
            None,
        )],
    })
}

fn extract_vertex_explicit_model_id(validated: &str) -> Result<&str, Box<dyn std::error::Error>> {
    validated.strip_prefix("vertex:").ok_or_else(|| {
        format!(
            "internal: Vertex `--model` value `{validated}` was not pre-validated by `validate_setup_model_input`"
        )
        .into()
    })
}

fn configure_vertex_provider_interactive(
    config: &mut Value,
    validated_requested_model: Option<&ValidatedSetupModel>,
) -> Result<ProviderSetupResult, Box<dyn std::error::Error>> {
    let project_id = prompt_required_visible_env_backed_config_value(
        &["VERTEX_PROJECT_ID"],
        "VERTEX_PROJECT_ID",
        "GCP project ID",
        None,
    )?;
    let location = prompt_required_visible_env_backed_config_value(
        &["VERTEX_LOCATION"],
        "VERTEX_LOCATION",
        "GCP location",
        Some("us-central1"),
    )?;
    let (route, model) = if let Some(validated) = validated_requested_model {
        // `--model vertex:default` keeps the default-route flow (still needs
        // a concrete `vertex.model` from VERTEX_MODEL); any other
        // `vertex:<id>` skips the route prompt and writes the explicit model.
        if validated
            .as_str()
            .eq_ignore_ascii_case(VERTEX_DEFAULT_SENTINEL)
        {
            let configured = prompt_required_visible_env_backed_config_value(
                &["VERTEX_MODEL"],
                "VERTEX_MODEL",
                "Vertex default model",
                None,
            )?;
            if configured.effective_value.is_none() {
                print_missing_setup_value_notice("VERTEX_MODEL", "Vertex default model");
            }
            (
                crate::onboarding::vertex::VertexModelRoute::Default,
                configured,
            )
        } else {
            let explicit_id = extract_vertex_explicit_model_id(validated.as_str())?.to_string();
            (
                crate::onboarding::vertex::VertexModelRoute::Explicit,
                SetupConfigValue {
                    config_value: explicit_id.clone(),
                    effective_value: Some(explicit_id),
                },
            )
        }
    } else {
        let route = prompt_vertex_setup_route()?;
        let model = match route {
            crate::onboarding::vertex::VertexModelRoute::Default => {
                let configured = prompt_required_visible_env_backed_config_value(
                    &["VERTEX_MODEL"],
                    "VERTEX_MODEL",
                    "Vertex default model",
                    None,
                )?;
                if configured.effective_value.is_none() {
                    print_missing_setup_value_notice("VERTEX_MODEL", "Vertex default model");
                }
                configured
            }
            crate::onboarding::vertex::VertexModelRoute::Explicit => {
                let explicit_model = prompt_vertex_explicit_model_id()?;
                SetupConfigValue {
                    config_value: explicit_model.clone(),
                    effective_value: Some(explicit_model),
                }
            }
        };
        (route, model)
    };

    if project_id.effective_value.is_none() {
        print_missing_setup_value_notice("VERTEX_PROJECT_ID", "Vertex project ID");
    }

    let config_input = crate::onboarding::vertex::VertexSetupInput {
        project_id: project_id.config_value.clone(),
        location: location.config_value.clone(),
        route,
        model: Some(model.config_value.clone()),
    };

    let mut result = ProviderSetupResult::default();
    let effective_model = model.effective_value.clone();
    let mut deferred_env_vars = Vec::new();
    if project_id.effective_value.is_none() {
        deferred_env_vars.push("`VERTEX_PROJECT_ID`");
    }
    if matches!(route, crate::onboarding::vertex::VertexModelRoute::Default)
        && effective_model.is_none()
    {
        deferred_env_vars.push("`VERTEX_MODEL`");
    }

    if deferred_env_vars.is_empty() {
        // The location prompt defaults to `us-central1`, so setup should always
        // have a concrete location before live validation runs.
        let effective_location = location.effective_value.clone().ok_or_else(|| {
            std::io::Error::other(
                "Vertex location prompt must produce a concrete value before validation",
            )
        })?;
        let effective_project_id = project_id.effective_value.clone().ok_or_else(|| {
            std::io::Error::other(
                "Vertex project prompt must produce a concrete value before validation",
            )
        })?;
        let validation_input = crate::onboarding::vertex::VertexSetupInput {
            project_id: effective_project_id,
            location: effective_location,
            route,
            model: effective_model.clone(),
        };
        result
            .observed_checks
            .push(validate_vertex_provider_interactive(
                config,
                &validation_input,
            )?);
    } else {
        let (detail, remediation) = match deferred_env_vars.as_slice() {
            ["`VERTEX_MODEL`"] => (
                "Vertex live validation was skipped because `vertex:default` still resolves `vertex.model` from `VERTEX_MODEL` later".to_string(),
                "set `VERTEX_MODEL` in the same shell and run `cara verify --outcome local-chat` once the default model is available".to_string(),
            ),
            [env_var] => (
                format!(
                    "Vertex live validation was skipped because {env_var} still resolves from the environment later"
                ),
                format!(
                    "set {env_var} in the same shell and run `cara verify --outcome local-chat` once it is available"
                ),
            ),
            deferred => (
                format!(
                    "Vertex live validation was skipped because {} still resolve from the environment later",
                    deferred.join(", ")
                ),
                format!(
                    "set {} in the same shell and run `cara verify --outcome local-chat` once they are available",
                    deferred.join(", ")
                ),
            ),
        };
        result
            .observed_checks
            .push(crate::onboarding::setup::SetupCheck::validation_skip(
                "Live provider validation",
                detail,
                Some(remediation),
                None,
            ));
    }

    crate::onboarding::vertex::write_vertex_config(config, &config_input)?;
    Ok(result)
}

fn handle_setup_validation_failure(
    provider: SetupProvider,
    requested_auth_mode: Option<SetupAuthModeSelection>,
    err: crate::agent::AgentError,
    resolved_model: Option<&str>,
) -> Result<crate::onboarding::setup::SetupCheck, Box<dyn std::error::Error>> {
    eprintln!("{}", render_setup_validation_failure(&err));
    let rerun = setup_rerun_command_for_remediation(provider, requested_auth_mode, resolved_model);
    let rerun_reference = crate::onboarding::setup::setup_command_reference(&rerun);
    eprintln!("Next step: fix the value and rerun {rerun_reference}.");
    if prompt_yes_no("Continue setup and write config anyway?", false)? {
        Ok(crate::onboarding::setup::SetupCheck::validation_fail(
            "Provider configuration validation",
            render_setup_validation_failure(&err),
            format!("fix the value and rerun {rerun_reference}"),
            None,
        ))
    } else {
        Err("setup aborted after provider configuration validation failure".into())
    }
}

fn configure_provider_interactive(
    config: &mut Value,
    provider: SetupProvider,
    hide_sensitive_input: bool,
    requested_auth_mode: Option<SetupAuthModeSelection>,
    validated_requested_model: Option<&ValidatedSetupModel>,
) -> Result<ProviderSetupResult, Box<dyn std::error::Error>> {
    if !matches!(provider, SetupProvider::Anthropic | SetupProvider::Gemini)
        && requested_auth_mode.is_some()
    {
        return Err("`--auth-mode` is only valid with `--provider anthropic|gemini`.".into());
    }

    /// Providers handled by the common interactive config writer. Vertex is
    /// excluded because its route/model prompts and `agents.defaults.model`
    /// write are owned by `configure_vertex_provider_interactive`.
    #[derive(Debug, Clone, Copy)]
    enum InteractiveConfigProvider {
        Anthropic,
        Codex,
        OpenAi,
        Ollama,
        Gemini,
        NearAi,
        Venice,
        Bedrock,
    }

    let config_provider = match provider {
        SetupProvider::Vertex => {
            return configure_vertex_provider_interactive(config, validated_requested_model);
        }
        SetupProvider::Anthropic => InteractiveConfigProvider::Anthropic,
        SetupProvider::Codex => InteractiveConfigProvider::Codex,
        SetupProvider::OpenAi => InteractiveConfigProvider::OpenAi,
        SetupProvider::Ollama => InteractiveConfigProvider::Ollama,
        SetupProvider::Gemini => InteractiveConfigProvider::Gemini,
        SetupProvider::NearAi => InteractiveConfigProvider::NearAi,
        SetupProvider::Venice => InteractiveConfigProvider::Venice,
        SetupProvider::Bedrock => InteractiveConfigProvider::Bedrock,
    };

    // Resolve the model up front so provider-specific validation (e.g. Bedrock
    // model-access check) can use it. Vertex returned above because its
    // route/model prompts live inside the Vertex-specific flow.
    let resolved_model = match validated_requested_model {
        Some(model) => model.as_str().to_string(),
        None => prompt_required_model(provider)?.as_str().to_string(),
    };

    let mut result = ProviderSetupResult::default();

    match config_provider {
        InteractiveConfigProvider::Anthropic => {
            let auth_mode = prompt_anthropic_setup_auth_mode(requested_auth_mode)?;
            match auth_mode {
                SetupAuthModeSelection::ApiKey => {
                    let api_key = prompt_required_secret_config_value(
                        "ANTHROPIC_API_KEY",
                        "Anthropic API key",
                        hide_sensitive_input,
                    )?;
                    if let Some(key) = api_key.effective_value.as_deref() {
                        result
                            .observed_checks
                            .push(validate_provider_credentials_interactive(
                                provider,
                                Some(SetupAuthModeSelection::ApiKey),
                                key,
                                Some(&resolved_model),
                            )?);
                    } else {
                        print_missing_setup_value_notice("ANTHROPIC_API_KEY", "Anthropic API key");
                    }
                    if api_key.effective_value.is_some() {
                        config["anthropic"] = serde_json::json!({ "apiKey": api_key.config_value });
                    }
                }
                SetupAuthModeSelection::SetupToken => {
                    let api_key_conflict =
                        crate::onboarding::anthropic::anthropic_setup_token_api_key_conflict(
                            config,
                        );
                    if api_key_conflict.config_api_key_present
                        && !prompt_yes_no(
                            "Replace existing `anthropic.apiKey` config with Anthropic setup-token auth?",
                            false,
                        )?
                    {
                        return Err(
                            "setup aborted before replacing existing Anthropic API key config"
                                .into(),
                        );
                    }
                    if api_key_conflict.env_api_key_present
                        && !prompt_yes_no(
                            "`ANTHROPIC_API_KEY` is set in this shell and will override Anthropic setup-token auth until you unset it. Continue storing the setup token anyway?",
                            false,
                        )?
                    {
                        return Err(
                            "setup aborted while `ANTHROPIC_API_KEY` would still override Anthropic setup-token auth"
                                .into(),
                        );
                    }
                    let token = prompt_required_secret_value_from_env(
                        "ANTHROPIC_SETUP_TOKEN",
                        "Anthropic setup-token",
                        hide_sensitive_input,
                    )?;
                    let token =
                        crate::onboarding::anthropic::validate_anthropic_setup_token_input(&token)
                            .map_err(std::io::Error::other)?;
                    result
                        .observed_checks
                        .push(validate_provider_credentials_interactive(
                            provider,
                            Some(SetupAuthModeSelection::SetupToken),
                            &token,
                            Some(&resolved_model),
                        )?);
                    let state_dir = resolve_state_dir();
                    std::fs::create_dir_all(&state_dir)?;
                    crate::onboarding::anthropic::persist_cli_anthropic_setup_token(
                        state_dir, config, &token,
                    )
                    .map_err(std::io::Error::other)?;
                }
                SetupAuthModeSelection::OAuth => {
                    return Err(
                        "`--auth-mode oauth` is only valid with `--provider gemini`.".into(),
                    );
                }
            }
        }
        InteractiveConfigProvider::Codex => {
            result = configure_codex_provider_interactive(config, hide_sensitive_input)?;
        }
        InteractiveConfigProvider::OpenAi => {
            let api_key = prompt_required_secret_config_value(
                "OPENAI_API_KEY",
                "API key",
                hide_sensitive_input,
            )?;
            if let Some(key) = api_key.effective_value.as_deref() {
                result
                    .observed_checks
                    .push(validate_provider_credentials_interactive(
                        provider,
                        None,
                        key,
                        Some(&resolved_model),
                    )?);
            } else {
                print_missing_setup_value_notice("OPENAI_API_KEY", "API key");
            }
            if api_key.effective_value.is_some() {
                config["openai"] = serde_json::json!({ "apiKey": api_key.config_value });
            }
        }
        InteractiveConfigProvider::Ollama => {
            let base_url = prompt_required_visible_config_value(
                &["OLLAMA_BASE_URL"],
                "Ollama base URL",
                crate::agent::ollama::DEFAULT_OLLAMA_BASE_URL,
            )?;

            let api_key = if prompt_yes_no("Does this Ollama endpoint require an API key?", false)?
            {
                Some(prompt_required_secret_config_value(
                    "OLLAMA_API_KEY",
                    "Ollama API key",
                    hide_sensitive_input,
                )?)
            } else {
                None
            };
            if api_key
                .as_ref()
                .is_some_and(|value| value.effective_value.is_none())
            {
                print_missing_setup_value_notice("OLLAMA_API_KEY", "Ollama API key");
            }

            match crate::agent::ollama::OllamaProvider::new()
                .and_then(|provider| {
                    provider.with_base_url(base_url.effective_value.clone().unwrap_or_default())
                })
                .map(|provider| {
                    if let Some(api_key) = api_key
                        .as_ref()
                        .and_then(|value| value.effective_value.clone())
                    {
                        provider.with_api_key(api_key)
                    } else {
                        provider
                    }
                }) {
                Ok(_) => {}
                Err(err) => result.observed_checks.push(handle_setup_validation_failure(
                    provider,
                    None,
                    err,
                    Some(&resolved_model),
                )?),
            }

            let mut ollama_config = serde_json::Map::new();
            ollama_config.insert(
                "baseUrl".to_string(),
                serde_json::json!(base_url.config_value),
            );
            if let Some(api_key) = api_key {
                ollama_config.insert(
                    "apiKey".to_string(),
                    serde_json::json!(api_key.config_value),
                );
            }
            config["providers"] = serde_json::json!({
                "ollama": Value::Object(ollama_config)
            });
        }
        InteractiveConfigProvider::Gemini => {
            result = configure_gemini_provider_interactive(
                config,
                hide_sensitive_input,
                requested_auth_mode,
                &resolved_model,
            )?;
        }
        InteractiveConfigProvider::NearAi => {
            let api_key = prompt_required_secret_config_value(
                "NEARAI_API_KEY",
                "NEAR AI Cloud API key",
                hide_sensitive_input,
            )?;
            if api_key.effective_value.is_none() {
                print_missing_setup_value_notice("NEARAI_API_KEY", "NEAR AI Cloud API key");
            }
            let base_url = prompt_optional_base_url_override(
                "NEAR AI Cloud",
                "NEARAI_BASE_URL",
                "https://cloud-api.near.ai/v1",
            )?;

            if let Some(key) = api_key.effective_value.clone() {
                let validation =
                    crate::agent::nearai::NearAiProvider::new(key).and_then(|provider| {
                        if let Some(base_url) = base_url
                            .as_ref()
                            .and_then(|value| value.effective_value.clone())
                        {
                            provider.with_base_url(base_url)
                        } else {
                            Ok(provider)
                        }
                    });
                if let Err(err) = validation {
                    result.observed_checks.push(handle_setup_validation_failure(
                        provider,
                        None,
                        err,
                        Some(&resolved_model),
                    )?);
                }
            }

            let mut nearai_config = serde_json::Map::new();
            nearai_config.insert(
                "apiKey".to_string(),
                serde_json::json!(api_key.config_value),
            );
            if let Some(base_url) = base_url {
                nearai_config.insert(
                    "baseUrl".to_string(),
                    serde_json::json!(base_url.config_value),
                );
            }
            config["nearai"] = Value::Object(nearai_config);
        }
        InteractiveConfigProvider::Venice => {
            let api_key = prompt_required_secret_config_value(
                "VENICE_API_KEY",
                "Venice API key",
                hide_sensitive_input,
            )?;
            if api_key.effective_value.is_none() {
                print_missing_setup_value_notice("VENICE_API_KEY", "Venice API key");
            }
            let base_url = prompt_optional_base_url_override(
                "Venice",
                "VENICE_BASE_URL",
                "https://api.venice.ai/api",
            )?;

            if let Some(key) = api_key.effective_value.clone() {
                let validation =
                    crate::agent::venice::VeniceProvider::new(key).and_then(|provider| {
                        if let Some(base_url) = base_url
                            .as_ref()
                            .and_then(|value| value.effective_value.clone())
                        {
                            provider.with_base_url(base_url)
                        } else {
                            Ok(provider)
                        }
                    });
                if let Err(err) = validation {
                    result.observed_checks.push(handle_setup_validation_failure(
                        provider,
                        None,
                        err,
                        Some(&resolved_model),
                    )?);
                }
            }

            let mut venice_config = serde_json::Map::new();
            venice_config.insert(
                "apiKey".to_string(),
                serde_json::json!(api_key.config_value),
            );
            if let Some(base_url) = base_url {
                venice_config.insert(
                    "baseUrl".to_string(),
                    serde_json::json!(base_url.config_value),
                );
            }
            config["venice"] = Value::Object(venice_config);
        }
        InteractiveConfigProvider::Bedrock => {
            let region = prompt_required_visible_config_value(
                &["AWS_REGION", "AWS_DEFAULT_REGION"],
                "AWS Bedrock region",
                "us-east-1",
            )?;
            let access_key = prompt_required_secret_config_value(
                "AWS_ACCESS_KEY_ID",
                "AWS access key ID",
                hide_sensitive_input,
            )?;
            let secret_key = prompt_required_secret_config_value(
                "AWS_SECRET_ACCESS_KEY",
                "AWS secret access key",
                hide_sensitive_input,
            )?;

            if access_key.effective_value.is_none() {
                print_missing_setup_value_notice("AWS_ACCESS_KEY_ID", "AWS access key ID");
            }
            if secret_key.effective_value.is_none() {
                print_missing_setup_value_notice("AWS_SECRET_ACCESS_KEY", "AWS secret access key");
            }

            let session_token = if env_var_present("AWS_SESSION_TOKEN") {
                if prompt_yes_no("Use AWS session token from $AWS_SESSION_TOKEN?", true)? {
                    Some(SetupConfigValue {
                        config_value: env_placeholder("AWS_SESSION_TOKEN"),
                        effective_value: env_var_value("AWS_SESSION_TOKEN"),
                    })
                } else if prompt_yes_no("Add an AWS session token?", false)? {
                    let entered =
                        prompt_sensitive_line("AWS session token", hide_sensitive_input, true)?;
                    if entered.is_empty() {
                        None
                    } else {
                        Some(SetupConfigValue {
                            config_value: entered.clone(),
                            effective_value: Some(entered),
                        })
                    }
                } else {
                    None
                }
            } else if prompt_yes_no("Add an AWS session token?", false)? {
                prompt_optional_secret_config_value(
                    "AWS_SESSION_TOKEN",
                    "AWS session token",
                    hide_sensitive_input,
                )?
            } else {
                None
            };

            // Live credential + model validation (includes region check).
            if let (Some(eff_region), Some(eff_access), Some(eff_secret)) = (
                region.effective_value.clone(),
                access_key.effective_value.clone(),
                secret_key.effective_value.clone(),
            ) {
                let check = validate_bedrock_credentials_interactive(
                    &eff_region,
                    &eff_access,
                    &eff_secret,
                    session_token
                        .as_ref()
                        .and_then(|v| v.effective_value.as_deref()),
                    &resolved_model,
                )?;
                result.observed_checks.extend(check);
            }

            config["bedrock"] = serde_json::json!({
                "region": region.config_value,
                "accessKeyId": access_key.config_value,
                "secretAccessKey": secret_key.config_value
            });
            if let Some(session_token) = session_token {
                config["bedrock"]["sessionToken"] = serde_json::json!(session_token.config_value);
            }
        }
    }

    config["agents"]["defaults"]["model"] = serde_json::json!(resolved_model);

    Ok(result)
}

fn noninteractive_missing_model_error(provider: SetupProvider) -> Box<dyn std::error::Error> {
    // Migration nudge: earlier releases silently wrote an opinionated default
    // model on non-interactive setup. That implicit default is gone; operators
    // must pass `--model` explicitly. The hint names the format without
    // prescribing a concrete model. Vertex additionally names its sentinel
    // because it preserves the existing environment-variable route.
    let prefix = provider.prompt_key();
    let vertex_hint = if provider == SetupProvider::Vertex {
        "\n                 hint: use `--model vertex:default` to keep the `$VERTEX_MODEL` environment-variable route."
    } else {
        ""
    };
    format!(
        "non-interactive setup requires `--model <{prefix}:model-id>`.\n\
                 hint: previous releases silently wrote a default model for `--provider {prefix}`; \
                 setup now requires an explicit choice. See `cara setup --help` for the \
                 `<{prefix}:model-id>` form.{vertex_hint}"
    )
    .into()
}

fn configure_provider_noninteractive(
    config: &mut Value,
    provider: SetupProvider,
    requested_auth_mode: Option<SetupAuthModeSelection>,
    model: Option<&ValidatedSetupModel>,
) -> Result<ProviderSetupResult, Box<dyn std::error::Error>> {
    /// Providers that use the common non-interactive config writer. Codex is
    /// excluded because non-interactive sign-in returns an error before config
    /// writes; Vertex is excluded because `write_vertex_config` owns the
    /// route-derived `agents.defaults.model` write.
    #[derive(Debug, Clone, Copy)]
    enum NoninteractiveConfigProvider {
        Anthropic,
        OpenAi,
        Ollama,
        Gemini,
        NearAi,
        Venice,
        Bedrock,
    }

    #[derive(Debug, Clone, Copy)]
    enum NoninteractiveProvider {
        Vertex,
        Common(NoninteractiveConfigProvider),
    }

    let setup_provider = provider;
    let provider = match setup_provider {
        SetupProvider::Codex => return Err(codex_noninteractive_setup_error().into()),
        SetupProvider::Vertex => NoninteractiveProvider::Vertex,
        SetupProvider::Anthropic => {
            NoninteractiveProvider::Common(NoninteractiveConfigProvider::Anthropic)
        }
        SetupProvider::OpenAi => {
            NoninteractiveProvider::Common(NoninteractiveConfigProvider::OpenAi)
        }
        SetupProvider::Ollama => {
            NoninteractiveProvider::Common(NoninteractiveConfigProvider::Ollama)
        }
        SetupProvider::Gemini => {
            NoninteractiveProvider::Common(NoninteractiveConfigProvider::Gemini)
        }
        SetupProvider::NearAi => {
            NoninteractiveProvider::Common(NoninteractiveConfigProvider::NearAi)
        }
        SetupProvider::Venice => {
            NoninteractiveProvider::Common(NoninteractiveConfigProvider::Venice)
        }
        SetupProvider::Bedrock => {
            NoninteractiveProvider::Common(NoninteractiveConfigProvider::Bedrock)
        }
    };
    // Keep provider-specific early exits above this shared model requirement
    // so providers that cannot run non-interactively can report that contract
    // before the common `--model` hint.
    let model = model
        .ok_or_else(|| noninteractive_missing_model_error(setup_provider))?
        .as_str();
    if !matches!(
        provider,
        NoninteractiveProvider::Common(
            NoninteractiveConfigProvider::Anthropic | NoninteractiveConfigProvider::Gemini
        )
    ) && requested_auth_mode.is_some()
    {
        return Err("`--auth-mode` is only valid with `--provider anthropic|gemini`.".into());
    }

    let provider = match provider {
        NoninteractiveProvider::Vertex => {
            // Vertex owns its `agents.defaults.model` write via
            // `write_vertex_config` (which derives the canonical string from
            // VertexSetupInput.route + .model through `route_model()`).
            // Writing the common default first would create stale
            // last-write-wins behavior, so keep Vertex outside the common
            // non-interactive provider path.
            let (route, vertex_model) = if model.eq_ignore_ascii_case(VERTEX_DEFAULT_SENTINEL) {
                (
                    crate::onboarding::vertex::VertexModelRoute::Default,
                    Some(env_placeholder("VERTEX_MODEL")),
                )
            } else {
                // Callers reach this branch via `handle_setup`, which validates
                // `requested_model` before dispatch. Keep the strip/error logic
                // shared with the interactive Vertex `--model` path so route
                // contract changes have one owning helper.
                let explicit_id = extract_vertex_explicit_model_id(model)?.to_string();
                (
                    crate::onboarding::vertex::VertexModelRoute::Explicit,
                    Some(explicit_id),
                )
            };
            crate::onboarding::vertex::write_vertex_config(
                config,
                &crate::onboarding::vertex::VertexSetupInput {
                    project_id: env_placeholder("VERTEX_PROJECT_ID"),
                    location: if env_var_present("VERTEX_LOCATION") {
                        env_placeholder("VERTEX_LOCATION")
                    } else {
                        "us-central1".to_string()
                    },
                    route,
                    model: vertex_model,
                },
            )?;
            return Ok(ProviderSetupResult::default());
        }
        NoninteractiveProvider::Common(provider) => provider,
    };

    config["agents"]["defaults"]["model"] = serde_json::json!(model);

    match provider {
        NoninteractiveConfigProvider::Anthropic => match requested_auth_mode {
            Some(SetupAuthModeSelection::SetupToken) => {
                let api_key_conflict =
                    crate::onboarding::anthropic::anthropic_setup_token_api_key_conflict(config);
                if api_key_conflict.config_api_key_present {
                    eprintln!(
                        "Replacing existing `anthropic.apiKey` config with `anthropic.authProfile` for Anthropic setup-token mode."
                    );
                }
                if api_key_conflict.env_api_key_present {
                    eprintln!(
                        "`ANTHROPIC_API_KEY` is set in this shell and will override `anthropic.authProfile` until you unset it."
                    );
                }
                let token = env_var_value("ANTHROPIC_SETUP_TOKEN").ok_or_else(|| {
                        std::io::Error::other(
                            "non-interactive Anthropic setup-token mode requires ANTHROPIC_SETUP_TOKEN.",
                        )
                    })?;
                let state_dir = resolve_state_dir();
                std::fs::create_dir_all(&state_dir)?;
                crate::onboarding::anthropic::persist_cli_anthropic_setup_token(
                    state_dir, config, &token,
                )
                .map_err(std::io::Error::other)?;
            }
            Some(SetupAuthModeSelection::OAuth) => {
                return Err(
                        "non-interactive Anthropic setup does not support `--auth-mode oauth`; use `api-key` or `setup-token`."
                            .into(),
                    );
            }
            _ => {
                config["anthropic"] =
                    serde_json::json!({ "apiKey": env_placeholder("ANTHROPIC_API_KEY") });
            }
        },
        NoninteractiveConfigProvider::OpenAi => {
            config["openai"] = serde_json::json!({ "apiKey": env_placeholder("OPENAI_API_KEY") });
        }
        NoninteractiveConfigProvider::Ollama => {
            let base_url = first_present_env_var(&["OLLAMA_BASE_URL"])
                .map(|(env_var, _)| env_placeholder(env_var))
                .unwrap_or_else(|| crate::agent::ollama::DEFAULT_OLLAMA_BASE_URL.to_string());
            let mut ollama_config = serde_json::Map::new();
            ollama_config.insert("baseUrl".to_string(), serde_json::json!(base_url));
            if env_var_present("OLLAMA_API_KEY") {
                ollama_config.insert(
                    "apiKey".to_string(),
                    serde_json::json!(env_placeholder("OLLAMA_API_KEY")),
                );
            }
            config["providers"] = serde_json::json!({
                "ollama": ollama_config
            });
        }
        NoninteractiveConfigProvider::Gemini => match requested_auth_mode {
            Some(SetupAuthModeSelection::ApiKey) => {
                crate::onboarding::gemini::write_gemini_api_key_config(
                    config,
                    &env_placeholder("GOOGLE_API_KEY"),
                    env_var_present("GOOGLE_API_BASE_URL")
                        .then(|| env_placeholder("GOOGLE_API_BASE_URL"))
                        .as_deref(),
                );
            }
            Some(SetupAuthModeSelection::OAuth) => {
                return Err(
                        "non-interactive Gemini Google sign-in is not supported; rerun interactively or use `--auth-mode api-key`."
                            .into(),
                    );
            }
            Some(SetupAuthModeSelection::SetupToken) => {
                return Err(
                    "non-interactive Gemini setup does not support `--auth-mode setup-token`; use `oauth` or `api-key`."
                        .into(),
                );
            }
            None => {
                return Err(
                    "non-interactive Gemini setup requires `--auth-mode oauth|api-key`.".into(),
                );
            }
        },
        NoninteractiveConfigProvider::NearAi => {
            config["nearai"] = serde_json::json!({
                "apiKey": env_placeholder("NEARAI_API_KEY")
            });
            if env_var_present("NEARAI_BASE_URL") {
                config["nearai"]["baseUrl"] = serde_json::json!(env_placeholder("NEARAI_BASE_URL"));
            }
        }
        NoninteractiveConfigProvider::Venice => {
            config["venice"] = serde_json::json!({
                "apiKey": env_placeholder("VENICE_API_KEY")
            });
            if env_var_present("VENICE_BASE_URL") {
                config["venice"]["baseUrl"] = serde_json::json!(env_placeholder("VENICE_BASE_URL"));
            }
        }
        NoninteractiveConfigProvider::Bedrock => {
            let region_placeholder = if env_var_present("AWS_REGION") {
                env_placeholder("AWS_REGION")
            } else if env_var_present("AWS_DEFAULT_REGION") {
                env_placeholder("AWS_DEFAULT_REGION")
            } else {
                "us-east-1".to_string()
            };
            config["bedrock"] = serde_json::json!({
                "region": region_placeholder,
                "accessKeyId": env_placeholder("AWS_ACCESS_KEY_ID"),
                "secretAccessKey": env_placeholder("AWS_SECRET_ACCESS_KEY")
            });
            if env_var_present("AWS_SESSION_TOKEN") {
                config["bedrock"]["sessionToken"] =
                    serde_json::json!(env_placeholder("AWS_SESSION_TOKEN"));
            }

            // Run live validation if actual credential values are available.
            let sources = crate::onboarding::bedrock::detect_credential_sources();
            let mut result = ProviderSetupResult::default();
            let effective_region = sources
                .region
                .as_ref()
                .map(|s| s.value.clone())
                .unwrap_or_else(|| "us-east-1".to_string());
            if sources.region.is_some() {
                result
                    .observed_checks
                    .push(crate::onboarding::bedrock::validate_region(
                        &effective_region,
                    ));
            }
            if let (Some(access_src), Some(secret_src)) = (&sources.access_key, &sources.secret_key)
            {
                let r = effective_region.clone();
                let a = access_src.value.clone();
                let s = secret_src.value.clone();
                let t = sources.session_token.as_ref().map(|v| v.value.clone());
                let default_model = model.to_string();

                match run_sync_blocking_send(async move {
                    Ok::<_, String>(
                        crate::onboarding::bedrock::validate_bedrock_credentials(
                            &r,
                            &a,
                            &s,
                            t.as_deref(),
                        )
                        .await,
                    )
                }) {
                    Ok((cred_check, models_json)) => {
                        result.observed_checks.push(cred_check);
                        if let Some(ref models) = models_json {
                            result.observed_checks.push(
                                crate::onboarding::bedrock::check_model_access(
                                    &default_model,
                                    models,
                                ),
                            );
                        }
                    }
                    Err(err) => {
                        result.observed_checks.push(
                            crate::onboarding::setup::SetupCheck::validation_fail(
                                "Live Bedrock validation",
                                format!("Credential validation runtime failed: {err}"),
                                "run `cara verify` after setup to exercise the configured provider path".to_string(),
                                None,
                            ),
                        );
                    }
                }
            }
            return Ok(result);
        }
    }
    Ok(ProviderSetupResult::default())
}

/// Run the `import openclaw` subcommand.
pub fn handle_import_openclaw(force: bool) -> Result<(), Box<dyn std::error::Error>> {
    use crate::migration::openclaw;

    let discovery = match openclaw::discover() {
        Some(d) => d,
        None => {
            eprintln!("No OpenClaw installation found.");
            eprintln!(
                "Checked: ~/.openclaw/, ~/.clawdbot/, $OPENCLAW_CONFIG_PATH, $OPENCLAW_STATE_DIR"
            );
            return Err("no OpenClaw config found".into());
        }
    };

    println!("Found OpenClaw config: {}", discovery.config_path.display());
    if let Some(ref env) = discovery.env_path {
        println!("Found .env file: {}", env.display());
    }
    if let Some(ref creds) = discovery.credentials_path {
        println!("Found credentials: {}", creds.display());
    }
    println!();

    let plan = openclaw::plan_import(&discovery);
    execute_import_plan(plan, force)
}

/// Run the `import opencode` subcommand.
pub fn handle_import_opencode(force: bool) -> Result<(), Box<dyn std::error::Error>> {
    use crate::migration::opencode;

    let discovery = match opencode::discover() {
        Some(d) => d,
        None => {
            eprintln!("No OpenCode installation found.");
            eprintln!(
                "Checked: ./.opencode.json, ~/.opencode.json, $XDG_CONFIG_HOME/opencode/, ~/.config/opencode/"
            );
            return Err("no OpenCode config found".into());
        }
    };

    println!("Found OpenCode config: {}", discovery.config_path.display());
    println!();

    let plan = opencode::plan_import(&discovery);
    execute_import_plan(plan, force)
}

/// Run the `import aider` subcommand.
pub fn handle_import_aider(force: bool) -> Result<(), Box<dyn std::error::Error>> {
    use crate::migration::aider;

    let discovery = match aider::discover() {
        Some(d) => d,
        None => {
            eprintln!("No Aider installation found.");
            eprintln!("Checked: ./.aider.conf.yml, ~/.aider.conf.yml, ./.env");
            return Err("no Aider config found".into());
        }
    };

    if let Some(ref config) = discovery.config_path {
        println!("Found Aider config: {}", config.display());
    }
    if let Some(ref env) = discovery.env_path {
        println!("Found .env file: {}", env.display());
    }
    println!();

    let plan = aider::plan_import(&discovery);
    execute_import_plan(plan, force)
}

/// Run the `import nemoclaw` subcommand.
pub fn handle_import_nemoclaw(force: bool) -> Result<(), Box<dyn std::error::Error>> {
    use crate::migration::nemoclaw;

    let discovery = match nemoclaw::discover() {
        Some(d) => d,
        None => {
            eprintln!("No NemoClaw installation found.");
            eprintln!("Checked: ~/.nemoclaw/config.json");
            return Err("no NemoClaw config found".into());
        }
    };

    println!("Found NemoClaw config: {}", discovery.config_path.display());
    println!();

    let plan = nemoclaw::plan_import(&discovery);
    execute_import_plan(plan, force)
}

fn execute_import_plan(
    plan: crate::migration::ImportPlan,
    force: bool,
) -> Result<(), Box<dyn std::error::Error>> {
    let config_path = config::get_config_path();
    if config_path.exists() && !force {
        eprintln!(
            "Carapace config already exists at {}.",
            config_path.display()
        );
        eprintln!("Use --force to overwrite, or edit the existing config manually.");
        return Err("existing config found; use --force to overwrite".into());
    }

    // SECURITY (B139): refuse import while a daemon is running.
    // `execute_import_plan` performs a full-config replace via
    // `persist_config_file` (line below). The cross-process flock
    // in `acquire_config_write_locks` serializes the WRITE for
    // atomicity but does NOT refresh the running daemon's
    // in-memory config cache. The daemon's next `config.set` would
    // write back from its stale snapshot, silently clobbering the
    // imported fields. Same daemon-attended-destructive-action
    // discipline as `cara setup` (B133), `cara reset` (B108),
    // `cara backup`, `cara matrix rekey-store`, etc. The plan's
    // `source_name` is included in the operator message so the
    // refusal makes sense ("cara import-openclaw" vs
    // "cara import-aider").
    let state_dir = crate::server::ws::resolve_state_dir();
    let import_command = format!("cara import-{}", plan.source_name.to_lowercase());
    let _running_daemon_guard =
        ensure_no_running_daemon_for_matrix_secret_mutation(&state_dir, &import_command)
            .map_err(|err| -> Box<dyn std::error::Error> { err.into() })?;

    for warning in &plan.warnings {
        eprintln!("Warning: {warning}");
    }

    if plan.is_empty() && plan.skipped.is_empty() {
        println!(
            "No importable configuration found in the {} config.",
            plan.source_name
        );
        return Ok(());
    }

    if !plan.mappings.is_empty() {
        println!("The following fields will be imported:\n");
        println!(
            "  {:<45} {:<30} Value",
            format!("{} source", plan.source_name),
            "Carapace key"
        );
        println!(
            "  {:<45} {:<30} {}",
            "-".repeat(44),
            "-".repeat(29),
            "-".repeat(20)
        );
        for mapping in &plan.mappings {
            let display_value = if mapping.sensitive {
                "[REDACTED]".to_string()
            } else {
                mapping
                    .value
                    .as_str()
                    .map(|s| truncate_display(s, 40))
                    .unwrap_or_else(|| mapping.value.to_string())
            };
            println!(
                "  {:<45} {:<30} {}",
                truncate_display(&mapping.source_path, 44),
                mapping.carapace_key,
                display_value
            );
        }
        println!();
    }

    if !plan.skipped.is_empty() {
        println!("Skipped (no Carapace mapping):\n");
        for skipped in &plan.skipped {
            println!("  {} - {}", skipped.source_path, skipped.reason);
        }
        println!();
    }

    if plan.is_empty() {
        println!("No importable fields found after scanning.");
        return Ok(());
    }

    if !prompt_yes_no(
        &format!(
            "Write {} field(s) to {}?",
            plan.mappings.len(),
            config_path.display()
        ),
        true,
    )? {
        println!("Import cancelled.");
        return Ok(());
    }

    let config = plan.build_carapace_config();
    if let Some(parent) = config_path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    crate::server::ws::persist_config_file(&config_path, &config)
        .map_err(|err| format!("failed to write imported config: {err}"))?;

    println!("\nConfig written to {}", config_path.display());
    println!();
    println!("Next steps:");
    println!("  cara verify    - validate that imported providers work");
    println!("  cara status    - check gateway health after starting");
    println!("  cara setup     - reconfigure or add providers interactively");

    Ok(())
}

fn truncate_display(s: &str, max: usize) -> String {
    if s.chars().count() <= max {
        s.to_string()
    } else {
        let truncated: String = s.chars().take(max.saturating_sub(1)).collect();
        format!("{truncated}...")
    }
}

fn terminal_safe_setup_input(raw: &str) -> String {
    raw.escape_debug().to_string()
}

/// Run the `setup` subcommand -- interactive first-run wizard.
pub fn handle_setup(
    force: bool,
    requested_provider: Option<SetupProvider>,
    requested_auth_mode: Option<SetupAuthModeSelection>,
    requested_model: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
    let config_path = config::get_config_path();

    // Check if config already exists.
    if config_path.exists() && !force {
        eprintln!(
            "Config already exists at {}. Use --force to overwrite.",
            config_path.display()
        );
        return Err("config already exists".into());
    }

    // SECURITY (B133): refuse setup while a daemon is running.
    // `cara setup` performs a full-config replace via
    // `persist_config_file`. The cross-process flock in
    // `acquire_config_write_locks` serializes the WRITE for
    // atomicity but does NOT refresh the running daemon's
    // in-memory config cache. The daemon's next `config.set`
    // would write back from its stale snapshot, silently
    // clobbering the new setup fields. Same daemon-attended
    // destructive-action discipline as `cara reset`,
    // `cara backup`, `cara matrix rekey-store`, etc.
    let state_dir = crate::server::ws::resolve_state_dir();
    let _running_daemon_guard =
        ensure_no_running_daemon_for_matrix_secret_mutation(&state_dir, "cara setup")
            .map_err(|err| -> Box<dyn std::error::Error> { err.into() })?;

    // Create the config directory if needed.
    if let Some(parent) = config_path.parent() {
        std::fs::create_dir_all(parent)?;
    }

    let interactive = stdin_is_interactive();

    let default_gateway_token = generate_hex_secret(32)?;

    // Build a minimal default config. `agents.defaults.model` is set by the
    // provider-specific flow from `--model` or an interactive prompt — never
    // by Carapace.
    let mut config = serde_json::json!({
        "gateway": {
            "port": DEFAULT_PORT,
            "bind": "loopback",
            "auth": {
                "mode": "token",
                "token": default_gateway_token
            }
        }
    });

    let setup_outcome;
    let mut hooks_enabled = false;
    let mut verify_discord_to: Option<String> = None;
    let mut verify_telegram_to: Option<String> = None;
    let mut verify_matrix_to: Option<String> = None;
    let configured_provider;
    let provider_setup_result;
    let resolved_setup_request = resolve_setup_request(requested_provider, requested_model)
        .map_err(|err| -> Box<dyn std::error::Error> { err.into() })?;

    if interactive {
        println!("Carapace setup wizard");
        println!("---------------------");
        println!("This interactive wizard writes first-run config for every supported provider.");
        println!(
            "Fastest first-run path: pick one provider, keep `local-chat`, then run `cara verify --outcome local-chat`."
        );

        let hide_sensitive_input = prompt_yes_no("Hide sensitive input while typing?", true)?;
        let bare_model_without_provider =
            resolved_setup_request.bare_model_without_provider.clone();
        if resolved_setup_request.provider.is_none() {
            if let Some(model) = bare_model_without_provider.as_deref() {
                let model = terminal_safe_setup_input(model);
                println!(
                    "`--model {model}` is a bare model id; pick a provider and setup will store it as `<provider>:{model}`."
                );
            }
        }
        let provider = prompt_setup_provider_interactive(resolved_setup_request.provider)?;
        let validated_requested_model = match resolved_setup_request.model {
            Some(model) if model.provider() == Some(provider) => Some(model),
            Some(model) => {
                let model = terminal_safe_setup_input(model.as_str());
                eprintln!(
                    "Invalid model for {}: `{model}` was validated for a different provider.",
                    provider.model_prompt_label()
                );
                Some(prompt_required_model(provider)?)
            }
            None => match requested_model {
                Some(raw) => {
                    let (model, model_from_requested_input) =
                        match ValidatedSetupModel::parse(raw, provider) {
                            Ok(model) => (model, true),
                            Err(err) => {
                                eprintln!(
                                    "Invalid model for {}: {err}",
                                    provider.model_prompt_label()
                                );
                                (prompt_required_model(provider)?, false)
                            }
                        };
                    if bare_model_without_provider.is_some() && model_from_requested_input {
                        let provider_inference = infer_bare_model_provider(raw, provider);
                        match provider_inference {
                            BareModelProviderInference::MatchesSelectedProvider => {
                                println!("Resolved default model: `{}`.", model.as_str());
                                Some(model)
                            }
                            BareModelProviderInference::MatchesDifferentProvider
                            | BareModelProviderInference::Unknown => {
                                if confirm_ambiguous_bare_model(
                                    provider_inference,
                                    raw,
                                    model.as_str(),
                                    provider,
                                )? {
                                    Some(model)
                                } else {
                                    Some(prompt_required_model(provider)?)
                                }
                            }
                        }
                    } else {
                        Some(model)
                    }
                }
                None => None,
            },
        };
        provider_setup_result = configure_provider_interactive(
            &mut config,
            provider,
            hide_sensitive_input,
            requested_auth_mode,
            validated_requested_model.as_ref(),
        )?;
        configured_provider = provider;

        let auth_mode = prompt_choice(
            "Gateway auth mode (token/password)",
            "token",
            &["token", "password"],
        )?;
        let auth_secret = if auth_mode == "token" {
            if prompt_yes_no("Generate a strong gateway token automatically?", true)? {
                generate_hex_secret(32)?
            } else {
                prompt_custom_secret("gateway token", hide_sensitive_input)?
            }
        } else if prompt_yes_no("Generate a strong gateway password automatically?", true)? {
            generate_hex_secret(24)?
        } else {
            prompt_custom_secret("gateway password", hide_sensitive_input)?
        };

        let bind_mode = loop {
            let bind = prompt_choice(
                "Gateway bind mode (loopback/lan)",
                "loopback",
                &["loopback", "lan"],
            )?;
            if bind == "lan" {
                eprintln!("Warning: LAN bind exposes Carapace to your local network.");
                eprintln!("Use strong auth and add TLS/reverse proxy before broader exposure.");
                if !prompt_yes_no("Continue with LAN bind?", false)? {
                    continue;
                }
            }
            break bind;
        };
        let gateway_port = prompt_port(DEFAULT_PORT)?;

        config["gateway"]["bind"] = serde_json::json!(bind_mode);
        config["gateway"]["port"] = serde_json::json!(gateway_port);
        if auth_mode == "token" {
            config["gateway"]["auth"] = serde_json::json!({
                "mode": "token",
                "token": auth_secret
            });
        } else {
            config["gateway"]["auth"] = serde_json::json!({
                "mode": "password",
                "password": auth_secret
            });
        }

        setup_outcome = prompt_setup_outcome()?;

        match setup_outcome {
            SetupOutcome::Discord => {
                prompt_and_configure_bot_channel(
                    &mut config,
                    "discord",
                    "Discord",
                    "DISCORD_BOT_TOKEN",
                    hide_sensitive_input,
                )?;
                let discord_configured =
                    resolve_channel_bot_token_from_config(&config, "discord").is_some();
                if discord_configured {
                    let destination = prompt_line(
                        "Optional: Discord channel ID for send-path verify (leave blank to skip): ",
                    )?;
                    verify_discord_to = normalize_optional_input(Some(destination));
                }
            }
            SetupOutcome::Telegram => {
                prompt_and_configure_bot_channel(
                    &mut config,
                    "telegram",
                    "Telegram",
                    "TELEGRAM_BOT_TOKEN",
                    hide_sensitive_input,
                )?;
                let telegram_configured =
                    resolve_channel_bot_token_from_config(&config, "telegram").is_some();
                if telegram_configured {
                    let destination = prompt_line(
                        "Optional: Telegram chat ID for send-path verify (leave blank to skip): ",
                    )?;
                    verify_telegram_to = normalize_optional_input(Some(destination));
                }
            }
            SetupOutcome::Matrix => {
                verify_matrix_to =
                    prompt_and_configure_matrix_channel(&mut config, hide_sensitive_input)?;
            }
            SetupOutcome::LocalChat | SetupOutcome::Hooks => {}
        }

        hooks_enabled = prompt_yes_no(
            "Enable hooks API (`/hooks`) for automations?",
            matches!(setup_outcome, SetupOutcome::Hooks),
        )?;
        if hooks_enabled {
            let hooks_token =
                if prompt_yes_no("Generate a strong hooks token automatically?", true)? {
                    generate_hex_secret(32)?
                } else {
                    prompt_custom_secret("hooks token", hide_sensitive_input)?
                };
            config["gateway"]["hooks"] = serde_json::json!({
                "enabled": true,
                "token": hooks_token
            });
        }

        let enable_control_ui =
            prompt_yes_no("Enable local Control UI dashboard (`/control`)?", false)?;
        if enable_control_ui {
            config["gateway"]["controlUi"] = serde_json::json!({
                "enabled": true
            });
        }
    } else if let Some(provider) = resolved_setup_request.provider {
        provider_setup_result = configure_provider_noninteractive(
            &mut config,
            provider,
            requested_auth_mode,
            resolved_setup_request.model.as_ref(),
        )?;
        configured_provider = provider;
        setup_outcome = infer_setup_outcome_from_config(&config);
    } else {
        let mut message =
            "non-interactive setup requires `--provider <provider>`; rerun with an explicit provider."
                .to_string();
        if requested_model.is_some() {
            message.push_str(" `--model` was supplied but cannot be applied without a provider.");
            if requested_model.is_some_and(setup_input_looks_like_bedrock_native_id) {
                message.push_str(" Bedrock native model IDs require `--provider bedrock`.");
            }
        }
        return Err(message.into());
    }

    crate::server::ws::persist_config_file(&config_path, &config)
        .map_err(|err| format!("failed to write config file: {err}"))?;

    println!("Config written to {}", config_path.display());
    println!("Start the server with: cara start");

    let setup_assessment = crate::onboarding::setup::assess_provider_setup(
        &config,
        &resolve_state_dir(),
        configured_provider.into(),
        provider_setup_result.observed_checks,
    );
    print_setup_assessment_summary(&setup_assessment);
    if let Some(remediation) = setup_assessment.recommended_remediation() {
        if setup_assessment.status == crate::onboarding::setup::SetupAssessmentStatus::Invalid {
            println!("Next fix: {remediation}");
        }
    }

    if interactive {
        let port = config
            .get("gateway")
            .and_then(|v| v.get("port"))
            .and_then(|v| v.as_u64())
            .and_then(|v| u16::try_from(v).ok())
            .unwrap_or(DEFAULT_PORT);

        let run_status = prompt_yes_no("Run setup smoke check now (`cara status`)?", true)?;
        let launch_chat_default = matches!(setup_outcome, SetupOutcome::LocalChat);
        let launch_chat_prompt = if launch_chat_default {
            "Run your first assistant action now (`cara chat`)?"
        } else {
            "Also launch local assistant chat now (`cara chat`)?"
        };
        let launch_chat = prompt_yes_no(launch_chat_prompt, launch_chat_default)?;
        let verify_default = match setup_outcome {
            SetupOutcome::LocalChat | SetupOutcome::Hooks => true,
            SetupOutcome::Discord => verify_discord_to.is_some(),
            SetupOutcome::Telegram => verify_telegram_to.is_some(),
            SetupOutcome::Matrix => verify_matrix_to.is_some(),
        };
        let verify_prompt = format!(
            "Run outcome verifier now (`cara verify --outcome {}`)?",
            setup_outcome.prompt_key()
        );
        let run_verify = prompt_yes_no(&verify_prompt, verify_default)?;

        if run_status || launch_chat || run_verify {
            if run_status || launch_chat {
                if let Err(err) =
                    run_sync_blocking_send(run_setup_post_checks(port, run_status, launch_chat))
                        .map_err(|err| format!("runtime execution failed: {err}"))
                {
                    eprintln!("Post-setup checks failed: {}", err);
                    eprintln!(
                        "You can retry manually with: cara status --port {}  and  cara chat --port {}",
                        port, port
                    );
                }
            }

            if run_verify {
                let verify_outcome = match setup_outcome {
                    SetupOutcome::LocalChat => VerifyOutcomeSelection::LocalChat,
                    SetupOutcome::Discord => VerifyOutcomeSelection::Discord,
                    SetupOutcome::Telegram => VerifyOutcomeSelection::Telegram,
                    SetupOutcome::Matrix => VerifyOutcomeSelection::Matrix,
                    SetupOutcome::Hooks => VerifyOutcomeSelection::Hooks,
                };
                if let Err(err) = run_sync_blocking_send(run_outcome_verifier(
                    verify_outcome,
                    port,
                    verify_discord_to,
                    verify_telegram_to,
                    verify_matrix_to,
                    config,
                ))
                .map_err(|err| format!("runtime execution failed: {err}"))
                {
                    eprintln!("Outcome verification failed: {}", err);
                    eprintln!("Fix the failing checks above, then rerun `cara verify`.");
                }
            }
        }

        print_setup_outcome_next_steps(setup_outcome, port, hooks_enabled);
    } else {
        let port = config
            .get("gateway")
            .and_then(|v| v.get("port"))
            .and_then(|v| v.as_u64())
            .and_then(|v| u16::try_from(v).ok())
            .unwrap_or(DEFAULT_PORT);
        print_setup_outcome_next_steps(setup_outcome, port, hooks_enabled);
    }

    Ok(())
}

/// Run the `pair` subcommand -- pair with a remote gateway node.
pub async fn handle_pair(
    url: &str,
    name: Option<&str>,
    trust: bool,
) -> Result<(), Box<dyn std::error::Error>> {
    // Validate and parse the URL.
    //
    // SECURITY: both eprintln-on-error paths used to echo the raw
    // operator-supplied `url` verbatim, which could include
    // `user:password@` userinfo or a `?token=...` querystring — the
    // exact secret material the redacted-display + userinfo-refusal
    // logic below was added to prevent leaking. Defer URL display
    // until after we've parsed and stripped to scheme/host/port.
    let parsed_url = Url::parse(url).map_err(|e| {
        eprintln!(
            "Invalid URL (parse error: {}); see `cara pair --help` for the expected form",
            e
        );
        "invalid URL".to_string()
    })?;
    if parsed_url.scheme() != "http" && parsed_url.scheme() != "https" {
        eprintln!("Invalid URL: scheme must be http:// or https://");
        return Err("invalid URL scheme".into());
    }
    // SECURITY: a `cara pair` URL has no legitimate use for HTTP Basic
    // userinfo. Authentication is sent over the established WebSocket
    // via the gateway token/password. Refusing non-empty userinfo here
    // avoids both (a) silently dropping credentials operators thought
    // they were passing and (b) leaking those credentials via the
    // redacted-URL display below. Mirrors the prior R14 hardening
    // sweep that removed userinfo from credential-validation paths.
    //
    // The `@` substring check defends against an empty-userinfo form
    // (`https://@gw.local:3001`) that `username().is_empty() &&
    // password().is_none()` would otherwise accept — there is no
    // legitimate reason for a bare `@` in a gateway URL.
    let authority_has_at = url.split_once("://").is_some_and(|(_, rest)| {
        rest.split_once('/')
            .map_or(rest, |(authority, _)| authority)
            .contains('@')
    });
    if !parsed_url.username().is_empty() || parsed_url.password().is_some() || authority_has_at {
        eprintln!(
            "Invalid URL: gateway URL must not contain userinfo (user:password@); \
             use --token or --password options instead."
        );
        return Err("gateway URL contains userinfo".into());
    }

    // Build a redacted display + persistence form (scheme://host:port).
    // The raw `parsed_url` may contain a `?token=...` query that the
    // pairing flow doesn't consume; persisting or printing it would
    // leak operator bootstrap secrets to stdout, scrollback, and
    // pairing.json.
    let display_url = redacted_gateway_url(&parsed_url)?;

    // Resolve the device name.
    let device_name = match name {
        Some(n) => n.to_string(),
        None => config::read_process_env("HOSTNAME").unwrap_or_else(|| "unknown".to_string()),
    };

    let ws_url = ws_url_from_http(&parsed_url)?;

    if trust {
        if parsed_url.scheme() == "https" {
            eprintln!(
                "Warning: --trust disables TLS certificate verification; use only for local/self-signed gateways."
            );
        } else {
            eprintln!("Warning: --trust has no effect for http URLs.");
        }
    }

    let auth = resolve_gateway_auth().await;
    if auth.token.is_none() && auth.password.is_none() {
        eprintln!("Gateway auth token/password required for pairing.");
        eprintln!("Set gateway.auth.token or gateway.auth.password in config.");
        eprintln!("You can also export CARAPACE_GATEWAY_TOKEN or CARAPACE_GATEWAY_PASSWORD.");
        return Err("missing gateway auth".into());
    }

    let state_dir = resolve_state_dir();
    let device_identity = load_or_create_device_identity(&state_dir).await?;

    println!("Pairing with: {}", display_url);
    println!("Device name: {}", device_name);

    let ws_stream = match connect_ws(&ws_url, trust).await {
        Ok(stream) => stream,
        Err(err) => {
            eprintln!("Connection error: {}", err);
            return Err(err);
        }
    };
    let (mut ws_write, mut ws_read) = ws_stream.split();

    let nonce = await_connect_challenge(&mut ws_read, &mut ws_write).await?;

    let role = "operator";
    let scopes = vec!["operator.pairing".to_string()];
    let mut connect_params = serde_json::json!({
        "minProtocol": 3,
        "maxProtocol": 3,
        "client": {
            "id": "cli",
            "version": env!("CARGO_PKG_VERSION"),
            "platform": std::env::consts::OS,
            "mode": "cli"
        },
        "role": role,
        "scopes": scopes.clone()
    });
    let GatewayAuth { token, password } = auth;
    let token_for_signature = token.clone();
    if let Some(token) = token {
        connect_params["auth"] = serde_json::json!({ "token": token });
    } else if let Some(password) = password {
        connect_params["auth"] = serde_json::json!({ "password": password });
    }
    connect_params["device"] = build_device_identity_for_connect(
        &device_identity,
        "cli",
        "cli",
        role,
        &scopes,
        token_for_signature.as_deref(),
        Some(&nonce),
    )?;

    let connect_frame = serde_json::json!({
        "type": "req",
        "id": "connect-1",
        "method": "connect",
        "params": connect_params
    });
    ws_write
        .send(Message::Text(serde_json::to_string(&connect_frame)?.into()))
        .await?;
    if let Err(err) = await_ws_response_with_error(&mut ws_read, &mut ws_write, "connect-1").await {
        if err.code.as_deref() == Some("not_paired") && err.message.contains("pairing required") {
            eprintln!("Device pairing required for this CLI.");
            if let Some(details) = err.details.as_ref() {
                if let Some(request_id) = extract_pairing_request_id(details) {
                    eprintln!("Pairing request ID: {}", request_id);
                }
            }
            eprintln!("Approve the request in the control UI, then retry.");
        } else if err.message.contains("device identity required") {
            eprintln!("WebSocket connect failed: {}", err.message);
            eprintln!("This gateway requires a paired device for WebSocket access.");
        } else {
            eprintln!("WebSocket connect failed: {}", err.message);
        }
        return Err(Box::new(err));
    }

    let node_id = format!("node-{}", uuid::Uuid::new_v4());
    let pair_frame = serde_json::json!({
        "type": "req",
        "id": "pair-1",
        "method": "node.pair.request",
        "params": {
            "nodeId": node_id,
            "displayName": device_name,
            "platform": std::env::consts::OS,
            "version": env!("CARGO_PKG_VERSION"),
            "silent": false
        }
    });
    ws_write
        .send(Message::Text(serde_json::to_string(&pair_frame)?.into()))
        .await?;
    let pair_response = await_ws_response(&mut ws_read, &mut ws_write, "pair-1").await?;
    let request_id = pair_response
        .get("request")
        .and_then(|v| v.get("requestId"))
        .and_then(|v| v.as_str())
        .ok_or("pairing request missing requestId")?;

    let approve_frame = serde_json::json!({
        "type": "req",
        "id": "pair-2",
        "method": "node.pair.approve",
        "params": { "requestId": request_id }
    });
    ws_write
        .send(Message::Text(serde_json::to_string(&approve_frame)?.into()))
        .await?;
    let approve_response = await_ws_response(&mut ws_read, &mut ws_write, "pair-2").await?;
    let node_token = approve_response
        .get("node")
        .and_then(|v| v.get("token"))
        .and_then(|v| v.as_str())
        .ok_or("pairing approval missing token")?;

    println!("Paired successfully! Node ID: {}", node_id);
    println!("Node token: {}", node_token);

    // Save pairing to state dir.
    std::fs::create_dir_all(&state_dir)?;
    let pairing_path = state_dir.join("pairing.json");
    let pairing_data = serde_json::json!({
        "node_id": node_id,
        "gateway_url": display_url,
        "device_name": device_name,
        "token": node_token,
        "paired_at": chrono::Utc::now().to_rfc3339(),
    });
    let pairing_body = serde_json::to_string_pretty(&pairing_data)?;
    // The pairing file carries the node token (privileged gateway
    // credential). Prior `std::fs::write` translated to O_CREAT|
    // O_WRONLY|O_TRUNC with NO O_NOFOLLOW and umask-defaulted mode
    // (typically 0o644 → world-readable on multi-user hosts).
    //
    // SECURITY + DURABILITY: B121 hardens this further to an atomic
    // tmp + rename pipeline. The prior in-place
    // `OpenOptions::create+truncate+write` was NOT atomic: a crash
    // mid-write left a truncated/empty pairing.json, and the next
    // CLI invocation could not authenticate to its own daemon. Use
    // the existing `create_atomic_tmp_owner_only` helper which
    // opens the tmp with `O_CREAT|O_EXCL|O_WRONLY|O_NOFOLLOW` at
    // mode 0o600 in one syscall, then `rename(tmp, dst)`
    // atomically replaces any existing dirent at the final path
    // (including a symlink) without ever following it.
    {
        use std::io::Write;
        let tmp_path = crate::paths::atomic_tmp_path(&pairing_path, "pairing");
        let write_result = (|| -> std::io::Result<()> {
            let mut file = crate::paths::create_atomic_tmp_owner_only(&tmp_path)?;
            file.write_all(pairing_body.as_bytes())?;
            file.sync_all()?;
            drop(file);
            std::fs::rename(&tmp_path, &pairing_path)?;
            crate::paths::sync_parent_dir_best_effort_blocking(&pairing_path);
            Ok(())
        })();
        if write_result.is_err() {
            let _ = std::fs::remove_file(&tmp_path);
            write_result?;
        }
    }
    println!("Pairing saved to {}", pairing_path.display());

    Ok(())
}

/// Run the `update` subcommand -- check for updates or self-update.
pub async fn handle_update(
    check: bool,
    version: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
    let current_version = env!("CARGO_PKG_VERSION");
    let release = match crate::update::fetch_release_info(current_version, version).await {
        Ok(release) => release,
        Err(err) => {
            eprintln!("Failed to check for updates: {}", err.message);
            if err.retryable {
                eprintln!("This may be a temporary issue; retry in a moment.");
            }
            return Err(err.message.into());
        }
    };
    let latest_version = crate::update::tag_to_version(&release.tag_name);
    let html_url = release.html_url.as_str();

    if check {
        println!("Current version: v{}", current_version);
        println!("Latest version:  v{}", latest_version);

        if current_version == latest_version.as_str() {
            println!("Already up to date (v{})", current_version);
        } else {
            println!(
                "Update available: v{} -> v{}",
                current_version, latest_version
            );
            println!("Run `cara update` to install");
        }
        return Ok(());
    }

    // Install mode.
    let target_version = version.unwrap_or(latest_version.as_str());
    if target_version == current_version {
        println!("Already up to date (v{})", current_version);
        return Ok(());
    }

    println!(
        "Updating from v{} to v{}...",
        current_version, target_version
    );

    let request = crate::update::InstallRequest {
        current_version: current_version.to_string(),
        state_dir: resolve_state_dir(),
        requested_version: Some(target_version.to_string()),
        apply_update: true,
        apply_confirmation: crate::update::UpdateApplyConfirmation::Explicit,
    };

    match crate::update::install_or_resume(request).await {
        Ok(outcome) => {
            if outcome.resumed {
                if let Some(max_attempts) = outcome.transaction.as_ref().map(|tx| tx.max_attempts) {
                    println!(
                        "Resumed pending update transaction (attempt {}/{}).",
                        outcome.attempt, max_attempts
                    );
                } else {
                    println!(
                        "Resumed pending update transaction (attempt {}).",
                        outcome.attempt
                    );
                }
            }
            println!("Update applied successfully.");
            println!("  Staged path: {}", outcome.staged_path);
            if let Some(apply) = outcome.apply_result {
                println!("  Binary: {}", apply.binary_path);
                println!("  SHA-256: {}", apply.sha256);
            }
            println!(
                "  Sigstore bundle verification: {}",
                if outcome.verification.bundle_verified {
                    "passed"
                } else {
                    "failed"
                }
            );
            println!(
                "  Checksum verification: {}",
                if outcome.verification.checksum_verified {
                    "passed"
                } else {
                    "not available"
                }
            );
            println!(
                "  Signing identity: {}",
                outcome.verification.expected_identity
            );
            println!("Restart cara to use v{}.", target_version);
        }
        Err(err) => {
            eprintln!("Update failed (phase: {:?}): {}", err.phase, err.message);
            if err.retryable {
                eprintln!("This failure is retryable; rerun `cara update` to resume.");
            } else {
                eprintln!(
                    "This failure is non-retryable; resolve release artifact/policy mismatch before retrying."
                );
            }
            eprintln!("Release page: {}", html_url);
            return Err(err.message.into());
        }
    }

    Ok(())
}

/// Count files matching a given extension in a directory (non-recursive top level).
fn count_files_in_dir(dir: &Path, extension: &str) -> usize {
    std::fs::read_dir(dir)
        .map(|entries| {
            entries
                .filter_map(|e| e.ok())
                .filter(|e| e.path().extension().is_some_and(|ext| ext == extension))
                .count()
        })
        .unwrap_or(0)
}

/// Format a file size in human-readable form.
fn format_file_size(bytes: u64) -> String {
    if bytes < 1024 {
        format!("{} B", bytes)
    } else if bytes < 1024 * 1024 {
        format!("{:.1} KB", bytes as f64 / 1024.0)
    } else if bytes < 1024 * 1024 * 1024 {
        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
    } else {
        format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
    }
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Navigate a JSON value by dot-notation path and return the leaf value.
fn get_value_at_path(root: &Value, path: &str) -> Option<Value> {
    let mut current = root;
    for part in path.split('.') {
        current = current.as_object()?.get(part)?;
    }
    Some(current.clone())
}

/// Set a value at a dot-notation path, creating intermediate objects
/// as needed. Returns `false` if the root or any intermediate is not
/// an Object — a non-Object base used to panic via the
/// `.expect("just inserted")` step.
#[must_use]
fn set_value_at_path(root: &mut Value, path: &str, value: Value) -> bool {
    let parts: Vec<&str> = path.split('.').collect();
    let mut current = root;

    for (i, part) in parts.iter().enumerate() {
        if i == parts.len() - 1 {
            if let Value::Object(map) = current {
                map.insert(part.to_string(), value);
                return true;
            }
            return false;
        }
        if !current.get(*part).is_some_and(|v| v.is_object()) {
            if let Value::Object(map) = current {
                map.insert(part.to_string(), Value::Object(serde_json::Map::new()));
            } else {
                return false;
            }
        }
        current = match current.get_mut(*part) {
            Some(v) => v,
            None => return false,
        };
    }
    true
}

/// Redact known secret keys in a JSON value (recursive).
fn redact_secrets(mut value: Value) -> Value {
    match &mut value {
        Value::Object(map) => {
            let keys: Vec<String> = map.keys().cloned().collect();
            for key in keys {
                let lower = key.to_lowercase();
                if secret_keys().iter().any(|s| lower.contains(s)) {
                    map.insert(key, Value::String("[REDACTED]".to_string()));
                } else if let Some(child) = map.remove(&key) {
                    map.insert(key, redact_secrets(child));
                }
            }
        }
        Value::Array(arr) => {
            for item in arr.iter_mut() {
                *item = redact_secrets(item.clone());
            }
        }
        _ => {}
    }
    value
}

/// Resolve the port to use for connecting to a running instance.
/// Tries (in order): explicit flag, config file value, DEFAULT_PORT.
pub(crate) fn resolve_port(explicit: Option<u16>) -> u16 {
    if let Some(p) = explicit {
        return p;
    }
    // Try reading from config.
    if let Ok(cfg) = config::load_config() {
        if let Some(port) = cfg
            .get("gateway")
            .and_then(|g| g.get("port"))
            .and_then(|v| v.as_u64())
        {
            return port as u16;
        }
    }
    DEFAULT_PORT
}

/// Format seconds into a human-readable duration string.
fn format_duration(seconds: i64) -> String {
    let days = seconds / 86400;
    let hours = (seconds % 86400) / 3600;
    let mins = (seconds % 3600) / 60;
    let secs = seconds % 60;
    if days > 0 {
        format!("{}d {}h {}m {}s", days, hours, mins, secs)
    } else if hours > 0 {
        format!("{}h {}m {}s", hours, mins, secs)
    } else if mins > 0 {
        format!("{}m {}s", mins, secs)
    } else {
        format!("{}s", secs)
    }
}

/// Format a Unix-ms timestamp for display.
fn format_timestamp(ms: u64) -> String {
    chrono::DateTime::from_timestamp_millis(ms as i64)
        .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string())
        .unwrap_or_else(|| ms.to_string())
}

// ---------------------------------------------------------------------------
// TLS / mTLS subcommand handlers
// ---------------------------------------------------------------------------

/// Run the `tls init-ca` subcommand.
pub fn handle_tls_init_ca(output: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
    let ca_dir = match output {
        Some(p) => PathBuf::from(p),
        None => crate::tls::ca::default_ca_dir(),
    };

    let cluster = crate::tls::ca::ClusterCA::generate(&ca_dir)?;

    println!("Cluster CA generated successfully");
    println!("  Directory:   {}", ca_dir.display());
    println!("  Certificate: {}", cluster.ca_cert_path().display());
    println!("  Fingerprint: {}", cluster.ca_fingerprint());
    println!("  Files:       ca.crt, ca.key, crl.json");
    println!();
    println!("Distribute the CA certificate to all gateway nodes.");
    println!("Keep the CA private key secure.");

    Ok(())
}

/// Run the `tls issue-cert` subcommand.
pub fn handle_tls_issue_cert(
    node_id: &str,
    ca_dir_opt: Option<&str>,
    output_opt: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
    let ca_dir = match ca_dir_opt {
        Some(p) => PathBuf::from(p),
        None => crate::tls::ca::default_ca_dir(),
    };

    let output_dir = match output_opt {
        Some(p) => PathBuf::from(p),
        None => ca_dir.join("nodes"),
    };

    let cluster = crate::tls::ca::ClusterCA::load(&ca_dir)?;
    let _issued = cluster.issue_node_cert(node_id, &output_dir)?;

    println!("Node certificate issued successfully");
    println!("  Output Dir:  {}", output_dir.display());
    println!();
    println!("Deploy these files to the node and configure gateway.mtls:");
    println!("  nodeCert: \"<path-to-output-dir>/node.crt\"");
    println!("  nodeKey:  \"<path-to-output-dir>/node.key\"");

    Ok(())
}

/// Run the `tls revoke-cert` subcommand.
pub fn handle_tls_revoke_cert(
    fingerprint: &str,
    node_id: &str,
    ca_dir_opt: Option<&str>,
    reason: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
    let ca_dir = match ca_dir_opt {
        Some(p) => PathBuf::from(p),
        None => crate::tls::ca::default_ca_dir(),
    };

    let ca = crate::tls::ca::ClusterCA::load(&ca_dir)?;
    let revoked = ca.revoke_cert(fingerprint, node_id, reason.map(|s| s.to_string()))?;

    if revoked {
        println!("Certificate revoked successfully");
        println!("  Fingerprint: {}", fingerprint);
        println!("  Node ID:     {}", node_id);
        if let Some(r) = reason {
            println!("  Reason:      {}", r);
        }
    } else {
        println!("Certificate was already revoked: {}", fingerprint);
    }

    Ok(())
}

/// Run the `tls show-ca` subcommand.
pub fn handle_tls_show_ca(ca_dir_opt: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
    let ca_dir = match ca_dir_opt {
        Some(p) => PathBuf::from(p),
        None => crate::tls::ca::default_ca_dir(),
    };

    let cluster = crate::tls::ca::ClusterCA::load(&ca_dir)?;

    println!("Cluster CA Information");
    println!("=====================");
    println!("  Directory:   {}", ca_dir.display());
    println!("  Certificate: {}", cluster.ca_cert_path().display());
    println!("  Fingerprint: {}", cluster.ca_fingerprint());
    println!("  Files:       ca.crt, ca.key, crl.json");

    let entries = cluster.crl_entries();
    if entries.is_empty() {
        println!();
        println!("Certificate Revocation List: (empty)");
    } else {
        println!();
        println!("Certificate Revocation List ({} entries):", entries.len());
        for entry in &entries {
            println!(
                "  - {} (node: {}, revoked at: {}{})",
                entry.fingerprint,
                entry.node_id,
                entry.revoked_at_ms,
                entry
                    .reason
                    .as_deref()
                    .map(|r| format!(", reason: {}", r))
                    .unwrap_or_default()
            );
        }
    }

    Ok(())
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::runtime_bridge::{run_sync_blocking, CURRENT_THREAD_RUNTIME_MESSAGE};
    use crate::test_support::{env::ScopedEnv, plugins::tool_plugin_component_bytes};
    use clap::Parser;
    use ed25519_dalek::{Signature, VerifyingKey};
    use std::collections::VecDeque;
    use std::path::PathBuf;

    // Model strings used to seed setup-wizard tests. Update one place to bump
    // the version a test exercises. These are test fixtures only — production
    // code reads the user's `agents.defaults.model`, not these constants.
    const TEST_MODEL_ANTHROPIC: &str = "anthropic:claude-sonnet-4-6";
    const TEST_MODEL_GEMINI: &str = "gemini:gemini-2.5-flash";
    const TEST_MODEL_OPENAI: &str = "openai:gpt-5.5";
    const TEST_MODEL_OPENAI_BARE: &str = "gpt-5.5";
    const TEST_MODEL_OLLAMA: &str = "ollama:llama3.2";
    const TEST_MODEL_OLLAMA_BARE: &str = "llama3.2";
    const TEST_MODEL_VENICE: &str = "venice:llama-3.3-70b";
    const TEST_MODEL_NEARAI: &str = "nearai:google/gemma-4-31B-it";
    const TEST_MODEL_BEDROCK: &str = "bedrock:anthropic.claude-sonnet-4-6";
    const TEST_MODEL_CODEX: &str = CODEX_DEFAULT_SENTINEL;
    const TEST_MODEL_VERTEX_DEFAULT_ROUTE: &str = VERTEX_DEFAULT_SENTINEL;
    const TEST_MODEL_VERTEX_EXPLICIT: &str = "vertex:gemini-2.5-flash";

    fn validated_setup_model(provider: SetupProvider, raw: &str) -> ValidatedSetupModel {
        ValidatedSetupModel::parse(raw, provider).expect("test model should be valid")
    }

    fn cli_rs_fn_body(fn_signature_prefix: &str) -> String {
        let source = include_str!("mod.rs").replace("\r\n", "\n");
        let fn_start = source
            .find(fn_signature_prefix)
            .unwrap_or_else(|| panic!("{fn_signature_prefix} must exist in cli/mod.rs"));
        let body_offset = source[fn_start..].find("\n}\n").unwrap_or_else(|| {
            panic!("{fn_signature_prefix} must have a `\\n}}\\n` closing brace")
        });
        source[fn_start..fn_start + body_offset].to_string()
    }

    /// Regression: `cara pair <url>` used to `println!` and persist the
    /// raw `parsed_url.as_str()`, leaking any `?token=...` querystring,
    /// fragment, or `user:pass@` userinfo to stdout, scrollback, and
    /// pairing.json. The redactor must keep only scheme/host/port.
    #[test]
    fn test_redacted_gateway_url_drops_query_userinfo_fragment_and_path() {
        let raw = "https://operator:hunter2@gw.local:3001/api/v2?token=abc#frag";
        let parsed = Url::parse(raw).expect("valid url");
        let redacted = redacted_gateway_url(&parsed).expect("redacts ok");
        assert_eq!(redacted, "https://gw.local:3001");
        assert!(!redacted.contains("hunter2"));
        assert!(!redacted.contains("operator"));
        assert!(!redacted.contains("token=abc"));
        assert!(!redacted.contains("/api"));
        assert!(!redacted.contains('#'));
    }

    #[test]
    fn test_redacted_gateway_url_preserves_explicit_port() {
        let parsed = Url::parse("http://localhost:9999/").expect("valid url");
        let redacted = redacted_gateway_url(&parsed).expect("redacts ok");
        assert_eq!(redacted, "http://localhost:9999");
    }

    #[test]
    fn test_redacted_gateway_url_uses_known_default_port_for_https() {
        // No explicit port — must fall back to the scheme's known default
        // so downstream consumers always see an explicit `:port` form.
        let parsed = Url::parse("https://gw.example.com/").expect("valid url");
        let redacted = redacted_gateway_url(&parsed).expect("redacts ok");
        assert_eq!(redacted, "https://gw.example.com:443");
    }

    #[test]
    fn test_redacted_gateway_url_renders_ipv6_with_brackets() {
        let parsed = Url::parse("https://[::1]:3001/").expect("valid url");
        let redacted = redacted_gateway_url(&parsed).expect("redacts ok");
        assert_eq!(redacted, "https://[::1]:3001");
    }

    #[test]
    fn test_redacted_gateway_url_rejects_non_http_scheme() {
        let parsed = Url::parse("ftp://example.com/").expect("valid url");
        assert!(redacted_gateway_url(&parsed).is_err());
    }

    /// Regression: `https://@gw.local:3001` (bare `@`, empty
    /// username, no password) used to pass `username().is_empty() &&
    /// password().is_none()`. The authority-substring `@` check must
    /// catch it. The check operates on the raw `url` input.
    #[test]
    fn test_pair_url_authority_at_substring_check_catches_bare_at() {
        let raw = "https://@gw.local:3001";
        let has_at = raw
            .split_once("://")
            .map(|(_, rest)| {
                rest.split_once('/')
                    .map_or(rest, |(authority, _)| authority)
                    .contains('@')
            })
            .unwrap_or(false);
        assert!(
            has_at,
            "authority `@` substring check must detect bare-at userinfo"
        );

        // Negative case: no `@` in authority is OK.
        let raw_clean = "https://gw.local:3001/path";
        let has_at_clean = raw_clean
            .split_once("://")
            .map(|(_, rest)| {
                rest.split_once('/')
                    .map_or(rest, |(authority, _)| authority)
                    .contains('@')
            })
            .unwrap_or(false);
        assert!(!has_at_clean, "clean URL must not trip the `@` check");
    }

    /// Pin the `extra.lastErrorKind` JSON path against
    /// `ChannelStatusItem`'s wire shape (server/control.rs:299).
    /// `/control/channels` flattens `ChannelInfo` → `ChannelStatusItem`,
    /// so `extra` lives at the top level of each channel entry.
    /// A prior version of `wait_for_matrix_runtime_ready` read
    /// `matrix.metadata.extra.lastErrorKind`, which permanently
    /// returned `None` and made every typed-arm in
    /// `verify_matrix_outcome` fall through to the generic hint —
    /// defeating the typed-routing surface. This test trips
    /// immediately if the JSON shape parsing regresses.
    #[test]
    fn test_matrix_runtime_ready_kind_from_channel_reads_top_level_extra() {
        let channel = serde_json::json!({
            "id": "matrix",
            "name": "Matrix",
            "status": "error",
            "lastError": "Matrix access token rejected by homeserver: ...",
            "extra": {
                "lastErrorKind": "auth-token-revoked",
                "joinedRoomCount": 0,
            },
        });
        let kind = matrix_runtime_ready_kind_from_channel(&channel);
        assert_eq!(kind.as_deref(), Some("auth-token-revoked"));
    }

    #[test]
    fn test_matrix_runtime_ready_kind_from_channel_returns_none_when_absent() {
        let channel = serde_json::json!({
            "id": "matrix",
            "status": "error",
            "lastError": "transient sync failure",
            "extra": {"joinedRoomCount": 0},
        });
        assert_eq!(matrix_runtime_ready_kind_from_channel(&channel), None);
    }

    #[test]
    fn test_operator_ssrf_config_from_cli_config_honors_tailscale_setting() {
        let cfg = serde_json::json!({
            "plugins": {
                "sandbox": {
                    "allow_tailscale": true
                }
            }
        });

        assert!(operator_ssrf_config_from_cli_config(&cfg).allow_tailscale);
        assert!(!operator_ssrf_config_from_cli_config(&serde_json::json!({})).allow_tailscale);
    }

    #[test]
    fn test_channel_credential_validation_policy_names_setup_and_verify_split() {
        let operator_cfg = serde_json::json!({
            "plugins": {
                "sandbox": {
                    "allow_tailscale": true
                }
            }
        });
        assert!(
            verify_channel_credential_validation_ssrf_config(&operator_cfg).allow_tailscale,
            "cara verify uses explicit operator SSRF config"
        );
        let ssrf_config = setup_channel_credential_validation_ssrf_config();
        assert!(
            !ssrf_config.allow_tailscale,
            "setup credential validation remains validation-only until operator base-url overrides exist"
        );
    }

    #[test]
    fn test_matrix_runtime_ready_kind_from_channel_rejects_metadata_wrapper() {
        // The PRIOR (broken) shape: `metadata.extra.lastErrorKind`.
        // Confirm the helper does NOT silently match this nested path
        // — if it did, a future contributor "fixing" the helper to
        // accept both shapes would re-enable the regression.
        let channel = serde_json::json!({
            "id": "matrix",
            "status": "error",
            "lastError": "...",
            "metadata": {"extra": {"lastErrorKind": "auth-token-revoked"}},
        });
        assert_eq!(matrix_runtime_ready_kind_from_channel(&channel), None);
    }

    #[test]
    fn test_verify_matrix_outcome_routes_residual_matrix_error_kinds() {
        let body = cli_rs_fn_body("async fn verify_matrix_outcome");
        for kind in [
            "session-history-corrupt",
            "legacy-dlq-envelope-refused",
            "dlq-crypto",
            "dlq-io",
            "dlq-serialization",
            "dlq-dispatch-failure",
            "dlq-cap-saturation",
            "recovery-key-restore-failed",
            "cross-signing-bootstrap-failed",
            "encrypted-state-io",
            "recovery-state-probe-failed",
            "recovery-state-io",
            "recovery-config-precondition",
            "recovery-key-promotion-refused",
            "sync-failed",
            "send-failed",
            "not-connected",
            "auth-probe",
        ] {
            assert!(
                body.contains(&format!("Some(\"{kind}\")")),
                "verify_matrix_outcome must route Matrix lastErrorKind={kind}"
            );
        }
    }

    #[test]
    fn test_matrix_confirm_args_default_does_not_skip_sas_prompt() {
        // The interactive SAS prompt is the only MITM-resistance step
        // in the protocol. The default for a `cara matrix confirm` invocation
        // MUST be "show the prompt"; flipping that default would silently
        // disable phishing resistance for every operator command.
        let cli = Cli::try_parse_from(["cara", "matrix", "confirm", "flow-1", "--match"])
            .expect("clap should parse `cara matrix confirm flow-1 --match`");
        let Some(Command::Matrix(MatrixCommand::Confirm(args))) = cli.command else {
            panic!("expected MatrixCommand::Confirm");
        };
        assert!(
            !args.unsafe_skip_sas_prompt,
            "default must NOT skip the SAS prompt"
        );
    }

    #[test]
    fn test_matrix_confirm_args_explicit_skip_sas_prompt() {
        // Confirm the opt-in path actually wires through. Automation
        // that sets the flag deliberately gets the unsafe-skip behavior;
        // a typo (e.g., `--unsafe-skip-prompt`) must NOT silently
        // succeed-as-default.
        let cli = Cli::try_parse_from([
            "cara",
            "matrix",
            "confirm",
            "flow-1",
            "--match",
            "--unsafe-skip-sas-prompt",
        ])
        .expect("clap should parse the unsafe-skip variant");
        let Some(Command::Matrix(MatrixCommand::Confirm(args))) = cli.command else {
            panic!("expected MatrixCommand::Confirm");
        };
        assert!(
            args.unsafe_skip_sas_prompt,
            "explicit --unsafe-skip-sas-prompt must set the flag"
        );

        // A typo of the flag must fail clap-parse, not be silently
        // ignored as "no flag set" (default = skip prompt).
        Cli::try_parse_from([
            "cara",
            "matrix",
            "confirm",
            "flow-1",
            "--match",
            "--unsafe-skip-prompt",
        ])
        .expect_err("typoed flag must not parse silently as default-skip");
    }

    struct SetupInteractiveHarnessGuard;

    impl Drop for SetupInteractiveHarnessGuard {
        fn drop(&mut self) {
            clear_setup_interactive_test_harness();
        }
    }

    fn install_setup_interactive_harness(
        harness: SetupInteractiveTestHarness,
    ) -> SetupInteractiveHarnessGuard {
        set_setup_interactive_test_harness(harness);
        SetupInteractiveHarnessGuard
    }

    struct SetupInteractiveTestEnv {
        _temp: tempfile::TempDir,
        config_path: PathBuf,
        _harness_guard: SetupInteractiveHarnessGuard,
    }

    fn setup_interactive_test_env(
        env_guard: &mut ScopedEnv,
        harness: SetupInteractiveTestHarness,
    ) -> SetupInteractiveTestEnv {
        let temp = tempfile::TempDir::new().unwrap();
        let config_path = temp.path().join("carapace.json");
        let state_dir = temp.path().join("state");
        // Isolate the state dir per-test so `handle_setup`'s
        // B133 daemon-running guard doesn't contend on the
        // operator's real `~/.config/carapace/.matrix-rekey.lock`
        // sentinel when tests run in parallel.
        std::fs::create_dir_all(&state_dir).unwrap();
        env_guard
            .set("CARAPACE_CONFIG_PATH", config_path.as_os_str())
            .set("CARAPACE_STATE_DIR", state_dir.as_os_str())
            .unset("CARAPACE_CONFIG_PASSWORD")
            .unset("OPENAI_API_KEY")
            .unset("ANTHROPIC_API_KEY")
            .unset("VERTEX_PROJECT_ID")
            .unset("VERTEX_LOCATION")
            .unset("VERTEX_MODEL")
            .unset("TELEGRAM_BOT_TOKEN")
            .unset("DISCORD_BOT_TOKEN");
        let harness_guard = install_setup_interactive_harness(harness);
        SetupInteractiveTestEnv {
            _temp: temp,
            config_path,
            _harness_guard: harness_guard,
        }
    }

    #[test]
    fn test_cli_no_args_defaults_to_none() {
        let cli = Cli::try_parse_from(["cara"]).unwrap();
        assert!(cli.command.is_none());
    }

    #[test]
    fn test_cli_start_subcommand() {
        let cli = Cli::try_parse_from(["cara", "start"]).unwrap();
        assert!(matches!(cli.command, Some(Command::Start)));
    }

    #[test]
    fn test_cli_version_subcommand() {
        let cli = Cli::try_parse_from(["cara", "version"]).unwrap();
        assert!(matches!(cli.command, Some(Command::Version)));
    }

    #[test]
    fn test_cli_config_show() {
        let cli = Cli::try_parse_from(["cara", "config", "show"]).unwrap();
        match cli.command {
            Some(Command::Config(ConfigCommand::Show)) => {}
            other => panic!("Expected Config(Show), got {:?}", other),
        }
    }

    #[test]
    fn test_cli_config_get() {
        let cli = Cli::try_parse_from(["cara", "config", "get", "gateway.port"]).unwrap();
        match cli.command {
            Some(Command::Config(ConfigCommand::Get { ref key })) => {
                assert_eq!(key, "gateway.port");
            }
            other => panic!("Expected Config(Get), got {:?}", other),
        }
    }

    #[test]
    fn test_cli_config_set() {
        let cli = Cli::try_parse_from(["cara", "config", "set", "gateway.port", "9000"]).unwrap();
        match cli.command {
            Some(Command::Config(ConfigCommand::Set { ref key, ref value })) => {
                assert_eq!(key, "gateway.port");
                assert_eq!(value, "9000");
            }
            other => panic!("Expected Config(Set), got {:?}", other),
        }
    }

    #[test]
    fn test_cli_config_get_redacts_matrix_secret_paths() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::tempdir().expect("tempdir");
        let config_path = temp.path().join("carapace.json5");
        std::fs::write(
            &config_path,
            r#"{
                matrix: {
                    accessToken: "token-that-must-not-print",
                    password: "password-that-must-not-print",
                    storePassphrase: "passphrase-that-must-not-print",
                    deviceId: "DEVICE",
                    homeserverUrl: "https://matrix.example.com"
                }
            }"#,
        )
        .expect("write config");
        env_guard
            .set("CARAPACE_CONFIG_PATH", config_path.as_os_str())
            .unset("CARAPACE_CONFIG_PASSWORD");
        crate::config::clear_cache();

        assert_eq!(
            config_get_value_for_display("matrix.accessToken"),
            Some(json!("[REDACTED]"))
        );
        assert_eq!(
            config_get_value_for_display("matrix.password"),
            Some(json!("[REDACTED]"))
        );
        assert_eq!(
            config_get_value_for_display("matrix.storePassphrase"),
            Some(json!("[REDACTED]"))
        );
        assert_eq!(
            config_get_value_for_display("matrix.homeserverUrl"),
            Some(json!("https://matrix.example.com"))
        );

        crate::config::clear_cache();
    }

    #[test]
    fn test_cli_config_set_preserves_env_backed_matrix_secret_and_rejects_protected_path() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::tempdir().expect("tempdir");
        let config_path = temp.path().join("carapace.json5");
        std::fs::write(
            &config_path,
            r#"{
                matrix: {
                    enabled: false,
                    password: "${MATRIX_PASSWORD}",
                    deviceId: "DEVICE",
                    homeserverUrl: "https://matrix.example.com"
                },
                gateway: { port: 18789 }
            }"#,
        )
        .expect("write config");
        env_guard
            .set("CARAPACE_CONFIG_PATH", config_path.as_os_str())
            .set(
                "MATRIX_PASSWORD",
                "env-secret-that-must-not-be-materialized",
            )
            .unset("CARAPACE_CONFIG_PASSWORD");
        crate::config::clear_cache();

        handle_config_set("gateway.port", "19000").expect("non-protected set succeeds");
        let written = std::fs::read_to_string(&config_path).expect("read config");
        assert!(
            written.contains("${MATRIX_PASSWORD}"),
            "env-backed Matrix password placeholder must be preserved"
        );
        assert!(
            !written.contains("env-secret-that-must-not-be-materialized"),
            "resolved env secret must never be written to config"
        );

        let err = handle_config_set("matrix.password", "\"changed\"")
            .expect_err("protected Matrix secret path must be rejected");
        assert!(
            err.to_string().contains("protected configuration"),
            "operator sees protected-path error"
        );
        let written_after_reject = std::fs::read_to_string(&config_path).expect("read config");
        assert!(
            written_after_reject.contains("${MATRIX_PASSWORD}"),
            "rejected protected write leaves placeholder intact"
        );

        crate::config::clear_cache();
    }

    #[test]
    fn test_prompt_matrix_password_mode_blank_password_skips_config() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env_guard = ScopedEnv::new();
        env_guard
            .unset("MATRIX_HOMESERVER_URL")
            .unset("MATRIX_USER_ID")
            .unset("MATRIX_ACCESS_TOKEN")
            .unset("MATRIX_PASSWORD")
            .unset("MATRIX_DEVICE_ID")
            .unset("MATRIX_STORE_PASSPHRASE");
        let _harness = install_setup_interactive_harness(SetupInteractiveTestHarness {
            force_interactive: Some(true),
            visible_inputs: VecDeque::from(vec![
                "https://matrix.example.com".to_string(),
                "@cara:example.com".to_string(),
                "password".to_string(),
                "".to_string(),
            ]),
            ..Default::default()
        });
        let mut config = serde_json::json!({});

        let destination = prompt_and_configure_matrix_channel(&mut config, false)
            .expect("matrix prompt should not error");

        assert!(destination.is_none());
        assert!(
            config.get("matrix").is_none(),
            "blank password must not write a broken enabled Matrix config"
        );
        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_prompt_and_configure_matrix_channel_encrypts_secrets_inline_when_password_set() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env_guard = ScopedEnv::new();
        env_guard
            .set("CARAPACE_CONFIG_PASSWORD", "test-config-password")
            .unset("MATRIX_HOMESERVER_URL")
            .unset("MATRIX_USER_ID")
            .unset("MATRIX_ACCESS_TOKEN")
            .unset("MATRIX_PASSWORD")
            .unset("MATRIX_DEVICE_ID")
            .unset("MATRIX_STORE_PASSPHRASE");
        crate::config::clear_cache();
        let _harness = install_setup_interactive_harness(SetupInteractiveTestHarness {
            force_interactive: Some(true),
            visible_inputs: VecDeque::from(vec![
                "https://matrix.example.com".to_string(),
                "@cara:example.com".to_string(),
                "password".to_string(),
                "matrix-secret-password".to_string(),
                "y".to_string(),
                "store-passphrase".to_string(),
                "".to_string(),
                "".to_string(),
                "".to_string(),
            ]),
            ..Default::default()
        });
        let mut config = serde_json::json!({});

        let _destination = prompt_and_configure_matrix_channel(&mut config, false)
            .expect("matrix prompt should not error");

        let matrix = config
            .get("matrix")
            .and_then(Value::as_object)
            .expect("matrix block present");
        let password = matrix
            .get("password")
            .and_then(Value::as_str)
            .expect("password present");
        let store_passphrase = matrix
            .get("storePassphrase")
            .and_then(Value::as_str)
            .expect("storePassphrase present");
        assert!(
            crate::config::secrets::is_encrypted(password),
            "matrix.password must be encrypted inline by the wizard \
             (assertion deliberately omits the value to avoid leaking \
             plaintext into test logs on failure)"
        );
        assert!(
            crate::config::secrets::is_encrypted(store_passphrase),
            "matrix.storePassphrase must be encrypted inline by the wizard \
             (assertion deliberately omits the value to avoid leaking \
             plaintext into test logs on failure)"
        );

        crate::config::clear_cache();
    }

    #[test]
    fn test_prompt_and_configure_matrix_channel_refuses_secrets_when_password_unset() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env_guard = ScopedEnv::new();
        env_guard
            .unset("CARAPACE_CONFIG_PASSWORD")
            .unset("MATRIX_HOMESERVER_URL")
            .unset("MATRIX_USER_ID")
            .unset("MATRIX_ACCESS_TOKEN")
            .unset("MATRIX_PASSWORD")
            .unset("MATRIX_DEVICE_ID")
            .unset("MATRIX_STORE_PASSPHRASE");
        crate::config::clear_cache();
        let _harness = install_setup_interactive_harness(SetupInteractiveTestHarness {
            force_interactive: Some(true),
            visible_inputs: VecDeque::from(vec![
                "https://matrix.example.com".to_string(),
                "@cara:example.com".to_string(),
                "password".to_string(),
                "matrix-secret-password".to_string(),
                "y".to_string(),
                "store-passphrase".to_string(),
                "".to_string(),
                "".to_string(),
            ]),
            ..Default::default()
        });
        let mut config = serde_json::json!({});

        let result = prompt_and_configure_matrix_channel(&mut config, false);
        let err = result.expect_err("wizard must refuse plaintext write without password");
        assert!(
            err.to_string()
                .contains("CARAPACE_CONFIG_PASSWORD is required"),
            "operator sees the password-required error; got: {err}"
        );

        crate::config::clear_cache();
    }

    #[test]
    fn test_prompt_and_configure_bot_channel_encrypts_token_when_password_set() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env_guard = ScopedEnv::new();
        env_guard
            .set("CARAPACE_CONFIG_PASSWORD", "test-config-password")
            .unset("DISCORD_BOT_TOKEN");
        crate::config::clear_cache();
        let _harness = install_setup_interactive_harness(SetupInteractiveTestHarness {
            force_interactive: Some(true),
            visible_inputs: VecDeque::from(vec![
                "discord-bot-token-plaintext".to_string(),
                "n".to_string(),
            ]),
            ..Default::default()
        });
        let mut config = serde_json::json!({});

        prompt_and_configure_bot_channel(
            &mut config,
            "discord",
            "Discord",
            "DISCORD_BOT_TOKEN",
            false,
        )
        .expect("bot channel prompt should not error");

        let bot_token = config
            .pointer("/discord/botToken")
            .and_then(Value::as_str)
            .expect("discord botToken present");
        assert!(
            crate::config::secrets::is_encrypted(bot_token),
            "discord.botToken must be encrypted inline by the wizard when password is set; \
             got: {bot_token}"
        );

        crate::config::clear_cache();
    }

    #[test]
    fn test_prompt_and_configure_bot_channel_falls_through_to_plaintext_when_password_unset() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env_guard = ScopedEnv::new();
        env_guard
            .unset("CARAPACE_CONFIG_PASSWORD")
            .unset("DISCORD_BOT_TOKEN");
        crate::config::clear_cache();
        let _harness = install_setup_interactive_harness(SetupInteractiveTestHarness {
            force_interactive: Some(true),
            visible_inputs: VecDeque::from(vec![
                "discord-bot-token-plaintext".to_string(),
                "n".to_string(),
            ]),
            ..Default::default()
        });
        let mut config = serde_json::json!({});

        prompt_and_configure_bot_channel(
            &mut config,
            "discord",
            "Discord",
            "DISCORD_BOT_TOKEN",
            false,
        )
        .expect("bot channel prompt should not error");

        let bot_token = config
            .pointer("/discord/botToken")
            .and_then(Value::as_str)
            .expect("discord botToken present");
        assert_eq!(
            bot_token, "discord-bot-token-plaintext",
            "without CARAPACE_CONFIG_PASSWORD the wizard falls through to plaintext \
             (mirrors gateway.auth.token first-run unencrypted setup)"
        );

        crate::config::clear_cache();
    }

    #[test]
    fn test_cli_config_path() {
        let cli = Cli::try_parse_from(["cara", "config", "path"]).unwrap();
        assert!(matches!(
            cli.command,
            Some(Command::Config(ConfigCommand::Path))
        ));
    }

    #[test]
    fn test_cli_import_sources() {
        for (source_arg, expected_source) in [
            ("openclaw", ImportSource::Openclaw),
            ("opencode", ImportSource::Opencode),
            ("aider", ImportSource::Aider),
            ("nemoclaw", ImportSource::Nemoclaw),
        ] {
            let cli = Cli::try_parse_from(["cara", "import", source_arg]).unwrap();
            match cli.command {
                Some(Command::Import { source, force }) => {
                    assert_eq!(source, expected_source);
                    assert!(!force);
                }
                other => panic!("Expected Import, got {:?}", other),
            }
        }
    }

    #[test]
    fn test_cli_import_force() {
        let cli = Cli::try_parse_from(["cara", "import", "openclaw", "--force"]).unwrap();
        match cli.command {
            Some(Command::Import { source, force }) => {
                assert!(matches!(source, ImportSource::Openclaw));
                assert!(force);
            }
            other => panic!("Expected Import, got {:?}", other),
        }
    }

    #[test]
    fn test_cli_status_defaults() {
        let cli = Cli::try_parse_from(["cara", "status"]).unwrap();
        match cli.command {
            Some(Command::Status {
                port,
                ref host,
                json,
            }) => {
                assert_eq!(port, None);
                assert_eq!(host, "127.0.0.1");
                assert!(!json);
            }
            other => panic!("Expected Status, got {:?}", other),
        }
    }

    #[test]
    fn test_cli_status_with_port() {
        let cli = Cli::try_parse_from(["cara", "status", "--port", "9000"]).unwrap();
        match cli.command {
            Some(Command::Status { port, .. }) => {
                assert_eq!(port, Some(9000));
            }
            other => panic!("Expected Status, got {:?}", other),
        }
    }

    #[test]
    fn test_cli_status_json_flag() {
        let cli = Cli::try_parse_from(["cara", "status", "--json"]).unwrap();
        match cli.command {
            Some(Command::Status { json, .. }) => {
                assert!(json);
            }
            other => panic!("Expected Status, got {:?}", other),
        }
    }

    #[tokio::test]
    async fn test_cli_status_json_payload_fetches_health_and_control_status() {
        let app = axum::Router::new()
            .route(
                "/health",
                axum::routing::get(|| async {
                    axum::Json(serde_json::json!({
                        "status": "ok",
                        "version": "test-version",
                        "uptimeSeconds": 42
                    }))
                }),
            )
            .route(
                "/control/status",
                axum::routing::get(|| async {
                    axum::Json(serde_json::json!({
                        "ok": true,
                        "connectedChannels": 1,
                        "totalChannels": 2,
                        "homeserverInfluenced": "bad\u{202e}",
                        "runtime": {
                            "platform": "linux",
                            "arch": "x86_64"
                        }
                    }))
                }),
            );
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("bind test status server");
        let port = listener
            .local_addr()
            .expect("read test status server address")
            .port();
        let server = tokio::spawn(async move {
            axum::serve(listener, app)
                .await
                .expect("serve test status responses");
        });

        let client = reqwest::Client::builder()
            .timeout(std::time::Duration::from_secs(5))
            .build()
            .expect("build test client");
        let health_response = client
            .get(format!("http://127.0.0.1:{port}/health"))
            .send()
            .await
            .expect("fetch health response");
        let health = read_response_json_value(health_response)
            .await
            .expect("parse health JSON");
        let control_status =
            fetch_optional_status_json(&client, &format!("http://127.0.0.1:{port}/control/status"))
                .await;
        let payload = status_json_payload(health, control_status);

        assert_eq!(payload["health"]["status"], "ok");
        assert_eq!(payload["health"]["version"], "test-version");
        assert_eq!(payload["health"]["uptimeSeconds"], 42);
        assert_eq!(payload["controlStatus"]["connectedChannels"], 1);
        assert_eq!(payload["controlStatus"]["totalChannels"], 2);
        assert_eq!(
            payload["controlStatus"]["homeserverInfluenced"],
            "bad\u{202e}"
        );
        assert_eq!(payload["controlStatus"]["runtime"]["platform"], "linux");
        assert_eq!(payload["controlStatus"]["runtime"]["arch"], "x86_64");

        let rendered = terminal_safe_pretty_json(&payload).expect("render safe JSON");
        assert!(rendered.contains("\"controlStatus\""));
        assert!(rendered.contains("\"connectedChannels\": 1"));
        assert!(!rendered.contains('\u{202e}'));
        assert!(rendered.contains("\"homeserverInfluenced\": \"bad\""));
        assert!(rendered.contains("\"platform\": \"linux\""));

        let mut status_output = Vec::new();
        handle_status_with_writer("127.0.0.1", Some(port), true, &mut status_output)
            .await
            .expect("status JSON path should fetch, merge, and render");
        let status_output = String::from_utf8(status_output).expect("status JSON is utf-8");
        let status_output_json: Value =
            serde_json::from_str(&status_output).expect("status output is JSON");
        assert_eq!(status_output_json["health"]["status"], "ok");
        assert_eq!(status_output_json["controlStatus"]["connectedChannels"], 1);
        assert_eq!(
            status_output_json["controlStatus"]["runtime"]["platform"],
            "linux"
        );
        assert!(!status_output.contains('\u{202e}'));

        let mut human_output = Vec::new();
        handle_status_with_writer("127.0.0.1", Some(port), false, &mut human_output)
            .await
            .expect("status human path should fetch and render");
        let human_output = String::from_utf8(human_output).expect("status human output is utf-8");
        assert!(human_output.starts_with("Carapace gateway status\n"));
        assert!(human_output.contains("  Version:  test-version\n"));
        assert!(human_output.contains("  Uptime:   42s\n"));
        assert!(human_output.contains("  Address:  127.0.0.1:"));
        assert!(human_output.contains("  Status:   ok\n"));
        assert!(human_output.contains("  Channels: 1/2 connected\n"));
        assert!(human_output.contains("  Platform: linux (x86_64)\n"));
        server.abort();
    }

    #[tokio::test]
    async fn test_cli_status_json_omits_control_status_when_unavailable() {
        let app = axum::Router::new().route(
            "/health",
            axum::routing::get(|| async {
                axum::Json(serde_json::json!({
                    "status": "ok",
                    "version": "test-version",
                    "uptimeSeconds": 42
                }))
            }),
        );
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("bind test status server");
        let port = listener
            .local_addr()
            .expect("read test status server address")
            .port();
        let server = tokio::spawn(async move {
            axum::serve(listener, app)
                .await
                .expect("serve test status responses");
        });

        let mut status_output = Vec::new();
        handle_status_with_writer("127.0.0.1", Some(port), true, &mut status_output)
            .await
            .expect("status JSON path should tolerate missing control status");
        server.abort();

        let status_output = String::from_utf8(status_output).expect("status JSON is utf-8");
        let status_output_json: Value =
            serde_json::from_str(&status_output).expect("status output is JSON");
        assert_eq!(status_output_json["health"]["status"], "ok");
        assert!(status_output_json.get("controlStatus").is_none());
    }

    #[test]
    fn test_cli_logs_defaults() {
        let cli = Cli::try_parse_from(["cara", "logs"]).unwrap();
        match cli.command {
            Some(Command::Logs {
                lines,
                port,
                ref host,
                tls,
                trust,
                allow_plaintext,
            }) => {
                assert_eq!(lines, 50);
                assert_eq!(port, None);
                assert_eq!(host, "127.0.0.1");
                assert!(!tls);
                assert!(!trust);
                assert!(!allow_plaintext);
            }
            other => panic!("Expected Logs, got {:?}", other),
        }
    }

    #[test]
    fn test_cli_logs_with_lines() {
        let cli = Cli::try_parse_from(["cara", "logs", "--lines", "100"]).unwrap();
        match cli.command {
            Some(Command::Logs { lines, .. }) => {
                assert_eq!(lines, 100);
            }
            other => panic!("Expected Logs, got {:?}", other),
        }
    }

    #[test]
    fn test_cli_logs_with_short_flag() {
        let cli = Cli::try_parse_from(["cara", "logs", "-n", "25"]).unwrap();
        match cli.command {
            Some(Command::Logs { lines, .. }) => {
                assert_eq!(lines, 25);
            }
            other => panic!("Expected Logs, got {:?}", other),
        }
    }

    #[test]
    fn test_cli_plugins_status_defaults() {
        let cli = Cli::try_parse_from(["cara", "plugins", "status"]).unwrap();
        match cli.command {
            Some(Command::Plugins(PluginsCommand::Status {
                json,
                name,
                plugin_id,
                source,
                state,
                only_failed,
                strict,
                connection,
            })) => {
                assert!(!json);
                assert_eq!(name, None);
                assert_eq!(plugin_id, None);
                assert_eq!(source, None);
                assert_eq!(state, None);
                assert!(!only_failed);
                assert!(!strict);
                assert_eq!(connection.port, None);
                assert_eq!(connection.host, "127.0.0.1");
                assert!(!connection.tls);
                assert!(!connection.trust);
                assert!(!connection.allow_plaintext);
            }
            other => panic!("Expected Plugins(Status), got {:?}", other),
        }
    }

    #[test]
    fn test_cli_plugins_status_filters() {
        let cli = Cli::try_parse_from([
            "cara",
            "plugins",
            "status",
            "--plugin-id",
            "demo.tool",
            "--source",
            "managed",
            "--state",
            "failed",
            "--only-failed",
            "--strict",
        ])
        .unwrap();
        match cli.command {
            Some(Command::Plugins(PluginsCommand::Status {
                plugin_id,
                source,
                state,
                only_failed,
                strict,
                ..
            })) => {
                assert_eq!(plugin_id.as_deref(), Some("demo.tool"));
                assert_eq!(source, Some(PluginSourceSelection::Managed));
                assert_eq!(state, Some(PluginStateSelection::Failed));
                assert!(only_failed);
                assert!(strict);
            }
            other => panic!("Expected Plugins(Status), got {:?}", other),
        }
    }

    #[test]
    fn test_cli_plugins_bins_defaults() {
        let cli = Cli::try_parse_from(["cara", "plugins", "bins"]).unwrap();
        match cli.command {
            Some(Command::Plugins(PluginsCommand::Bins { json, connection })) => {
                assert!(!json);
                assert_eq!(connection.port, None);
                assert_eq!(connection.host, "127.0.0.1");
            }
            other => panic!("Expected Plugins(Bins), got {:?}", other),
        }
    }

    #[test]
    fn test_cli_plugins_install_with_file() {
        let cli = Cli::try_parse_from([
            "cara",
            "plugins",
            "install",
            "demo-plugin",
            "--file",
            "./demo.wasm",
            "--version",
            "1.2.3",
            "--publisher-key",
            "pubkey",
            "--signature",
            "sig",
        ])
        .unwrap();
        match cli.command {
            Some(Command::Plugins(PluginsCommand::Install(PluginMutationArgs {
                name,
                url,
                file,
                version,
                publisher_key,
                signature,
                ..
            }))) => {
                assert_eq!(name, "demo-plugin");
                assert_eq!(url, None);
                assert_eq!(file, Some(PathBuf::from("./demo.wasm")));
                assert_eq!(version.as_deref(), Some("1.2.3"));
                assert_eq!(publisher_key.as_deref(), Some("pubkey"));
                assert_eq!(signature.as_deref(), Some("sig"));
            }
            other => panic!("Expected Plugins(Install), got {:?}", other),
        }
    }

    #[test]
    fn test_cli_plugins_install_requires_exactly_one_source() {
        let err = Cli::try_parse_from([
            "cara",
            "plugins",
            "install",
            "demo-plugin",
            "--url",
            "https://example.com/demo.wasm",
            "--file",
            "./demo.wasm",
        ])
        .unwrap_err();
        let rendered = err.to_string();
        assert!(rendered.contains("--url"));
        assert!(rendered.contains("--file"));
    }

    #[test]
    fn test_cli_plugins_update_with_url() {
        let cli = Cli::try_parse_from([
            "cara",
            "plugins",
            "update",
            "demo-plugin",
            "--url",
            "https://example.com/demo.wasm",
            "--json",
        ])
        .unwrap();
        match cli.command {
            Some(Command::Plugins(PluginsCommand::Update(PluginMutationArgs {
                name,
                url,
                file,
                json,
                ..
            }))) => {
                assert_eq!(name, "demo-plugin");
                assert_eq!(url.as_deref(), Some("https://example.com/demo.wasm"));
                assert_eq!(file, None);
                assert!(json);
            }
            other => panic!("Expected Plugins(Update), got {:?}", other),
        }
    }

    #[tokio::test]
    async fn test_stage_plugin_file_into_managed_dir_requires_loopback_host() {
        let temp = tempfile::TempDir::new().unwrap();
        let plugin_path = temp.path().join("demo.wasm");
        std::fs::write(&plugin_path, tool_plugin_component_bytes()).unwrap();
        let connection = WsConnectionArgs {
            port: Some(18789),
            host: "10.0.0.12".to_string(),
            tls: true,
            trust: false,
            allow_plaintext: false,
        };

        let err = stage_plugin_file_into_managed_dir(&connection, "demo", &plugin_path)
            .await
            .unwrap_err();
        assert!(err
            .to_string()
            .contains("--file is only supported for loopback"));
    }

    #[tokio::test]
    async fn test_stage_plugin_file_into_managed_dir_stages_file() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());

        let plugin_path = temp.path().join("demo-input.wasm");
        std::fs::write(&plugin_path, tool_plugin_component_bytes()).unwrap();
        let connection = WsConnectionArgs {
            port: Some(18789),
            host: "127.0.0.1".to_string(),
            tls: false,
            trust: false,
            allow_plaintext: false,
        };

        let transaction =
            stage_plugin_file_into_managed_dir(&connection, "demo-plugin", &plugin_path)
                .await
                .unwrap();
        assert!(transaction.commit().await.is_ok());
        let staged = temp.path().join("plugins").join("demo-plugin.wasm");
        let lock = temp
            .path()
            .join("plugins")
            .join("demo-plugin.wasm.cli-lock");
        assert!(staged.is_file());
        assert!(!lock.exists());
        assert_eq!(
            std::fs::read(staged).unwrap(),
            tool_plugin_component_bytes()
        );
    }

    #[tokio::test]
    async fn test_stage_plugin_file_into_managed_dir_rejects_invalid_plugin_name() {
        let temp = tempfile::TempDir::new().unwrap();
        let plugin_path = temp.path().join("demo-input.wasm");
        std::fs::write(&plugin_path, tool_plugin_component_bytes()).unwrap();
        let connection = WsConnectionArgs {
            port: Some(18789),
            host: "127.0.0.1".to_string(),
            tls: false,
            trust: false,
            allow_plaintext: false,
        };

        let err = stage_plugin_file_into_managed_dir(&connection, "../escape", &plugin_path)
            .await
            .unwrap_err();
        assert!(err.to_string().contains("plugin name may only contain"));
    }

    #[tokio::test]
    async fn test_stage_plugin_file_into_managed_dir_rollback_removes_new_file() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());

        let plugin_path = temp.path().join("demo-input.wasm");
        std::fs::write(&plugin_path, tool_plugin_component_bytes()).unwrap();
        let connection = WsConnectionArgs {
            port: Some(18789),
            host: "127.0.0.1".to_string(),
            tls: false,
            trust: false,
            allow_plaintext: false,
        };

        let transaction =
            stage_plugin_file_into_managed_dir(&connection, "demo-plugin", &plugin_path)
                .await
                .unwrap();
        let rollback_note = transaction.rollback().await.unwrap();
        let staged = temp.path().join("plugins").join("demo-plugin.wasm");
        let lock = temp
            .path()
            .join("plugins")
            .join("demo-plugin.wasm.cli-lock");
        assert!(!staged.exists());
        assert!(!lock.exists());
        assert!(rollback_note.contains("removed staged local managed artifact"));
    }

    #[tokio::test]
    async fn test_stage_plugin_file_into_managed_dir_rollback_restores_previous_file() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());

        let plugins_dir = temp.path().join("plugins");
        std::fs::create_dir_all(&plugins_dir).unwrap();
        let existing = plugins_dir.join("demo-plugin.wasm");
        std::fs::write(&existing, b"old-plugin-bytes").unwrap();

        let plugin_path = temp.path().join("demo-input.wasm");
        std::fs::write(&plugin_path, tool_plugin_component_bytes()).unwrap();
        let connection = WsConnectionArgs {
            port: Some(18789),
            host: "127.0.0.1".to_string(),
            tls: false,
            trust: false,
            allow_plaintext: false,
        };

        let transaction =
            stage_plugin_file_into_managed_dir(&connection, "demo-plugin", &plugin_path)
                .await
                .unwrap();
        assert_eq!(
            std::fs::read(&existing).unwrap(),
            tool_plugin_component_bytes()
        );

        let rollback_note = transaction.rollback().await.unwrap();
        assert_eq!(std::fs::read(&existing).unwrap(), b"old-plugin-bytes");
        assert!(rollback_note.contains("restored previous local managed artifact"));
        assert!(!plugins_dir.join("demo-plugin.wasm.cli-backup").exists());
        assert!(!plugins_dir.join("demo-plugin.wasm.cli-lock").exists());
    }

    #[tokio::test]
    async fn test_managed_plugin_file_transaction_rollback_releases_lock_on_restore_failure() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let dest = temp.path().join("demo-plugin.wasm");
        let backup = temp.path().join("demo-plugin.wasm.cli-backup");
        let lock = temp.path().join("demo-plugin.wasm.cli-lock");

        std::fs::write(&dest, b"new-plugin-bytes").unwrap();
        std::fs::write(&backup, b"old-plugin-bytes").unwrap();
        std::fs::write(&lock, b"locked").unwrap();
        env_guard.set("CARAPACE_TEST_FAIL_RESTORE_PLUGIN_DEST", dest.as_os_str());

        let err = ManagedPluginFileTransaction {
            dest: dest.clone(),
            backup: Some(backup.clone()),
            lock: lock.clone(),
            drop_action: ManagedPluginFileTransactionDropAction::RollbackArtifact,
        }
        .rollback()
        .await
        .unwrap_err();

        assert!(err
            .to_string()
            .contains("failed to restore previous plugin artifact"));
        assert!(!lock.exists());
        assert!(backup.exists());
        assert!(dest.exists());
    }

    #[tokio::test]
    async fn test_managed_plugin_file_transaction_rollback_reports_restore_and_lock_failure() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let dest = temp.path().join("demo-plugin.wasm");
        let backup = temp.path().join("demo-plugin.wasm.cli-backup");
        let lock = temp.path().join("demo-plugin.wasm.cli-lock");

        std::fs::write(&dest, b"new-plugin-bytes").unwrap();
        std::fs::write(&backup, b"old-plugin-bytes").unwrap();
        std::fs::create_dir_all(&lock).unwrap();
        env_guard.set("CARAPACE_TEST_FAIL_RESTORE_PLUGIN_DEST", dest.as_os_str());

        let err = ManagedPluginFileTransaction {
            dest: dest.clone(),
            backup: Some(backup.clone()),
            lock: lock.clone(),
            drop_action: ManagedPluginFileTransactionDropAction::RollbackArtifact,
        }
        .rollback()
        .await
        .unwrap_err();

        let rendered = err.to_string();
        assert!(rendered.contains("failed to restore previous plugin artifact"));
        assert!(rendered.contains("failed to remove staging lock"));
        assert!(lock.exists());
        assert!(backup.exists());
        assert!(dest.exists());
    }

    #[tokio::test]
    async fn test_stage_plugin_file_into_managed_dir_commit_removes_backup() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());

        let plugins_dir = temp.path().join("plugins");
        std::fs::create_dir_all(&plugins_dir).unwrap();
        let existing = plugins_dir.join("demo-plugin.wasm");
        std::fs::write(&existing, b"old-plugin-bytes").unwrap();

        let plugin_path = temp.path().join("demo-input.wasm");
        std::fs::write(&plugin_path, tool_plugin_component_bytes()).unwrap();
        let connection = WsConnectionArgs {
            port: Some(18789),
            host: "127.0.0.1".to_string(),
            tls: false,
            trust: false,
            allow_plaintext: false,
        };

        let transaction =
            stage_plugin_file_into_managed_dir(&connection, "demo-plugin", &plugin_path)
                .await
                .unwrap();
        assert!(transaction.commit().await.is_ok());

        assert_eq!(
            std::fs::read(&existing).unwrap(),
            tool_plugin_component_bytes()
        );
        assert!(!plugins_dir.join("demo-plugin.wasm.cli-backup").exists());
        assert!(!plugins_dir.join("demo-plugin.wasm.cli-lock").exists());
    }

    #[tokio::test]
    async fn test_restore_previous_plugin_artifact_replaces_partial_dest() {
        let temp = tempfile::TempDir::new().unwrap();
        let dest = temp.path().join("demo-plugin.wasm");
        let backup = temp.path().join("demo-plugin.wasm.cli-backup");
        std::fs::write(&dest, b"partial-new-bytes").unwrap();
        std::fs::write(&backup, b"old-plugin-bytes").unwrap();

        restore_previous_plugin_artifact(&backup, &dest)
            .await
            .unwrap();

        assert_eq!(std::fs::read(&dest).unwrap(), b"old-plugin-bytes");
        assert!(!backup.exists());
    }

    #[tokio::test]
    async fn test_commit_returns_error_when_backup_cleanup_fails() {
        let temp = tempfile::TempDir::new().unwrap();
        let dest = temp.path().join("demo-plugin.wasm");
        let backup = temp.path().join("demo-plugin.wasm.cli-backup");
        std::fs::write(&dest, b"new-plugin-bytes").unwrap();
        std::fs::create_dir_all(&backup).unwrap();

        let err = ManagedPluginFileTransaction {
            dest,
            backup: Some(backup.clone()),
            lock: temp.path().join("demo-plugin.wasm.cli-lock"),
            drop_action: ManagedPluginFileTransactionDropAction::RollbackArtifact,
        }
        .commit()
        .await
        .unwrap_err();

        let rendered = err.to_string();
        assert!(rendered.contains("plugin request succeeded"));
        assert!(rendered.contains("failed to remove staging backup"));
        assert!(rendered.contains("remove or recover that backup"));
        assert!(backup.exists());
    }

    #[tokio::test]
    async fn test_commit_returns_error_when_backup_cleanup_and_lock_release_fail() {
        let temp = tempfile::TempDir::new().unwrap();
        let dest = temp.path().join("demo-plugin.wasm");
        let backup = temp.path().join("demo-plugin.wasm.cli-backup");
        let lock = temp.path().join("demo-plugin.wasm.cli-lock");
        std::fs::write(&dest, b"new-plugin-bytes").unwrap();
        std::fs::create_dir_all(&backup).unwrap();
        std::fs::create_dir_all(&lock).unwrap();

        let err = ManagedPluginFileTransaction {
            dest,
            backup: Some(backup.clone()),
            lock: lock.clone(),
            drop_action: ManagedPluginFileTransactionDropAction::RollbackArtifact,
        }
        .commit()
        .await
        .unwrap_err();

        let rendered = err.to_string();
        assert!(rendered.contains("failed to remove staging backup"));
        assert!(rendered.contains("failed to remove staging lock"));
        assert!(rendered.contains("remove or recover that backup"));
        assert!(rendered
            .contains("remove that lock file before the next local `--file` plugin mutation"));
        assert!(backup.exists());
        assert!(lock.exists());
    }

    #[tokio::test]
    async fn test_finalize_plugin_file_mutation_returns_error_on_commit_cleanup_failure() {
        let temp = tempfile::TempDir::new().unwrap();
        let dest = temp.path().join("demo-plugin.wasm");
        let backup = temp.path().join("demo-plugin.wasm.cli-backup");
        std::fs::write(&dest, b"new-plugin-bytes").unwrap();
        std::fs::create_dir_all(&backup).unwrap();

        let result = finalize_plugin_file_mutation(
            Some(ManagedPluginFileTransaction {
                dest,
                backup: Some(backup.clone()),
                lock: temp.path().join("demo-plugin.wasm.cli-lock"),
                drop_action: ManagedPluginFileTransactionDropAction::RollbackArtifact,
            }),
            async { Ok::<(), Box<dyn std::error::Error>>(()) },
        )
        .await
        .unwrap_err();

        assert!(result
            .to_string()
            .contains("failed to remove staging backup"));
        assert!(backup.exists());
    }

    #[tokio::test]
    async fn test_finalize_plugin_file_mutation_without_transaction_passthroughs_ok() {
        let result = finalize_plugin_file_mutation(None, async {
            Ok::<_, Box<dyn std::error::Error>>("ok".to_string())
        })
        .await
        .unwrap();

        assert_eq!(result, "ok");
    }

    #[tokio::test]
    async fn test_finalize_plugin_file_mutation_without_transaction_passthroughs_err() {
        let err = finalize_plugin_file_mutation(None, async {
            Err::<(), Box<dyn std::error::Error>>(cli_error(
                "plugins.update failed: simulated passthrough failure",
            ))
        })
        .await
        .unwrap_err();

        assert_eq!(
            err.to_string(),
            "plugins.update failed: simulated passthrough failure"
        );
    }

    #[tokio::test]
    async fn test_cleanup_partially_staged_plugin_artifact_removes_file() {
        let temp = tempfile::TempDir::new().unwrap();
        let dest = temp.path().join("demo-plugin.wasm");
        std::fs::write(&dest, b"partial-plugin-bytes").unwrap();

        cleanup_partially_staged_plugin_artifact(&dest)
            .await
            .unwrap();

        assert!(!dest.exists());
    }

    #[tokio::test]
    async fn test_cleanup_partially_staged_plugin_artifact_reports_remove_failure() {
        let temp = tempfile::TempDir::new().unwrap();
        let dest = temp.path().join("demo-plugin.wasm");
        std::fs::create_dir_all(&dest).unwrap();

        let err = cleanup_partially_staged_plugin_artifact(&dest)
            .await
            .unwrap_err();

        assert!(err
            .to_string()
            .contains("failed to remove partially staged plugin artifact"));
    }

    #[tokio::test]
    async fn test_release_plugin_file_transaction_lock_with_context_appends_failure() {
        let temp = tempfile::TempDir::new().unwrap();
        let lock = temp.path().join("demo-plugin.wasm.cli-lock");
        std::fs::create_dir_all(&lock).unwrap();

        let message =
            release_plugin_file_transaction_lock_with_context(&lock, "base failure".to_string())
                .await;

        assert!(message.contains("base failure"));
        assert!(message.contains("failed to remove staging lock"));
        assert!(message
            .contains("remove that lock file before the next local `--file` plugin mutation"));
    }

    #[test]
    fn test_managed_plugin_file_transaction_drop_warns_when_unfinished() {
        let temp = tempfile::TempDir::new().unwrap();
        let transaction = ManagedPluginFileTransaction {
            dest: temp.path().join("demo-plugin.wasm"),
            backup: None,
            lock: temp.path().join("demo-plugin.wasm.cli-lock"),
            drop_action: ManagedPluginFileTransactionDropAction::RollbackArtifact,
        };

        let result = std::panic::catch_unwind(|| drop(transaction));
        assert!(result.is_ok());
    }

    #[test]
    fn test_pending_plugin_file_transaction_lock_drop_releases_lock() {
        let temp = tempfile::TempDir::new().unwrap();
        let lock = temp.path().join("demo-plugin.wasm.cli-lock");
        std::fs::write(&lock, b"locked").unwrap();

        drop(PendingPluginFileTransactionLock::new(lock.clone()));

        assert!(!lock.exists());
    }

    #[test]
    fn test_managed_plugin_file_transaction_drop_rolls_back_new_file() {
        let temp = tempfile::TempDir::new().unwrap();
        let dest = temp.path().join("demo-plugin.wasm");
        let lock = temp.path().join("demo-plugin.wasm.cli-lock");
        std::fs::write(&dest, b"new-plugin-bytes").unwrap();
        std::fs::write(&lock, b"locked").unwrap();

        drop(ManagedPluginFileTransaction {
            dest: dest.clone(),
            backup: None,
            lock: lock.clone(),
            drop_action: ManagedPluginFileTransactionDropAction::RollbackArtifact,
        });

        assert!(!dest.exists());
        assert!(!lock.exists());
    }

    #[test]
    fn test_managed_plugin_file_transaction_drop_restores_previous_file() {
        let temp = tempfile::TempDir::new().unwrap();
        let dest = temp.path().join("demo-plugin.wasm");
        let backup = temp.path().join("demo-plugin.wasm.cli-backup");
        let lock = temp.path().join("demo-plugin.wasm.cli-lock");
        std::fs::write(&dest, b"new-plugin-bytes").unwrap();
        std::fs::write(&backup, b"old-plugin-bytes").unwrap();
        std::fs::write(&lock, b"locked").unwrap();

        drop(ManagedPluginFileTransaction {
            dest: dest.clone(),
            backup: Some(backup.clone()),
            lock: lock.clone(),
            drop_action: ManagedPluginFileTransactionDropAction::RollbackArtifact,
        });

        assert_eq!(std::fs::read(&dest).unwrap(), b"old-plugin-bytes");
        assert!(!backup.exists());
        assert!(!lock.exists());
    }

    #[test]
    fn test_managed_plugin_file_transaction_drop_finishes_commit_cleanup() {
        let temp = tempfile::TempDir::new().unwrap();
        let dest = temp.path().join("demo-plugin.wasm");
        let backup = temp.path().join("demo-plugin.wasm.cli-backup");
        let lock = temp.path().join("demo-plugin.wasm.cli-lock");
        std::fs::write(&dest, b"new-plugin-bytes").unwrap();
        std::fs::write(&backup, b"old-plugin-bytes").unwrap();
        std::fs::write(&lock, b"locked").unwrap();

        drop(ManagedPluginFileTransaction {
            dest: dest.clone(),
            backup: Some(backup.clone()),
            lock: lock.clone(),
            drop_action: ManagedPluginFileTransactionDropAction::CommitCleanup,
        });

        assert_eq!(std::fs::read(&dest).unwrap(), b"new-plugin-bytes");
        assert!(!backup.exists());
        assert!(!lock.exists());
    }

    #[test]
    fn test_managed_plugin_file_transaction_drop_releases_lock_only() {
        let temp = tempfile::TempDir::new().unwrap();
        let dest = temp.path().join("demo-plugin.wasm");
        let lock = temp.path().join("demo-plugin.wasm.cli-lock");
        std::fs::write(&dest, b"restored-plugin-bytes").unwrap();
        std::fs::write(&lock, b"locked").unwrap();

        drop(ManagedPluginFileTransaction {
            dest: dest.clone(),
            backup: None,
            lock: lock.clone(),
            drop_action: ManagedPluginFileTransactionDropAction::ReleaseLockOnly,
        });

        assert_eq!(std::fs::read(&dest).unwrap(), b"restored-plugin-bytes");
        assert!(!lock.exists());
    }

    #[tokio::test]
    async fn test_acquire_plugin_file_transaction_lock_writes_pid() {
        let temp = tempfile::TempDir::new().unwrap();
        let lock = temp.path().join("demo-plugin.wasm.cli-lock");

        acquire_plugin_file_transaction_lock(&lock).await.unwrap();

        assert_eq!(
            std::fs::read_to_string(&lock).unwrap(),
            std::process::id().to_string()
        );
        release_plugin_file_transaction_lock(&lock).await.unwrap();
    }

    /// B139 regression: CLI plugin-file-lock acquire reaps a
    /// dead-PID stale sentinel before failing. Without this,
    /// a SIGKILL'd prior CLI invocation leaves a sentinel that
    /// blocks every subsequent `cara plugins install --file`
    /// for that plugin until the next daemon restart (the only
    /// place B118's sweep runs).
    #[cfg(unix)]
    #[tokio::test]
    async fn test_acquire_plugin_file_transaction_lock_reaps_dead_pid_sentinel() {
        let temp = tempfile::TempDir::new().unwrap();
        let lock = temp.path().join("demo-plugin.wasm.cli-lock");
        // Pre-seed the sentinel with a dead PID (2_000_000_001 is
        // well above any realistic running PID; kill(pid, 0)
        // returns ESRCH).
        std::fs::write(&lock, "2000000001").unwrap();

        // Acquire should reap the dead-PID sentinel and succeed
        // by writing OUR PID to the lock.
        acquire_plugin_file_transaction_lock(&lock).await.unwrap();

        assert_eq!(
            std::fs::read_to_string(&lock).unwrap(),
            std::process::id().to_string(),
            "stale sentinel must be reaped and replaced with this process's PID"
        );
        release_plugin_file_transaction_lock(&lock).await.unwrap();
    }

    /// B139 regression: an alive-PID sentinel is NOT reaped; the
    /// acquire returns the original "lock exists" error.
    #[tokio::test]
    async fn test_acquire_plugin_file_transaction_lock_refuses_alive_pid_sentinel() {
        let temp = tempfile::TempDir::new().unwrap();
        let lock = temp.path().join("demo-plugin.wasm.cli-lock");
        // Pre-seed with our own PID (definitely alive).
        std::fs::write(&lock, std::process::id().to_string()).unwrap();

        let result = acquire_plugin_file_transaction_lock(&lock).await;
        assert!(
            result.is_err(),
            "alive-PID sentinel must NOT be reaped; acquire must fail"
        );
        let err_msg = result.unwrap_err().to_string();
        assert!(
            err_msg.contains("staging lock"),
            "operator-facing error must reference the staging lock: {err_msg}"
        );
    }

    #[tokio::test]
    async fn test_stage_plugin_file_into_managed_dir_rejects_preexisting_backup() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());

        let plugins_dir = temp.path().join("plugins");
        std::fs::create_dir_all(&plugins_dir).unwrap();
        std::fs::write(
            plugins_dir.join("demo-plugin.wasm.cli-backup"),
            b"stale-backup",
        )
        .unwrap();

        let plugin_path = temp.path().join("demo-input.wasm");
        std::fs::write(&plugin_path, tool_plugin_component_bytes()).unwrap();
        let connection = WsConnectionArgs {
            port: Some(18789),
            host: "127.0.0.1".to_string(),
            tls: false,
            trust: false,
            allow_plaintext: false,
        };

        let err = stage_plugin_file_into_managed_dir(&connection, "demo-plugin", &plugin_path)
            .await
            .unwrap_err();
        assert!(err.to_string().contains("rollback backup"));
        assert!(!plugins_dir.join("demo-plugin.wasm.cli-lock").exists());
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn test_stage_plugin_file_into_managed_dir_rejects_broken_symlink_backup() {
        use std::os::unix::fs::symlink;

        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());

        let plugins_dir = temp.path().join("plugins");
        std::fs::create_dir_all(&plugins_dir).unwrap();
        symlink(
            temp.path().join("missing-backup-target"),
            plugins_dir.join("demo-plugin.wasm.cli-backup"),
        )
        .unwrap();

        let plugin_path = temp.path().join("demo-input.wasm");
        std::fs::write(&plugin_path, tool_plugin_component_bytes()).unwrap();
        let connection = WsConnectionArgs {
            port: Some(18789),
            host: "127.0.0.1".to_string(),
            tls: false,
            trust: false,
            allow_plaintext: false,
        };

        let err = stage_plugin_file_into_managed_dir(&connection, "demo-plugin", &plugin_path)
            .await
            .unwrap_err();

        assert!(err.to_string().contains("rollback backup"));
        assert!(!plugins_dir.join("demo-plugin.wasm.cli-lock").exists());
    }

    #[tokio::test]
    async fn test_stage_plugin_file_into_managed_dir_removes_stale_staged_artifact() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());

        let plugins_dir = temp.path().join("plugins");
        std::fs::create_dir_all(&plugins_dir).unwrap();
        let staged = plugins_dir.join("demo-plugin.wasm.cli-staged");
        let dest = plugins_dir.join("demo-plugin.wasm");
        std::fs::write(&staged, b"stale-staged-bytes").unwrap();

        let new_bytes = tool_plugin_component_bytes();
        let plugin_path = temp.path().join("demo-input.wasm");
        std::fs::write(&plugin_path, &new_bytes).unwrap();
        let connection = WsConnectionArgs {
            port: Some(18789),
            host: "127.0.0.1".to_string(),
            tls: false,
            trust: false,
            allow_plaintext: false,
        };

        let transaction =
            stage_plugin_file_into_managed_dir(&connection, "demo-plugin", &plugin_path)
                .await
                .unwrap();

        assert_eq!(std::fs::read(&dest).unwrap(), new_bytes);
        assert!(!staged.exists());
        assert!(plugins_dir.join("demo-plugin.wasm.cli-lock").exists());

        let note = transaction.rollback().await.unwrap();
        assert!(note.contains("removed staged local managed artifact"));
        assert!(!dest.exists());
        assert!(!plugins_dir.join("demo-plugin.wasm.cli-lock").exists());
    }

    #[tokio::test]
    async fn test_stage_plugin_file_into_managed_dir_rejects_non_regular_destination() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());

        let plugins_dir = temp.path().join("plugins");
        std::fs::create_dir_all(plugins_dir.join("demo-plugin.wasm")).unwrap();

        let plugin_path = temp.path().join("demo-input.wasm");
        std::fs::write(&plugin_path, tool_plugin_component_bytes()).unwrap();
        let connection = WsConnectionArgs {
            port: Some(18789),
            host: "127.0.0.1".to_string(),
            tls: false,
            trust: false,
            allow_plaintext: false,
        };

        let err = stage_plugin_file_into_managed_dir(&connection, "demo-plugin", &plugin_path)
            .await
            .unwrap_err();

        assert!(err.to_string().contains("not a regular file"));
        assert!(!plugins_dir.join("demo-plugin.wasm.cli-lock").exists());
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn test_stage_plugin_file_into_managed_dir_rejects_symlink_destination() {
        use std::os::unix::fs::symlink;

        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());

        let plugins_dir = temp.path().join("plugins");
        std::fs::create_dir_all(&plugins_dir).unwrap();
        let real_plugin = temp.path().join("real-plugin.wasm");
        std::fs::write(&real_plugin, tool_plugin_component_bytes()).unwrap();
        symlink(&real_plugin, plugins_dir.join("demo-plugin.wasm")).unwrap();

        let plugin_path = temp.path().join("demo-input.wasm");
        std::fs::write(&plugin_path, tool_plugin_component_bytes()).unwrap();
        let connection = WsConnectionArgs {
            port: Some(18789),
            host: "127.0.0.1".to_string(),
            tls: false,
            trust: false,
            allow_plaintext: false,
        };

        let err = stage_plugin_file_into_managed_dir(&connection, "demo-plugin", &plugin_path)
            .await
            .unwrap_err();

        assert!(err.to_string().contains("not a regular file"));
        assert!(!plugins_dir.join("demo-plugin.wasm.cli-lock").exists());
    }

    #[tokio::test]
    async fn test_stage_plugin_file_into_managed_dir_restores_backup_after_write_failure() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());

        let plugins_dir = temp.path().join("plugins");
        std::fs::create_dir_all(&plugins_dir).unwrap();
        let existing = plugins_dir.join("demo-plugin.wasm");
        let staged = plugins_dir.join("demo-plugin.wasm.cli-staged");
        std::fs::write(&existing, b"old-plugin-bytes").unwrap();

        let plugin_path = temp.path().join("demo-input.wasm");
        std::fs::write(&plugin_path, tool_plugin_component_bytes()).unwrap();
        env_guard.set(
            "CARAPACE_TEST_FAIL_STAGE_PLUGIN_WRITE_DEST",
            staged.as_os_str(),
        );
        let connection = WsConnectionArgs {
            port: Some(18789),
            host: "127.0.0.1".to_string(),
            tls: false,
            trust: false,
            allow_plaintext: false,
        };

        let err = stage_plugin_file_into_managed_dir(&connection, "demo-plugin", &plugin_path)
            .await
            .unwrap_err();

        assert!(err.to_string().contains("failed to stage plugin file"));
        assert!(err
            .to_string()
            .contains("restored previous local managed artifact"));
        assert_eq!(std::fs::read(&existing).unwrap(), b"old-plugin-bytes");
        assert!(!plugins_dir.join("demo-plugin.wasm.cli-backup").exists());
        assert!(!staged.exists());
        assert!(!plugins_dir.join("demo-plugin.wasm.cli-lock").exists());
    }

    #[tokio::test]
    async fn test_stage_plugin_file_into_managed_dir_cleans_new_file_after_write_failure() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());

        let plugins_dir = temp.path().join("plugins");
        std::fs::create_dir_all(&plugins_dir).unwrap();
        let staged_dest = plugins_dir.join("demo-plugin.wasm");
        let staged_temp = plugins_dir.join("demo-plugin.wasm.cli-staged");

        let plugin_path = temp.path().join("demo-input.wasm");
        std::fs::write(&plugin_path, tool_plugin_component_bytes()).unwrap();
        env_guard.set(
            "CARAPACE_TEST_FAIL_STAGE_PLUGIN_WRITE_DEST",
            staged_temp.as_os_str(),
        );
        let connection = WsConnectionArgs {
            port: Some(18789),
            host: "127.0.0.1".to_string(),
            tls: false,
            trust: false,
            allow_plaintext: false,
        };

        let err = stage_plugin_file_into_managed_dir(&connection, "demo-plugin", &plugin_path)
            .await
            .unwrap_err();

        assert!(err.to_string().contains("failed to stage plugin file"));
        assert!(!err
            .to_string()
            .contains("restored previous local managed artifact"));
        assert!(!staged_dest.exists());
        assert!(!staged_temp.exists());
        assert!(!plugins_dir.join("demo-plugin.wasm.cli-backup").exists());
        assert!(!plugins_dir.join("demo-plugin.wasm.cli-lock").exists());
    }

    #[tokio::test]
    async fn test_stage_plugin_file_into_managed_dir_cleans_new_file_after_rename_failure() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());

        let plugins_dir = temp.path().join("plugins");
        std::fs::create_dir_all(&plugins_dir).unwrap();
        let staged_dest = plugins_dir.join("demo-plugin.wasm");
        let staged_temp = plugins_dir.join("demo-plugin.wasm.cli-staged");

        let plugin_path = temp.path().join("demo-input.wasm");
        std::fs::write(&plugin_path, tool_plugin_component_bytes()).unwrap();
        env_guard.set(
            "CARAPACE_TEST_FAIL_STAGE_PLUGIN_RENAME_DEST",
            staged_dest.as_os_str(),
        );
        let connection = WsConnectionArgs {
            port: Some(18789),
            host: "127.0.0.1".to_string(),
            tls: false,
            trust: false,
            allow_plaintext: false,
        };

        let err = stage_plugin_file_into_managed_dir(&connection, "demo-plugin", &plugin_path)
            .await
            .unwrap_err();

        assert!(err
            .to_string()
            .contains("failed to finalize staged plugin artifact"));
        assert!(!err
            .to_string()
            .contains("restored previous local managed artifact"));
        assert!(!staged_dest.exists());
        assert!(!staged_temp.exists());
        assert!(!plugins_dir.join("demo-plugin.wasm.cli-backup").exists());
        assert!(!plugins_dir.join("demo-plugin.wasm.cli-lock").exists());
    }

    #[tokio::test]
    async fn test_stage_plugin_file_into_managed_dir_restores_backup_after_rename_failure() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());

        let plugins_dir = temp.path().join("plugins");
        std::fs::create_dir_all(&plugins_dir).unwrap();
        let existing = plugins_dir.join("demo-plugin.wasm");
        let staged = plugins_dir.join("demo-plugin.wasm.cli-staged");
        std::fs::write(&existing, b"old-plugin-bytes").unwrap();

        let plugin_path = temp.path().join("demo-input.wasm");
        std::fs::write(&plugin_path, tool_plugin_component_bytes()).unwrap();
        env_guard.set(
            "CARAPACE_TEST_FAIL_STAGE_PLUGIN_RENAME_DEST",
            existing.as_os_str(),
        );
        let connection = WsConnectionArgs {
            port: Some(18789),
            host: "127.0.0.1".to_string(),
            tls: false,
            trust: false,
            allow_plaintext: false,
        };

        let err = stage_plugin_file_into_managed_dir(&connection, "demo-plugin", &plugin_path)
            .await
            .unwrap_err();

        assert!(err
            .to_string()
            .contains("failed to finalize staged plugin artifact"));
        assert!(err
            .to_string()
            .contains("restored previous local managed artifact"));
        assert_eq!(std::fs::read(&existing).unwrap(), b"old-plugin-bytes");
        assert!(!plugins_dir.join("demo-plugin.wasm.cli-backup").exists());
        assert!(!staged.exists());
        assert!(!plugins_dir.join("demo-plugin.wasm.cli-lock").exists());
    }

    #[tokio::test]
    async fn test_stage_plugin_file_into_managed_dir_reports_write_and_cleanup_failure() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());

        let plugins_dir = temp.path().join("plugins");
        std::fs::create_dir_all(&plugins_dir).unwrap();
        let staged_dest = plugins_dir.join("demo-plugin.wasm");
        let staged_temp = plugins_dir.join("demo-plugin.wasm.cli-staged");

        let plugin_path = temp.path().join("demo-input.wasm");
        std::fs::write(&plugin_path, tool_plugin_component_bytes()).unwrap();
        env_guard.set(
            "CARAPACE_TEST_FAIL_STAGE_PLUGIN_WRITE_DEST",
            staged_temp.as_os_str(),
        );
        env_guard.set(
            "CARAPACE_TEST_FAIL_STAGE_PLUGIN_CLEANUP_DEST",
            staged_temp.as_os_str(),
        );
        let connection = WsConnectionArgs {
            port: Some(18789),
            host: "127.0.0.1".to_string(),
            tls: false,
            trust: false,
            allow_plaintext: false,
        };

        let err = stage_plugin_file_into_managed_dir(&connection, "demo-plugin", &plugin_path)
            .await
            .unwrap_err();

        let rendered = err.to_string();
        assert!(rendered.contains("failed to stage plugin file"));
        assert!(rendered.contains("additionally failed to remove partial staged file"));
        assert!(!staged_dest.exists());
        assert!(!plugins_dir.join("demo-plugin.wasm.cli-lock").exists());
    }

    #[tokio::test]
    async fn test_stage_plugin_file_into_managed_dir_reports_write_and_restore_failure_and_releases_lock(
    ) {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());

        let plugins_dir = temp.path().join("plugins");
        std::fs::create_dir_all(&plugins_dir).unwrap();
        let existing = plugins_dir.join("demo-plugin.wasm");
        let staged = plugins_dir.join("demo-plugin.wasm.cli-staged");
        std::fs::write(&existing, b"old-plugin-bytes").unwrap();

        let plugin_path = temp.path().join("demo-input.wasm");
        std::fs::write(&plugin_path, tool_plugin_component_bytes()).unwrap();
        env_guard.set(
            "CARAPACE_TEST_FAIL_STAGE_PLUGIN_WRITE_DEST",
            staged.as_os_str(),
        );
        env_guard.set(
            "CARAPACE_TEST_FAIL_RESTORE_PLUGIN_DEST",
            existing.as_os_str(),
        );
        let connection = WsConnectionArgs {
            port: Some(18789),
            host: "127.0.0.1".to_string(),
            tls: false,
            trust: false,
            allow_plaintext: false,
        };

        let err = stage_plugin_file_into_managed_dir(&connection, "demo-plugin", &plugin_path)
            .await
            .unwrap_err();

        let rendered = err.to_string();
        assert!(rendered.contains("failed to stage plugin file"));
        assert!(rendered.contains("rollback also failed"));
        assert!(!staged.exists());
        assert!(!plugins_dir.join("demo-plugin.wasm.cli-lock").exists());
        assert!(plugins_dir.join("demo-plugin.wasm.cli-backup").exists());
    }

    #[tokio::test]
    async fn test_stage_plugin_file_into_managed_dir_rejects_preexisting_lock() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());

        let plugins_dir = temp.path().join("plugins");
        std::fs::create_dir_all(&plugins_dir).unwrap();
        std::fs::write(plugins_dir.join("demo-plugin.wasm.cli-lock"), b"busy").unwrap();

        let plugin_path = temp.path().join("demo-input.wasm");
        std::fs::write(&plugin_path, tool_plugin_component_bytes()).unwrap();
        let connection = WsConnectionArgs {
            port: Some(18789),
            host: "127.0.0.1".to_string(),
            tls: false,
            trust: false,
            allow_plaintext: false,
        };

        let err = stage_plugin_file_into_managed_dir(&connection, "demo-plugin", &plugin_path)
            .await
            .unwrap_err();
        assert!(err.to_string().contains("staging lock"));
        assert!(err.to_string().contains("remove the lock file and retry"));
        assert!(err
            .to_string()
            .contains("PID in the lock file may have been recycled"));
    }

    #[tokio::test]
    async fn test_finalize_plugin_file_mutation_reports_original_error_and_rollback() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());

        let plugin_path = temp.path().join("demo-input.wasm");
        std::fs::write(&plugin_path, tool_plugin_component_bytes()).unwrap();
        let connection = WsConnectionArgs {
            port: Some(18789),
            host: "127.0.0.1".to_string(),
            tls: false,
            trust: false,
            allow_plaintext: false,
        };

        let transaction =
            stage_plugin_file_into_managed_dir(&connection, "demo-plugin", &plugin_path)
                .await
                .unwrap();
        let err = finalize_plugin_file_mutation(Some(transaction), async {
            Err::<(), Box<dyn std::error::Error>>(cli_error(
                "plugins.install failed: simulated failure",
            ))
        })
        .await
        .unwrap_err();
        let rendered = err.to_string();
        assert!(rendered.contains("plugins.install failed: simulated failure"));
        assert!(rendered.contains("removed staged local managed artifact"));
        assert!(!temp
            .path()
            .join("plugins")
            .join("demo-plugin.wasm")
            .exists());
    }

    #[tokio::test]
    async fn test_finalize_plugin_file_mutation_reports_original_and_rollback_failure() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let dest = temp.path().join("demo-plugin.wasm");
        let backup = temp.path().join("demo-plugin.wasm.cli-backup");
        let lock = temp.path().join("demo-plugin.wasm.cli-lock");

        std::fs::write(&dest, b"new-plugin-bytes").unwrap();
        std::fs::write(&backup, b"old-plugin-bytes").unwrap();
        std::fs::create_dir_all(&lock).unwrap();
        env_guard.set("CARAPACE_TEST_FAIL_RESTORE_PLUGIN_DEST", dest.as_os_str());

        let err = finalize_plugin_file_mutation(
            Some(ManagedPluginFileTransaction {
                dest,
                backup: Some(backup.clone()),
                lock: lock.clone(),
                drop_action: ManagedPluginFileTransactionDropAction::RollbackArtifact,
            }),
            async {
                Err::<(), Box<dyn std::error::Error>>(cli_error(
                    "plugins.install failed: simulated failure",
                ))
            },
        )
        .await
        .unwrap_err();

        let rendered = err.to_string();
        assert!(rendered.contains("plugins.install failed: simulated failure"));
        assert!(rendered.contains("rollback also failed"));
        assert!(rendered.contains("failed to restore previous plugin artifact"));
        assert!(rendered.contains("failed to remove staging lock"));
        assert!(backup.exists());
        assert!(lock.exists());
    }

    #[test]
    fn test_cli_chat_defaults() {
        let cli = Cli::try_parse_from(["cara", "chat"]).unwrap();
        match cli.command {
            Some(Command::Chat { new, port }) => {
                assert!(!new);
                assert_eq!(port, None);
            }
            other => panic!("Expected Chat, got {:?}", other),
        }
    }

    #[test]
    fn test_cli_chat_with_new_and_port() {
        let cli = Cli::try_parse_from(["cara", "chat", "--new", "--port", "9000"]).unwrap();
        match cli.command {
            Some(Command::Chat { new, port }) => {
                assert!(new);
                assert_eq!(port, Some(9000));
            }
            other => panic!("Expected Chat, got {:?}", other),
        }
    }

    #[test]
    fn test_cli_verify_defaults() {
        let cli = Cli::try_parse_from(["cara", "verify"]).unwrap();
        match cli.command {
            Some(Command::Verify {
                outcome,
                port,
                discord_to,
                telegram_to,
                matrix_to,
            }) => {
                assert_eq!(outcome, VerifyOutcomeSelection::Auto);
                assert_eq!(port, None);
                assert!(discord_to.is_none());
                assert!(telegram_to.is_none());
                assert!(matrix_to.is_none());
            }
            other => panic!("Expected Verify, got {:?}", other),
        }
    }

    #[test]
    fn test_cli_verify_with_options() {
        let cli = Cli::try_parse_from([
            "cara",
            "verify",
            "--outcome",
            "discord",
            "--port",
            "19000",
            "--discord-to",
            "1234567890",
        ])
        .unwrap();
        match cli.command {
            Some(Command::Verify {
                outcome,
                port,
                discord_to,
                telegram_to,
                matrix_to,
            }) => {
                assert_eq!(outcome, VerifyOutcomeSelection::Discord);
                assert_eq!(port, Some(19000));
                assert_eq!(discord_to.as_deref(), Some("1234567890"));
                assert!(telegram_to.is_none());
                assert!(matrix_to.is_none());
            }
            other => panic!("Expected Verify, got {:?}", other),
        }
    }

    #[test]
    fn test_cli_verify_autonomy_outcome() {
        let cli = Cli::try_parse_from(["cara", "verify", "--outcome", "autonomy"]).unwrap();
        match cli.command {
            Some(Command::Verify {
                outcome,
                port,
                discord_to,
                telegram_to,
                matrix_to,
            }) => {
                assert_eq!(outcome, VerifyOutcomeSelection::Autonomy);
                assert_eq!(port, None);
                assert!(discord_to.is_none());
                assert!(telegram_to.is_none());
                assert!(matrix_to.is_none());
            }
            other => panic!("Expected Verify autonomy outcome, got {:?}", other),
        }
    }

    #[test]
    fn test_cli_verify_matrix_outcome() {
        let cli = Cli::try_parse_from([
            "cara",
            "verify",
            "--outcome",
            "matrix",
            "--matrix-to",
            "!room:example.com",
        ])
        .unwrap();
        match cli.command {
            Some(Command::Verify {
                outcome, matrix_to, ..
            }) => {
                assert_eq!(outcome, VerifyOutcomeSelection::Matrix);
                assert_eq!(matrix_to.as_deref(), Some("!room:example.com"));
            }
            other => panic!("Expected Verify matrix outcome, got {:?}", other),
        }
    }

    #[test]
    fn test_verify_outcome_selection_autonomy_resolved() {
        let cfg = serde_json::json!({});
        assert_eq!(
            VerifyOutcomeSelection::Autonomy.resolved(&cfg),
            VerifyOutcome::Autonomy
        );
    }

    #[test]
    fn test_cli_task_create() {
        let cli = Cli::try_parse_from([
            "cara",
            "task",
            "create",
            "--payload",
            r#"{"kind":"systemEvent","text":"hello"}"#,
            "--next-run-at-ms",
            "1234",
            "--max-attempts",
            "5",
            "--max-total-runtime-ms",
            "60000",
            "--max-turns",
            "12",
            "--max-run-timeout-seconds",
            "45",
            "--port",
            "19123",
            "--host",
            "localhost",
        ])
        .unwrap();
        match cli.command {
            Some(Command::Task(TaskCommand::Create {
                payload,
                next_run_at_ms,
                max_attempts,
                max_total_runtime_ms,
                max_turns,
                max_run_timeout_seconds,
                connection,
            })) => {
                assert_eq!(payload, r#"{"kind":"systemEvent","text":"hello"}"#);
                assert_eq!(next_run_at_ms, Some(1234));
                assert_eq!(max_attempts, Some(5));
                assert_eq!(max_total_runtime_ms, Some(60_000));
                assert_eq!(max_turns, Some(12));
                assert_eq!(max_run_timeout_seconds, Some(45));
                assert_eq!(connection.port, Some(19123));
                assert_eq!(connection.host, "localhost");
            }
            other => panic!("Expected Task(Create), got {:?}", other),
        }
    }

    #[test]
    fn test_cli_task_list_with_filters() {
        let cli = Cli::try_parse_from([
            "cara",
            "task",
            "list",
            "--state",
            "retry_wait",
            "--limit",
            "25",
        ])
        .unwrap();
        match cli.command {
            Some(Command::Task(TaskCommand::List { state, limit, .. })) => {
                assert_eq!(state.as_deref(), Some("retry_wait"));
                assert_eq!(limit, Some(25));
            }
            other => panic!("Expected Task(List), got {:?}", other),
        }
    }

    #[test]
    fn test_cli_task_get() {
        let cli = Cli::try_parse_from(["cara", "task", "get", "task-123"]).unwrap();
        match cli.command {
            Some(Command::Task(TaskCommand::Get { id, connection })) => {
                assert_eq!(id, "task-123");
                assert_eq!(connection.port, None);
                assert_eq!(connection.host, "127.0.0.1");
            }
            other => panic!("Expected Task(Get), got {:?}", other),
        }
    }

    #[test]
    fn test_cli_task_cancel() {
        let cli = Cli::try_parse_from([
            "cara",
            "task",
            "cancel",
            "task-123",
            "--reason",
            "operator requested",
        ])
        .unwrap();
        match cli.command {
            Some(Command::Task(TaskCommand::Cancel {
                id,
                reason,
                connection,
            })) => {
                assert_eq!(id, "task-123");
                assert_eq!(reason.as_deref(), Some("operator requested"));
                assert_eq!(connection.port, None);
                assert_eq!(connection.host, "127.0.0.1");
            }
            other => panic!("Expected Task(Cancel), got {:?}", other),
        }
    }

    #[test]
    fn test_cli_task_retry() {
        let cli = Cli::try_parse_from([
            "cara",
            "task",
            "retry",
            "task-123",
            "--delay-ms",
            "500",
            "--reason",
            "retry now",
        ])
        .unwrap();
        match cli.command {
            Some(Command::Task(TaskCommand::Retry {
                id,
                delay_ms,
                reason,
                connection,
            })) => {
                assert_eq!(id, "task-123");
                assert_eq!(delay_ms, Some(500));
                assert_eq!(reason.as_deref(), Some("retry now"));
                assert_eq!(connection.port, None);
                assert_eq!(connection.host, "127.0.0.1");
            }
            other => panic!("Expected Task(Retry), got {:?}", other),
        }
    }

    #[test]
    fn test_cli_task_resume() {
        let cli = Cli::try_parse_from([
            "cara",
            "task",
            "resume",
            "task-123",
            "--delay-ms",
            "250",
            "--reason",
            "unblocked",
        ])
        .unwrap();
        match cli.command {
            Some(Command::Task(TaskCommand::Resume {
                id,
                delay_ms,
                reason,
                connection,
            })) => {
                assert_eq!(id, "task-123");
                assert_eq!(delay_ms, Some(250));
                assert_eq!(reason.as_deref(), Some("unblocked"));
                assert_eq!(connection.port, None);
                assert_eq!(connection.host, "127.0.0.1");
            }
            other => panic!("Expected Task(Resume), got {:?}", other),
        }
    }

    #[test]
    fn test_cli_task_update() {
        let cli = Cli::try_parse_from([
            "cara",
            "task",
            "update",
            "task-123",
            "--payload",
            r#"{"kind":"systemEvent","text":"updated"}"#,
            "--max-attempts",
            "10",
            "--max-total-runtime-ms",
            "90000",
            "--max-turns",
            "15",
            "--max-run-timeout-seconds",
            "30",
            "--reason",
            "operator patch",
        ])
        .unwrap();
        match cli.command {
            Some(Command::Task(TaskCommand::Update {
                id,
                payload,
                max_attempts,
                max_total_runtime_ms,
                max_turns,
                max_run_timeout_seconds,
                reason,
                connection,
            })) => {
                assert_eq!(id, "task-123");
                assert_eq!(
                    payload.as_deref(),
                    Some(r#"{"kind":"systemEvent","text":"updated"}"#)
                );
                assert_eq!(max_attempts, Some(10));
                assert_eq!(max_total_runtime_ms, Some(90_000));
                assert_eq!(max_turns, Some(15));
                assert_eq!(max_run_timeout_seconds, Some(30));
                assert_eq!(reason.as_deref(), Some("operator patch"));
                assert_eq!(connection.port, None);
                assert_eq!(connection.host, "127.0.0.1");
            }
            other => panic!("Expected Task(Update), got {:?}", other),
        }
    }

    #[test]
    fn test_extract_control_error_message_prefers_error_field() {
        let body = br#"{"ok":false,"error":"Task not found"}"#;
        assert_eq!(extract_control_error_message(body), "Task not found");
    }

    #[test]
    fn test_extract_control_error_message_falls_back_to_text_body() {
        let body = b"plain error text";
        assert_eq!(extract_control_error_message(body), "plain error text");
    }

    #[test]
    fn test_extract_control_error_message_handles_empty_body() {
        assert_eq!(extract_control_error_message(b""), "empty response body");
    }

    #[test]
    fn test_setup_post_checks_bridge_inside_current_thread_runtime_does_not_panic() {
        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .unwrap();

        let call_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
            rt.block_on(async {
                run_sync_blocking(run_setup_post_checks(DEFAULT_PORT, false, false))
                    .expect_err("expected bridge error from current-thread runtime")
            })
        }));

        assert!(
            call_result.is_ok(),
            "current-thread runtime should not panic when running setup post checks through sync bridge"
        );
        let err = call_result.unwrap().to_string();
        assert!(
            err.contains(CURRENT_THREAD_RUNTIME_MESSAGE),
            "expected explicit current-thread bridge guard error, got: {err}"
        );
    }

    #[test]
    fn test_validate_channel_credentials_bridge_inside_current_thread_runtime_is_panic_free() {
        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .unwrap();

        let call_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
            rt.block_on(async {
                run_sync_blocking_send(validate_channel_credentials_owned(
                    "unsupported".to_string(),
                    "ignored-token".to_string(),
                ))
                .expect_err("unsupported channels should be rejected, not panic")
            })
        }));

        assert!(
            call_result.is_ok(),
            "current-thread runtime should not panic when validating channel creds through send bridge"
        );
        let err = call_result.unwrap().to_string();
        assert!(
            err.contains("unsupported channel for validation: unsupported"),
            "expected unsupported-channel rejection path, got: {err}"
        );
    }

    #[test]
    fn test_device_identity_round_trip() {
        let identity = generate_device_identity().unwrap();
        validate_device_identity(&identity).unwrap();
        let signing_key = signing_key_from_identity(&identity).unwrap();
        let public_key = signing_key.verifying_key().to_bytes();
        assert_eq!(encode_base64_url(&public_key), identity.public_key);
    }

    #[test]
    fn test_device_identity_file_fallback_error_policy() {
        assert!(should_fallback_to_device_identity_file(
            &credentials::CredentialError::StoreUnavailable("no dbus".to_string())
        ));
        assert!(should_fallback_to_device_identity_file(
            &credentials::CredentialError::StoreLocked
        ));
        assert!(should_fallback_to_device_identity_file(
            &credentials::CredentialError::AccessDenied
        ));
        assert!(!should_fallback_to_device_identity_file(
            &credentials::CredentialError::JsonError("bad identity".to_string())
        ));
    }

    #[test]
    fn test_device_identity_file_round_trip() {
        let temp = tempfile::tempdir().unwrap();
        let identity = generate_device_identity().unwrap();
        let path = device_identity_path(temp.path());

        write_device_identity_file(&path, &identity).unwrap();

        let raw = std::fs::read_to_string(&path).unwrap();
        let loaded: StoredDeviceIdentity = serde_json::from_str(&raw).unwrap();
        validate_device_identity(&loaded).unwrap();
        assert_eq!(loaded.device_id, identity.device_id);
    }

    #[test]
    fn test_device_auth_payload_format_v2() {
        let payload = build_device_auth_payload(DeviceAuthPayload {
            device_id: "device-1".to_string(),
            client_id: "cli".to_string(),
            client_mode: "cli".to_string(),
            role: "operator".to_string(),
            scopes: vec!["operator.read".to_string(), "operator.write".to_string()],
            signed_at_ms: 1234,
            token: Some("tok".to_string()),
            nonce: Some("nonce-1".to_string()),
        });
        assert_eq!(
            payload,
            "v2|device-1|cli|cli|operator|operator.read,operator.write|1234|tok|nonce-1"
        );
    }

    #[test]
    fn test_device_signature_verifies() {
        let identity = generate_device_identity().unwrap();
        let payload = build_device_auth_payload(DeviceAuthPayload {
            device_id: identity.device_id.clone(),
            client_id: "cli".to_string(),
            client_mode: "cli".to_string(),
            role: "operator".to_string(),
            scopes: vec!["operator.read".to_string()],
            signed_at_ms: 42,
            token: Some("tok".to_string()),
            nonce: Some("nonce".to_string()),
        });
        let signature = sign_device_payload(&identity, &payload).unwrap();
        let public_key_raw = decode_base64_any(&identity.public_key).unwrap();
        let sig_raw = decode_base64_any(&signature).unwrap();
        let pubkey_bytes: [u8; 32] = public_key_raw.as_slice().try_into().unwrap();
        let sig_bytes: [u8; 64] = sig_raw.as_slice().try_into().unwrap();
        let verifying_key = VerifyingKey::from_bytes(&pubkey_bytes).unwrap();
        let sig = Signature::from_bytes(&sig_bytes);
        verifying_key
            .verify_strict(payload.as_bytes(), &sig)
            .unwrap();
    }

    #[test]
    fn test_decode_base64_any_accepts_urlsafe_and_standard() {
        let bytes = b"hello-world";
        let urlsafe = URL_SAFE_NO_PAD.encode(bytes);
        let standard = STANDARD.encode(bytes);
        assert_eq!(decode_base64_any(&urlsafe).unwrap(), bytes);
        assert_eq!(decode_base64_any(&standard).unwrap(), bytes);
    }

    #[test]
    fn test_extract_pairing_request_id() {
        let direct = serde_json::json!({ "requestId": "abc" });
        let nested = serde_json::json!({ "details": { "requestId": "xyz" } });
        assert_eq!(extract_pairing_request_id(&direct), Some("abc".to_string()));
        assert_eq!(extract_pairing_request_id(&nested), Some("xyz".to_string()));
    }

    #[test]
    fn test_get_value_at_path_simple() {
        let val = serde_json::json!({"gateway": {"port": 9000}});
        let result = get_value_at_path(&val, "gateway.port");
        assert_eq!(result, Some(serde_json::json!(9000)));
    }

    #[test]
    fn test_get_value_at_path_top_level() {
        let val = serde_json::json!({"key": "value"});
        let result = get_value_at_path(&val, "key");
        assert_eq!(result, Some(serde_json::json!("value")));
    }

    #[test]
    fn test_get_value_at_path_missing() {
        let val = serde_json::json!({"a": 1});
        let result = get_value_at_path(&val, "b.c");
        assert_eq!(result, None);
    }

    #[test]
    fn test_set_value_at_path_creates_intermediate() {
        let mut val = serde_json::json!({});
        assert!(set_value_at_path(&mut val, "a.b.c", serde_json::json!(42)));
        assert_eq!(val["a"]["b"]["c"], 42);
    }

    #[test]
    fn test_set_value_at_path_overwrites() {
        let mut val = serde_json::json!({"gateway": {"port": 8080}});
        assert!(set_value_at_path(
            &mut val,
            "gateway.port",
            serde_json::json!(9000)
        ));
        assert_eq!(val["gateway"]["port"], 9000);
    }

    #[test]
    fn test_set_value_at_path_null_root_returns_false() {
        let mut val = serde_json::Value::Null;
        assert!(!set_value_at_path(&mut val, "a.b", serde_json::json!(1)));
    }

    #[test]
    fn test_redact_secrets() {
        let val = serde_json::json!({
            "gateway": {
                "port": 9000,
                "auth": {
                    "mode": "token",
                    "token": "my-secret-token"
                }
            },
            "anthropic": {
                "apiKey": "sk-ant-abc123"
            },
            "matrix": {
                "storePassphrase": "matrix-passphrase"
            },
            "safe": "visible"
        });
        let redacted = redact_secrets(val);
        // Batch 32 narrows the operator-facing redactor: "auth" is no
        // longer in the canonical SECRET_KEY_NAMES list (it
        // over-redacts non-secret fields like `auth.mode`). The
        // recursion still scrubs the secret-named `token` inside.
        assert_eq!(redacted["gateway"]["auth"]["mode"], "token");
        assert_eq!(redacted["gateway"]["auth"]["token"], "[REDACTED]");
        assert_eq!(redacted["anthropic"]["apiKey"], "[REDACTED]");
        assert_eq!(redacted["matrix"]["storePassphrase"], "[REDACTED]");
        assert_eq!(redacted["gateway"]["port"], 9000);
        assert_eq!(redacted["safe"], "visible");
    }

    #[test]
    fn test_redact_secrets_array() {
        let val = serde_json::json!([{"apiKey": "secret"}, {"safe": "ok"}]);
        let redacted = redact_secrets(val);
        assert_eq!(redacted[0]["apiKey"], "[REDACTED]");
        assert_eq!(redacted[1]["safe"], "ok");
    }

    #[test]
    fn test_format_duration() {
        assert_eq!(format_duration(5), "5s");
        assert_eq!(format_duration(65), "1m 5s");
        assert_eq!(format_duration(3665), "1h 1m 5s");
        assert_eq!(format_duration(90061), "1d 1h 1m 1s");
    }

    #[test]
    fn test_resolve_port_explicit() {
        assert_eq!(resolve_port(Some(1234)), 1234);
    }

    #[test]
    fn test_resolve_port_default() {
        // resolve_port(None) reads the live config file, so it may return
        // the configured port rather than DEFAULT_PORT. Just verify it
        // returns a valid non-zero port.
        let port = resolve_port(None);
        assert_ne!(port, 0);
    }

    // -----------------------------------------------------------------------
    // Backup / Restore / Reset CLI parsing tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_cli_backup_no_args() {
        let cli = Cli::try_parse_from(["cara", "backup"]).unwrap();
        match cli.command {
            Some(Command::Backup { output, force }) => {
                assert!(output.is_none());
                assert!(!force, "--force defaults to false");
            }
            other => panic!("Expected Backup, got {:?}", other),
        }
    }

    #[test]
    fn test_cli_backup_with_output() {
        let cli =
            Cli::try_parse_from(["cara", "backup", "--output", "/tmp/my-backup.tar.gz"]).unwrap();
        match cli.command {
            Some(Command::Backup { output, force }) => {
                assert_eq!(output.as_deref(), Some("/tmp/my-backup.tar.gz"));
                assert!(!force);
            }
            other => panic!("Expected Backup, got {:?}", other),
        }
    }

    #[test]
    fn test_cli_backup_with_short_flag() {
        let cli = Cli::try_parse_from(["cara", "backup", "-o", "/tmp/backup.tar.gz"]).unwrap();
        match cli.command {
            Some(Command::Backup { output, force }) => {
                assert_eq!(output.as_deref(), Some("/tmp/backup.tar.gz"));
                assert!(!force);
            }
            other => panic!("Expected Backup, got {:?}", other),
        }
    }

    #[test]
    fn test_cli_backup_force_flag_parses() {
        let cli = Cli::try_parse_from(["cara", "backup", "-o", "/tmp/b.tgz", "--force"]).unwrap();
        match cli.command {
            Some(Command::Backup { output, force }) => {
                assert_eq!(output.as_deref(), Some("/tmp/b.tgz"));
                assert!(force);
            }
            other => panic!("Expected Backup, got {:?}", other),
        }
    }

    #[test]
    fn test_cli_restore_requires_path() {
        let result = Cli::try_parse_from(["cara", "restore"]);
        assert!(result.is_err(), "restore should require a path argument");
    }

    #[test]
    fn test_cli_restore_with_path() {
        let cli = Cli::try_parse_from(["cara", "restore", "/tmp/backup.tar.gz"]).unwrap();
        match cli.command {
            Some(Command::Restore { ref path, force }) => {
                assert_eq!(path, "/tmp/backup.tar.gz");
                assert!(!force);
            }
            other => panic!("Expected Restore, got {:?}", other),
        }
    }

    #[test]
    fn test_cli_restore_with_force() {
        let cli =
            Cli::try_parse_from(["cara", "restore", "/tmp/backup.tar.gz", "--force"]).unwrap();
        match cli.command {
            Some(Command::Restore { force, .. }) => {
                assert!(force);
            }
            other => panic!("Expected Restore, got {:?}", other),
        }
    }

    #[test]
    fn test_cli_reset_no_flags() {
        let cli = Cli::try_parse_from(["cara", "reset"]).unwrap();
        match cli.command {
            Some(Command::Reset {
                sessions,
                cron,
                usage,
                memory,
                all,
                force,
            }) => {
                assert!(!sessions);
                assert!(!cron);
                assert!(!usage);
                assert!(!memory);
                assert!(!all);
                assert!(!force);
            }
            other => panic!("Expected Reset, got {:?}", other),
        }
    }

    #[test]
    fn test_cli_reset_all_force() {
        let cli = Cli::try_parse_from(["cara", "reset", "--all", "--force"]).unwrap();
        match cli.command {
            Some(Command::Reset { all, force, .. }) => {
                assert!(all);
                assert!(force);
            }
            other => panic!("Expected Reset, got {:?}", other),
        }
    }

    #[test]
    fn test_cli_reset_individual_flags() {
        let cli = Cli::try_parse_from([
            "cara",
            "reset",
            "--sessions",
            "--cron",
            "--usage",
            "--memory",
            "--force",
        ])
        .unwrap();
        match cli.command {
            Some(Command::Reset {
                sessions,
                cron,
                usage,
                memory,
                all,
                force,
            }) => {
                assert!(sessions);
                assert!(cron);
                assert!(usage);
                assert!(memory);
                assert!(!all);
                assert!(force);
            }
            other => panic!("Expected Reset, got {:?}", other),
        }
    }

    // -----------------------------------------------------------------------
    // Backup/Restore round-trip tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_backup_creates_valid_archive() {
        let temp = tempfile::TempDir::new().unwrap();
        let state_dir = temp.path().join("state");
        let sessions_dir = state_dir.join("sessions");
        let cron_dir = state_dir.join("cron");
        let tasks_dir = state_dir.join("tasks");
        std::fs::create_dir_all(&sessions_dir).unwrap();
        std::fs::create_dir_all(&cron_dir).unwrap();
        std::fs::create_dir_all(&tasks_dir).unwrap();

        // Create some fake session data.
        std::fs::write(
            sessions_dir.join("abc123.json"),
            r#"{"id":"abc123","agentId":"test"}"#,
        )
        .unwrap();
        std::fs::write(
            sessions_dir.join("abc123.jsonl"),
            r#"{"id":"m1","role":"user","content":"hello"}"#,
        )
        .unwrap();

        // Create fake cron data.
        std::fs::write(cron_dir.join("jobs.json"), r#"{"version":1,"jobs":[]}"#).unwrap();

        // Create fake usage data.
        std::fs::write(state_dir.join("usage.json"), r#"{"totalTokens":42}"#).unwrap();
        // Create fake task data (durable task queue is stored as a JSON array).
        std::fs::write(tasks_dir.join("queue.json"), r#"[]"#).unwrap();

        // Build archive.
        let archive_path = temp.path().join("test-backup.tar.gz");
        let file = std::fs::File::create(&archive_path).unwrap();
        let enc = flate2::write::GzEncoder::new(file, flate2::Compression::default());
        let mut builder = tar::Builder::new(enc);

        // Write marker.
        let marker = b"carapace-backup v1\n";
        let mut header = tar::Header::new_gnu();
        header.set_size(marker.len() as u64);
        header.set_mode(0o644);
        header.set_cksum();
        builder
            .append_data(&mut header, BACKUP_MARKER, &marker[..])
            .unwrap();

        // Add sessions.
        builder.append_dir_all("sessions", &sessions_dir).unwrap();
        // Add cron.
        builder.append_dir_all("cron", &cron_dir).unwrap();
        // Add tasks.
        builder.append_dir_all("tasks", &tasks_dir).unwrap();
        // Add usage.
        builder
            .append_path_with_name(state_dir.join("usage.json"), "usage/usage.json")
            .unwrap();

        let enc = builder.into_inner().unwrap();
        enc.finish().unwrap();

        // Verify the archive can be read and contains the marker.
        let file = std::fs::File::open(&archive_path).unwrap();
        let dec = flate2::read::GzDecoder::new(file);
        let mut archive = tar::Archive::new(dec);

        let mut found_marker = false;
        let mut found_session = false;
        let mut found_cron = false;
        let mut found_tasks = false;
        let mut found_usage = false;

        for entry in archive.entries().unwrap() {
            let entry = entry.unwrap();
            let path = entry.path().unwrap().to_string_lossy().to_string();
            if path == BACKUP_MARKER {
                found_marker = true;
            } else if path.contains("abc123.json") {
                found_session = true;
            } else if path.contains("jobs.json") {
                found_cron = true;
            } else if path.contains("queue.json") {
                found_tasks = true;
            } else if path.contains("usage.json") {
                found_usage = true;
            }
        }

        assert!(found_marker, "Archive should contain backup marker");
        assert!(found_session, "Archive should contain session data");
        assert!(found_cron, "Archive should contain cron data");
        assert!(found_tasks, "Archive should contain task queue data");
        assert!(found_usage, "Archive should contain usage data");
    }

    #[test]
    fn test_backup_restore_round_trip() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();

        // Set up source state directory.
        let source_state = temp.path().join("source");
        let source_sessions = source_state.join("sessions");
        let source_cron = source_state.join("cron");
        let source_tasks = source_state.join("tasks");
        std::fs::create_dir_all(&source_sessions).unwrap();
        std::fs::create_dir_all(&source_cron).unwrap();
        std::fs::create_dir_all(&source_tasks).unwrap();

        std::fs::write(source_sessions.join("sess1.json"), r#"{"id":"sess1"}"#).unwrap();
        std::fs::write(source_cron.join("store.json"), r#"{"version":1}"#).unwrap();
        std::fs::write(source_tasks.join("queue.json"), r#"[]"#).unwrap();
        std::fs::write(source_state.join("usage.json"), r#"{"totalTokens":100}"#).unwrap();

        // Create an archive.
        let archive_path = temp.path().join("roundtrip.tar.gz");
        let file = std::fs::File::create(&archive_path).unwrap();
        let enc = flate2::write::GzEncoder::new(file, flate2::Compression::default());
        let mut builder = tar::Builder::new(enc);

        let marker = b"carapace-backup v1\n";
        let mut header = tar::Header::new_gnu();
        header.set_size(marker.len() as u64);
        header.set_mode(0o644);
        header.set_cksum();
        builder
            .append_data(&mut header, BACKUP_MARKER, &marker[..])
            .unwrap();
        builder
            .append_dir_all("sessions", &source_sessions)
            .unwrap();
        builder.append_dir_all("cron", &source_cron).unwrap();
        builder.append_dir_all("tasks", &source_tasks).unwrap();
        builder
            .append_path_with_name(source_state.join("usage.json"), "usage/usage.json")
            .unwrap();

        let enc = builder.into_inner().unwrap();
        enc.finish().unwrap();

        // Set up a fresh target state directory and restore into it using the
        // production restore path.
        let target_state = temp.path().join("target");
        let target_config = temp.path().join("target-config.json5");
        std::fs::write(&target_config, "{}").unwrap();
        env_guard.set("CARAPACE_STATE_DIR", target_state.as_os_str());
        env_guard.set("CARAPACE_CONFIG_PATH", target_config.as_os_str());
        let (mut restored_sections, restored_sessions) =
            restore_files_from_tar(&archive_path).unwrap();
        assert_eq!(restored_sessions, 1);
        restored_sections.sort_unstable();
        assert_eq!(
            restored_sections,
            vec![
                "cron".to_string(),
                "sessions".to_string(),
                "tasks".to_string(),
                "usage".to_string()
            ]
        );

        // Verify restored data matches original.
        let restored_session =
            std::fs::read_to_string(target_state.join("sessions").join("sess1.json")).unwrap();
        assert_eq!(restored_session, r#"{"id":"sess1"}"#);

        let restored_cron =
            std::fs::read_to_string(target_state.join("cron").join("store.json")).unwrap();
        assert_eq!(restored_cron, r#"{"version":1}"#);

        let restored_usage = std::fs::read_to_string(target_state.join("usage.json")).unwrap();
        assert_eq!(restored_usage, r#"{"totalTokens":100}"#);

        let restored_tasks =
            std::fs::read_to_string(target_state.join("tasks").join("queue.json")).unwrap();
        assert_eq!(restored_tasks, r#"[]"#);
    }

    /// Regression: `cara backup -o PATH` used to silently truncate an
    /// existing file via `File::create`. An operator typo like
    /// `cara backup -o ~/.ssh/known_hosts` overwrote the target with
    /// the tar.gz stream. Refuse to clobber pre-existing paths unless
    /// `--force` is passed.
    #[test]
    fn test_handle_backup_refuses_to_clobber_existing_output_without_force() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let state_dir = temp.path().join("state");
        std::fs::create_dir_all(&state_dir).unwrap();
        let config_path = temp.path().join("carapace.json5");
        std::fs::write(&config_path, "{}").unwrap();
        let archive_path = temp.path().join("preexisting.tar.gz");
        std::fs::write(&archive_path, b"important pre-existing bytes").unwrap();

        env_guard.set("CARAPACE_STATE_DIR", state_dir.as_os_str());
        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());

        let err = handle_backup(Some(archive_path.to_string_lossy().as_ref()), false)
            .expect_err("backup must refuse to overwrite existing path without --force");
        let msg = err.to_string();
        assert!(
            msg.contains("refusing to overwrite") && msg.contains("--force"),
            "error must point operator at --force; got: {msg}"
        );

        // The pre-existing file must remain intact, byte-for-byte.
        let after = std::fs::read(&archive_path).unwrap();
        assert_eq!(
            after, b"important pre-existing bytes",
            "pre-existing file must NOT be truncated when --force is absent"
        );

        // With --force, the same call must overwrite cleanly.
        handle_backup(Some(archive_path.to_string_lossy().as_ref()), true)
            .expect("--force allows overwrite");
        let overwritten = std::fs::read(&archive_path).unwrap();
        assert_ne!(
            overwritten, b"important pre-existing bytes",
            "--force must actually overwrite"
        );
    }

    #[test]
    fn test_handle_backup_includes_tasks_section() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let state_dir = temp.path().join("state");
        let tasks_dir = state_dir.join("tasks");
        std::fs::create_dir_all(&tasks_dir).unwrap();
        std::fs::write(tasks_dir.join("queue.json"), r#"[]"#).unwrap();

        let config_path = temp.path().join("carapace.json5");
        std::fs::write(&config_path, "{}").unwrap();
        let archive_path = temp.path().join("backup-with-tasks.tar.gz");

        env_guard.set("CARAPACE_STATE_DIR", state_dir.as_os_str());
        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());

        handle_backup(Some(archive_path.to_string_lossy().as_ref()), false).unwrap();
        let sections = validate_backup_file(&archive_path).unwrap();
        assert!(
            sections.contains(&"tasks".to_string()),
            "backup should report tasks section when state/tasks exists"
        );
    }

    #[test]
    fn test_handle_backup_restore_round_trip_preserves_tasks() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let home_dir = temp.path().join("home");
        std::fs::create_dir_all(&home_dir).unwrap();
        env_guard.set("HOME", home_dir.as_os_str());
        env_guard.set("USERPROFILE", home_dir.as_os_str());

        let source_state = temp.path().join("source-state");
        let source_tasks = source_state.join("tasks");
        let source_sessions = source_state.join("sessions");
        let source_cron = source_state.join("cron");
        std::fs::create_dir_all(&source_tasks).unwrap();
        std::fs::create_dir_all(&source_sessions).unwrap();
        std::fs::create_dir_all(&source_cron).unwrap();
        std::fs::write(
            source_tasks.join("queue.json"),
            r#"[{"id":"task-1","state":"queued"}]"#,
        )
        .unwrap();
        std::fs::write(source_sessions.join("sess-a.json"), r#"{"id":"sess-a"}"#).unwrap();
        std::fs::write(source_cron.join("jobs.json"), r#"{"jobs":[]}"#).unwrap();
        std::fs::write(source_state.join("usage.json"), r#"{"sessions":{}}"#).unwrap();

        let source_config = temp.path().join("source-config.json5");
        std::fs::write(&source_config, "{}").unwrap();
        let archive_path = temp.path().join("backup-roundtrip.tar.gz");

        {
            env_guard.set("CARAPACE_STATE_DIR", source_state.as_os_str());
            env_guard.set("CARAPACE_CONFIG_PATH", source_config.as_os_str());
            handle_backup(Some(archive_path.to_string_lossy().as_ref()), false).unwrap();
        }

        let target_state = temp.path().join("target-state");
        let target_config = temp.path().join("target-config.json5");
        std::fs::write(&target_config, "{}").unwrap();

        {
            env_guard.set("CARAPACE_STATE_DIR", target_state.as_os_str());
            env_guard.set("CARAPACE_CONFIG_PATH", target_config.as_os_str());
            handle_restore(archive_path.to_string_lossy().as_ref(), true).unwrap();
        }

        let restored_tasks =
            std::fs::read_to_string(target_state.join("tasks").join("queue.json")).unwrap();
        assert_eq!(restored_tasks, r#"[{"id":"task-1","state":"queued"}]"#);
    }

    #[test]
    fn test_tasks_section_detected_and_restored_for_directory_entry() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let archive_path = temp.path().join("tasks-dir-only.tar.gz");

        let file = std::fs::File::create(&archive_path).unwrap();
        let enc = flate2::write::GzEncoder::new(file, flate2::Compression::default());
        let mut builder = tar::Builder::new(enc);

        let marker = b"carapace-backup v1\n";
        let mut marker_header = tar::Header::new_gnu();
        marker_header.set_size(marker.len() as u64);
        marker_header.set_mode(0o644);
        marker_header.set_cksum();
        builder
            .append_data(&mut marker_header, BACKUP_MARKER, &marker[..])
            .unwrap();

        let mut dir_header = tar::Header::new_gnu();
        dir_header.set_entry_type(tar::EntryType::Directory);
        dir_header.set_size(0);
        dir_header.set_mode(0o755);
        dir_header.set_cksum();
        builder
            .append_data(&mut dir_header, "tasks", std::io::empty())
            .unwrap();

        let enc = builder.into_inner().unwrap();
        enc.finish().unwrap();

        let sections = validate_backup_file(&archive_path).unwrap();
        assert!(
            sections.contains(&"tasks".to_string()),
            "top-level tasks directory entry should be detected as tasks section"
        );

        let target_state = temp.path().join("target-state");
        let target_config = temp.path().join("target-config.json5");
        std::fs::write(&target_config, "{}").unwrap();

        env_guard.set("CARAPACE_STATE_DIR", target_state.as_os_str());
        env_guard.set("CARAPACE_CONFIG_PATH", target_config.as_os_str());

        let (restored, _) = restore_files_from_tar(&archive_path).unwrap();
        assert!(restored.contains(&"tasks".to_string()));
        assert!(
            target_state.join("tasks").is_dir(),
            "restore should recreate empty tasks directory from top-level tasks entry"
        );
    }

    #[test]
    fn test_is_safe_archive_path_rejects_traversal() {
        assert!(!is_safe_archive_path(Path::new("../evil.json")));
        assert!(!is_safe_archive_path(Path::new(
            "sessions/../config/evil.json"
        )));
        assert!(!is_safe_archive_path(Path::new("/etc/passwd")));
        assert!(is_safe_archive_path(Path::new("sessions/ok.json")));
    }

    // -----------------------------------------------------------------------
    // Reset logic tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_reset_sessions_deletes_directory() {
        let temp = tempfile::TempDir::new().unwrap();
        let sessions_dir = temp.path().join("sessions");
        std::fs::create_dir_all(&sessions_dir).unwrap();
        std::fs::write(sessions_dir.join("s1.json"), "{}").unwrap();
        std::fs::write(sessions_dir.join("s2.json"), "{}").unwrap();

        assert!(sessions_dir.exists());
        std::fs::remove_dir_all(&sessions_dir).unwrap();
        assert!(!sessions_dir.exists());
    }

    #[test]
    fn test_reset_usage_deletes_file() {
        let temp = tempfile::TempDir::new().unwrap();
        let usage_path = temp.path().join("usage.json");
        std::fs::write(&usage_path, r#"{"tokens":0}"#).unwrap();

        assert!(usage_path.exists());
        std::fs::remove_file(&usage_path).unwrap();
        assert!(!usage_path.exists());
    }

    #[test]
    fn test_count_files_in_dir() {
        let temp = tempfile::TempDir::new().unwrap();
        std::fs::write(temp.path().join("a.json"), "{}").unwrap();
        std::fs::write(temp.path().join("b.json"), "{}").unwrap();
        std::fs::write(temp.path().join("c.txt"), "").unwrap();
        std::fs::write(temp.path().join("d.jsonl"), "").unwrap();

        let count = count_files_in_dir(temp.path(), "json");
        assert_eq!(count, 2);

        let count_txt = count_files_in_dir(temp.path(), "txt");
        assert_eq!(count_txt, 1);

        let count_missing = count_files_in_dir(&temp.path().join("nonexistent"), "json");
        assert_eq!(count_missing, 0);
    }

    // -----------------------------------------------------------------------
    // Helper function tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_format_file_size() {
        assert_eq!(format_file_size(0), "0 B");
        assert_eq!(format_file_size(512), "512 B");
        assert_eq!(format_file_size(1024), "1.0 KB");
        assert_eq!(format_file_size(1536), "1.5 KB");
        assert_eq!(format_file_size(1048576), "1.0 MB");
        assert_eq!(format_file_size(1073741824), "1.00 GB");
    }

    #[test]
    fn test_parse_setup_outcome_aliases() {
        assert_eq!(
            parse_setup_outcome("local-chat"),
            Some(SetupOutcome::LocalChat)
        );
        assert_eq!(parse_setup_outcome("local"), Some(SetupOutcome::LocalChat));
        assert_eq!(parse_setup_outcome("discord"), Some(SetupOutcome::Discord));
        assert_eq!(
            parse_setup_outcome("telegram"),
            Some(SetupOutcome::Telegram)
        );
        assert_eq!(parse_setup_outcome("matrix"), Some(SetupOutcome::Matrix));
        assert_eq!(parse_setup_outcome("element"), Some(SetupOutcome::Matrix));
        assert_eq!(parse_setup_outcome("webhooks"), Some(SetupOutcome::Hooks));
    }

    #[test]
    fn test_parse_setup_outcome_invalid() {
        assert_eq!(parse_setup_outcome(""), None);
        assert_eq!(parse_setup_outcome("unknown"), None);
    }

    #[test]
    fn test_detect_setup_provider_env_hints() {
        let mut env_guard = ScopedEnv::new();
        {
            env_guard.unset("ANTHROPIC_API_KEY");
            env_guard.unset("OPENAI_API_KEY");
            env_guard.unset("OPENAI_OAUTH_CLIENT_ID");
            env_guard.unset("OPENAI_OAUTH_CLIENT_SECRET");
            env_guard.unset("CARAPACE_CONFIG_PASSWORD");
            env_guard.unset("OLLAMA_BASE_URL");
            env_guard.unset("GOOGLE_API_KEY");
            env_guard.unset("VERTEX_PROJECT_ID");
            env_guard.unset("NEARAI_API_KEY");
            env_guard.unset("VENICE_API_KEY");
            env_guard.unset("AWS_REGION");
            env_guard.unset("AWS_DEFAULT_REGION");
            env_guard.unset("AWS_ACCESS_KEY_ID");
            env_guard.unset("AWS_SECRET_ACCESS_KEY");
            assert!(detect_setup_provider_env_hints().is_empty());
        }

        {
            env_guard.set("ANTHROPIC_API_KEY", "sk-anthropic");
            env_guard.unset("OPENAI_API_KEY");
            env_guard.unset("OPENAI_OAUTH_CLIENT_ID");
            env_guard.unset("OPENAI_OAUTH_CLIENT_SECRET");
            env_guard.unset("CARAPACE_CONFIG_PASSWORD");
            env_guard.unset("NEARAI_API_KEY");
            assert_eq!(
                detect_setup_provider_env_hints(),
                vec![SetupProvider::Anthropic]
            );
        }

        {
            env_guard.unset("ANTHROPIC_API_KEY");
            env_guard.set("OPENAI_API_KEY", "sk-openai");
            env_guard.unset("OPENAI_OAUTH_CLIENT_ID");
            env_guard.unset("OPENAI_OAUTH_CLIENT_SECRET");
            env_guard.unset("CARAPACE_CONFIG_PASSWORD");
            env_guard.unset("NEARAI_API_KEY");
            assert_eq!(
                detect_setup_provider_env_hints(),
                vec![SetupProvider::OpenAi]
            );
        }

        {
            env_guard.unset("ANTHROPIC_API_KEY");
            env_guard.unset("OPENAI_API_KEY");
            env_guard.set("GOOGLE_API_KEY", "google-key");
            env_guard.unset("OPENAI_OAUTH_CLIENT_ID");
            env_guard.unset("OPENAI_OAUTH_CLIENT_SECRET");
            env_guard.unset("CARAPACE_CONFIG_PASSWORD");
            env_guard.unset("VERTEX_PROJECT_ID");
            env_guard.unset("NEARAI_API_KEY");
            assert_eq!(
                detect_setup_provider_env_hints(),
                vec![SetupProvider::Gemini]
            );
        }

        {
            env_guard.unset("ANTHROPIC_API_KEY");
            env_guard.unset("OPENAI_API_KEY");
            env_guard.unset("GOOGLE_API_KEY");
            env_guard.set("CARAPACE_CONFIG_PASSWORD", "test-config-password");
            env_guard.set("OPENAI_OAUTH_CLIENT_ID", "openai-client-id");
            env_guard.set("OPENAI_OAUTH_CLIENT_SECRET", "openai-client-secret");
            env_guard.unset("VERTEX_PROJECT_ID");
            env_guard.unset("NEARAI_API_KEY");
            assert_eq!(
                detect_setup_provider_env_hints(),
                vec![SetupProvider::Codex]
            );
        }

        {
            env_guard.unset("ANTHROPIC_API_KEY");
            env_guard.unset("OPENAI_API_KEY");
            env_guard.unset("GOOGLE_API_KEY");
            env_guard.unset("OPENAI_OAUTH_CLIENT_ID");
            env_guard.unset("OPENAI_OAUTH_CLIENT_SECRET");
            env_guard.unset("CARAPACE_CONFIG_PASSWORD");
            env_guard.set("VERTEX_PROJECT_ID", "vertex-project");
            env_guard.unset("NEARAI_API_KEY");
            assert_eq!(
                detect_setup_provider_env_hints(),
                vec![SetupProvider::Vertex]
            );
        }

        {
            env_guard.unset("ANTHROPIC_API_KEY");
            env_guard.unset("OPENAI_API_KEY");
            env_guard.unset("GOOGLE_API_KEY");
            env_guard.unset("OPENAI_OAUTH_CLIENT_ID");
            env_guard.unset("OPENAI_OAUTH_CLIENT_SECRET");
            env_guard.unset("CARAPACE_CONFIG_PASSWORD");
            env_guard.unset("VERTEX_PROJECT_ID");
            env_guard.set("NEARAI_API_KEY", "nearai-test-key");
            assert_eq!(
                detect_setup_provider_env_hints(),
                vec![SetupProvider::NearAi]
            );
        }

        {
            env_guard.set("ANTHROPIC_API_KEY", "sk-anthropic");
            env_guard.set("OPENAI_API_KEY", "sk-openai");
            env_guard.unset("OPENAI_OAUTH_CLIENT_ID");
            env_guard.unset("OPENAI_OAUTH_CLIENT_SECRET");
            env_guard.unset("CARAPACE_CONFIG_PASSWORD");
            env_guard.unset("OLLAMA_BASE_URL");
            env_guard.unset("GOOGLE_API_KEY");
            env_guard.unset("VERTEX_PROJECT_ID");
            env_guard.unset("NEARAI_API_KEY");
            env_guard.unset("VENICE_API_KEY");
            env_guard.unset("AWS_REGION");
            env_guard.unset("AWS_DEFAULT_REGION");
            env_guard.unset("AWS_ACCESS_KEY_ID");
            env_guard.unset("AWS_SECRET_ACCESS_KEY");
            assert_eq!(
                detect_setup_provider_env_hints(),
                vec![SetupProvider::Anthropic, SetupProvider::OpenAi]
            );
        }
    }

    #[test]
    fn test_local_chat_verify_next_step_without_provider_config() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("VERTEX_PROJECT_ID");
        env_guard.unset("VERTEX_LOCATION");
        env_guard.unset("VERTEX_MODEL");
        env_guard.unset("NEARAI_API_KEY");
        let cfg = serde_json::json!({});
        assert_eq!(
            local_chat_verify_next_step(&cfg),
            "set `agents.defaults.model` to a provider:model value (for example `provider:<model-id>`; replace `<model-id>` with your chosen model), then retry `cara verify --outcome local-chat`"
        );
    }

    #[test]
    fn test_local_chat_verify_next_step_for_missing_provider_env_var() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("ANTHROPIC_API_KEY");
        let cfg = serde_json::json!({
            "anthropic": { "apiKey": "${ANTHROPIC_API_KEY}" },
            "agents": { "defaults": { "model": "anthropic:claude-sonnet-4-6" } }
        });
        assert_eq!(
            local_chat_verify_next_step(&cfg),
            "set `$ANTHROPIC_API_KEY` in the same shell you use for `cara start` and `cara verify`, or rerun `cara setup --force` to write the key into config, then retry `cara verify --outcome local-chat`"
        );
    }

    #[test]
    fn test_local_chat_verify_next_step_for_configured_provider() {
        let cfg = serde_json::json!({
            "openai": { "apiKey": "sk-openai-inline" },
            "agents": { "defaults": { "model": "openai:gpt-5.5" } }
        });
        assert_eq!(
            local_chat_verify_next_step(&cfg),
            "check OpenAI API key/model and retry `cara verify --outcome local-chat`"
        );
    }

    #[test]
    fn test_local_chat_verify_next_step_for_env_only_openai() {
        let mut env_guard = ScopedEnv::new();
        env_guard.set("OPENAI_API_KEY", "sk-openai-env");
        env_guard.unset("ANTHROPIC_API_KEY");
        let cfg = serde_json::json!({
            "agents": { "defaults": { "model": "openai:gpt-5.5" } }
        });
        assert_eq!(
            local_chat_verify_next_step(&cfg),
            "check OpenAI API key/model and retry `cara verify --outcome local-chat`"
        );
    }

    #[test]
    fn test_local_chat_verify_next_step_for_multi_provider_config_routes_by_model() {
        let cfg = serde_json::json!({
            "anthropic": { "apiKey": "sk-anthropic-inline" },
            "openai": { "apiKey": "sk-openai-inline" },
            "agents": { "defaults": { "model": "openai:gpt-5.5" } }
        });
        assert_eq!(
            local_chat_verify_next_step(&cfg),
            "check OpenAI API key/model and retry `cara verify --outcome local-chat`"
        );
    }

    #[test]
    fn test_local_chat_verify_next_step_for_ollama_model() {
        let mut env_guard = ScopedEnv::new();
        env_guard.set("OLLAMA_BASE_URL", "http://127.0.0.1:11434");
        let cfg = serde_json::json!({
            "agents": { "defaults": { "model": "ollama:llama3.2" } }
        });
        assert_eq!(
            local_chat_verify_next_step(&cfg),
            "check Ollama server reachability/base URL and selected model, then retry `cara verify --outcome local-chat`"
        );
    }

    #[test]
    fn test_local_chat_verify_next_step_for_missing_gemini_provider() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("GOOGLE_API_KEY");
        env_guard.unset("VERTEX_PROJECT_ID");
        env_guard.unset("VERTEX_LOCATION");
        env_guard.unset("VERTEX_MODEL");
        env_guard.unset("NEARAI_API_KEY");
        let cfg = serde_json::json!({
            "agents": { "defaults": { "model": "gemini:gemini-2.5-flash" } }
        });
        assert_eq!(
            local_chat_verify_next_step(&cfg),
            "configure a provider for the selected model, or rerun `cara setup --force`, then retry `cara verify --outcome local-chat`"
        );
    }

    #[test]
    fn test_local_chat_verify_next_step_for_missing_gemini_provider_env_var() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("GOOGLE_API_KEY");
        let cfg = serde_json::json!({
            "google": { "apiKey": "${GOOGLE_API_KEY}" },
            "agents": { "defaults": { "model": "gemini:gemini-2.5-flash" } }
        });
        assert_eq!(
            local_chat_verify_next_step(&cfg),
            "set `$GOOGLE_API_KEY` in the same shell you use for `cara start` and `cara verify`, or rerun `cara setup --force` to write the key into config, then retry `cara verify --outcome local-chat`"
        );
    }

    #[test]
    fn test_local_chat_verify_next_step_for_vertex_config() {
        let cfg = serde_json::json!({
            "vertex": {
                "projectId": "my-project",
                "location": "us-central1",
                "model": "gemini-2.5-flash"
            },
            "agents": { "defaults": { "model": "vertex:default" } }
        });
        assert_eq!(
            local_chat_verify_next_step(&cfg),
            "check Vertex auth, project, location, and selected model, then retry `cara verify --outcome local-chat`"
        );
    }

    #[test]
    fn test_local_chat_verify_next_step_for_vertex_explicit_route() {
        let cfg = serde_json::json!({
            "vertex": {
                "projectId": "my-project",
                "location": "us-central1"
            },
            "agents": { "defaults": { "model": "vertex:gemini-2.5-flash" } }
        });
        assert_eq!(
            local_chat_verify_next_step(&cfg),
            "check Vertex auth, project, location, and selected model, then retry `cara verify --outcome local-chat`"
        );
    }

    #[test]
    fn test_local_chat_verify_next_step_for_vertex_default_alias_missing_model() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("VERTEX_MODEL");
        let cfg = serde_json::json!({
            "vertex": {
                "projectId": "my-project",
                "location": "us-central1"
            },
            "agents": { "defaults": { "model": "vertex:default" } }
        });
        assert_eq!(
            local_chat_verify_next_step(&cfg),
            "set `VERTEX_MODEL` in the same shell you use for `cara start` and `cara verify`, or configure `vertex.model`, then retry `cara verify --outcome local-chat`"
        );
    }

    #[test]
    fn test_local_chat_verify_next_step_for_missing_vertex_project_env_var() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("VERTEX_PROJECT_ID");
        let cfg = serde_json::json!({
            "vertex": {
                "projectId": "${VERTEX_PROJECT_ID}",
                "location": "us-central1",
                "model": "gemini-2.5-flash"
            },
            "agents": { "defaults": { "model": "vertex:default" } }
        });
        assert_eq!(
            local_chat_verify_next_step(&cfg),
            "set `$VERTEX_PROJECT_ID` in the same shell you use for `cara start` and `cara verify`, or rerun `cara setup --force` to write the key into config, then retry `cara verify --outcome local-chat`"
        );
    }

    #[test]
    fn test_local_chat_verify_next_step_for_model_provider_mismatch() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("VERTEX_PROJECT_ID");
        env_guard.unset("VERTEX_LOCATION");
        env_guard.unset("VERTEX_MODEL");
        env_guard.unset("NEARAI_API_KEY");
        let cfg = serde_json::json!({
            "openai": { "apiKey": "sk-openai-inline" },
            "agents": { "defaults": { "model": "anthropic:claude-sonnet-4-6" } }
        });
        assert_eq!(
            local_chat_verify_next_step(&cfg),
            "the selected model currently routes to Anthropic; configure Anthropic or switch `agents.defaults.model` to one of the other usable providers (OpenAI), then retry `cara verify --outcome local-chat`"
        );
    }

    #[test]
    fn test_local_chat_verify_next_step_prefers_anthropic_api_key_guidance_when_both_paths_exist() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("ANTHROPIC_API_KEY");
        env_guard.unset("CARAPACE_CONFIG_PASSWORD");
        env_guard.unset("VERTEX_PROJECT_ID");
        env_guard.unset("VERTEX_LOCATION");
        env_guard.unset("VERTEX_MODEL");
        let cfg = serde_json::json!({
            "anthropic": {
                "apiKey": "sk-ant-inline",
                "authProfile": "anthropic:default"
            },
            "agents": { "defaults": { "model": "anthropic:claude-sonnet-4-6" } }
        });

        assert_eq!(
            local_chat_verify_next_step(&cfg),
            "check Anthropic API key/model and retry `cara verify --outcome local-chat` Note: both `anthropic.apiKey` and `anthropic.authProfile` are configured; the API key configuration will be used."
        );
    }

    #[test]
    fn test_local_chat_verify_next_step_flags_missing_anthropic_api_key_placeholder_before_auth_profile(
    ) {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("ANTHROPIC_API_KEY");
        env_guard.set("CARAPACE_CONFIG_PASSWORD", "test-config-password");
        env_guard.unset("VERTEX_PROJECT_ID");
        env_guard.unset("VERTEX_LOCATION");
        env_guard.unset("VERTEX_MODEL");
        let cfg = serde_json::json!({
            "anthropic": {
                "apiKey": "${ANTHROPIC_API_KEY}",
                "authProfile": "anthropic:default"
            },
            "agents": { "defaults": { "model": "anthropic:claude-sonnet-4-6" } }
        });

        assert_eq!(
            local_chat_verify_next_step(&cfg),
            "set `$ANTHROPIC_API_KEY` in the same shell you use for `cara start` and `cara verify`, or rerun `cara setup --force` to write the key into config, then retry `cara verify --outcome local-chat`"
        );
    }

    #[test]
    fn test_usable_provider_labels_ignores_empty_api_keys() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("VERTEX_PROJECT_ID");
        env_guard.unset("VERTEX_LOCATION");
        env_guard.unset("VERTEX_MODEL");
        let cfg = serde_json::json!({
            "openai": { "apiKey": "   " },
            "anthropic": { "apiKey": "sk-anthropic-inline" }
        });
        assert_eq!(usable_provider_labels(&cfg), vec!["Anthropic"]);
    }

    #[test]
    fn test_usable_provider_labels_ignore_missing_env_placeholders() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("OPENAI_API_KEY");
        env_guard.unset("NEARAI_API_KEY");
        env_guard.unset("VENICE_API_KEY");
        env_guard.unset("CARAPACE_CONFIG_PASSWORD");
        env_guard.unset("VERTEX_PROJECT_ID");
        env_guard.unset("VERTEX_LOCATION");
        env_guard.unset("VERTEX_MODEL");
        let cfg = serde_json::json!({
            "openai": { "apiKey": "${OPENAI_API_KEY}" },
            "venice": { "apiKey": "${VENICE_API_KEY}" }
        });
        assert!(usable_provider_labels(&cfg).is_empty());
    }

    #[test]
    fn test_usable_provider_labels_require_config_password_for_codex() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("CARAPACE_CONFIG_PASSWORD");
        env_guard.unset("VERTEX_PROJECT_ID");
        env_guard.unset("VERTEX_LOCATION");
        env_guard.unset("VERTEX_MODEL");
        env_guard.unset("NEARAI_API_KEY");
        let cfg = serde_json::json!({
            "codex": { "authProfile": "openai-abc123" }
        });
        assert!(usable_provider_labels(&cfg).is_empty());

        env_guard.set("CARAPACE_CONFIG_PASSWORD", "test-config-password");
        assert_eq!(usable_provider_labels(&cfg), vec!["Codex"]);
    }

    #[test]
    fn test_usable_provider_labels_require_complete_bedrock_credentials() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("VERTEX_PROJECT_ID");
        env_guard.unset("VERTEX_LOCATION");
        env_guard.unset("VERTEX_MODEL");
        env_guard.unset("NEARAI_API_KEY");
        let cfg = serde_json::json!({
            "bedrock": {
                "region": "us-east-1",
                "accessKeyId": "AKIA..."
            }
        });
        assert!(usable_provider_labels(&cfg).is_empty());
    }

    #[test]
    fn test_usable_provider_labels_ignore_vertex_credentials_for_non_vertex_route() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("VERTEX_PROJECT_ID");
        env_guard.unset("VERTEX_LOCATION");
        env_guard.unset("VERTEX_MODEL");
        let cfg = serde_json::json!({
            "vertex": {
                "projectId": "my-project",
                "location": "us-central1",
                "model": "gemini-2.5-flash"
            },
            "openai": {
                "apiKey": "sk-openai-inline"
            },
            "agents": {
                "defaults": {
                    "model": "openai:gpt-5.5"
                }
            }
        });

        assert_eq!(usable_provider_labels(&cfg), vec!["OpenAI"]);
    }

    #[test]
    fn test_usable_provider_labels_include_vertex_for_explicit_route() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("VERTEX_PROJECT_ID");
        env_guard.unset("VERTEX_LOCATION");
        env_guard.unset("VERTEX_MODEL");
        let cfg = serde_json::json!({
            "vertex": {
                "projectId": "my-project",
                "location": "us-central1"
            },
            "agents": {
                "defaults": {
                    "model": "vertex:gemini-2.5-flash"
                }
            }
        });

        assert_eq!(usable_provider_labels(&cfg), vec!["Vertex"]);
    }

    #[test]
    fn test_local_chat_verify_next_step_for_missing_venice_provider_env_var() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("VENICE_API_KEY");
        let cfg = serde_json::json!({
            "venice": { "apiKey": "${VENICE_API_KEY}" },
            "agents": { "defaults": { "model": "venice:llama-3.3-70b" } }
        });
        assert_eq!(
            local_chat_verify_next_step(&cfg),
            "set `$VENICE_API_KEY` in the same shell you use for `cara start` and `cara verify`, or rerun `cara setup --force` to write the key into config, then retry `cara verify --outcome local-chat`"
        );
    }

    #[test]
    fn test_local_chat_verify_next_step_for_missing_nearai_provider_env_var() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("NEARAI_API_KEY");
        let cfg = serde_json::json!({
            "nearai": { "apiKey": "${NEARAI_API_KEY}" },
            "agents": { "defaults": { "model": "nearai:google/gemma-4-31B-it" } }
        });
        assert_eq!(
            local_chat_verify_next_step(&cfg),
            "set `$NEARAI_API_KEY` in the same shell you use for `cara start` and `cara verify`, or rerun `cara setup --force` to write the key into config, then retry `cara verify --outcome local-chat`"
        );
    }

    #[test]
    fn test_local_chat_verify_next_step_for_missing_bedrock_provider_env_var() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("AWS_REGION");
        env_guard.unset("AWS_ACCESS_KEY_ID");
        env_guard.unset("AWS_SECRET_ACCESS_KEY");
        let cfg = serde_json::json!({
            "bedrock": {
                "region": "${AWS_REGION}",
                "accessKeyId": "${AWS_ACCESS_KEY_ID}",
                "secretAccessKey": "${AWS_SECRET_ACCESS_KEY}"
            },
            "agents": { "defaults": { "model": "bedrock:anthropic.claude-sonnet-4-6" } }
        });
        assert_eq!(
            local_chat_verify_next_step(&cfg),
            "set `$AWS_REGION` in the same shell you use for `cara start` and `cara verify`, or rerun `cara setup --force` to write the key into config, then retry `cara verify --outcome local-chat`"
        );
    }

    #[test]
    fn test_local_chat_verify_next_step_for_missing_embedded_bedrock_env_var() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("AWS_REGION_SUFFIX");
        let cfg = serde_json::json!({
            "bedrock": {
                "region": "us-${AWS_REGION_SUFFIX}-1",
                "accessKeyId": "AKIA...",
                "secretAccessKey": "secret"
            },
            "agents": { "defaults": { "model": "bedrock:anthropic.claude-sonnet-4-6" } }
        });
        assert_eq!(
            local_chat_verify_next_step(&cfg),
            "set `$AWS_REGION_SUFFIX` in the same shell you use for `cara start` and `cara verify`, or rerun `cara setup --force` to write the key into config, then retry `cara verify --outcome local-chat`"
        );
    }

    #[test]
    fn test_detect_setup_provider_env_hints_ignore_partial_aws_env() {
        let mut env_guard = ScopedEnv::new();
        env_guard.set("AWS_REGION", "us-east-1");
        env_guard.unset("AWS_ACCESS_KEY_ID");
        env_guard.unset("AWS_SECRET_ACCESS_KEY");
        env_guard.unset("GOOGLE_API_KEY");
        env_guard.unset("VERTEX_PROJECT_ID");
        env_guard.unset("OLLAMA_BASE_URL");
        env_guard.unset("NEARAI_API_KEY");
        env_guard.unset("VENICE_API_KEY");

        assert!(detect_setup_provider_env_hints().is_empty());
    }

    #[test]
    fn test_detect_setup_provider_env_hints_include_complete_bedrock_env() {
        let mut env_guard = ScopedEnv::new();
        env_guard.set("AWS_REGION", "us-east-1");
        env_guard.set("AWS_ACCESS_KEY_ID", "AKIA...");
        env_guard.set("AWS_SECRET_ACCESS_KEY", "secret");
        env_guard.unset("GOOGLE_API_KEY");
        env_guard.unset("VERTEX_PROJECT_ID");
        env_guard.unset("OLLAMA_BASE_URL");
        env_guard.unset("NEARAI_API_KEY");
        env_guard.unset("VENICE_API_KEY");

        assert_eq!(
            detect_setup_provider_env_hints(),
            vec![SetupProvider::Bedrock]
        );
    }

    #[test]
    fn test_verify_failure_follow_up_url() {
        assert_eq!(
            verify_failure_follow_up_url(VerifyOutcome::LocalChat),
            "https://getcara.io/help.html#guided-setup-help"
        );
        assert_eq!(
            verify_failure_follow_up_url(VerifyOutcome::Discord),
            "https://getcara.io/cookbook/discord-assistant.html"
        );
        assert_eq!(
            verify_failure_follow_up_url(VerifyOutcome::Telegram),
            "https://getcara.io/cookbook/telegram-webhook-assistant.html"
        );
        assert_eq!(
            verify_failure_follow_up_url(VerifyOutcome::Matrix),
            "https://getcara.io/help.html#guided-setup-help"
        );
    }

    #[test]
    fn test_sensitive_prompt_text_hidden_with_skip_hint() {
        let prompt = sensitive_prompt_text("API key", true, true);
        assert_eq!(
            prompt,
            "Enter API key (input hidden; pasted text will not be shown, leave blank to skip for now): "
        );
    }

    #[test]
    fn test_sensitive_prompt_text_visible_warns_without_skip_hint() {
        let prompt = sensitive_prompt_text("Telegram bot token", false, false);
        assert_eq!(
            prompt,
            "Enter Telegram bot token (input visible (WARNING: secrets will be shown on screen)): "
        );
    }

    #[test]
    fn test_prompt_sensitive_line_with_uses_hidden_reader() {
        let expected_prompt = "Enter gateway token (input hidden; pasted text will not be shown): ";
        let value = prompt_sensitive_line_with(
            expected_prompt,
            true,
            |prompt| {
                assert_eq!(prompt, expected_prompt);
                Ok("hidden-value".to_string())
            },
            |_prompt| panic!("visible reader should not be called when hide_sensitive_input=true"),
        )
        .expect("hidden prompt should succeed");

        assert_eq!(value, "hidden-value");
    }

    #[test]
    fn test_prompt_sensitive_line_with_uses_visible_reader() {
        let expected_prompt =
            "Enter gateway token (input visible (WARNING: secrets will be shown on screen)): ";
        let value = prompt_sensitive_line_with(
            expected_prompt,
            false,
            |_prompt| panic!("hidden reader should not be called when hide_sensitive_input=false"),
            |prompt| {
                assert_eq!(prompt, expected_prompt);
                Ok("visible-value".to_string())
            },
        )
        .expect("visible prompt should succeed");

        assert_eq!(value, "visible-value");
    }

    #[test]
    fn test_map_channel_validation_error_redacts_auth_detail() {
        let err = crate::channels::ChannelAuthError::auth("token abc123 rejected");
        let message = map_channel_validation_error("Telegram", err);
        assert_eq!(
            message,
            "Telegram credential check failed. Details are hidden because they may contain sensitive information."
        );
    }

    #[test]
    fn test_map_channel_validation_error_redacts_transient_detail() {
        let err = crate::channels::ChannelAuthError::transient("upstream returned HTML body");
        let message = map_channel_validation_error("Discord", err);
        assert_eq!(
            message,
            "Discord credential check hit a transient error. Details are hidden because they may contain sensitive information."
        );
    }

    #[test]
    fn test_resolve_env_placeholder_handles_literal_and_placeholder_values() {
        let mut env_guard = ScopedEnv::new();
        let key = "DISCORD_BOT_TOKEN";
        env_guard.set(key, "  resolved-value  ");
        env_guard.set("MATRIX_ACCESS_TOKEN", "  matrix-token  ");

        assert_eq!(
            resolve_env_placeholder("${DISCORD_BOT_TOKEN}"),
            Some("resolved-value".to_string())
        );
        assert_eq!(
            resolve_env_placeholder("${MATRIX_ACCESS_TOKEN}"),
            Some("matrix-token".to_string())
        );
        assert_eq!(
            resolve_env_placeholder("{DISCORD_BOT_TOKEN}"),
            Some("{DISCORD_BOT_TOKEN}".to_string())
        );
        assert_eq!(
            resolve_env_placeholder("$$${{DISCORD_BOT_TOKEN}"),
            Some("$$${{DISCORD_BOT_TOKEN}".to_string())
        );
    }

    #[test]
    fn test_resolve_env_placeholder_missing_var_returns_none() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("DISCORD_BOT_TOKEN");
        assert_eq!(resolve_env_placeholder("${DISCORD_BOT_TOKEN}"), None);
    }

    #[test]
    fn test_resolve_env_placeholder_rejects_non_allowlisted_keys() {
        let mut env_guard = ScopedEnv::new();
        env_guard.set("CARAPACE_TEST_NON_ALLOWLISTED_SECRET", "secret");
        assert_eq!(
            resolve_env_placeholder("${CARAPACE_TEST_NON_ALLOWLISTED_SECRET}"),
            None
        );
    }

    #[test]
    fn test_resolve_env_placeholder_rejects_custom_bot_token_names() {
        let mut env_guard = ScopedEnv::new();
        env_guard.set("MY_DISCORD_BOT_TOKEN", "custom-token");
        assert_eq!(resolve_env_placeholder("${MY_DISCORD_BOT_TOKEN}"), None);
    }

    /// Pin the CLI-side `read_matrix_recovery_key_input` key-file cap.
    /// Pre-fix the function called std::fs::read_to_string with no
    /// size bound; an operator pointing --key-file at /dev/zero (or a
    /// stray tail log) buffered gigabytes before the format validator
    /// rejected, AND left intermediate read_to_string allocations
    /// outside the Zeroizing wipe path. The fix wraps the read in
    /// `file.take(MATRIX_RECOVERY_KEY_FILE_MAX_BYTES + 1).read_to_string`
    /// with a post-read len check.
    #[test]
    fn test_read_matrix_recovery_key_input_key_file_rejects_over_cap() {
        let cap = crate::channels::matrix::MATRIX_RECOVERY_KEY_FILE_MAX_BYTES;
        let temp = tempfile::tempdir().expect("tempdir");
        let over = temp.path().join("rk.over_cap");
        std::fs::write(&over, vec![b'x'; (cap + 1) as usize]).expect("write over-cap");
        let err = read_matrix_recovery_key_input(Some(&over), false)
            .expect_err("over-cap key-file must fail");
        let msg = err.to_string();
        assert!(
            msg.contains(&format!("exceeds {cap} bytes")),
            "error must surface cap: {msg}"
        );
    }

    #[test]
    fn test_read_matrix_recovery_key_input_key_file_accepts_at_cap() {
        let cap = crate::channels::matrix::MATRIX_RECOVERY_KEY_FILE_MAX_BYTES;
        let temp = tempfile::tempdir().expect("tempdir");
        let at = temp.path().join("rk.at_cap");
        std::fs::write(&at, vec![b'x'; cap as usize]).expect("write at-cap");
        let got =
            read_matrix_recovery_key_input(Some(&at), false).expect("at-cap read must succeed");
        assert_eq!(got.len() as u64, cap);
    }

    /// Write a minimal matrix.encrypted=true config to `dir` and set
    /// `CARAPACE_CONFIG_PATH` via the provided `ScopedEnv`. Tests that
    /// exercise `handle_matrix_recovery_key` must do this because the
    /// handler now refuses Show/Restore when `matrix.encrypted=false`
    /// — the pre-fix shape happily wrote a dormant key under
    /// encrypted=false and misled operators with the "restart will
    /// activate it" message. Real operator runs always have a
    /// matrix-enabled config; tests need to mirror that to exercise
    /// the legitimate code paths.
    fn write_matrix_encrypted_config_and_set_path(
        dir: &std::path::Path,
        env_guard: &mut ScopedEnv,
    ) {
        let config_path = dir.join("carapace.json5");
        let cfg = serde_json::json!({
            "matrix": {
                "enabled": true,
                "encrypted": true,
                "homeserverUrl": "https://matrix.example.com",
                "userId": "@bot:example.com",
                "accessToken": "tok",
                "deviceId": "DEVICE",
                "storePassphrase": "test-passphrase",
            }
        });
        std::fs::write(&config_path, serde_json::to_string(&cfg).unwrap())
            .expect("write test config");
        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", dir.as_os_str());
    }

    #[tokio::test]
    async fn test_handle_matrix_recovery_key_restore_rejects_empty() {
        let temp = tempfile::tempdir().expect("tempdir");
        let mut env_guard = ScopedEnv::new();
        write_matrix_encrypted_config_and_set_path(temp.path(), &mut env_guard);
        let key_file = temp.path().join("empty-recovery-key");
        std::fs::write(&key_file, "   ").expect("write empty recovery key");
        let err = handle_matrix_recovery_key(MatrixRecoveryKeyCommand::Restore {
            key_file: Some(key_file),
            stdin: false,
        })
        .await
        .expect_err("empty Matrix recovery key must be rejected");

        assert!(err
            .to_string()
            .contains("Matrix recovery key cannot be empty"));
    }

    /// Pin the encrypted=false guard added to handle_matrix_recovery_key:
    /// Show and Restore both refuse when matrix.encrypted=false, with
    /// a message that names the disabled-encryption inconsistency.
    /// Pre-fix Restore happily wrote a dormant key and Show happily
    /// printed one — both under a config the daemon would never
    /// consult (`maybe_restore_recovery_key` early-returns on
    /// `!config.encrypted()`).
    #[tokio::test]
    async fn test_handle_matrix_recovery_key_restore_refuses_when_encrypted_false() {
        let temp = tempfile::tempdir().expect("tempdir");
        let mut env_guard = ScopedEnv::new();
        let config_path = temp.path().join("carapace.json5");
        let cfg = serde_json::json!({
            "matrix": {
                "enabled": true,
                "encrypted": false,
                "homeserverUrl": "https://matrix.example.com",
                "userId": "@bot:example.com",
                "accessToken": "tok",
                "deviceId": "DEVICE",
            }
        });
        std::fs::write(&config_path, serde_json::to_string(&cfg).unwrap())
            .expect("write encrypted=false config");
        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        let key_file = temp.path().join("rk");
        std::fs::write(
            &key_file,
            "1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 111",
        )
        .expect("write recovery key");
        let err = handle_matrix_recovery_key(MatrixRecoveryKeyCommand::Restore {
            key_file: Some(key_file),
            stdin: false,
        })
        .await
        .expect_err("restore must refuse under matrix.encrypted=false");
        assert!(
            err.to_string().contains("matrix.encrypted=true"),
            "restore-refusal must name the required setting: {err}"
        );
    }

    /// Sibling of `test_handle_matrix_recovery_key_restore_refuses_when_encrypted_false`:
    /// Show must also refuse under matrix.encrypted=false, so a future
    /// regression that breaks the Show arm of the matches! at the top
    /// of handle_matrix_recovery_key (e.g. someone splits the pattern
    /// match) cannot silently restore the pre-fix behavior where Show
    /// happily printed a dormant key the daemon would never consult.
    #[tokio::test]
    async fn test_handle_matrix_recovery_key_show_refuses_when_encrypted_false() {
        let temp = tempfile::tempdir().expect("tempdir");
        let mut env_guard = ScopedEnv::new();
        let config_path = temp.path().join("carapace.json5");
        let cfg = serde_json::json!({
            "matrix": {
                "enabled": true,
                "encrypted": false,
                "homeserverUrl": "https://matrix.example.com",
                "userId": "@bot:example.com",
                "accessToken": "tok",
                "deviceId": "DEVICE",
            }
        });
        std::fs::write(&config_path, serde_json::to_string(&cfg).unwrap())
            .expect("write encrypted=false config");
        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        let key_path = matrix_recovery_key_path_for_state_dir(temp.path());
        if let Some(parent) = key_path.parent() {
            std::fs::create_dir_all(parent).expect("create state subdir");
        }
        std::fs::write(
            &key_path,
            "1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 111",
        )
        .expect("write recovery key");
        let err = handle_matrix_recovery_key(MatrixRecoveryKeyCommand::Show {
            allow_non_terminal: true,
        })
        .await
        .expect_err("show must refuse under matrix.encrypted=false");
        assert!(
            err.to_string().contains("matrix.encrypted=true"),
            "show-refusal must name the required setting: {err}"
        );
    }

    #[tokio::test]
    async fn test_handle_matrix_recovery_key_restore_respects_rekey_lock() {
        let temp = tempfile::tempdir().expect("tempdir");
        let key_file = temp.path().join("recovery-key");
        std::fs::write(
            &key_file,
            "1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 111",
        )
        .expect("write recovery key");
        let mut env_guard = ScopedEnv::new();
        write_matrix_encrypted_config_and_set_path(temp.path(), &mut env_guard);
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        let _held =
            crate::sessions::file_lock::FileLock::try_acquire(&matrix_rekey_lock_path(temp.path()))
                .expect("try lock")
                .expect("lock available");

        let err = handle_matrix_recovery_key(MatrixRecoveryKeyCommand::Restore {
            key_file: Some(key_file),
            stdin: false,
        })
        .await
        .expect_err("restore must share the daemon/rekey lock");

        assert!(
            err.to_string()
                .contains("Matrix recovery-key restore refused"),
            "unexpected error: {err}"
        );
        assert!(
            !matrix_recovery_key_path_for_state_dir(temp.path()).exists(),
            "restore must not write key material when the rekey lock is held"
        );
    }

    #[test]
    fn test_cleanup_matrix_recovery_restore_removes_rotating_marker() {
        let temp = tempfile::tempdir().expect("tempdir");
        let mut env_guard = ScopedEnv::new();
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        let pending_path = matrix_recovery_pending_key_path_for_state_dir(temp.path());
        let rotating_path = matrix_recovery_rotating_marker_path_for_state_dir(temp.path());
        let minting_path = matrix_recovery_minting_marker_path_for_state_dir(temp.path());
        let journal_path =
            crate::channels::matrix::matrix_recovery_cleanup_journal_path(temp.path());
        std::fs::create_dir_all(pending_path.parent().unwrap()).expect("create matrix dir");
        std::fs::write(&pending_path, b"pending key").expect("write pending");
        std::fs::write(&rotating_path, b"marker").expect("write marker");
        std::fs::write(&minting_path, b"marker").expect("write minting marker");

        cleanup_matrix_recovery_pending_key_after_restore(temp.path()).unwrap();

        assert!(!pending_path.exists());
        assert!(!rotating_path.exists());
        assert!(!minting_path.exists());
        assert!(
            !journal_path.exists(),
            "successful cleanup must remove the completed cleanup journal"
        );
    }

    #[test]
    fn test_cleanup_matrix_recovery_restore_records_journal_when_marker_cleanup_fails() {
        let temp = tempfile::tempdir().expect("tempdir");
        let mut env_guard = ScopedEnv::new();
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        let pending_path = matrix_recovery_pending_key_path_for_state_dir(temp.path());
        let rotating_path = matrix_recovery_rotating_marker_path_for_state_dir(temp.path());
        let journal_path =
            crate::channels::matrix::matrix_recovery_cleanup_journal_path(temp.path());
        std::fs::create_dir_all(pending_path.parent().unwrap()).expect("create matrix dir");
        std::fs::write(&pending_path, b"pending key").expect("write pending");
        std::fs::create_dir(&rotating_path).expect("create marker dir");

        let err = cleanup_matrix_recovery_pending_key_after_restore(temp.path())
            .expect_err("marker cleanup failure must fail restore cleanup");

        assert!(
            rotating_path.exists(),
            "rotation marker must remain when marker cleanup fails"
        );
        assert!(
            !pending_path.exists(),
            "journaled cleanup may remove pending after the full artifact set is durably listed"
        );
        let journal = std::fs::read_to_string(&journal_path)
            .expect("failed cleanup must preserve the cleanup journal");
        assert!(journal.contains("\"phase\": \"started\""));
        assert!(journal.contains("\"role\": \"rotation_marker\""));
        assert!(journal.contains("\"state\": \"failed\""));
        assert!(journal.contains("\"role\": \"pending_key\""));
        assert!(journal.contains("\"state\": \"removed\""));
        assert!(err.to_string().contains("rotation marker"));
        assert!(
            !err.to_string()
                .contains(&rotating_path.display().to_string()),
            "operator-visible cleanup error must not expose recovery-key artifact paths: {err}"
        );
        let audit_log = std::fs::read_to_string(temp.path().join("audit.jsonl"))
            .expect("cleanup failure must write a durable audit event");
        assert!(
            audit_log.contains("matrix_recovery_key_restore_cleanup_failed"),
            "cleanup failure audit event missing from audit log: {audit_log}"
        );
        assert!(
            audit_log.contains("\"label\":\"rotation_marker\""),
            "cleanup audit must use artifact labels, not absolute paths: {audit_log}"
        );
        assert!(
            !audit_log.contains(&rotating_path.display().to_string()),
            "cleanup audit must not persist absolute artifact paths: {audit_log}"
        );
    }

    #[test]
    fn test_matrix_cli_verifier_exceptions_invariants() {
        // Pinned by the wire-guard script's CLI-partition leg
        // (scripts/check-matrix-wire-guards.sh). If this test fails, the
        // script's partition check will also fail in CI; this test
        // exists so the failure surfaces at unit-test runtime with a
        // pinpoint diagnostic rather than as a script error during a
        // full validation run.
        let entries = super::MATRIX_CLI_VERIFIER_EXCEPTIONS;
        assert!(
            !entries.is_empty(),
            "CLI verifier exception table must not be empty"
        );

        let kind_charset = |kind: &str| {
            kind.chars()
                .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
        };

        let mut prev: Option<&str> = None;
        for (kind, justification) in entries {
            assert!(
                !kind.is_empty(),
                "CLI verifier exception kind must not be empty"
            );
            assert!(
                kind_charset(kind),
                "CLI verifier exception kind {kind:?} must be kebab-case (a-z 0-9 -)"
            );
            assert!(
                !justification.trim().is_empty(),
                "CLI verifier exception {kind:?} must carry a non-empty justification"
            );
            if let Some(previous) = prev {
                assert!(
                    previous < *kind,
                    "CLI verifier exception table must be sorted by kind \
                     (entry {kind:?} comes after {previous:?})"
                );
            }
            prev = Some(*kind);
        }
    }

    /// The cleanup journal must anchor the restore intent BEFORE the key
    /// file is written. Without this, a crash between the key write and
    /// the artifact-cleanup loop leaves stale rotating/pending markers
    /// with no journal evidence; the daemon's startup recovery path
    /// inspects the markers, sees a half-complete rotation, and refuses
    /// to boot a runtime whose recovery key is in fact fully restored.
    #[test]
    fn test_anchor_matrix_recovery_cleanup_journal_writes_started_phase() {
        let temp = tempfile::tempdir().expect("tempdir");
        let journal_path =
            crate::channels::matrix::matrix_recovery_cleanup_journal_path(temp.path());

        anchor_matrix_recovery_cleanup_journal_for_restore(temp.path())
            .expect("anchor must succeed on an empty state dir");

        let journal =
            std::fs::read_to_string(&journal_path).expect("anchor must write the cleanup journal");
        assert!(
            journal.contains("\"phase\": \"started\""),
            "anchored journal must record the Started phase: {journal}"
        );
        assert!(
            journal.contains("\"role\": \"rotation_marker\"")
                && journal.contains("\"role\": \"minting_marker\"")
                && journal.contains("\"role\": \"pending_key\""),
            "anchored journal must list every restore-cleanup artifact: {journal}"
        );
    }

    /// Regression for R58 H-RC4: the anchor must emit a durable
    /// audit event so a crash between anchor and key-write leaves a
    /// trail proving a restore was initiated. Before this fix the
    /// only signal was a post-cleanup audit row that fired after the
    /// full sequence completed — the crash-window had zero audit
    /// trail.
    #[test]
    fn test_anchor_matrix_recovery_cleanup_journal_emits_audit_event() {
        let temp = tempfile::tempdir().expect("tempdir");
        let mut env_guard = ScopedEnv::new();
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());

        anchor_matrix_recovery_cleanup_journal_for_restore(temp.path())
            .expect("anchor must succeed");

        let audit_log = std::fs::read_to_string(temp.path().join("audit.jsonl"))
            .expect("anchor must emit a durable audit row");
        assert!(
            audit_log.contains("matrix_recovery_key_restore_cleanup_anchored"),
            "anchor audit event missing: {audit_log}"
        );
        assert!(
            audit_log.contains("\"rotation_marker\"")
                && audit_log.contains("\"minting_marker\"")
                && audit_log.contains("\"pending_key\""),
            "anchor audit must enumerate every artifact label: {audit_log}"
        );
    }

    /// A re-run of `cara matrix recovery-key restore` after a crash
    /// between anchor and key write must NOT clobber the existing
    /// Started journal — the cleanup loop in
    /// `cleanup_matrix_recovery_pending_key_after_restore` keys off the
    /// journal phase to resume.
    #[test]
    fn test_anchor_matrix_recovery_cleanup_journal_is_idempotent() {
        let temp = tempfile::tempdir().expect("tempdir");
        let journal_path =
            crate::channels::matrix::matrix_recovery_cleanup_journal_path(temp.path());

        anchor_matrix_recovery_cleanup_journal_for_restore(temp.path()).unwrap();
        let first = std::fs::read_to_string(&journal_path).expect("first anchor");

        // A second anchor call (e.g., operator retry after a crash) must
        // not rewrite the journal. Rewriting would lose any
        // partial-removal `result` state the previous cleanup loop
        // recorded.
        anchor_matrix_recovery_cleanup_journal_for_restore(temp.path()).unwrap();
        let second = std::fs::read_to_string(&journal_path).expect("second anchor");

        assert_eq!(first, second, "anchor must be idempotent across retries");
    }

    /// Crash-window regression: anchor + cleanup must complete the
    /// journal lifecycle so the daemon's recovery probe sees a clean
    /// state. Anchor a journal, simulate a crash by not writing the
    /// recovery key, then resume via the cleanup function — the journal
    /// must transition to Completed and be removed.
    #[test]
    fn test_anchor_then_cleanup_resumes_to_completed_journal() {
        let temp = tempfile::tempdir().expect("tempdir");
        let mut env_guard = ScopedEnv::new();
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        let pending_path = matrix_recovery_pending_key_path_for_state_dir(temp.path());
        let rotating_path = matrix_recovery_rotating_marker_path_for_state_dir(temp.path());
        let journal_path =
            crate::channels::matrix::matrix_recovery_cleanup_journal_path(temp.path());
        std::fs::create_dir_all(pending_path.parent().unwrap()).expect("create matrix dir");
        std::fs::write(&pending_path, b"stale pending key").expect("write pending");
        std::fs::write(&rotating_path, b"stale marker").expect("write marker");

        // Step 1: operator initiates restore; anchor journal lands.
        anchor_matrix_recovery_cleanup_journal_for_restore(temp.path()).unwrap();
        assert!(journal_path.exists(), "anchor must create journal");

        // Step 2 (simulated crash): the operator's key file is NOT
        // written. On retry, the operator re-runs restore; cleanup
        // resumes from the existing Started journal and removes the
        // stale artifacts that the daemon would otherwise refuse to
        // recover from.
        cleanup_matrix_recovery_pending_key_after_restore(temp.path()).unwrap();

        assert!(!pending_path.exists(), "cleanup must drop stale pending");
        assert!(!rotating_path.exists(), "cleanup must drop stale marker");
        assert!(
            !journal_path.exists(),
            "completed cleanup must remove the journal"
        );
    }

    /// Regression for R58 H-RC1: a prior `cara matrix recovery-key
    /// restore` may write the key file successfully but crash before
    /// the cleanup pass transitions the journal to Completed. The
    /// daemon then refuses to boot (see
    /// `inspect_matrix_recovery_cleanup_journal`). The operator's
    /// only recovery is to re-run the restore command — and the
    /// re-run MUST detect the outstanding journal and resume cleanup
    /// rather than refusing with "key already exists, remove the
    /// file" which would steer a panicked operator into deleting
    /// their only recovery copy.
    #[tokio::test]
    async fn test_handle_matrix_recovery_key_restore_resumes_from_journal_when_key_already_present()
    {
        use crate::channels::matrix::matrix_recovery_cleanup_journal_path;
        let temp = tempfile::tempdir().expect("tempdir");
        let state_dir = temp.path();
        let mut env_guard = ScopedEnv::new();
        write_matrix_encrypted_config_and_set_path(state_dir, &mut env_guard);
        env_guard.set("CARAPACE_STATE_DIR", state_dir.as_os_str());

        let key_path = matrix_recovery_key_path_for_state_dir(state_dir);
        let journal_path = matrix_recovery_cleanup_journal_path(state_dir);
        std::fs::create_dir_all(key_path.parent().unwrap()).expect("matrix dir");

        // Simulate the partial-cleanup state: the key is on disk
        // from a prior successful restore, the journal is anchored
        // in Started phase (cleanup never reached Completed).
        std::fs::write(
            &key_path,
            "1234 5678 9ABC DEFG HJKL MNPQ RSTU VWXY Zabc defg hjkm npqr",
        )
        .expect("write key");
        anchor_matrix_recovery_cleanup_journal_for_restore(state_dir).expect("anchor journal");
        assert!(journal_path.exists(), "anchor must leave the journal");

        // The resume path runs with no operator input — operators
        // typically do not have the key handy when racing to unblock
        // the daemon.
        handle_matrix_recovery_key(MatrixRecoveryKeyCommand::Restore {
            key_file: None,
            stdin: false,
        })
        .await
        .expect("resume restore must succeed when journal + key are both present");

        assert!(
            !journal_path.exists(),
            "completed cleanup must remove the journal so the daemon can boot"
        );
        assert!(
            key_path.exists(),
            "resume must NOT touch the recovery key file"
        );
        let audit_log = std::fs::read_to_string(state_dir.join("audit.jsonl"))
            .expect("resume must emit a durable audit row");
        assert!(
            audit_log.contains("matrix_recovery_key_restore_cleanup_resumed"),
            "resume audit event missing: {audit_log}"
        );
    }

    #[test]
    fn test_validate_matrix_recovery_key_format_rejects_garbage() {
        let err = validate_matrix_recovery_key_format("not a recovery key")
            .expect_err("garbage recovery key must be rejected");

        assert!(err.to_string().contains("12 base58 groups"));
    }

    #[test]
    fn test_validate_matrix_recovery_key_format_accepts_canonical_shape() {
        validate_matrix_recovery_key_format(
            "1234 5678 9ABC DEFG HJKL MNPQ RSTU VWXY Zabc defg hjkm npqr",
        )
        .expect("base58 recovery key shape should be accepted");
    }

    /// `cara matrix rekey-store --new` is a two-phase lifecycle. The
    /// advance driver must be idempotent across per-store cipher
    /// state — these tests pin the three failure modes:
    /// (a) detection-time failure on a corrupt store before any
    /// UPDATE lands, (b) per-store rotation tolerated alongside
    /// already-rotated stores (recovery from a crashed prior run),
    /// and (c) clean rollback restoring the original cipher when a
    /// later store fails to rotate.
    #[test]
    fn test_advance_matrix_sqlite_store_ciphers_no_stores() {
        let temp = tempfile::tempdir().expect("tempdir");
        let err = advance_matrix_sqlite_store_ciphers(temp.path(), "old", "new")
            .expect_err("missing store dir must fail");
        assert!(err.to_string().contains("no Matrix SQLite stores"));
    }

    #[test]
    fn test_advance_matrix_sqlite_store_ciphers_idempotent_when_already_new() {
        let temp = tempfile::tempdir().expect("tempdir");
        let store_dir = temp.path().join("matrix");
        std::fs::create_dir_all(&store_dir).expect("matrix dir");
        // Seed a single store with a "new"-cipher value so detection
        // classifies it as `NewOnly` and the advance is a no-op.
        seed_test_matrix_store(
            &store_dir.join("matrix-sdk-state.sqlite3"),
            "new-passphrase",
        );

        match advance_matrix_sqlite_store_ciphers(temp.path(), "old-passphrase", "new-passphrase")
            .expect("advance idempotent")
        {
            MatrixRekeyAdvance::Completed {
                rotated,
                already_new,
            } => {
                assert!(rotated.is_empty(), "no rotation when already new");
                assert_eq!(already_new.len(), 1);
            }
            MatrixRekeyAdvance::Failed { error, .. } => {
                panic!("expected idempotent advance to complete, got Failed: {error}")
            }
        }
    }

    #[test]
    fn test_advance_matrix_sqlite_store_ciphers_rotates_old_only_store() {
        let temp = tempfile::tempdir().expect("tempdir");
        let store_dir = temp.path().join("matrix");
        std::fs::create_dir_all(&store_dir).expect("matrix dir");
        seed_test_matrix_store(
            &store_dir.join("matrix-sdk-state.sqlite3"),
            "old-passphrase",
        );

        match advance_matrix_sqlite_store_ciphers(temp.path(), "old-passphrase", "new-passphrase")
            .expect("advance rotates")
        {
            MatrixRekeyAdvance::Completed {
                rotated,
                already_new,
            } => {
                assert_eq!(rotated.len(), 1, "old-only store must be rotated");
                assert!(already_new.is_empty());
            }
            MatrixRekeyAdvance::Failed { error, .. } => {
                panic!("expected rotation to complete, got Failed: {error}")
            }
        }
        // Re-running the advance must be a no-op since the rotation
        // already landed.
        match advance_matrix_sqlite_store_ciphers(temp.path(), "old-passphrase", "new-passphrase")
            .expect("advance idempotent on second run")
        {
            MatrixRekeyAdvance::Completed {
                rotated,
                already_new,
            } => {
                assert!(rotated.is_empty());
                assert_eq!(already_new.len(), 1);
            }
            MatrixRekeyAdvance::Failed { error, .. } => {
                panic!("expected second advance to be no-op, got Failed: {error}")
            }
        }
    }

    #[test]
    fn test_advance_matrix_sqlite_store_ciphers_rotates_all_sdk_stores() {
        let temp = tempfile::tempdir().expect("tempdir");
        let matrix_dir = temp.path().join("matrix");
        let cache_dir = matrix_dir.join("cache");
        std::fs::create_dir_all(&cache_dir).expect("matrix cache dir");

        for path in [
            matrix_dir.join("matrix-sdk-state.sqlite3"),
            matrix_dir.join("matrix-sdk-crypto.sqlite3"),
            cache_dir.join("matrix-sdk-event-cache.sqlite3"),
            cache_dir.join("matrix-sdk-media.sqlite3"),
        ] {
            seed_test_matrix_store(&path, "old-passphrase");
        }

        match advance_matrix_sqlite_store_ciphers(temp.path(), "old-passphrase", "new-passphrase")
            .expect("advance rotates every SDK store")
        {
            MatrixRekeyAdvance::Completed {
                rotated,
                already_new,
            } => {
                assert_eq!(
                    rotated.len(),
                    4,
                    "state, crypto, event cache, and media stores must all rotate"
                );
                assert!(already_new.is_empty());
            }
            MatrixRekeyAdvance::Failed { error, .. } => {
                panic!("expected all-store rotation to complete, got Failed: {error}")
            }
        }

        match advance_matrix_sqlite_store_ciphers(temp.path(), "old-passphrase", "new-passphrase")
            .expect("advance is idempotent after all-store rotation")
        {
            MatrixRekeyAdvance::Completed {
                rotated,
                already_new,
            } => {
                assert!(rotated.is_empty());
                assert_eq!(already_new.len(), 4);
            }
            MatrixRekeyAdvance::Failed { error, .. } => {
                panic!("expected second all-store advance to be no-op, got Failed: {error}")
            }
        }
    }

    /// CRITICAL regression pin: `recover_interrupted_matrix_store_rekey`
    /// must NOT call `resolve_matrix_store_passphrase`, because that
    /// function returns `MatrixError::StartupFailed` whenever
    /// `(pending || marker)` is on disk — which is precisely the
    /// precondition for entering recovery. Round-21 fix: derive the
    /// old passphrase directly via
    /// `derive_matrix_store_passphrase_from_config_password`, which
    /// bypasses the daemon-side fail-closed gate.
    ///
    /// This test seeds the partial-rekey state (pending + marker
    /// present, final missing), runs recovery, and asserts that the
    /// recovery path completes without surfacing a `StartupFailed`.
    /// Before the fix, recovery exited at the first call into
    /// `resolve_matrix_store_passphrase` with "interrupted Matrix
    /// store rekey detected" — meaning every interrupted rekey was
    /// unrecoverable through the supported CLI command.
    #[test]
    fn test_recover_interrupted_matrix_store_rekey_uses_config_password_derivation() {
        let temp = tempfile::tempdir().expect("tempdir");
        let state_dir = temp.path();
        let matrix_dir = state_dir.join("matrix");
        std::fs::create_dir_all(&matrix_dir).expect("matrix dir");

        // Seed installation_id with a 64-lowercase-hex value
        // matching the format `read_existing_installation_id`
        // expects. Random bytes via `getrandom` so the literal
        // doesn't fire CodeQL's hardcoded-crypto rule.
        let installation_id =
            crate::crypto::generate_hex_secret(32).expect("generate test installation_id");
        std::fs::write(state_dir.join("installation_id"), &installation_id)
            .expect("seed installation_id");

        // Set CARAPACE_CONFIG_PASSWORD so the derivation can proceed.
        let mut env = crate::test_support::env::ScopedEnv::new();
        env.set("CARAPACE_CONFIG_PASSWORD", "test-config-password");

        // Compute the OLD passphrase that the recovery should derive
        // (HKDF over CARAPACE_CONFIG_PASSWORD + installation_id).
        let old_passphrase = hex::encode(
            crate::channels::matrix::derive_matrix_store_key(
                b"test-config-password",
                installation_id.as_bytes(),
            )
            .expect("derive old passphrase"),
        );

        // Seed two stores: one on the OLD cipher (HKDF-derived).
        seed_test_matrix_store(
            &matrix_dir.join("matrix-sdk-state.sqlite3"),
            &old_passphrase,
        );

        // Pending passphrase that the rekey was advancing toward.
        let pending_passphrase = "0".repeat(64);
        std::fs::write(
            matrix_dir.join("store_passphrase.pending"),
            &pending_passphrase,
        )
        .expect("seed pending");
        std::fs::write(matrix_dir.join("store_passphrase.rekeying"), "rekeying\n")
            .expect("seed marker");

        let passphrase_path = state_dir.join("matrix").join("store_passphrase");
        let pending_path = matrix_dir.join("store_passphrase.pending");
        let marker_path = matrix_dir.join("store_passphrase.rekeying");

        // Build a minimal MatrixConfig — recovery doesn't actually use
        // it post-fix, but the signature still requires one.
        let config = crate::channels::matrix::MatrixConfig {
            homeserver_url: "https://example.com".to_string(),
            user_id: "@cara:example.com".to_string(),
            access_token: None,
            password: None,
            device_id: None,
            security: crate::channels::matrix::MatrixSecurity::Encrypted {
                passphrase_source:
                    crate::channels::matrix::PassphraseSource::DeriveFromConfigPassword,
            },
            auto_join: crate::channels::matrix::MatrixAutoJoinConfig::default(),
            legacy_dlq_envelope_policy:
                crate::channels::matrix::MatrixLegacyDlqEnvelopePolicy::Accept,
        };

        let recovered = recover_interrupted_matrix_store_rekey(
            state_dir,
            &config,
            &passphrase_path,
            &pending_path,
            &marker_path,
        )
        .expect("recovery must succeed when pending+marker present without final");
        assert!(recovered, "recover must report it ran the advance");
        assert!(
            passphrase_path.exists(),
            "recovery must promote pending → final"
        );
        assert!(
            !pending_path.exists(),
            "recovery must clean up the pending file"
        );
        assert!(
            !marker_path.exists(),
            "recovery must clean up the rekey marker"
        );
    }

    #[test]
    fn test_advance_matrix_sqlite_store_ciphers_fails_before_writes_when_passphrases_wrong() {
        let temp = tempfile::tempdir().expect("tempdir");
        let store_dir = temp.path().join("matrix");
        std::fs::create_dir_all(&store_dir).expect("matrix dir");
        seed_test_matrix_store(
            &store_dir.join("matrix-sdk-state.sqlite3"),
            "actual-passphrase",
        );

        // Neither "wrong-old" nor "wrong-new" can import the cipher.
        let err = advance_matrix_sqlite_store_ciphers(temp.path(), "wrong-old", "wrong-new")
            .expect_err("must reject when neither passphrase imports");
        assert!(
            err.to_string().contains("accepts neither"),
            "operator-actionable error message, got: {err}"
        );
    }

    /// Helper for the rekey-advance tests. Writes a real
    /// `matrix-sdk-store-encryption` cipher record with the given
    /// passphrase into a fresh SQLite DB at `path`. The advance code
    /// then sees a real importable cipher exactly as the runtime
    /// produces.
    fn seed_test_matrix_store(path: &Path, passphrase: &str) {
        use matrix_sdk_store_encryption::StoreCipher;
        let cipher = StoreCipher::new().expect("new cipher");
        let blob = cipher.export(passphrase).expect("export");
        let conn = rusqlite::Connection::open(path).expect("open store");
        conn.execute("CREATE TABLE kv (key TEXT PRIMARY KEY, value BLOB)", [])
            .expect("create kv");
        conn.execute(
            "INSERT INTO kv (key, value) VALUES ('cipher', ?1)",
            rusqlite::params![blob],
        )
        .expect("seed cipher");
    }

    /// The Matrix SDK owns the SQLite store at runtime, while the
    /// `rekey-store` CLI path directly imports/exports the serialized
    /// `StoreCipher` blob. The direct dependency can be newer than the
    /// SDK's internal store-encryption crate, but the blob wire format
    /// must stay interoperable in both directions.
    #[test]
    fn test_matrix_store_cipher_direct_dependency_matches_sdk_wire_format() {
        use matrix_sdk_store_encryption::StoreCipher as DirectStoreCipher;
        use matrix_sdk_store_encryption_016::StoreCipher as SdkStoreCipher;

        let passphrase = "matrix-store-passphrase";

        // Generated with matrix-sdk-store-encryption 0.16.1
        // StoreCipher::_insecure_export_fast_for_testing(passphrase).
        // Hardcoding avoids constructing the 0.16.1 RNG path in this
        // process while still proving the exact serialized blob shape
        // emitted by the SDK-version crate.
        const SDK_016_STORE_CIPHER_BLOB: &[u8] = &[
            130, 168, 107, 100, 102, 95, 105, 110, 102, 111, 129, 184, 80, 98, 107, 100, 102, 50,
            84, 111, 67, 104, 97, 67, 104, 97, 50, 48, 80, 111, 108, 121, 49, 51, 48, 53, 130, 166,
            114, 111, 117, 110, 100, 115, 205, 3, 232, 168, 107, 100, 102, 95, 115, 97, 108, 116,
            220, 0, 32, 103, 204, 216, 204, 166, 21, 204, 178, 52, 114, 204, 203, 8, 121, 204, 130,
            105, 4, 100, 204, 174, 204, 165, 95, 105, 204, 174, 7, 103, 16, 101, 204, 212, 204,
            217, 7, 204, 194, 99, 120, 204, 253, 119, 10, 175, 99, 105, 112, 104, 101, 114, 116,
            101, 120, 116, 95, 105, 110, 102, 111, 129, 176, 67, 104, 97, 67, 104, 97, 50, 48, 80,
            111, 108, 121, 49, 51, 48, 53, 130, 165, 110, 111, 110, 99, 101, 220, 0, 24, 204, 158,
            51, 99, 59, 23, 204, 137, 79, 204, 240, 204, 134, 204, 130, 204, 218, 81, 204, 160,
            102, 124, 204, 171, 4, 42, 204, 204, 204, 146, 22, 204, 163, 68, 204, 128, 170, 99,
            105, 112, 104, 101, 114, 116, 101, 120, 116, 220, 0, 80, 24, 204, 191, 204, 224, 204,
            229, 16, 36, 204, 161, 40, 15, 99, 204, 165, 204, 198, 100, 76, 127, 204, 145, 204,
            248, 67, 204, 225, 73, 123, 12, 204, 190, 35, 80, 11, 44, 204, 131, 204, 149, 85, 54,
            50, 99, 204, 153, 204, 159, 204, 222, 29, 204, 133, 204, 206, 87, 28, 114, 14, 25, 92,
            204, 131, 28, 105, 52, 204, 141, 116, 34, 204, 216, 204, 217, 204, 153, 204, 171, 91,
            74, 204, 191, 204, 131, 204, 168, 30, 204, 225, 67, 62, 204, 203, 204, 174, 25, 110,
            204, 133, 124, 48, 204, 154, 14, 34, 204, 203, 12, 100, 204, 143, 204, 175,
        ];
        DirectStoreCipher::import(passphrase, SDK_016_STORE_CIPHER_BLOB)
            .expect("direct store-encryption must import SDK-version cipher blobs");

        let direct_cipher = DirectStoreCipher::new().expect("new direct-version cipher");
        let direct_blob = direct_cipher
            ._insecure_export_fast_for_testing(passphrase)
            .expect("export direct-version cipher");
        SdkStoreCipher::import(passphrase, &direct_blob)
            .expect("SDK-version store-encryption must import direct cipher blobs after rekey");
    }

    /// Detection-time error before any UPDATE means the operator can
    /// retry without partial-rotation cleanup. This guards the
    /// transition between detection and UPDATE in the advance driver.
    #[test]
    fn test_advance_classifies_old_and_new_distinctly() {
        let temp = tempfile::tempdir().expect("tempdir");
        let store_dir = temp.path().join("matrix");
        std::fs::create_dir_all(&store_dir).expect("matrix dir");
        let path = store_dir.join("matrix-sdk-state.sqlite3");
        seed_test_matrix_store(&path, "old-passphrase");

        let probe = detect_matrix_store_cipher_state(&path, "old-passphrase", "new-passphrase")
            .expect("detect");
        assert!(probe.importable_with_old);
        assert!(!probe.importable_with_new);

        let probe = detect_matrix_store_cipher_state(&path, "wrong", "old-passphrase")
            .expect("detect with different roles");
        assert!(!probe.importable_with_old, "wrong is not the cipher key");
        assert!(
            probe.importable_with_new,
            "old-passphrase is the cipher key"
        );
    }

    /// `roll_back_rotated_stores` must NOT silently swallow per-store
    /// rollback failures — the whole point of surfacing
    /// `rollback_failed: Vec<(PathBuf, String)>` from
    /// `MatrixRekeyAdvance::Failed` is so the caller (and operator)
    /// can refuse cleanup and inspect each store. This test forces a
    /// rollback failure by chmod'ing one of the SQLite files
    /// read-only after the rotation, and asserts the rollback driver
    /// reports the expected failed entry.
    #[cfg(unix)]
    #[test]
    fn test_roll_back_rotated_stores_surfaces_per_store_failures() {
        use std::os::unix::fs::PermissionsExt;
        let temp = tempfile::tempdir().expect("tempdir");
        let store_dir = temp.path().join("matrix");
        std::fs::create_dir_all(&store_dir).expect("matrix dir");
        let path_a = store_dir.join("matrix-sdk-state.sqlite3");
        let path_b = store_dir.join("matrix-sdk-crypto.sqlite3");
        seed_test_matrix_store(&path_a, "old-passphrase");
        seed_test_matrix_store(&path_b, "old-passphrase");

        // Detect both stores and pretend we rotated each.
        let probe_a = detect_matrix_store_cipher_state(&path_a, "old-passphrase", "new-passphrase")
            .expect("detect a");
        let probe_b = detect_matrix_store_cipher_state(&path_b, "old-passphrase", "new-passphrase")
            .expect("detect b");
        let rotated = vec![path_a.clone(), path_b.clone()];
        let probes = vec![probe_a, probe_b];

        // Force rollback failure on path_b by removing write
        // permission on the file. SQLite's UPDATE requires the file
        // to be writable; the open may succeed but the execute will
        // produce an error that `roll_back_rotated_stores` must
        // capture rather than silence.
        std::fs::set_permissions(&path_b, std::fs::Permissions::from_mode(0o400))
            .expect("chmod 0o400");
        // Restore parent dir's permissions so cleanup succeeds.
        let (rolled_back, rollback_failed) = roll_back_rotated_stores(&rotated, &probes);

        // path_a's rollback should succeed; path_b's should be in failed.
        assert!(
            rolled_back.iter().any(|p| p == &path_a),
            "writable store must be rolled back; got rolled_back={rolled_back:?} failed={rollback_failed:?}"
        );
        assert!(
            rollback_failed.iter().any(|(p, _)| p == &path_b),
            "read-only store must be in rollback_failed; got {rollback_failed:?}"
        );
        // Restore write permission so tempdir cleanup doesn't ENOENT.
        let _ = std::fs::set_permissions(&path_b, std::fs::Permissions::from_mode(0o600));
    }

    /// `roll_back_rotated_stores` reports an internal-error entry
    /// when a path appears in `rotated` without a matching probe —
    /// shouldn't happen in practice, but pins the defensive branch.
    #[test]
    fn test_roll_back_rotated_stores_handles_missing_probe() {
        let rotated = vec![std::path::PathBuf::from("/state/matrix/orphan.sqlite3")];
        let probes: Vec<MatrixStoreCipherProbe> = Vec::new();
        let (rolled_back, rollback_failed) = roll_back_rotated_stores(&rotated, &probes);
        assert!(rolled_back.is_empty());
        assert_eq!(rollback_failed.len(), 1);
        assert!(
            rollback_failed[0].1.contains("no detection probe"),
            "expected internal-error message; got {:?}",
            rollback_failed[0]
        );
    }

    /// `MatrixStoreCipherProbe`'s hand-rolled `Debug` must elide the
    /// cipher blob (length only). Pin the format so a future
    /// contributor cannot accidentally `derive(Debug)` and land the
    /// ciphertext bytes in operator logs / RedactingWriter.
    #[test]
    fn test_matrix_store_cipher_probe_debug_elides_blob() {
        let probe = MatrixStoreCipherProbe {
            path: std::path::PathBuf::from("/tmp/matrix-state.sqlite3"),
            cipher_blob: vec![0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE],
            importable_with_old: true,
            importable_with_new: false,
        };
        let formatted = format!("{:?}", probe);
        assert!(
            !formatted.contains("0xde")
                && !formatted.contains("0xDE")
                && !formatted.contains("ca, fe")
                && !formatted.contains("[222"),
            "Debug must not include cipher blob bytes; got {formatted}"
        );
        assert!(
            formatted.contains("<elided 8 bytes>"),
            "expected elided-byte-count format; got {formatted}"
        );
    }

    /// `format_matrix_rekey_failure` must produce operator-actionable
    /// text in BOTH the rolled-back and rollback-failed cases. A
    /// regression that drops the "ROLLBACK ALSO FAILED" string would
    /// silently turn a worst-case rekey scenario into the same
    /// message as a clean rollback.
    #[test]
    fn test_format_matrix_rekey_failure_phrasing() {
        let pending = std::path::PathBuf::from("/state/matrix/store_passphrase.pending");
        let marker = std::path::PathBuf::from("/state/matrix/store_passphrase.rekeying");

        // Clean rollback: must mention "Rolled back" but NOT
        // "rollback ALSO FAILED" / "rollback ALSO".
        let rolled_back = vec![std::path::PathBuf::from("/state/matrix/store-1.sqlite3")];
        let err =
            format_matrix_rekey_failure("decrypt failed", &rolled_back, &[], &pending, &marker);
        let msg = err.to_string();
        assert!(msg.contains("Rolled back 1"), "got: {msg}");
        assert!(!msg.to_uppercase().contains("ROLLBACK ALSO"), "got: {msg}");
        assert!(
            msg.contains("matrix/inbound_dlq.jsonl.pre-rekey"),
            "clean rollback guidance must mention the .pre-rekey DLQ backup; got: {msg}"
        );

        // Rollback-failed: must include the per-store error path AND
        // a clear "ROLLBACK ALSO FAILED" indication so the operator
        // knows to inspect the stores.
        let rollback_failed = vec![(
            std::path::PathBuf::from("/state/matrix/store-2.sqlite3"),
            "permission denied".to_string(),
        )];
        let err = format_matrix_rekey_failure(
            "decrypt failed",
            &rolled_back,
            &rollback_failed,
            &pending,
            &marker,
        );
        let msg = err.to_string();
        // Case-insensitive: "rollback ALSO FAILED" is the format
        // string's actual phrasing; the operator-visible signal is
        // the "ALSO FAILED" emphasis next to "rollback".
        assert!(
            msg.to_uppercase().contains("ROLLBACK ALSO FAILED"),
            "rollback-failed case must surface 'rollback ALSO FAILED'; got: {msg}"
        );
        assert!(
            msg.contains("store-2.sqlite3"),
            "must mention the failing store path; got: {msg}"
        );
        assert!(
            msg.contains(&pending.display().to_string())
                && msg.contains(&marker.display().to_string()),
            "must mention preserved pending+marker paths; got: {msg}"
        );
        assert!(
            msg.contains("matrix/inbound_dlq.jsonl.pre-rekey"),
            "rollback-failed guidance must mention the .pre-rekey DLQ backup; got: {msg}"
        );
    }

    #[test]
    fn test_write_owner_only_cli_secret_refuses_overwrite() {
        let temp = tempfile::tempdir().expect("tempdir");
        let path = temp.path().join("matrix-recovery-key");
        write_owner_only_cli_secret_no_replace(&path, "old-key")
            .expect("initial recovery key write");

        let err = write_owner_only_cli_secret_no_replace(&path, "new-key")
            .expect_err("restore must not overwrite existing key");
        assert!(err.to_string().contains("refusing to overwrite"));
        assert_eq!(
            std::fs::read_to_string(&path).expect("read key").trim(),
            "old-key"
        );
    }

    #[test]
    fn test_sanitize_channel_delivery_error_redacts_untrusted_body() {
        assert_eq!(
            sanitize_channel_delivery_error(
                "<html>stacktrace and debug secrets</html>".to_string()
            ),
            "send path rejected request (provider details hidden for safety)"
        );
    }

    #[test]
    fn test_sanitize_channel_delivery_error_redacts_short_standard_message() {
        assert_eq!(
            sanitize_channel_delivery_error("Unauthorized".to_string()),
            "send path rejected request (provider details hidden for safety)"
        );
    }

    #[test]
    fn test_infer_setup_outcome_from_config_prefers_discord_then_telegram() {
        let cfg = serde_json::json!({
            "discord": { "botToken": "discord-token" },
            "telegram": { "botToken": "telegram-token" },
            "matrix": {
                "homeserverUrl": "https://matrix.example.com",
                "userId": "@cara:example.com",
                "accessToken": "token",
                "deviceId": "DEVICE",
                "encrypted": false
            },
            "gateway": { "hooks": { "enabled": true } }
        });
        assert_eq!(infer_setup_outcome_from_config(&cfg), SetupOutcome::Discord);

        let cfg = serde_json::json!({
            "telegram": { "botToken": "telegram-token" },
            "gateway": { "hooks": { "enabled": true } }
        });
        assert_eq!(
            infer_setup_outcome_from_config(&cfg),
            SetupOutcome::Telegram
        );

        let cfg = serde_json::json!({
            "matrix": {
                "homeserverUrl": "https://matrix.example.com",
                "userId": "@cara:example.com",
                "accessToken": "token",
                "deviceId": "DEVICE",
                "encrypted": false
            },
            "gateway": { "hooks": { "enabled": true } }
        });
        assert_eq!(infer_setup_outcome_from_config(&cfg), SetupOutcome::Matrix);
    }

    #[test]
    fn test_infer_setup_outcome_from_config_hooks_then_local() {
        let hooks_cfg = serde_json::json!({
            "gateway": { "hooks": { "enabled": true } }
        });
        assert_eq!(
            infer_setup_outcome_from_config(&hooks_cfg),
            SetupOutcome::Hooks
        );

        let local_cfg = serde_json::json!({});
        assert_eq!(
            infer_setup_outcome_from_config(&local_cfg),
            SetupOutcome::LocalChat
        );
    }

    #[test]
    fn test_infer_setup_outcome_ignores_empty_or_unresolved_channel_tokens() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("CARAPACE_TEST_VERIFY_MISSING_TELEGRAM_TOKEN");
        let empty_discord_token_cfg = serde_json::json!({
            "discord": { "enabled": true, "botToken": "   " },
            "gateway": { "hooks": { "enabled": true } }
        });
        assert_eq!(
            infer_setup_outcome_from_config(&empty_discord_token_cfg),
            SetupOutcome::Hooks
        );

        let unresolved_placeholder_cfg = serde_json::json!({
            "telegram": { "enabled": true, "botToken": "${CARAPACE_TEST_VERIFY_MISSING_TELEGRAM_TOKEN}" },
            "gateway": { "hooks": { "enabled": true } }
        });
        assert_eq!(
            infer_setup_outcome_from_config(&unresolved_placeholder_cfg),
            SetupOutcome::Hooks
        );
    }

    // -----------------------------------------------------------------------
    // Setup subcommand tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_cli_setup_no_force() {
        let cli = Cli::try_parse_from(["cara", "setup"]).unwrap();
        match cli.command {
            Some(Command::Setup {
                force,
                provider: None,
                auth_mode: None,
                model: None,
            }) => {
                assert!(!force);
            }
            other => panic!("Expected Setup, got {:?}", other),
        }
    }

    #[test]
    fn test_cli_setup_with_force() {
        let cli = Cli::try_parse_from(["cara", "setup", "--force"]).unwrap();
        match cli.command {
            Some(Command::Setup {
                force,
                provider: None,
                auth_mode: None,
                model: None,
            }) => {
                assert!(force);
            }
            other => panic!("Expected Setup, got {:?}", other),
        }
    }

    #[test]
    fn test_cli_setup_with_provider() {
        let cli = Cli::try_parse_from(["cara", "setup", "--provider", "ollama"]).unwrap();
        match cli.command {
            Some(Command::Setup {
                force,
                provider: Some(SetupProvider::Ollama),
                auth_mode: None,
                model: None,
            }) => {
                assert!(!force);
            }
            other => panic!("Expected Setup with provider, got {:?}", other),
        }
    }

    #[test]
    fn test_cli_setup_with_vertex_provider() {
        let cli = Cli::try_parse_from(["cara", "setup", "--provider", "vertex"]).unwrap();
        match cli.command {
            Some(Command::Setup {
                force,
                provider: Some(SetupProvider::Vertex),
                auth_mode: None,
                model: None,
            }) => {
                assert!(!force);
            }
            other => panic!("Expected Setup with vertex provider, got {:?}", other),
        }
    }

    #[test]
    fn test_cli_setup_with_provider_aliases() {
        let claude_cli = Cli::try_parse_from(["cara", "setup", "--provider", "claude"]).unwrap();
        match claude_cli.command {
            Some(Command::Setup {
                force,
                provider: Some(SetupProvider::Anthropic),
                auth_mode: None,
                model: None,
            }) => {
                assert!(!force);
            }
            other => panic!("Expected Setup with anthropic alias, got {:?}", other),
        }

        let gpt_cli = Cli::try_parse_from(["cara", "setup", "--provider", "gpt"]).unwrap();
        match gpt_cli.command {
            Some(Command::Setup {
                force,
                provider: Some(SetupProvider::OpenAi),
                auth_mode: None,
                model: None,
            }) => {
                assert!(!force);
            }
            other => panic!("Expected Setup with openai alias, got {:?}", other),
        }
    }

    #[test]
    fn test_cli_setup_with_gemini_auth_mode() {
        let cli = Cli::try_parse_from([
            "cara",
            "setup",
            "--provider",
            "gemini",
            "--auth-mode",
            "oauth",
        ])
        .unwrap();
        match cli.command {
            Some(Command::Setup {
                force,
                provider: Some(SetupProvider::Gemini),
                auth_mode: Some(SetupAuthModeSelection::OAuth),
                model: None,
            }) => {
                assert!(!force);
            }
            other => panic!(
                "Expected Setup with Gemini OAuth auth mode, got {:?}",
                other
            ),
        }
    }

    #[test]
    fn test_cli_setup_with_model_flag() {
        let cli = Cli::try_parse_from([
            "cara",
            "setup",
            "--provider",
            "anthropic",
            "--model",
            TEST_MODEL_ANTHROPIC,
        ])
        .unwrap();
        match cli.command {
            Some(Command::Setup {
                force,
                provider: Some(SetupProvider::Anthropic),
                auth_mode: None,
                model: Some(model),
            }) => {
                assert!(!force);
                assert_eq!(model, TEST_MODEL_ANTHROPIC);
            }
            other => panic!("Expected Setup with model flag, got {:?}", other),
        }
    }

    #[test]
    fn test_validate_setup_model_input_accepts_canonical_form() {
        let result = validate_setup_model_input(TEST_MODEL_ANTHROPIC, SetupProvider::Anthropic);
        assert_eq!(result.as_deref(), Ok(TEST_MODEL_ANTHROPIC));

        let result = validate_setup_model_input(TEST_MODEL_NEARAI, SetupProvider::NearAi);
        assert_eq!(result.as_deref(), Ok(TEST_MODEL_NEARAI));

        let result = validate_setup_model_input(
            "vertex:publishers/google/models/gemini-2.5-flash",
            SetupProvider::Vertex,
        );
        assert_eq!(
            result.as_deref(),
            Ok("vertex:publishers/google/models/gemini-2.5-flash")
        );
    }

    #[test]
    fn test_validated_setup_model_tracks_provider_prefix() {
        let model = ValidatedSetupModel::parse(TEST_MODEL_OPENAI, SetupProvider::OpenAi)
            .expect("OpenAI setup model");
        assert_eq!(model.provider(), Some(SetupProvider::OpenAi));
    }

    #[test]
    fn test_setup_provider_prompt_key_lookup_tracks_provider_registry() {
        for provider in crate::onboarding::setup::SetupProvider::all() {
            assert!(
                !provider.prompt_key().contains('.'),
                "setup provider prompt key `{}` must stay dot-free for Bedrock native ID disambiguation",
                provider.prompt_key()
            );
            assert!(
                is_setup_provider_prompt_key(provider.prompt_key()),
                "prompt key lookup must recognize registered provider `{}`",
                provider.prompt_key()
            );
        }
        assert!(!is_setup_provider_prompt_key("madeup"));
    }

    #[test]
    fn test_setup_provider_implied_by_model_input_unrecognized_prefix_error_uses_canonical_form() {
        let err = setup_provider_implied_by_model_input("MADEUP: gpt-5.5")
            .expect_err("unrecognized provider prefixes should error");
        assert!(
            err.contains("`madeup:gpt-5.5` uses unrecognized provider prefix `madeup:`"),
            "error should show the normalized provider/model form, got: {err}"
        );
        for prefix in [
            "`anthropic:`",
            "`codex:`",
            "`openai:`",
            "`ollama:`",
            "`gemini:`",
            "`vertex:`",
            "`nearai:`",
            "`venice:`",
            "`bedrock:`",
        ] {
            assert!(
                err.contains(prefix),
                "error should list recognized provider prefix {prefix}, got: {err}"
            );
        }
        assert!(
            !err.contains("MADEUP"),
            "error should normalize provider prefix casing, got: {err}"
        );
        assert!(
            !err.contains("madeup: gpt-5.5"),
            "error should trim whitespace after the colon, got: {err}"
        );
    }

    #[test]
    fn test_setup_provider_implied_by_model_input_rejects_provider_prefix_whitespace() {
        let err = setup_provider_implied_by_model_input("open ai:gpt-5.5")
            .expect_err("provider prefixes should reject internal whitespace");
        assert!(
            err.contains("provider prefix `open ai` must not contain whitespace"),
            "error should identify prefix whitespace, got: {err}"
        );
    }

    #[test]
    fn test_setup_provider_implied_by_model_input_rejects_bare_model_whitespace() {
        let err = setup_provider_implied_by_model_input("claude sonnet")
            .expect_err("bare model IDs should reject internal whitespace");
        assert!(
            err.contains("model id `claude sonnet` must not contain whitespace"),
            "error should identify bare model whitespace, got: {err}"
        );
    }

    #[test]
    fn test_setup_provider_implied_by_model_input_skips_bedrock_native_id_without_provider() {
        let inferred = setup_provider_implied_by_model_input("anthropic.claude-v1:0")
            .expect("dotted Bedrock native IDs should defer provider inference");
        assert_eq!(inferred, None);
    }

    #[test]
    fn test_setup_provider_implied_by_model_input_rejects_bedrock_arn_without_provider() {
        let err = setup_provider_implied_by_model_input(
            "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-v2",
        )
        .expect_err("Bedrock ARN model IDs should be rejected before provider inference");
        assert!(
            err.contains("Bedrock ARN model identifiers are not supported"),
            "error should identify unsupported ARN-style Bedrock model IDs, got: {err}"
        );
        assert!(
            err.contains("anthropic.claude-v1:0"),
            "error should point operators toward native Bedrock model IDs, got: {err}"
        );

        let err = setup_provider_implied_by_model_input(
            "arn:aws:bedrock-runtime:us-east-1:123456789012:foundation-model/anthropic.claude-v2",
        )
        .expect_err("Bedrock runtime ARN model IDs should be rejected before provider inference");
        assert!(
            err.contains("Bedrock ARN model identifiers are not supported"),
            "error should identify unsupported Bedrock runtime ARN-style model IDs, got: {err}"
        );
    }

    #[test]
    fn test_resolve_setup_request_infers_provider_and_canonical_model() {
        let request = resolve_setup_request(None, Some("OPENAI: gpt-5.5"))
            .expect("known provider prefix should infer provider");
        assert_eq!(request.provider, Some(SetupProvider::OpenAi));
        assert_eq!(
            request.model.as_ref().map(ValidatedSetupModel::as_str),
            Some(TEST_MODEL_OPENAI)
        );
        assert_eq!(request.bare_model_without_provider, None);
    }

    #[test]
    fn test_resolve_setup_request_marks_bare_model_without_provider() {
        let request = resolve_setup_request(None, Some("  gpt-5.5  "))
            .expect("bare model should wait for provider selection");
        assert_eq!(request.provider, None);
        assert_eq!(request.model, None);
        assert_eq!(
            request.bare_model_without_provider.as_deref(),
            Some("gpt-5.5")
        );
    }

    #[test]
    fn test_setup_provider_model_prompt_label_distinguishes_codex_from_openai() {
        assert_eq!(SetupProvider::Codex.label(), "OpenAI");
        assert_eq!(SetupProvider::Codex.model_prompt_label(), "Codex");
        assert_eq!(SetupProvider::OpenAi.model_prompt_label(), "OpenAI");
    }

    #[test]
    fn test_validate_setup_model_input_normalizes_prefix_case() {
        let result =
            validate_setup_model_input("Anthropic: claude-sonnet-4-6", SetupProvider::Anthropic);
        assert_eq!(result.as_deref(), Ok(TEST_MODEL_ANTHROPIC));
    }

    #[test]
    fn test_validate_setup_model_input_auto_prefixes_bare_model() {
        let result = validate_setup_model_input("claude-opus-4-7", SetupProvider::Anthropic);
        assert_eq!(result.as_deref(), Ok("anthropic:claude-opus-4-7"));
    }

    #[test]
    fn test_validate_setup_model_input_rejects_empty() {
        let result = validate_setup_model_input("   ", SetupProvider::Anthropic);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("required"));
    }

    #[test]
    fn test_validate_setup_model_input_rejects_provider_mismatch() {
        let result = validate_setup_model_input(TEST_MODEL_OPENAI, SetupProvider::Anthropic);
        let err = result.expect_err("mismatch should error");
        assert!(err.contains("uses the `openai:` provider prefix"));
        assert!(err.contains("`--provider anthropic`"));
    }

    #[test]
    fn test_validate_setup_model_input_rejects_empty_model_id_after_prefix() {
        let result = validate_setup_model_input("anthropic:", SetupProvider::Anthropic);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("model id after"));
    }

    #[test]
    fn test_validate_setup_model_input_rejects_model_placeholder() {
        let result = validate_setup_model_input("<MODEL-ID>", SetupProvider::OpenAi);
        let err = result.expect_err("bare placeholder should be rejected");
        assert!(
            err.contains("replace `<model-id>`"),
            "error should tell operators to substitute the placeholder, got: {err}"
        );

        let result = validate_setup_model_input("openai:<Model-ID>", SetupProvider::OpenAi);
        let err = result.expect_err("prefixed placeholder should be rejected");
        assert!(
            err.contains("replace `<model-id>`"),
            "error should tell operators to substitute the placeholder, got: {err}"
        );

        let result = validate_setup_model_input("<YOUR-MODEL>", SetupProvider::OpenAi);
        let err = result.expect_err("angle-bracket model should be rejected");
        assert!(
            err.contains("replace `<model-id>`"),
            "generic angle-bracket input should get the template-placeholder hint, got: {err}"
        );
        assert!(
            err.contains("angle-bracket placeholder text"),
            "generic angle-bracket input should not imply only the literal `<model-id>` template, got: {err}"
        );

        let result = validate_setup_model_input("<>", SetupProvider::OpenAi);
        let err = result.expect_err("empty angle-bracket model should be rejected");
        assert!(
            err.contains("replace `<model-id>`"),
            "empty angle-bracket input should get the template-placeholder hint, got: {err}"
        );

        let result = validate_setup_model_input("openai:<gpt-model>", SetupProvider::OpenAi);
        let err = result.expect_err("generic prefixed angle-bracket model should be rejected");
        assert!(
            err.contains("replace `<model-id>`"),
            "generic prefixed angle-bracket input should get the template-placeholder hint, got: {err}"
        );
    }

    #[test]
    fn test_validate_setup_model_input_rejects_repeated_provider_prefix() {
        let result =
            validate_setup_model_input("vertex:vertex:gemini-2.5-flash", SetupProvider::Vertex);
        let err = result.expect_err("repeated provider prefix should be rejected");
        assert!(
            err.contains("must not repeat the `vertex:` provider prefix"),
            "error should identify the repeated provider prefix, got: {err}"
        );

        let result = validate_setup_model_input("ollama:ollama:qwen3", SetupProvider::Ollama);
        let err = result.expect_err("Ollama model IDs should not repeat the provider prefix");
        assert!(
            err.contains("must not repeat the `ollama:` provider prefix"),
            "error should identify the repeated Ollama provider prefix, got: {err}"
        );
    }

    #[test]
    fn test_validate_setup_model_input_rejects_unexpected_multi_colon_model_id() {
        let result = validate_setup_model_input("openai:gpt-5.5:latest", SetupProvider::OpenAi);
        let err = result.expect_err("OpenAI model IDs should not accept tag-style suffixes");
        assert!(
            err.contains("must not contain `:` for `openai:` models"),
            "error should identify provider-specific colon misuse, got: {err}"
        );

        let result =
            validate_setup_model_input("anthropic:claude-sonnet:latest", SetupProvider::Anthropic);
        let err = result.expect_err("Anthropic model IDs should not accept tag-style suffixes");
        assert!(
            err.contains("must not contain `:` for `anthropic:` models"),
            "error should identify provider-specific colon misuse, got: {err}"
        );
    }

    #[test]
    fn test_validate_setup_model_input_rejects_model_id_whitespace() {
        let result = validate_setup_model_input("claude sonnet", SetupProvider::Anthropic);
        let err = result.expect_err("bare model IDs should reject internal whitespace");
        assert!(
            err.contains("must not contain whitespace"),
            "error should explain the whitespace problem, got: {err}"
        );

        let result =
            validate_setup_model_input("anthropic:claude sonnet", SetupProvider::Anthropic);
        let err = result.expect_err("prefixed model IDs should reject internal whitespace");
        assert!(
            err.contains("must not contain whitespace"),
            "error should explain the whitespace problem, got: {err}"
        );
    }

    #[test]
    fn test_validate_setup_model_input_rejects_shell_metacharacters() {
        let result =
            validate_setup_model_input("anthropic:claude$(evil)", SetupProvider::Anthropic);
        let err = result.expect_err("model IDs should reject shell metacharacters");
        assert!(
            err.contains("must contain only letters, numbers"),
            "error should identify unsupported model-id characters, got: {err}"
        );

        let result = validate_setup_model_input("ollama:qwen2.5-coder:32b", SetupProvider::Ollama);
        assert_eq!(result.as_deref(), Ok("ollama:qwen2.5-coder:32b"));
    }

    #[test]
    fn test_validate_setup_model_input_escapes_control_chars_in_errors() {
        let result =
            validate_setup_model_input("anthropic:claude\u{1b}[31m", SetupProvider::Anthropic);
        let err = result.expect_err("model IDs should reject ANSI escape characters");
        assert!(
            err.contains("claude\\u{1b}[31m"),
            "error should escape control characters, got: {err}"
        );
        assert!(
            !err.contains('\u{1b}'),
            "error must not contain raw terminal control characters, got: {err:?}"
        );
    }

    #[test]
    fn test_validate_setup_model_input_rejects_flag_like_model_id() {
        let result = validate_setup_model_input("--help", SetupProvider::Anthropic);
        let err = result.expect_err("bare flag-like model IDs should be rejected");
        assert!(
            err.contains("must not start with `-`"),
            "error should identify flag-like model IDs, got: {err}"
        );

        let result = validate_setup_model_input("anthropic:--help", SetupProvider::Anthropic);
        let err = result.expect_err("prefixed flag-like model IDs should be rejected");
        assert!(
            err.contains("must not start with `-`"),
            "error should identify flag-like prefixed model IDs, got: {err}"
        );

        let result =
            validate_setup_model_input("bedrock:anthropic.claude-v1:-1", SetupProvider::Bedrock);
        let err =
            result.expect_err("colon-delimited flag-like Bedrock suffixes should be rejected");
        assert!(
            err.contains("segment starting with `-`"),
            "error should identify flag-like nested model ID segments, got: {err}"
        );
    }

    #[test]
    fn test_validate_setup_model_input_rejects_provider_prefix_whitespace() {
        let result = validate_setup_model_input("open ai:gpt-5.5", SetupProvider::OpenAi);
        let err = result.expect_err("provider prefixes should reject internal whitespace");
        assert!(
            err.contains("provider prefix `open ai` must not contain whitespace"),
            "error should identify prefix whitespace, got: {err}"
        );
    }

    #[test]
    fn test_validate_setup_model_input_accepts_vertex_default_sentinel() {
        let result =
            validate_setup_model_input(TEST_MODEL_VERTEX_DEFAULT_ROUTE, SetupProvider::Vertex);
        assert_eq!(result.as_deref(), Ok(TEST_MODEL_VERTEX_DEFAULT_ROUTE));

        let result = validate_setup_model_input("vertex:Default", SetupProvider::Vertex);
        assert_eq!(result.as_deref(), Ok(TEST_MODEL_VERTEX_DEFAULT_ROUTE));

        let result = validate_setup_model_input("Default", SetupProvider::Vertex);
        assert_eq!(result.as_deref(), Ok(TEST_MODEL_VERTEX_DEFAULT_ROUTE));
    }

    #[test]
    fn test_validate_setup_model_input_accepts_codex_default_sentinel() {
        let result = validate_setup_model_input(TEST_MODEL_CODEX, SetupProvider::Codex);
        assert_eq!(result.as_deref(), Ok(TEST_MODEL_CODEX));

        let result = validate_setup_model_input("default", SetupProvider::Codex);
        assert_eq!(result.as_deref(), Ok(TEST_MODEL_CODEX));

        let result = validate_setup_model_input("codex:Default", SetupProvider::Codex);
        assert_eq!(result.as_deref(), Ok(TEST_MODEL_CODEX));
    }

    #[test]
    fn test_validate_setup_model_input_accepts_codex_explicit_model() {
        let result = validate_setup_model_input("gpt-5.5", SetupProvider::Codex);
        assert_eq!(result.as_deref(), Ok("codex:gpt-5.5"));

        let result = validate_setup_model_input("Codex:gpt-5.5", SetupProvider::Codex);
        assert_eq!(result.as_deref(), Ok("codex:gpt-5.5"));
    }

    #[test]
    fn test_codex_noninteractive_setup_error_uses_setup_command() {
        let error = codex_noninteractive_setup_error();
        let command = crate::onboarding::setup::SetupProvider::Codex.setup_command(None);
        assert!(
            error.contains(CODEX_DEFAULT_SENTINEL),
            "Codex non-interactive setup guidance must stay in sync with the default sentinel"
        );
        assert!(
            error.contains(&format!("`{command}`")),
            "Codex non-interactive setup guidance must use the canonical setup command"
        );
    }

    #[test]
    fn test_infer_bare_model_provider_requires_positive_provider_match() {
        assert_eq!(
            infer_bare_model_provider("gpt-5.5", SetupProvider::OpenAi),
            BareModelProviderInference::MatchesSelectedProvider
        );
        assert_eq!(
            infer_bare_model_provider("gpt-5.5", SetupProvider::Anthropic),
            BareModelProviderInference::MatchesDifferentProvider
        );
        assert_eq!(
            infer_bare_model_provider("llama3", SetupProvider::Anthropic),
            BareModelProviderInference::Unknown
        );
        assert_eq!(
            infer_bare_model_provider("llama3", SetupProvider::Ollama),
            BareModelProviderInference::MatchesSelectedProvider
        );
        assert_eq!(
            infer_bare_model_provider("default", SetupProvider::Codex),
            BareModelProviderInference::MatchesSelectedProvider
        );
        assert_eq!(
            infer_bare_model_provider("default", SetupProvider::Anthropic),
            BareModelProviderInference::Unknown
        );
        assert_eq!(
            infer_bare_model_provider("default", SetupProvider::Vertex),
            BareModelProviderInference::MatchesSelectedProvider
        );
    }

    #[test]
    fn test_bare_model_confirmation_prompt_repeats_cross_provider_warning() {
        let prompt = bare_model_confirmation_prompt(
            AmbiguousBareModelProviderInference::MatchesDifferentProvider,
            "gpt-5.5\u{1b}[31m",
            "anthropic:gpt-5.5",
            SetupProvider::Anthropic,
        );

        assert!(
            prompt.contains("different provider"),
            "confirmation prompt must repeat the cross-provider warning: {prompt}"
        );
        assert!(
            prompt.contains("`anthropic:gpt-5.5`"),
            "confirmation prompt must include the resolved model: {prompt}"
        );
        assert!(
            prompt.contains("gpt-5.5\\u{1b}[31m"),
            "confirmation prompt must escape raw input control characters: {prompt}"
        );
        assert!(
            !prompt.contains('\u{1b}'),
            "confirmation prompt must not contain raw terminal control characters: {prompt:?}"
        );
    }

    #[test]
    fn test_bare_model_confirmation_prompt_repeats_unknown_provider_warning() {
        let prompt = bare_model_confirmation_prompt(
            AmbiguousBareModelProviderInference::Unknown,
            "custom-model",
            "gemini:custom-model",
            SetupProvider::Gemini,
        );

        assert!(
            prompt.contains("cannot infer"),
            "confirmation prompt must repeat the unknown-provider warning: {prompt}"
        );
        assert!(
            prompt.contains("`gemini:custom-model`"),
            "confirmation prompt must include the resolved model: {prompt}"
        );
    }

    #[test]
    fn test_validate_setup_model_input_trims_whitespace_around_colon() {
        assert_eq!(
            validate_setup_model_input("openai: gpt-5.5", SetupProvider::OpenAi).as_deref(),
            Ok("openai:gpt-5.5")
        );
        assert_eq!(
            validate_setup_model_input("  openai :  gpt-5.5  ", SetupProvider::OpenAi).as_deref(),
            Ok("openai:gpt-5.5")
        );
    }

    #[test]
    fn test_validate_setup_model_input_auto_prefixes_bedrock_native_id() {
        // Bedrock native model IDs like `anthropic.claude-v1:0` contain a
        // colon as part of the model id, not as a provider/model separator.
        // The validator must treat them as bare and auto-prefix them.
        let result = validate_setup_model_input("anthropic.claude-v1:0", SetupProvider::Bedrock);
        assert_eq!(result.as_deref(), Ok("bedrock:anthropic.claude-v1:0"));

        let result = validate_setup_model_input("anthropic.claude-v1: 0", SetupProvider::Bedrock);
        assert_eq!(result.as_deref(), Ok("bedrock:anthropic.claude-v1:0"));

        let result = validate_setup_model_input("ANTHROPIC.CLAUDE-V1:0", SetupProvider::Bedrock);
        assert_eq!(result.as_deref(), Ok("bedrock:ANTHROPIC.CLAUDE-V1:0"));

        let result = validate_setup_model_input(
            "us.anthropic.claude-3-5-haiku-20241022-v1:0",
            SetupProvider::Bedrock,
        );
        assert_eq!(
            result.as_deref(),
            Ok("bedrock:us.anthropic.claude-3-5-haiku-20241022-v1:0")
        );

        let result = validate_setup_model_input("anthropic.claude-v2", SetupProvider::Bedrock);
        assert_eq!(result.as_deref(), Ok("bedrock:anthropic.claude-v2"));
    }

    #[test]
    fn test_validate_setup_model_input_rejects_bedrock_native_id_for_wrong_provider() {
        let result = validate_setup_model_input("anthropic.claude-v1:0", SetupProvider::OpenAi);
        let err = result.expect_err("Bedrock native IDs should not auto-prefix as OpenAI");
        assert!(
            err.contains("`anthropic.claude-v1:0`"),
            "error should keep the suspicious input visible, got: {err}"
        );
        assert!(
            err.contains("looks like a Bedrock native model ID"),
            "error should identify the Bedrock native ID shape, got: {err}"
        );
        assert!(
            err.contains("`--provider bedrock`"),
            "error should point at the likely provider, got: {err}"
        );
        assert!(
            err.contains("`--provider openai`"),
            "error should point at the configured provider, got: {err}"
        );
    }

    #[test]
    fn test_validate_setup_model_input_rejects_bedrock_native_id_extra_colon() {
        let result =
            validate_setup_model_input("anthropic.claude-v1:0:extra", SetupProvider::Bedrock);
        let err = result.expect_err("Bedrock native IDs should contain exactly one colon");
        assert!(
            err.contains("must contain exactly one colon"),
            "error should identify the malformed native Bedrock ID, got: {err}"
        );

        let result =
            validate_setup_model_input("Anthropic.Claude-V1:0:Extra", SetupProvider::Bedrock);
        let err = result.expect_err("Bedrock native ID errors should preserve entered casing");
        assert!(
            err.contains("Anthropic.Claude-V1:0:Extra"),
            "error should echo the operator's original model ID casing, got: {err}"
        );

        let result = validate_setup_model_input(
            "bedrock:anthropic.claude-v1:0:extra",
            SetupProvider::Bedrock,
        );
        let err = result.expect_err("prefixed Bedrock IDs should reject extra colons too");
        assert!(
            err.contains("must contain at most one native-model suffix colon"),
            "error should identify the malformed prefixed Bedrock ID, got: {err}"
        );
    }

    #[test]
    fn test_validate_setup_model_input_rejects_empty_bedrock_colon_segments() {
        let result =
            validate_setup_model_input("bedrock:anthropic.claude-v1:", SetupProvider::Bedrock);
        let err = result.expect_err("prefixed Bedrock IDs should reject empty native suffixes");
        assert!(
            err.contains("must not contain empty `:` segments"),
            "error should identify the empty Bedrock suffix segment, got: {err}"
        );

        let result = validate_setup_model_input("bedrock::0", SetupProvider::Bedrock);
        let err = result.expect_err("prefixed Bedrock IDs should reject empty native prefixes");
        assert!(
            err.contains("must not contain empty `:` segments"),
            "error should identify the empty Bedrock prefix segment, got: {err}"
        );
    }

    #[test]
    fn test_validate_setup_model_input_ollama_tag_error_does_not_claim_bedrock() {
        let result = validate_setup_model_input("llama3.2:3b", SetupProvider::Ollama);
        let err = result.expect_err("Ollama tags need the canonical provider prefix");
        assert!(
            err.contains("uses unrecognized provider prefix `llama3.2:`"),
            "error should identify the unrecognized tag prefix, got: {err}"
        );
        assert!(
            err.contains("ollama:<model-id>"),
            "error should point at the configured Ollama provider, got: {err}"
        );
        assert!(
            !err.contains("Bedrock"),
            "Ollama tag errors must not claim the input is Bedrock-specific, got: {err}"
        );
    }

    #[test]
    fn test_validate_setup_model_input_accepts_prefixed_ollama_tag() {
        assert_eq!(
            validate_setup_model_input("ollama:qwen3-coder:30b", SetupProvider::Ollama).as_deref(),
            Ok("ollama:qwen3-coder:30b")
        );

        let err = validate_setup_model_input("qwen3-coder:30b", SetupProvider::Ollama)
            .expect_err("Ollama tags with ':' require the canonical provider prefix");
        assert!(
            err.contains("uses unrecognized provider prefix `qwen3-coder:`"),
            "error should tell operators to use the full prefixed Ollama tag, got: {err}"
        );
        assert!(
            err.contains("ollama:<model-id>"),
            "error should include the expected Ollama shape, got: {err}"
        );
    }

    #[test]
    fn test_validate_setup_model_input_rejects_empty_ollama_tag_segments() {
        let result = validate_setup_model_input("ollama:qwen3:", SetupProvider::Ollama);
        let err = result.expect_err("Ollama tags should reject empty trailing segments");
        assert!(
            err.contains("must not contain empty `:` segments"),
            "error should identify the empty Ollama tag segment, got: {err}"
        );

        let result = validate_setup_model_input("ollama::qwen3", SetupProvider::Ollama);
        let err = result.expect_err("Ollama tags should reject empty leading segments");
        assert!(
            err.contains("must not contain empty `:` segments"),
            "error should identify the empty Ollama model segment, got: {err}"
        );
    }

    #[test]
    fn test_validate_setup_model_input_rejects_bedrock_arn_with_targeted_error() {
        let result = validate_setup_model_input(
            "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-v2",
            SetupProvider::Bedrock,
        );
        let err = result.expect_err("Bedrock ARN model IDs are not setup model IDs");
        assert!(
            err.contains("Bedrock ARN model identifiers are not supported"),
            "error should identify unsupported ARN-style Bedrock model IDs, got: {err}"
        );
        assert!(
            err.contains("anthropic.claude-v1:0"),
            "error should point operators toward native Bedrock model IDs, got: {err}"
        );
    }

    #[test]
    fn test_validate_setup_model_input_rejects_unrecognized_prefix_without_model_type_claim() {
        let result = validate_setup_model_input("madeup:gpt-5.5", SetupProvider::OpenAi);
        let err = result.expect_err("unrecognized provider prefixes should error");
        assert!(
            err.contains("uses unrecognized provider prefix `madeup:`"),
            "error should identify the unrecognized prefix, got: {err}"
        );
        assert!(
            !err.contains("is a `madeup` model"),
            "error should not call an unrecognized prefix a model type, got: {err}"
        );
    }

    #[test]
    fn test_validate_setup_model_input_accepts_canonical_bedrock_form() {
        let result = validate_setup_model_input(TEST_MODEL_BEDROCK, SetupProvider::Bedrock);
        assert_eq!(result.as_deref(), Ok(TEST_MODEL_BEDROCK));

        let result =
            validate_setup_model_input("BEDROCK:ANTHROPIC.CLAUDE-V1:0", SetupProvider::Bedrock);
        assert_eq!(result.as_deref(), Ok("bedrock:ANTHROPIC.CLAUDE-V1:0"));
    }

    #[test]
    fn test_validate_setup_model_input_mismatch_error_uses_canonical_form() {
        // Whitespace inside the colon-separated form is trimmed both on
        // success and on the mismatch error; prefix casing is normalized to
        // the canonical lower-case provider key for the same reason.
        let result = validate_setup_model_input("OPENAI: gpt-5.5", SetupProvider::Anthropic);
        let err = result.expect_err("mismatch should error");
        assert!(
            err.contains("`openai:gpt-5.5`"),
            "error should show canonical form, got: {err}"
        );
        assert!(!err.contains("OPENAI"));
        assert!(!err.contains("openai: gpt-5.5"));
    }

    #[test]
    fn test_prompt_required_model_reprompts_until_valid_input_arrives() {
        // Empty input fails, provider/model mismatch fails, then a bare
        // model id auto-prefixes and is accepted. All three inputs must
        // be consumed in order, and the function must return the
        // canonical form for the final entry.
        let _guard = install_setup_interactive_harness(SetupInteractiveTestHarness {
            visible_inputs: VecDeque::from(vec![
                "".to_string(),
                "openai:gpt-5.5".to_string(),
                "claude-sonnet-4-6".to_string(),
            ]),
            ..Default::default()
        });

        let result = prompt_required_model(SetupProvider::Anthropic)
            .expect("prompt loop should eventually accept a valid model");

        assert_eq!(result.as_str(), TEST_MODEL_ANTHROPIC);
        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.visible_prompt_count, 3);
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_prompt_required_model_accepts_bedrock_native_id_without_prefix() {
        let _guard = install_setup_interactive_harness(SetupInteractiveTestHarness {
            visible_inputs: VecDeque::from(vec!["anthropic.claude-v1:0".to_string()]),
            ..Default::default()
        });

        let result = prompt_required_model(SetupProvider::Bedrock)
            .expect("Bedrock native model ID should be accepted bare");

        assert_eq!(result.as_str(), "bedrock:anthropic.claude-v1:0");
        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.visible_prompt_count, 1);
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_prompt_vertex_explicit_model_id_reprompts_on_whitespace() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("VERTEX_MODEL");
        let _guard = install_setup_interactive_harness(SetupInteractiveTestHarness {
            visible_inputs: VecDeque::from(vec![
                "gemini 2.5 flash".to_string(),
                "publishers/google/models/gemini-2.5-flash".to_string(),
            ]),
            ..Default::default()
        });

        let result = prompt_vertex_explicit_model_id()
            .expect("prompt loop should eventually accept a command-safe Vertex model id");

        assert_eq!(result, "publishers/google/models/gemini-2.5-flash");
        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.visible_prompt_count, 2);
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_prompt_vertex_explicit_model_id_reprompts_on_repeated_prefix() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("VERTEX_MODEL");
        let _guard = install_setup_interactive_harness(SetupInteractiveTestHarness {
            visible_inputs: VecDeque::from(vec![
                "vertex:vertex:gemini-2.5-flash".to_string(),
                "vertex:publishers/google/models/gemini-2.5-flash".to_string(),
            ]),
            ..Default::default()
        });

        let result = prompt_vertex_explicit_model_id()
            .expect("prompt loop should eventually accept a prefixed Vertex model id");

        assert_eq!(result, "publishers/google/models/gemini-2.5-flash");
        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.visible_prompt_count, 2);
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_configure_provider_interactive_drives_prompt_required_model_when_flag_omitted() {
        // Integration coverage for the omitted-`--model`
        // `prompt_required_model(provider)?` arm inside `configure_provider_interactive`.
        // The unit test above
        // exercises `prompt_required_model` in isolation; this test confirms the
        // surrounding wiring fires the loop and writes the canonicalized result
        // into `agents.defaults.model` for a non-Vertex provider when `--model`
        // is omitted.
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("OPENAI_API_KEY");
        let _guard = install_setup_interactive_harness(SetupInteractiveTestHarness {
            visible_inputs: VecDeque::from(vec![
                // Model prompt loop: empty → mismatch → valid bare (auto-prefixed)
                "".to_string(),
                "anthropic:claude-sonnet-4-6".to_string(),
                "gpt-5.5".to_string(),
                // OpenAI API key prompt (hide_sensitive_input = false here)
                "sk-openai-integration-test".to_string(),
                // "Validate provider credentials now?" yes/no prompt
                "y".to_string(),
            ]),
            provider_validation_results: VecDeque::from(vec![Ok(())]),
            ..Default::default()
        });
        let mut config = serde_json::json!({});

        let result = configure_provider_interactive(
            &mut config,
            SetupProvider::OpenAi,
            false,
            None,
            None, // <- this is the path under test
        )
        .expect("interactive OpenAI setup without --model");

        assert_eq!(config["agents"]["defaults"]["model"], TEST_MODEL_OPENAI);
        assert_eq!(config["openai"]["apiKey"], "sk-openai-integration-test");
        assert_eq!(result.observed_checks.len(), 1);
        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        // 3 model-prompt attempts + 1 API-key + 1 validate-y = 5 visible reads.
        assert_eq!(state.visible_prompt_count, 5);
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_handle_setup_interactive_drives_model_prompt_when_flag_omitted() {
        // End-to-end coverage for `handle_setup(true, ..., requested_model=None)`.
        // This pins the handoff into `configure_provider_interactive`; the
        // direct integration test above pins the provider-level write.
        let mut env_guard = ScopedEnv::new();
        let env = setup_interactive_test_env(
            &mut env_guard,
            SetupInteractiveTestHarness {
                force_interactive: Some(true),
                visible_inputs: VecDeque::from(vec![
                    // Hide sensitive input? no.
                    "n".to_string(),
                    // Model prompt loop: empty -> wrong provider -> valid bare.
                    "".to_string(),
                    "anthropic:claude-sonnet-4-6".to_string(),
                    "gpt-5.5".to_string(),
                    // OpenAI API key prompt.
                    "sk-openai-handle-setup-test".to_string(),
                    // Validate provider credentials now.
                    "y".to_string(),
                    // Gateway auth mode: token.
                    "token".to_string(),
                    // Generate gateway token automatically.
                    "y".to_string(),
                    // Gateway bind, port, first-run outcome: defaults.
                    "".to_string(),
                    "".to_string(),
                    "".to_string(),
                    // Hooks and Control UI disabled.
                    "n".to_string(),
                    "n".to_string(),
                    // Do not run post-setup commands from the test.
                    "n".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                ]),
                provider_validation_results: VecDeque::from(vec![Ok(())]),
                ..Default::default()
            },
        );

        let result = handle_setup(true, Some(SetupProvider::OpenAi), None, None);
        assert!(
            result.is_ok(),
            "interactive setup without --model should succeed"
        );

        let content = std::fs::read_to_string(&env.config_path).unwrap();
        let parsed: serde_json::Value = json5::from_str(&content).unwrap();
        assert_eq!(parsed["agents"]["defaults"]["model"], TEST_MODEL_OPENAI);
        assert_eq!(parsed["openai"]["apiKey"], "sk-openai-handle-setup-test");

        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        let expected_visible_prompts = 1 // hide-sensitive prompt
            + 3 // model prompt attempts
            + 1 // OpenAI API key
            + 1 // provider credential validation
            + 3 // gateway auth mode, generated token, bind mode
            + 1 // gateway port
            + 1 // setup outcome
            + 2 // hooks and Control UI toggles
            + 3; // post-setup status, chat, and verify prompts
        assert_eq!(state.provider_validation_calls, 1);
        assert!(state.provider_validation_results.is_empty());
        assert_eq!(
            state.visible_prompt_count, expected_visible_prompts,
            "script should consume the documented visible prompt sequence"
        );
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_handle_setup_interactive_prefixed_model_implies_provider_before_wizard() {
        let mut env_guard = ScopedEnv::new();
        let env = setup_interactive_test_env(
            &mut env_guard,
            SetupInteractiveTestHarness {
                force_interactive: Some(true),
                visible_inputs: VecDeque::from(vec![
                    // Hide sensitive input? no.
                    "n".to_string(),
                    // OpenAI API key prompt. A provider-selection prompt would
                    // consume this as an invalid provider and exhaust the script.
                    "sk-openai-inferred".to_string(),
                    // Skip live provider validation.
                    "n".to_string(),
                    // Gateway auth mode: token.
                    "token".to_string(),
                    // Generate gateway token automatically.
                    "y".to_string(),
                    // Gateway bind, port, first-run outcome: defaults.
                    "".to_string(),
                    "".to_string(),
                    "".to_string(),
                    // Hooks and Control UI disabled.
                    "n".to_string(),
                    "n".to_string(),
                    // Do not run post-setup commands from the test.
                    "n".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                ]),
                ..Default::default()
            },
        );

        let result = handle_setup(true, None, None, Some(TEST_MODEL_OPENAI));
        assert!(
            result.is_ok(),
            "prefixed --model should imply the provider before interactive prompts"
        );

        let content = std::fs::read_to_string(&env.config_path).unwrap();
        let parsed: serde_json::Value = json5::from_str(&content).unwrap();
        assert_eq!(parsed["agents"]["defaults"]["model"], TEST_MODEL_OPENAI);
        assert_eq!(parsed["openai"]["apiKey"], "sk-openai-inferred");

        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.provider_validation_calls, 0);
        assert_eq!(
            state.visible_prompt_count, 13,
            "script should skip provider and OpenAI auth-variant prompts"
        );
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_handle_setup_interactive_prefixed_vertex_model_skips_route_prompt() {
        let mut env_guard = ScopedEnv::new();
        let env = setup_interactive_test_env(
            &mut env_guard,
            SetupInteractiveTestHarness {
                force_interactive: Some(true),
                visible_inputs: VecDeque::from(vec![
                    // Hide sensitive input? no.
                    "n".to_string(),
                    // Vertex project and location prompts. A provider-selection
                    // or route prompt would consume these and exhaust the script.
                    "vertex-project".to_string(),
                    "us-central1".to_string(),
                    // Skip live provider validation.
                    "n".to_string(),
                    // Gateway auth mode: token.
                    "token".to_string(),
                    // Generate gateway token automatically.
                    "y".to_string(),
                    // Gateway bind, port, first-run outcome: defaults.
                    "".to_string(),
                    "".to_string(),
                    "".to_string(),
                    // Hooks and Control UI disabled.
                    "n".to_string(),
                    "n".to_string(),
                    // Do not run post-setup commands from the test.
                    "n".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                ]),
                ..Default::default()
            },
        );

        let result = handle_setup(true, None, None, Some(TEST_MODEL_VERTEX_EXPLICIT));
        assert!(
            result.is_ok(),
            "prefixed Vertex --model should imply provider and skip route prompts: {:?}",
            result.err().map(|e| e.to_string())
        );

        let content = std::fs::read_to_string(&env.config_path).unwrap();
        let parsed: serde_json::Value = json5::from_str(&content).unwrap();
        assert_eq!(parsed["vertex"]["projectId"], "vertex-project");
        assert_eq!(parsed["vertex"]["location"], "us-central1");
        assert!(
            parsed["vertex"].get("model").is_none(),
            "explicit Vertex route should not write `vertex.model`"
        );
        assert_eq!(
            parsed["agents"]["defaults"]["model"],
            TEST_MODEL_VERTEX_EXPLICIT
        );

        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.provider_validation_calls, 0);
        assert_eq!(
            state.visible_prompt_count, 14,
            "script should skip provider, route, and Vertex model prompts"
        );
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_handle_setup_interactive_vertex_default_model_prompts_for_default_route_model() {
        let mut env_guard = ScopedEnv::new();
        let env = setup_interactive_test_env(
            &mut env_guard,
            SetupInteractiveTestHarness {
                force_interactive: Some(true),
                visible_inputs: VecDeque::from(vec![
                    // Hide sensitive input? no.
                    "n".to_string(),
                    // Provider is inferred from `vertex:default`; route is
                    // fixed to default, so only project/location/model remain.
                    "vertex-project".to_string(),
                    "us-central1".to_string(),
                    "gemini-2.5-flash".to_string(),
                    // Skip live provider validation.
                    "n".to_string(),
                    // Gateway auth mode: token.
                    "token".to_string(),
                    // Generate gateway token automatically.
                    "y".to_string(),
                    // Gateway bind, port, first-run outcome: defaults.
                    "".to_string(),
                    "".to_string(),
                    "".to_string(),
                    // Hooks and Control UI disabled.
                    "n".to_string(),
                    "n".to_string(),
                    // Do not run post-setup commands from the test.
                    "n".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                ]),
                ..Default::default()
            },
        );

        let result = handle_setup(true, None, None, Some(TEST_MODEL_VERTEX_DEFAULT_ROUTE));
        assert!(
            result.is_ok(),
            "vertex:default should imply provider and prompt for VERTEX_MODEL: {:?}",
            result.err().map(|e| e.to_string())
        );

        let content = std::fs::read_to_string(&env.config_path).unwrap();
        let parsed: serde_json::Value = json5::from_str(&content).unwrap();
        assert_eq!(parsed["vertex"]["projectId"], "vertex-project");
        assert_eq!(parsed["vertex"]["location"], "us-central1");
        assert_eq!(parsed["vertex"]["model"], "gemini-2.5-flash");
        assert_eq!(
            parsed["agents"]["defaults"]["model"],
            TEST_MODEL_VERTEX_DEFAULT_ROUTE
        );

        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.provider_validation_calls, 0);
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_handle_setup_interactive_bare_model_rejection_reprompts_after_provider_selection() {
        let mut env_guard = ScopedEnv::new();
        let env = setup_interactive_test_env(
            &mut env_guard,
            SetupInteractiveTestHarness {
                force_interactive: Some(true),
                visible_inputs: VecDeque::from(vec![
                    // Hide sensitive input? no.
                    "n".to_string(),
                    // Pick OpenAI after passing a bare Anthropic-looking model.
                    "openai".to_string(),
                    // Use the OpenAI API-key path.
                    "api-key".to_string(),
                    // Reject the auto-prefixed `openai:claude-sonnet-4-6`.
                    "n".to_string(),
                    // Re-prompted OpenAI model.
                    "gpt-5.5".to_string(),
                    // OpenAI API key prompt.
                    "sk-openai-bare-reprompt".to_string(),
                    // Validate provider credentials now.
                    "y".to_string(),
                    // Gateway auth mode: token.
                    "token".to_string(),
                    // Generate gateway token automatically.
                    "y".to_string(),
                    // Gateway bind, port, first-run outcome: defaults.
                    "".to_string(),
                    "".to_string(),
                    "".to_string(),
                    // Hooks and Control UI disabled.
                    "n".to_string(),
                    "n".to_string(),
                    // Do not run post-setup commands from the test.
                    "n".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                ]),
                provider_validation_results: VecDeque::from(vec![Ok(())]),
                ..Default::default()
            },
        );

        let result = handle_setup(true, None, None, Some("claude-sonnet-4-6"));
        assert!(
            result.is_ok(),
            "interactive setup should re-prompt after rejecting a bare cross-provider model"
        );

        let content = std::fs::read_to_string(&env.config_path).unwrap();
        let parsed: serde_json::Value = json5::from_str(&content).unwrap();
        assert_eq!(parsed["agents"]["defaults"]["model"], TEST_MODEL_OPENAI);
        assert_eq!(parsed["openai"]["apiKey"], "sk-openai-bare-reprompt");

        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.provider_validation_calls, 1);
        assert_eq!(
            state.visible_prompt_count, 17,
            "script should consume provider selection, rejection confirmation, and re-prompt"
        );
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_handle_setup_interactive_bare_model_accepts_cross_provider_confirmation() {
        let mut env_guard = ScopedEnv::new();
        let env = setup_interactive_test_env(
            &mut env_guard,
            SetupInteractiveTestHarness {
                force_interactive: Some(true),
                visible_inputs: VecDeque::from(vec![
                    // Hide sensitive input? no.
                    "n".to_string(),
                    // Pick OpenAI after passing a bare Anthropic-looking model.
                    "openai".to_string(),
                    // Use the OpenAI API-key path.
                    "api-key".to_string(),
                    // Confirm the cross-provider-looking model anyway.
                    "y".to_string(),
                    // OpenAI API key prompt.
                    "sk-openai-bare-accepted".to_string(),
                    // Validate provider credentials now.
                    "y".to_string(),
                    // Gateway auth mode: token.
                    "token".to_string(),
                    // Generate gateway token automatically.
                    "y".to_string(),
                    // Gateway bind, port, first-run outcome: defaults.
                    "".to_string(),
                    "".to_string(),
                    "".to_string(),
                    // Hooks and Control UI disabled.
                    "n".to_string(),
                    "n".to_string(),
                    // Do not run post-setup commands from the test.
                    "n".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                ]),
                provider_validation_results: VecDeque::from(vec![Ok(())]),
                ..Default::default()
            },
        );

        let result = handle_setup(true, None, None, Some("claude-sonnet-4-6"));
        assert!(
            result.is_ok(),
            "interactive setup should allow explicit confirmation of a bare cross-provider model"
        );

        let content = std::fs::read_to_string(&env.config_path).unwrap();
        let parsed: serde_json::Value = json5::from_str(&content).unwrap();
        assert_eq!(
            parsed["agents"]["defaults"]["model"],
            "openai:claude-sonnet-4-6"
        );
        assert_eq!(parsed["openai"]["apiKey"], "sk-openai-bare-accepted");

        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.provider_validation_calls, 1);
        assert_eq!(
            state.visible_prompt_count, 16,
            "script should consume provider selection and cross-provider confirmation"
        );
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_handle_setup_interactive_bare_model_accepts_unknown_confirmation() {
        let mut env_guard = ScopedEnv::new();
        let env = setup_interactive_test_env(
            &mut env_guard,
            SetupInteractiveTestHarness {
                force_interactive: Some(true),
                visible_inputs: VecDeque::from(vec![
                    // Hide sensitive input? no.
                    "n".to_string(),
                    // Pick OpenAI after passing a bare model with no known provider signal.
                    "openai".to_string(),
                    // Use the OpenAI API-key path.
                    "api-key".to_string(),
                    // Confirm the unknown model belongs to OpenAI.
                    "y".to_string(),
                    // OpenAI API key prompt.
                    "sk-openai-unknown-accepted".to_string(),
                    // Validate provider credentials now.
                    "y".to_string(),
                    // Gateway auth mode: token.
                    "token".to_string(),
                    // Generate gateway token automatically.
                    "y".to_string(),
                    // Gateway bind, port, first-run outcome: defaults.
                    "".to_string(),
                    "".to_string(),
                    "".to_string(),
                    // Hooks and Control UI disabled.
                    "n".to_string(),
                    "n".to_string(),
                    // Do not run post-setup commands from the test.
                    "n".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                ]),
                provider_validation_results: VecDeque::from(vec![Ok(())]),
                ..Default::default()
            },
        );

        let result = handle_setup(true, None, None, Some("internal-coder"));
        assert!(
            result.is_ok(),
            "interactive setup should allow explicit confirmation of an unknown bare model"
        );

        let content = std::fs::read_to_string(&env.config_path).unwrap();
        let parsed: serde_json::Value = json5::from_str(&content).unwrap();
        assert_eq!(
            parsed["agents"]["defaults"]["model"],
            "openai:internal-coder"
        );
        assert_eq!(parsed["openai"]["apiKey"], "sk-openai-unknown-accepted");

        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.provider_validation_calls, 1);
        assert_eq!(
            state.visible_prompt_count, 16,
            "script should consume provider selection and unknown-model confirmation"
        );
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_handle_setup_interactive_model_without_provider_rejects_empty_before_prompts() {
        let mut env_guard = ScopedEnv::new();
        let env = setup_interactive_test_env(
            &mut env_guard,
            SetupInteractiveTestHarness {
                force_interactive: Some(true),
                ..Default::default()
            },
        );

        let result = handle_setup(true, None, None, Some("   "));
        let err = result.expect_err("empty interactive --model should fail before prompts");
        assert!(
            err.to_string().contains("model is required"),
            "unexpected empty-model error: {err}"
        );
        assert!(
            !env.config_path.exists(),
            "setup should not write config when --model is empty"
        );

        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(
            state.visible_prompt_count, 0,
            "empty --model should fail before the interactive wizard prompts"
        );
        assert_eq!(state.hidden_prompt_count, 0);
    }

    #[test]
    fn test_handle_setup_interactive_provider_with_empty_model_rejects_before_prompts() {
        let mut env_guard = ScopedEnv::new();
        let env = setup_interactive_test_env(
            &mut env_guard,
            SetupInteractiveTestHarness {
                force_interactive: Some(true),
                ..Default::default()
            },
        );

        let result = handle_setup(true, Some(SetupProvider::OpenAi), None, Some("   "));
        let err = result.expect_err("empty interactive --model should fail before prompts");
        assert!(
            err.to_string().contains("model is required"),
            "unexpected empty-model error: {err}"
        );
        assert!(
            !env.config_path.exists(),
            "setup should not write config when --model is empty"
        );

        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(
            state.visible_prompt_count, 0,
            "empty --model should fail before the interactive wizard prompts"
        );
        assert_eq!(state.hidden_prompt_count, 0);
    }

    #[test]
    fn test_handle_setup_errors_when_config_exists_no_force() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let config_path = temp.path().join("carapace.json");
        std::fs::write(&config_path, "{}").unwrap();

        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        let result = handle_setup(false, None, None, None);

        assert!(
            result.is_err(),
            "Should error when config exists and force=false"
        );
    }

    #[test]
    fn test_handle_setup_force_creates_config() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let config_path = temp.path().join("carapace.json");
        std::fs::write(&config_path, "{}").unwrap();

        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        env_guard.set("ANTHROPIC_API_KEY", "sk-ant-test");
        let result = handle_setup(
            true,
            Some(SetupProvider::Anthropic),
            None,
            Some(TEST_MODEL_ANTHROPIC),
        );

        assert!(
            result.is_ok(),
            "Should succeed with force=true even when config exists"
        );
        assert!(config_path.exists(), "Config file should exist after setup");
    }

    #[test]
    fn test_handle_setup_noninteractive_without_provider_errors() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let config_path = temp.path().join("carapace.json");

        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        let result = handle_setup(false, None, None, None);

        assert!(result.is_err(), "Setup should require --provider");
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("non-interactive setup requires `--provider <provider>`"),
            "unexpected error message"
        );
        assert!(
            !config_path.exists(),
            "setup should not write a providerless config in non-interactive mode"
        );
    }

    #[test]
    fn test_handle_setup_noninteractive_bare_model_without_provider_errors() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let config_path = temp.path().join("carapace.json");

        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        let result = handle_setup(false, None, None, Some(TEST_MODEL_OPENAI_BARE));

        assert!(
            result.is_err(),
            "non-interactive setup cannot apply bare --model without --provider"
        );
        let err = result.unwrap_err().to_string();
        assert!(
            err.contains("non-interactive setup requires `--provider <provider>`"),
            "unexpected provider error: {err}"
        );
        assert!(
            err.contains("`--model` was supplied but cannot be applied without a provider"),
            "missing orphaned --model explanation: {err}"
        );
        assert!(
            !config_path.exists(),
            "setup should not write a providerless config in non-interactive mode"
        );
    }

    #[test]
    fn test_handle_setup_noninteractive_bedrock_native_model_without_provider_errors() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let config_path = temp.path().join("carapace.json");

        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        let result = handle_setup(false, None, None, Some("anthropic.claude-v1:0"));

        assert!(
            result.is_err(),
            "non-interactive setup cannot infer Bedrock from bare native IDs"
        );
        let err = result.unwrap_err().to_string();
        assert!(
            err.contains("Bedrock native model IDs require `--provider bedrock`"),
            "missing Bedrock provider hint: {err}"
        );
        assert!(
            !config_path.exists(),
            "setup should not write a providerless config in non-interactive mode"
        );
    }

    #[test]
    fn test_handle_setup_noninteractive_ollama_tag_without_provider_omits_bedrock_hint() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let config_path = temp.path().join("carapace.json");

        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        let result = handle_setup(false, None, None, Some("llama3.2:3b"));

        assert!(
            result.is_err(),
            "non-interactive setup cannot infer provider from an Ollama tag"
        );
        let err = result.unwrap_err().to_string();
        assert!(
            err.contains("`--model` was supplied but cannot be applied without a provider"),
            "missing orphaned --model explanation: {err}"
        );
        assert!(
            !err.contains("Bedrock native model IDs require `--provider bedrock`"),
            "Ollama tagged model IDs should not get Bedrock-specific guidance: {err}"
        );
        assert!(
            !config_path.exists(),
            "setup should not write a providerless config in non-interactive mode"
        );
    }

    #[test]
    fn test_handle_setup_noninteractive_prefixed_model_implies_provider() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let config_path = temp.path().join("carapace.json");

        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        env_guard.set("OPENAI_API_KEY", "sk-openai-test");
        let result = handle_setup(false, None, None, Some("OPENAI: gpt-5.5"));

        assert!(
            result.is_ok(),
            "non-interactive setup should infer provider from prefixed --model: {:?}",
            result.err()
        );
        let content = std::fs::read_to_string(&config_path).unwrap();
        let parsed: serde_json::Value = json5::from_str(&content).unwrap();
        assert_eq!(parsed["agents"]["defaults"]["model"], TEST_MODEL_OPENAI);
        assert_eq!(parsed["openai"]["apiKey"], "${OPENAI_API_KEY}");
    }

    #[test]
    fn test_handle_setup_noninteractive_explicit_provider_auto_prefixes_bare_model() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let config_path = temp.path().join("carapace.json");

        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        env_guard.set("OPENAI_API_KEY", "sk-openai-test");
        let result = handle_setup(
            false,
            Some(SetupProvider::OpenAi),
            None,
            Some(TEST_MODEL_OPENAI_BARE),
        );

        assert!(
            result.is_ok(),
            "non-interactive setup should auto-prefix a bare model for the explicit provider: {:?}",
            result.err()
        );
        let content = std::fs::read_to_string(&config_path).unwrap();
        let parsed: serde_json::Value = json5::from_str(&content).unwrap();
        assert_eq!(parsed["agents"]["defaults"]["model"], TEST_MODEL_OPENAI);
        assert_eq!(parsed["openai"]["apiKey"], "${OPENAI_API_KEY}");
    }

    #[test]
    fn test_handle_setup_noninteractive_vertex_model_implies_provider() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let config_path = temp.path().join("carapace.json");

        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        env_guard.set("VERTEX_PROJECT_ID", "vertex-project");
        env_guard.set("VERTEX_MODEL", "gemini-2.5-flash");
        let result = handle_setup(false, None, None, Some("vertex:default"));

        assert!(
            result.is_ok(),
            "non-interactive setup should infer Vertex from `--model vertex:default`: {:?}",
            result.err()
        );
        let content = std::fs::read_to_string(&config_path).unwrap();
        let parsed: serde_json::Value = json5::from_str(&content).unwrap();
        assert_eq!(parsed["agents"]["defaults"]["model"], "vertex:default");
        assert_eq!(parsed["vertex"]["projectId"], "${VERTEX_PROJECT_ID}");
        assert_eq!(parsed["vertex"]["location"], "us-central1");
        assert_eq!(parsed["vertex"]["model"], "${VERTEX_MODEL}");
    }

    #[test]
    fn test_handle_setup_noninteractive_without_model_errors() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let config_path = temp.path().join("carapace.json");

        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        env_guard.set("ANTHROPIC_API_KEY", "sk-ant-test");
        let result = handle_setup(false, Some(SetupProvider::Anthropic), None, None);

        assert!(result.is_err(), "Setup should require --model");
        let err = result.unwrap_err().to_string();
        assert!(
            err.contains("non-interactive setup requires `--model"),
            "unexpected --model error: {err}"
        );
        // Migration hint must direct operators to stable CLI help without
        // naming a specific model — `prompt_required_model` is the source of
        // truth.
        assert!(
            err.contains("previous releases silently wrote a default model"),
            "missing migration hint: {err}"
        );
        assert!(
            err.contains("See `cara setup --help`"),
            "hint should point at stable CLI help: {err}"
        );
        assert!(
            !err.contains("docs/getting-started.md"),
            "hint should not hard-code a docs path: {err}"
        );
        assert!(
            err.contains("<anthropic:model-id>"),
            "hint should reference the provider-prefixed form: {err}"
        );
        assert!(
            !config_path.exists(),
            "setup should not write config when --model is missing"
        );
    }

    #[test]
    fn test_handle_setup_noninteractive_vertex_without_model_mentions_default_route() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let config_path = temp.path().join("carapace.json");

        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        let result = handle_setup(false, Some(SetupProvider::Vertex), None, None);

        assert!(result.is_err(), "Vertex setup should require --model");
        let err = result.unwrap_err().to_string();
        assert!(
            err.contains("`--model vertex:default`"),
            "missing Vertex default route hint: {err}"
        );
        assert!(
            err.contains("$VERTEX_MODEL"),
            "Vertex hint should explain the environment-variable route: {err}"
        );
        assert!(
            !config_path.exists(),
            "setup should not write config when --model is missing"
        );
    }

    #[test]
    fn test_handle_setup_noninteractive_with_mismatched_model_errors() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let config_path = temp.path().join("carapace.json");

        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        env_guard.set("ANTHROPIC_API_KEY", "sk-ant-test");
        let result = handle_setup(
            false,
            Some(SetupProvider::Anthropic),
            None,
            Some("openai:gpt-5.5"),
        );

        assert!(
            result.is_err(),
            "mismatched --provider / --model should fail"
        );
        let err = result.unwrap_err().to_string();
        assert!(
            err.contains("`openai:gpt-5.5` uses the `openai:` provider prefix")
                && err.contains("`--provider anthropic` is configured"),
            "unexpected provider/model mismatch error: {err}"
        );
        assert!(
            !config_path.exists(),
            "setup must not write config on mismatched --model"
        );
    }

    #[test]
    fn test_handle_setup_noninteractive_runtime_validation_failure_writes_no_config() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let config_path = temp.path().join("carapace.json");

        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        env_guard.unset("ANTHROPIC_API_KEY");
        let result = handle_setup(
            false,
            Some(SetupProvider::Anthropic),
            None,
            Some(TEST_MODEL_ANTHROPIC),
        );

        assert!(
            result.is_err(),
            "config persistence should reject unresolved secret placeholders"
        );
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("failed to write config file"),
            "unexpected setup error"
        );
        assert!(
            !config_path.exists(),
            "runtime-validation failure must leave no config file behind"
        );
    }

    #[test]
    fn test_handle_setup_noninteractive_provider_flag_keeps_default_gateway_values() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let config_path = temp.path().join("carapace.json");

        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        env_guard.set("ANTHROPIC_API_KEY", "sk-ant-test");
        let result = handle_setup(
            false,
            Some(SetupProvider::Anthropic),
            None,
            Some(TEST_MODEL_ANTHROPIC),
        );

        assert!(result.is_ok());

        let content = std::fs::read_to_string(&config_path).unwrap();
        let parsed: serde_json::Value = json5::from_str(&content).unwrap();

        assert_eq!(
            parsed["gateway"]["port"], DEFAULT_PORT,
            "Default port should match server default"
        );
        assert_eq!(
            parsed["gateway"]["bind"], "loopback",
            "Default bind should be loopback"
        );
        assert_eq!(
            parsed["gateway"]["auth"]["mode"], "token",
            "Default auth mode should be token"
        );
        assert!(
            parsed["gateway"]["auth"]["token"]
                .as_str()
                .map(|v| !v.is_empty())
                .unwrap_or(false),
            "Default setup should generate a non-empty gateway token"
        );
        assert_eq!(
            parsed["agents"]["defaults"]["model"], TEST_MODEL_ANTHROPIC,
            "agents.defaults.model should be the --model value"
        );
        assert_eq!(parsed["anthropic"]["apiKey"], "${ANTHROPIC_API_KEY}");
    }

    #[test]
    fn test_handle_setup_noninteractive_provider_flag_writes_gemini_config() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let config_path = temp.path().join("carapace.json");
        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());

        let result = handle_setup(
            false,
            Some(SetupProvider::Gemini),
            None,
            Some(TEST_MODEL_GEMINI),
        );
        assert!(result.is_err(), "Gemini should require explicit auth mode");
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("non-interactive Gemini setup requires `--auth-mode oauth|api-key`"),
            "unexpected Gemini auth-mode error"
        );
        assert!(
            !config_path.exists(),
            "Gemini setup without auth mode should not write config"
        );
    }

    #[test]
    fn test_handle_setup_noninteractive_gemini_api_key_mode_writes_config() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let config_path = temp.path().join("carapace.json");
        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        env_guard.set("GOOGLE_API_KEY", "AIza-test-key");

        let result = handle_setup(
            false,
            Some(SetupProvider::Gemini),
            Some(SetupAuthModeSelection::ApiKey),
            Some(TEST_MODEL_GEMINI),
        );
        assert!(
            result.is_ok(),
            "non-interactive provider setup should succeed"
        );

        let content = std::fs::read_to_string(&config_path).unwrap();
        let parsed: serde_json::Value = json5::from_str(&content).unwrap();
        assert_eq!(parsed["google"]["apiKey"], "${GOOGLE_API_KEY}");
        assert_eq!(parsed["agents"]["defaults"]["model"], TEST_MODEL_GEMINI);
    }

    #[test]
    fn test_handle_setup_noninteractive_gemini_oauth_mode_errors() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let config_path = temp.path().join("carapace.json");
        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());

        let result = handle_setup(
            false,
            Some(SetupProvider::Gemini),
            Some(SetupAuthModeSelection::OAuth),
            Some(TEST_MODEL_GEMINI),
        );
        assert!(result.is_err(), "non-interactive Gemini OAuth should fail");
        assert!(
            result.unwrap_err().to_string().contains(
                "non-interactive Gemini Google sign-in is not supported; rerun interactively or use `--auth-mode api-key`."
            ),
            "unexpected Gemini OAuth non-interactive error"
        );
        assert!(
            !config_path.exists(),
            "non-interactive Gemini OAuth should not write config"
        );
    }

    #[test]
    fn test_handle_setup_noninteractive_codex_errors() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let config_path = temp.path().join("carapace.json");
        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());

        let result = handle_setup(
            false,
            Some(SetupProvider::Codex),
            None,
            Some(TEST_MODEL_CODEX),
        );
        assert!(result.is_err(), "non-interactive Codex sign-in should fail");
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains(&codex_noninteractive_setup_error()),
            "unexpected Codex non-interactive error"
        );
        assert!(
            !config_path.exists(),
            "non-interactive Codex sign-in should not write config"
        );
    }

    #[test]
    fn test_configure_provider_noninteractive_codex_returns_clean_error() {
        let mut config = serde_json::json!({});
        let model = validated_setup_model(SetupProvider::Codex, TEST_MODEL_CODEX);

        let err = configure_provider_noninteractive(
            &mut config,
            SetupProvider::Codex,
            None,
            Some(&model),
        )
        .expect_err("non-interactive Codex setup should fail")
        .to_string();

        assert_eq!(err, codex_noninteractive_setup_error());
        assert_eq!(
            config,
            serde_json::json!({}),
            "Codex non-interactive setup should return before config writes"
        );
    }

    #[test]
    fn test_handle_setup_noninteractive_codex_without_model_errors_before_model_hint() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let config_path = temp.path().join("carapace.json");
        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());

        let result = handle_setup(false, Some(SetupProvider::Codex), None, None);
        let err = result.expect_err("non-interactive Codex sign-in should fail");
        let err = err.to_string();
        assert!(
            err.contains(&codex_noninteractive_setup_error()),
            "unexpected Codex non-interactive error: {err}"
        );
        assert!(
            !err.contains("requires `--model"),
            "Codex non-interactive setup should not suggest that adding --model is sufficient: {err}"
        );
        assert!(
            !config_path.exists(),
            "non-interactive Codex sign-in should not write config"
        );
    }

    #[test]
    fn test_handle_setup_noninteractive_provider_flag_writes_ollama_api_key_placeholder() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let config_path = temp.path().join("carapace.json");
        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        env_guard.set("OLLAMA_BASE_URL", "http://127.0.0.1:11434");
        env_guard.set("OLLAMA_API_KEY", "ollama-token");

        let result = handle_setup(
            false,
            Some(SetupProvider::Ollama),
            None,
            Some(TEST_MODEL_OLLAMA),
        );
        assert!(
            result.is_ok(),
            "non-interactive provider setup should succeed"
        );

        let content = std::fs::read_to_string(&config_path).unwrap();
        let parsed: serde_json::Value = json5::from_str(&content).unwrap();
        assert_eq!(parsed["providers"]["ollama"]["apiKey"], "${OLLAMA_API_KEY}");
        assert_eq!(parsed["agents"]["defaults"]["model"], TEST_MODEL_OLLAMA);
    }

    #[test]
    fn test_handle_setup_noninteractive_provider_flag_writes_venice_config() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let config_path = temp.path().join("carapace.json");
        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        env_guard.set("VENICE_API_KEY", "venice-test-key");

        let result = handle_setup(
            false,
            Some(SetupProvider::Venice),
            None,
            Some(TEST_MODEL_VENICE),
        );
        assert!(
            result.is_ok(),
            "non-interactive provider setup should succeed"
        );

        let content = std::fs::read_to_string(&config_path).unwrap();
        let parsed: serde_json::Value = json5::from_str(&content).unwrap();
        assert_eq!(parsed["venice"]["apiKey"], "${VENICE_API_KEY}");
        assert_eq!(parsed["agents"]["defaults"]["model"], TEST_MODEL_VENICE);
    }

    #[test]
    fn test_handle_setup_noninteractive_provider_flag_writes_nearai_config() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let config_path = temp.path().join("carapace.json");
        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        env_guard.set("NEARAI_API_KEY", "nearai-test-key");

        let result = handle_setup(
            false,
            Some(SetupProvider::NearAi),
            None,
            Some(TEST_MODEL_NEARAI),
        );
        assert!(
            result.is_ok(),
            "non-interactive provider setup should succeed"
        );

        let content = std::fs::read_to_string(&config_path).unwrap();
        let parsed: serde_json::Value = json5::from_str(&content).unwrap();
        assert_eq!(parsed["nearai"]["apiKey"], "${NEARAI_API_KEY}");
        assert_eq!(parsed["agents"]["defaults"]["model"], TEST_MODEL_NEARAI);
    }

    #[test]
    fn test_handle_setup_noninteractive_provider_flag_writes_bedrock_config() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let config_path = temp.path().join("carapace.json");
        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        env_guard.set("AWS_ACCESS_KEY_ID", "AKIA_TEST");
        env_guard.set("AWS_SECRET_ACCESS_KEY", "secret-test-key");
        env_guard.unset("AWS_REGION");
        env_guard.unset("AWS_DEFAULT_REGION");

        let result = handle_setup(
            false,
            Some(SetupProvider::Bedrock),
            None,
            Some(TEST_MODEL_BEDROCK),
        );
        assert!(
            result.is_ok(),
            "non-interactive provider setup should succeed"
        );

        let content = std::fs::read_to_string(&config_path).unwrap();
        let parsed: serde_json::Value = json5::from_str(&content).unwrap();
        assert_eq!(parsed["bedrock"]["region"], "us-east-1");
        assert_eq!(parsed["bedrock"]["accessKeyId"], "${AWS_ACCESS_KEY_ID}");
        assert_eq!(
            parsed["bedrock"]["secretAccessKey"],
            "${AWS_SECRET_ACCESS_KEY}"
        );
        assert_eq!(parsed["agents"]["defaults"]["model"], TEST_MODEL_BEDROCK);
    }

    #[test]
    fn test_handle_setup_noninteractive_provider_flag_writes_vertex_config() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let config_path = temp.path().join("carapace.json");
        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        env_guard.set("VERTEX_PROJECT_ID", "vertex-project");
        env_guard.set("VERTEX_MODEL", "gemini-2.5-flash");
        env_guard.unset("VERTEX_LOCATION");

        let result = handle_setup(
            false,
            Some(SetupProvider::Vertex),
            None,
            Some(TEST_MODEL_VERTEX_DEFAULT_ROUTE),
        );
        assert!(
            result.is_ok(),
            "non-interactive Vertex setup should succeed"
        );

        let content = std::fs::read_to_string(&config_path).unwrap();
        let parsed: serde_json::Value = json5::from_str(&content).unwrap();
        assert_eq!(parsed["vertex"]["projectId"], "${VERTEX_PROJECT_ID}");
        assert_eq!(parsed["vertex"]["location"], "us-central1");
        assert_eq!(parsed["vertex"]["model"], "${VERTEX_MODEL}");
        assert_eq!(
            parsed["agents"]["defaults"]["model"],
            TEST_MODEL_VERTEX_DEFAULT_ROUTE
        );
    }

    #[test]
    fn test_handle_setup_noninteractive_vertex_explicit_model_clears_vertex_model() {
        let mut env_guard = ScopedEnv::new();
        let temp = tempfile::TempDir::new().unwrap();
        let config_path = temp.path().join("carapace.json");
        env_guard.set("CARAPACE_CONFIG_PATH", config_path.as_os_str());
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        env_guard.set("VERTEX_PROJECT_ID", "vertex-project");
        env_guard.unset("VERTEX_LOCATION");

        let result = handle_setup(
            false,
            Some(SetupProvider::Vertex),
            None,
            Some(TEST_MODEL_VERTEX_EXPLICIT),
        );
        assert!(
            result.is_ok(),
            "explicit-model Vertex setup should succeed: {:?}",
            result.err().map(|e| e.to_string())
        );

        let content = std::fs::read_to_string(&config_path).unwrap();
        let parsed: serde_json::Value = json5::from_str(&content).unwrap();
        assert_eq!(parsed["vertex"]["projectId"], "${VERTEX_PROJECT_ID}");
        assert!(
            parsed["vertex"].get("model").is_none(),
            "explicit Vertex route should clear `vertex.model`"
        );
        assert_eq!(
            parsed["agents"]["defaults"]["model"],
            TEST_MODEL_VERTEX_EXPLICIT
        );
    }

    #[test]
    fn test_render_setup_validation_failure_redacts_sensitive_input() {
        let rendered = render_setup_validation_failure(&crate::agent::AgentError::InvalidBaseUrl(
            "invalid URL \"https://user:secret@example.com\": bad input".to_string(),
        ));
        assert_eq!(
            rendered,
            "Provider configuration check failed: the supplied base URL is invalid or unsupported."
        );
        assert!(!rendered.contains("secret"));
        assert!(!rendered.contains("example.com"));
    }

    #[test]
    fn test_vertex_validation_failure_remediation_mentions_validation_request_for_probe_rejected() {
        assert_eq!(
            vertex_validation_failure_remediation(
                &crate::agent::vertex::VertexSetupValidationError::ProbeRejected,
                "cara setup --force --provider vertex --model vertex:default",
            ),
            "check the Vertex project, location, and model values; if they look correct, this may indicate a malformed Vertex validation request in Carapace"
        );
    }

    #[test]
    fn test_vertex_validation_failure_remediation_mentions_retry_for_rate_limited() {
        assert_eq!(
            vertex_validation_failure_remediation(
                &crate::agent::vertex::VertexSetupValidationError::RateLimited,
                "cara setup --force --provider vertex --model vertex:default",
            ),
            "retry after the current Vertex AI rate limit window, then rerun `cara setup --force --provider vertex --model vertex:default`"
        );
    }

    #[test]
    fn test_vertex_validation_failure_remediation_unsupported_model_matches_default_rerun() {
        let remediation = vertex_validation_failure_remediation(
            &crate::agent::vertex::VertexSetupValidationError::UnsupportedModel,
            "cara setup --force --provider vertex --model vertex:default",
        );
        assert_eq!(
            remediation,
            "configure a supported Vertex model for the selected route, then rerun `cara setup --force --provider vertex --model vertex:default`"
        );
    }

    #[test]
    fn test_vertex_validation_failure_remediation_preserves_explicit_model_rerun() {
        let remediation = vertex_validation_failure_remediation(
            &crate::agent::vertex::VertexSetupValidationError::UnsupportedModel,
            "cara setup --force --provider vertex --model vertex:gemini-2.5-flash",
        );
        assert_eq!(
            remediation,
            "configure a supported Vertex model for the selected route, then rerun `cara setup --force --provider vertex --model vertex:gemini-2.5-flash`"
        );
    }

    #[test]
    fn test_vertex_validation_failure_remediation_reruns_include_model() {
        let errors = [
            crate::agent::vertex::VertexSetupValidationError::InvalidProjectId,
            crate::agent::vertex::VertexSetupValidationError::InvalidLocation,
            crate::agent::vertex::VertexSetupValidationError::MissingDefaultModel,
            crate::agent::vertex::VertexSetupValidationError::UnsupportedModel,
            crate::agent::vertex::VertexSetupValidationError::ClientInit(
                "builder failed".to_string(),
            ),
            crate::agent::vertex::VertexSetupValidationError::AuthUnavailable,
            crate::agent::vertex::VertexSetupValidationError::AccessDenied,
            crate::agent::vertex::VertexSetupValidationError::Unavailable,
            crate::agent::vertex::VertexSetupValidationError::Rejected,
            crate::agent::vertex::VertexSetupValidationError::RateLimited,
            crate::agent::vertex::VertexSetupValidationError::Transport,
        ];

        for err in errors {
            let remediation = vertex_validation_failure_remediation(
                &err,
                "cara setup --force --provider vertex --model vertex:default",
            );
            if remediation.contains("rerun `cara setup") {
                assert!(
                    remediation.contains("--model vertex:default`"),
                    "Vertex setup rerun remediation must include --model, got: {remediation}"
                );
                assert!(
                    !remediation.contains("--provider vertex`"),
                    "Vertex setup rerun remediation must not end before --model, got: {remediation}"
                );
            }
        }
    }

    #[test]
    fn test_setup_rerun_command_reference_notes_model_placeholders() {
        assert_eq!(
            crate::onboarding::setup::setup_command_reference(
                "cara setup --force --provider openai --model openai:<model-id>"
            ),
            "`cara setup --force --provider openai --model openai:<model-id>` (replace `<model-id>` with your chosen model before running the command)"
        );
        assert_eq!(
            crate::onboarding::setup::setup_command_reference(
                "cara setup --force --provider codex --model codex:default"
            ),
            "`cara setup --force --provider codex --model codex:default`"
        );
    }

    #[test]
    fn test_setup_rerun_command_uses_resolved_model_argument() {
        assert_eq!(
            setup_rerun_command(
                SetupProvider::OpenAi,
                Some(SetupAuthModeSelection::ApiKey),
                Some(TEST_MODEL_OPENAI),
            )
            .expect("validated model should render command"),
            "cara setup --force --provider openai --model openai:gpt-5.5"
        );
        assert_eq!(
            setup_rerun_command(
                SetupProvider::Vertex,
                None,
                Some(TEST_MODEL_VERTEX_EXPLICIT),
            )
            .expect("validated model should render command"),
            "cara setup --force --provider vertex --model vertex:gemini-2.5-flash"
        );
    }

    #[test]
    fn test_setup_rerun_command_for_remediation_falls_back_on_invalid_model() {
        assert_eq!(
            setup_rerun_command_for_remediation(SetupProvider::OpenAi, None, Some("openai:gpt$5")),
            "cara setup --force --provider openai --model openai:<model-id>"
        );
    }

    #[test]
    fn test_setup_command_with_resolved_model_appends_missing_model_argument() {
        assert_eq!(
            setup_command_with_resolved_model(
                "cara setup --force --provider vertex".to_string(),
                TEST_MODEL_VERTEX_EXPLICIT,
            )
            .expect("validated model should render command"),
            "cara setup --force --provider vertex --model vertex:gemini-2.5-flash"
        );
    }

    #[test]
    fn test_setup_command_with_resolved_model_preserves_flags_after_model_argument() {
        assert_eq!(
            setup_command_with_resolved_model(
                "cara setup --force --provider vertex --model vertex:default --extra".to_string(),
                TEST_MODEL_VERTEX_EXPLICIT,
            )
            .expect("validated model should render command"),
            "cara setup --force --provider vertex --model vertex:gemini-2.5-flash --extra"
        );
    }

    #[test]
    fn test_setup_command_with_resolved_model_replaces_existing_model_argument() {
        assert_eq!(
            setup_command_with_resolved_model(
                "cara setup --force --provider openai --model openai:<model-id>".to_string(),
                TEST_MODEL_OPENAI,
            )
            .expect("validated model should render command"),
            "cara setup --force --provider openai --model openai:gpt-5.5"
        );
    }

    #[test]
    fn test_terminal_safe_setup_input_escapes_control_chars() {
        assert_eq!(terminal_safe_setup_input("bad\u{1b}[31m"), "bad\\u{1b}[31m");
    }

    #[test]
    fn test_setup_command_with_resolved_model_errors_on_unvalidated_model_argument() {
        let err = setup_command_with_resolved_model(
            "cara setup --force --provider openai".to_string(),
            "openai:gpt 5.5",
        )
        .expect_err("unvalidated model should be rejected before command rendering");
        assert_eq!(
            err.to_string(),
            "setup remediation command rendering requires a validated model id and a simple setup command template"
        );
    }

    #[test]
    fn test_validate_provider_credentials_failure_remediation_uses_resolved_model() {
        let _guard = install_setup_interactive_harness(SetupInteractiveTestHarness {
            visible_inputs: VecDeque::from(vec!["y".to_string(), "y".to_string()]),
            provider_validation_results: VecDeque::from(vec![Err(
                "OpenAI credential check failed".to_string(),
            )]),
            ..Default::default()
        });

        let result = validate_provider_credentials_interactive(
            SetupProvider::OpenAi,
            Some(SetupAuthModeSelection::ApiKey),
            "sk-test",
            Some(TEST_MODEL_OPENAI),
        )
        .expect("continued setup should return a failed validation check");

        assert_eq!(
            result.remediation.as_deref(),
            Some(
                "fix the credential and rerun `cara setup --force --provider openai --model openai:gpt-5.5` or run `cara verify` after updating config"
            )
        );
    }

    #[test]
    fn test_configure_provider_interactive_bedrock_passes_prompted_model_to_validation() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("AWS_REGION");
        env_guard.unset("AWS_DEFAULT_REGION");
        env_guard.unset("AWS_ACCESS_KEY_ID");
        env_guard.unset("AWS_SECRET_ACCESS_KEY");
        env_guard.unset("AWS_SESSION_TOKEN");

        let _guard = install_setup_interactive_harness(SetupInteractiveTestHarness {
            visible_inputs: VecDeque::from(vec![
                "anthropic.claude-v1:0".to_string(),
                "".to_string(),
                "n".to_string(),
                "y".to_string(),
            ]),
            hidden_inputs: VecDeque::from(vec![
                "AKIA_TEST".to_string(),
                "secret-test-key".to_string(),
            ]),
            bedrock_validation_results: VecDeque::from(vec![Ok(vec![
                crate::onboarding::setup::SetupCheck::validation_pass(
                    "Scripted Bedrock validation",
                    "scripted Bedrock validation succeeded",
                    None,
                ),
            ])]),
            ..Default::default()
        });
        let mut config = serde_json::json!({});

        let result =
            configure_provider_interactive(&mut config, SetupProvider::Bedrock, true, None, None)
                .expect("interactive Bedrock setup");

        assert_eq!(config["bedrock"]["region"], "us-east-1");
        assert_eq!(config["bedrock"]["accessKeyId"], "AKIA_TEST");
        assert_eq!(config["bedrock"]["secretAccessKey"], "secret-test-key");
        assert_eq!(
            config["agents"]["defaults"]["model"],
            "bedrock:anthropic.claude-v1:0"
        );
        assert_eq!(result.observed_checks.len(), 1);
        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.visible_prompt_count, 4);
        assert_eq!(state.hidden_prompt_count, 2);
        assert!(state.visible_inputs.is_empty());
        assert!(state.hidden_inputs.is_empty());
        assert_eq!(
            state.bedrock_validation_calls,
            vec![BedrockValidationCall {
                region: "us-east-1".to_string(),
                access_key: "AKIA_TEST".to_string(),
                secret_key: "secret-test-key".to_string(),
                session_token: None,
                default_model: "bedrock:anthropic.claude-v1:0".to_string(),
            }]
        );
    }

    #[test]
    fn test_configure_provider_interactive_bedrock_passes_flag_model_to_validation() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("AWS_REGION");
        env_guard.unset("AWS_DEFAULT_REGION");
        env_guard.unset("AWS_ACCESS_KEY_ID");
        env_guard.unset("AWS_SECRET_ACCESS_KEY");
        env_guard.unset("AWS_SESSION_TOKEN");

        let _guard = install_setup_interactive_harness(SetupInteractiveTestHarness {
            visible_inputs: VecDeque::from(vec!["".to_string(), "n".to_string(), "y".to_string()]),
            hidden_inputs: VecDeque::from(vec![
                "AKIA_TEST".to_string(),
                "secret-test-key".to_string(),
            ]),
            bedrock_validation_results: VecDeque::from(vec![Ok(vec![
                crate::onboarding::setup::SetupCheck::validation_pass(
                    "Scripted Bedrock validation",
                    "scripted Bedrock validation succeeded",
                    None,
                ),
            ])]),
            ..Default::default()
        });
        let mut config = serde_json::json!({});
        let model = validated_setup_model(SetupProvider::Bedrock, TEST_MODEL_BEDROCK);

        configure_provider_interactive(
            &mut config,
            SetupProvider::Bedrock,
            true,
            None,
            Some(&model),
        )
        .expect("interactive Bedrock setup with --model");

        assert_eq!(config["agents"]["defaults"]["model"], TEST_MODEL_BEDROCK);
        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.visible_prompt_count, 3);
        assert_eq!(state.hidden_prompt_count, 2);
        assert!(state.visible_inputs.is_empty());
        assert!(state.hidden_inputs.is_empty());
        assert_eq!(
            state.bedrock_validation_calls,
            vec![BedrockValidationCall {
                region: "us-east-1".to_string(),
                access_key: "AKIA_TEST".to_string(),
                secret_key: "secret-test-key".to_string(),
                session_token: None,
                default_model: TEST_MODEL_BEDROCK.to_string(),
            }]
        );
    }

    #[test]
    fn test_configure_provider_interactive_gemini_api_key_sets_default_model() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("GOOGLE_API_KEY");
        env_guard.unset("GOOGLE_API_BASE_URL");
        let _guard = install_setup_interactive_harness(SetupInteractiveTestHarness {
            visible_inputs: VecDeque::from(vec!["n".to_string(), "AIza-test-key".to_string()]),
            ..Default::default()
        });
        let mut config = serde_json::json!({});
        let model = validated_setup_model(SetupProvider::Gemini, TEST_MODEL_GEMINI);

        let result = configure_provider_interactive(
            &mut config,
            SetupProvider::Gemini,
            false,
            Some(SetupAuthModeSelection::ApiKey),
            Some(&model),
        )
        .expect("interactive Gemini setup");

        assert!(result.observed_checks.is_empty());
        assert_eq!(config["google"]["apiKey"], "AIza-test-key");
        assert_eq!(config["agents"]["defaults"]["model"], TEST_MODEL_GEMINI);
        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.provider_validation_calls, 0);
        assert!(state.visible_inputs.is_empty());
        // With `Some(TEST_MODEL_GEMINI)` supplied, `prompt_required_model`
        // must not fire — only the 2 prompts in the Gemini api-key flow
        // (the `Use API key from $GOOGLE_API_KEY?` y/n at index 0, and the
        // API-key entry at index 1) should consume the queue. A regression
        // that spuriously prompted for the model would push this count higher.
        assert_eq!(state.visible_prompt_count, 2);
    }

    #[test]
    fn test_configure_provider_interactive_anthropic_setup_token_skips_live_validation() {
        let mut env_guard = ScopedEnv::new();
        let state_dir = tempfile::TempDir::new().unwrap();
        env_guard.unset("ANTHROPIC_SETUP_TOKEN");
        env_guard.unset("ANTHROPIC_API_KEY");
        env_guard.set("CARAPACE_STATE_DIR", state_dir.path().as_os_str());
        env_guard.set("CARAPACE_CONFIG_PASSWORD", "test-password");

        let payload_len = crate::onboarding::anthropic::ANTHROPIC_SETUP_TOKEN_MIN_TOTAL_LENGTH
            - crate::onboarding::anthropic::ANTHROPIC_SETUP_TOKEN_PREFIX.len();
        let token = format!(
            "{}{}",
            crate::onboarding::anthropic::ANTHROPIC_SETUP_TOKEN_PREFIX,
            "a".repeat(payload_len)
        );
        let _guard = install_setup_interactive_harness(SetupInteractiveTestHarness {
            hidden_inputs: VecDeque::from(vec![token.clone()]),
            ..Default::default()
        });
        let mut config = serde_json::json!({});
        let model = validated_setup_model(SetupProvider::Anthropic, TEST_MODEL_ANTHROPIC);

        let result = configure_provider_interactive(
            &mut config,
            SetupProvider::Anthropic,
            true,
            Some(SetupAuthModeSelection::SetupToken),
            Some(&model),
        )
        .expect("interactive Anthropic setup-token setup");

        assert_eq!(result.observed_checks.len(), 1);
        assert_eq!(
            result.observed_checks[0].status,
            crate::onboarding::setup::SetupCheckStatus::Skip
        );
        assert_eq!(
            result.observed_checks[0].kind,
            crate::onboarding::setup::SetupCheckKind::Validation
        );
        assert!(result.observed_checks[0].detail.contains("setup-token"));
        assert_eq!(
            result.observed_checks[0].remediation.as_deref(),
            Some(
                "run `cara verify --outcome local-chat` after setup to exercise the configured Anthropic setup-token path"
            )
        );

        assert_eq!(
            config["anthropic"]["authProfile"],
            crate::onboarding::anthropic::DEFAULT_ANTHROPIC_AUTH_PROFILE_ID
        );
        assert_eq!(
            config["auth"]["profiles"]["enabled"],
            serde_json::json!(true)
        );

        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.provider_validation_calls, 0);
        assert_eq!(state.hidden_prompt_count, 1);
        assert!(state.hidden_inputs.is_empty());
        // With `Some(TEST_MODEL_ANTHROPIC)` supplied, no visible prompts
        // should fire (the setup-token entry is hidden). A regression that
        // entered `prompt_required_model` would show up as a non-zero count.
        assert_eq!(state.visible_prompt_count, 0);

        let raw = std::fs::read_to_string(state_dir.path().join("auth_profiles.json")).unwrap();
        assert!(raw.contains("enc:v2:"));
        assert!(!raw.contains(&token));
    }

    #[test]
    fn test_configure_provider_interactive_anthropic_setup_token_confirms_api_key_replacement() {
        let mut env_guard = ScopedEnv::new();
        let state_dir = tempfile::TempDir::new().unwrap();
        env_guard.unset("ANTHROPIC_SETUP_TOKEN");
        env_guard.unset("ANTHROPIC_API_KEY");
        env_guard.set("CARAPACE_STATE_DIR", state_dir.path().as_os_str());
        env_guard.set("CARAPACE_CONFIG_PASSWORD", "test-password");

        let payload_len = crate::onboarding::anthropic::ANTHROPIC_SETUP_TOKEN_MIN_TOTAL_LENGTH
            - crate::onboarding::anthropic::ANTHROPIC_SETUP_TOKEN_PREFIX.len();
        let token = format!(
            "{}{}",
            crate::onboarding::anthropic::ANTHROPIC_SETUP_TOKEN_PREFIX,
            "a".repeat(payload_len)
        );
        let _guard = install_setup_interactive_harness(SetupInteractiveTestHarness {
            visible_inputs: VecDeque::from(vec!["y".to_string()]),
            hidden_inputs: VecDeque::from(vec![token.clone()]),
            ..Default::default()
        });
        let mut config = serde_json::json!({
            "anthropic": {
                "apiKey": "${ANTHROPIC_API_KEY}"
            }
        });
        let model = validated_setup_model(SetupProvider::Anthropic, TEST_MODEL_ANTHROPIC);

        let result = configure_provider_interactive(
            &mut config,
            SetupProvider::Anthropic,
            true,
            Some(SetupAuthModeSelection::SetupToken),
            Some(&model),
        )
        .expect("interactive Anthropic setup-token setup");

        assert_eq!(result.observed_checks.len(), 1);
        assert!(config["anthropic"].get("apiKey").is_none());
        assert_eq!(
            config["anthropic"]["authProfile"],
            crate::onboarding::anthropic::DEFAULT_ANTHROPIC_AUTH_PROFILE_ID
        );

        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.visible_prompt_count, 1);
        assert_eq!(state.hidden_prompt_count, 1);
        assert!(state.visible_inputs.is_empty());
        assert!(state.hidden_inputs.is_empty());
    }

    #[test]
    fn test_configure_provider_interactive_anthropic_setup_token_decline_api_key_replacement() {
        let mut env_guard = ScopedEnv::new();
        let state_dir = tempfile::TempDir::new().unwrap();
        env_guard.unset("ANTHROPIC_SETUP_TOKEN");
        env_guard.unset("ANTHROPIC_API_KEY");
        env_guard.set("CARAPACE_STATE_DIR", state_dir.path().as_os_str());
        env_guard.set("CARAPACE_CONFIG_PASSWORD", "test-password");

        let _guard = install_setup_interactive_harness(SetupInteractiveTestHarness {
            visible_inputs: VecDeque::from(vec!["n".to_string()]),
            ..Default::default()
        });
        let mut config = serde_json::json!({
            "anthropic": {
                "apiKey": "${ANTHROPIC_API_KEY}"
            }
        });
        let model = validated_setup_model(SetupProvider::Anthropic, TEST_MODEL_ANTHROPIC);

        let result = configure_provider_interactive(
            &mut config,
            SetupProvider::Anthropic,
            true,
            Some(SetupAuthModeSelection::SetupToken),
            Some(&model),
        );

        assert!(
            result.is_err(),
            "setup should abort when replacement is declined"
        );
        assert_eq!(
            result
                .expect_err("expected replacement-decline abort")
                .to_string(),
            "setup aborted before replacing existing Anthropic API key config"
        );
        assert_eq!(config["anthropic"]["apiKey"], "${ANTHROPIC_API_KEY}");
        assert!(config["anthropic"].get("authProfile").is_none());

        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.visible_prompt_count, 1);
        assert_eq!(state.hidden_prompt_count, 0);
        assert!(state.visible_inputs.is_empty());
        assert!(state.hidden_inputs.is_empty());
        assert!(!state_dir.path().join("auth_profiles.json").exists());
    }

    #[test]
    fn test_configure_provider_interactive_anthropic_setup_token_confirms_env_api_key_override() {
        let mut env_guard = ScopedEnv::new();
        let state_dir = tempfile::TempDir::new().unwrap();
        env_guard.unset("ANTHROPIC_SETUP_TOKEN");
        env_guard.set("ANTHROPIC_API_KEY", "sk-anthropic");
        env_guard.set("CARAPACE_STATE_DIR", state_dir.path().as_os_str());
        env_guard.set("CARAPACE_CONFIG_PASSWORD", "test-password");

        let payload_len = crate::onboarding::anthropic::ANTHROPIC_SETUP_TOKEN_MIN_TOTAL_LENGTH
            - crate::onboarding::anthropic::ANTHROPIC_SETUP_TOKEN_PREFIX.len();
        let token = format!(
            "{}{}",
            crate::onboarding::anthropic::ANTHROPIC_SETUP_TOKEN_PREFIX,
            "a".repeat(payload_len)
        );
        let _guard = install_setup_interactive_harness(SetupInteractiveTestHarness {
            visible_inputs: VecDeque::from(vec!["y".to_string()]),
            hidden_inputs: VecDeque::from(vec![token]),
            ..Default::default()
        });
        let mut config = serde_json::json!({});
        let model = validated_setup_model(SetupProvider::Anthropic, TEST_MODEL_ANTHROPIC);

        let result = configure_provider_interactive(
            &mut config,
            SetupProvider::Anthropic,
            true,
            Some(SetupAuthModeSelection::SetupToken),
            Some(&model),
        )
        .expect("interactive Anthropic setup-token setup");

        assert_eq!(result.observed_checks.len(), 1);
        assert_eq!(
            config["anthropic"]["authProfile"],
            crate::onboarding::anthropic::DEFAULT_ANTHROPIC_AUTH_PROFILE_ID
        );

        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.visible_prompt_count, 1);
        assert_eq!(state.hidden_prompt_count, 1);
        assert!(state.visible_inputs.is_empty());
        assert!(state.hidden_inputs.is_empty());
    }

    #[test]
    fn test_configure_provider_interactive_anthropic_setup_token_decline_env_api_key_override() {
        let mut env_guard = ScopedEnv::new();
        let state_dir = tempfile::TempDir::new().unwrap();
        env_guard.unset("ANTHROPIC_SETUP_TOKEN");
        env_guard.set("ANTHROPIC_API_KEY", "sk-anthropic");
        env_guard.set("CARAPACE_STATE_DIR", state_dir.path().as_os_str());
        env_guard.set("CARAPACE_CONFIG_PASSWORD", "test-password");

        let _guard = install_setup_interactive_harness(SetupInteractiveTestHarness {
            visible_inputs: VecDeque::from(vec!["n".to_string()]),
            ..Default::default()
        });
        let mut config = serde_json::json!({});
        let model = validated_setup_model(SetupProvider::Anthropic, TEST_MODEL_ANTHROPIC);

        let result = configure_provider_interactive(
            &mut config,
            SetupProvider::Anthropic,
            true,
            Some(SetupAuthModeSelection::SetupToken),
            Some(&model),
        );

        assert!(
            result.is_err(),
            "setup should abort when env API key override is declined"
        );
        assert_eq!(
            result
                .expect_err("expected env-override-decline abort")
                .to_string(),
            "setup aborted while `ANTHROPIC_API_KEY` would still override Anthropic setup-token auth"
        );
        assert!(config["anthropic"].get("authProfile").is_none());

        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.visible_prompt_count, 1);
        assert_eq!(state.hidden_prompt_count, 0);
        assert!(state.visible_inputs.is_empty());
        assert!(state.hidden_inputs.is_empty());
        assert!(!state_dir.path().join("auth_profiles.json").exists());
    }

    #[test]
    fn test_configure_provider_interactive_vertex_default_route_writes_config_and_validates() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("VERTEX_PROJECT_ID");
        env_guard.unset("VERTEX_LOCATION");
        env_guard.unset("VERTEX_MODEL");
        let _guard = install_setup_interactive_harness(SetupInteractiveTestHarness {
            visible_inputs: VecDeque::from(vec![
                "my-project".to_string(),
                "us-central1".to_string(),
                "default-route".to_string(),
                "gemini-2.5-flash".to_string(),
                "y".to_string(),
            ]),
            provider_validation_results: VecDeque::from(vec![Ok(())]),
            ..Default::default()
        });
        let mut config = serde_json::json!({});

        let result =
            configure_provider_interactive(&mut config, SetupProvider::Vertex, false, None, None)
                .expect("interactive Vertex setup");

        assert_eq!(config["vertex"]["projectId"], "my-project");
        assert_eq!(config["vertex"]["location"], "us-central1");
        assert_eq!(config["vertex"]["model"], "gemini-2.5-flash");
        assert_eq!(config["agents"]["defaults"]["model"], "vertex:default");
        assert_eq!(result.observed_checks.len(), 1);
        assert_eq!(
            result.observed_checks[0].detail,
            "Vertex auth, project, location, and model access validated"
        );
        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.provider_validation_calls, 1);
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_configure_provider_interactive_vertex_with_explicit_model_skips_route_prompt() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("VERTEX_PROJECT_ID");
        env_guard.unset("VERTEX_LOCATION");
        env_guard.unset("VERTEX_MODEL");
        // No route or explicit-model prompts in the queue — they must be
        // skipped when `--model vertex:<id>` is supplied. Only project/location
        // and the post-validation confirmation remain.
        let _guard = install_setup_interactive_harness(SetupInteractiveTestHarness {
            visible_inputs: VecDeque::from(vec![
                "my-project".to_string(),
                "us-central1".to_string(),
                "y".to_string(),
            ]),
            provider_validation_results: VecDeque::from(vec![Ok(())]),
            ..Default::default()
        });
        let mut config = serde_json::json!({});
        let model = validated_setup_model(SetupProvider::Vertex, TEST_MODEL_VERTEX_EXPLICIT);

        configure_provider_interactive(
            &mut config,
            SetupProvider::Vertex,
            false,
            None,
            Some(&model),
        )
        .expect("interactive Vertex setup with --model");

        assert_eq!(config["vertex"]["projectId"], "my-project");
        assert_eq!(config["vertex"]["location"], "us-central1");
        assert!(
            config["vertex"].get("model").is_none(),
            "explicit `--model` should write `agents.defaults.model` only, not `vertex.model`"
        );
        assert_eq!(
            config["agents"]["defaults"]["model"],
            TEST_MODEL_VERTEX_EXPLICIT
        );
        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        // The harness queue should be fully consumed by exactly project+location+validate.
        assert_eq!(state.visible_prompt_count, 3);
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_configure_provider_interactive_vertex_with_default_route_model_skips_route_prompt() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("VERTEX_PROJECT_ID");
        env_guard.unset("VERTEX_LOCATION");
        env_guard.unset("VERTEX_MODEL");
        // `--model vertex:default` keeps the default-route flow, so a VERTEX_MODEL
        // prompt still fires (plus project/location/validation confirmation).
        let _guard = install_setup_interactive_harness(SetupInteractiveTestHarness {
            visible_inputs: VecDeque::from(vec![
                "my-project".to_string(),
                "us-central1".to_string(),
                "gemini-2.5-flash".to_string(),
                "y".to_string(),
            ]),
            provider_validation_results: VecDeque::from(vec![Ok(())]),
            ..Default::default()
        });
        let mut config = serde_json::json!({});
        let model = validated_setup_model(SetupProvider::Vertex, TEST_MODEL_VERTEX_DEFAULT_ROUTE);

        configure_provider_interactive(
            &mut config,
            SetupProvider::Vertex,
            false,
            None,
            Some(&model),
        )
        .expect("interactive Vertex default-route setup with --model");

        assert_eq!(config["vertex"]["projectId"], "my-project");
        assert_eq!(config["vertex"]["location"], "us-central1");
        assert_eq!(config["vertex"]["model"], "gemini-2.5-flash");
        assert_eq!(
            config["agents"]["defaults"]["model"],
            TEST_MODEL_VERTEX_DEFAULT_ROUTE
        );
        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.visible_prompt_count, 4);
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_handle_setup_interactive_vertex_rejects_mismatched_model_before_prompts() {
        let mut env_guard = ScopedEnv::new();
        let env = setup_interactive_test_env(
            &mut env_guard,
            SetupInteractiveTestHarness {
                force_interactive: Some(true),
                ..Default::default()
            },
        );

        let result = handle_setup(
            true,
            Some(SetupProvider::Vertex),
            None,
            Some("openai:gpt-5.5"),
        );

        assert!(result.is_err(), "mismatched --model should fail fast");
        assert!(
            result
                .expect_err("expected provider/model mismatch error")
                .to_string()
                .contains("--provider vertex"),
            "error message should reference --provider vertex"
        );
        assert!(
            !env.config_path.exists(),
            "setup must not write config after a provider/model mismatch"
        );
        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.visible_prompt_count, 0);
    }

    #[test]
    fn test_configure_provider_interactive_vertex_explicit_route_omits_vertex_model() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("VERTEX_PROJECT_ID");
        env_guard.unset("VERTEX_LOCATION");
        env_guard.unset("VERTEX_MODEL");
        let _guard = install_setup_interactive_harness(SetupInteractiveTestHarness {
            visible_inputs: VecDeque::from(vec![
                "my-project".to_string(),
                "us-central1".to_string(),
                "explicit-model".to_string(),
                "vertex:google/gemini-1.5-pro".to_string(),
                "n".to_string(),
            ]),
            ..Default::default()
        });
        let mut config = serde_json::json!({});

        let result =
            configure_provider_interactive(&mut config, SetupProvider::Vertex, false, None, None)
                .expect("interactive Vertex setup");

        assert_eq!(config["vertex"]["projectId"], "my-project");
        assert_eq!(config["vertex"]["location"], "us-central1");
        assert!(
            config["vertex"].get("model").is_none(),
            "explicit route should not persist `vertex.model`"
        );
        assert_eq!(
            config["agents"]["defaults"]["model"],
            "vertex:google/gemini-1.5-pro"
        );
        assert_eq!(result.observed_checks.len(), 1);
        assert_eq!(
            result.observed_checks[0].detail,
            "Vertex live validation was skipped"
        );
        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.provider_validation_calls, 0);
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_vertex_explicit_route_requires_model_without_env_default() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("VERTEX_PROJECT_ID");
        env_guard.unset("VERTEX_LOCATION");
        env_guard.unset("VERTEX_MODEL");
        let _guard = install_setup_interactive_harness(SetupInteractiveTestHarness {
            visible_inputs: VecDeque::from(vec![
                "my-project".to_string(),
                "us-central1".to_string(),
                "explicit-model".to_string(),
                "".to_string(),
                "publishers/example/models/current".to_string(),
                "n".to_string(),
            ]),
            ..Default::default()
        });
        let mut config = serde_json::json!({});

        let result =
            configure_provider_interactive(&mut config, SetupProvider::Vertex, false, None, None)
                .expect("interactive Vertex setup");

        assert!(
            config["vertex"].get("model").is_none(),
            "explicit route should not fall back to a baked-in `vertex.model`"
        );
        assert_eq!(
            config["agents"]["defaults"]["model"],
            "vertex:publishers/example/models/current"
        );
        assert_eq!(result.observed_checks.len(), 1);
        assert_eq!(
            result.observed_checks[0].detail,
            "Vertex live validation was skipped"
        );
        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.provider_validation_calls, 0);
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_configure_provider_interactive_vertex_validation_failure_can_continue() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("VERTEX_PROJECT_ID");
        env_guard.unset("VERTEX_LOCATION");
        env_guard.unset("VERTEX_MODEL");
        let _guard = install_setup_interactive_harness(SetupInteractiveTestHarness {
            visible_inputs: VecDeque::from(vec![
                "my-project".to_string(),
                "us-central1".to_string(),
                "default-route".to_string(),
                "gemini-2.5-flash".to_string(),
                "y".to_string(),
                "y".to_string(),
            ]),
            provider_validation_results: VecDeque::from(vec![Err(
                "Vertex rejected access to the configured project".to_string(),
            )]),
            ..Default::default()
        });
        let mut config = serde_json::json!({});

        let result =
            configure_provider_interactive(&mut config, SetupProvider::Vertex, false, None, None)
                .expect("interactive Vertex setup");

        assert_eq!(result.observed_checks.len(), 1);
        assert_eq!(
            result.observed_checks[0].status,
            crate::onboarding::setup::SetupCheckStatus::Fail
        );
        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.provider_validation_calls, 1);
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_configure_provider_interactive_vertex_default_route_with_unresolved_model_skips_validation(
    ) {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("VERTEX_PROJECT_ID");
        env_guard.unset("VERTEX_LOCATION");
        env_guard.unset("VERTEX_MODEL");
        let _guard = install_setup_interactive_harness(SetupInteractiveTestHarness {
            visible_inputs: VecDeque::from(vec![
                "my-project".to_string(),
                "us-central1".to_string(),
                "default-route".to_string(),
                "".to_string(),
            ]),
            ..Default::default()
        });
        let mut config = serde_json::json!({});

        let result =
            configure_provider_interactive(&mut config, SetupProvider::Vertex, false, None, None)
                .expect("interactive Vertex setup");

        assert_eq!(config["vertex"]["projectId"], "my-project");
        assert_eq!(config["vertex"]["location"], "us-central1");
        assert_eq!(config["vertex"]["model"], "${VERTEX_MODEL}");
        assert_eq!(config["agents"]["defaults"]["model"], "vertex:default");
        assert_eq!(result.observed_checks.len(), 1);
        assert_eq!(
            result.observed_checks[0].status,
            crate::onboarding::setup::SetupCheckStatus::Skip
        );
        assert_eq!(
            result.observed_checks[0].detail,
            "Vertex live validation was skipped because `vertex:default` still resolves `vertex.model` from `VERTEX_MODEL` later"
        );
        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.provider_validation_calls, 0);
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_configure_provider_interactive_vertex_with_unresolved_project_skips_validation() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("VERTEX_PROJECT_ID");
        env_guard.unset("VERTEX_LOCATION");
        env_guard.unset("VERTEX_MODEL");
        let _guard = install_setup_interactive_harness(SetupInteractiveTestHarness {
            visible_inputs: VecDeque::from(vec![
                "".to_string(),
                "us-central1".to_string(),
                "default-route".to_string(),
                "gemini-2.5-flash".to_string(),
            ]),
            ..Default::default()
        });
        let mut config = serde_json::json!({});

        let result =
            configure_provider_interactive(&mut config, SetupProvider::Vertex, false, None, None)
                .expect("interactive Vertex setup");

        assert_eq!(config["vertex"]["projectId"], "${VERTEX_PROJECT_ID}");
        assert_eq!(config["vertex"]["location"], "us-central1");
        assert_eq!(config["vertex"]["model"], "gemini-2.5-flash");
        assert_eq!(config["agents"]["defaults"]["model"], "vertex:default");
        assert_eq!(result.observed_checks.len(), 1);
        assert_eq!(
            result.observed_checks[0].status,
            crate::onboarding::setup::SetupCheckStatus::Skip
        );
        assert_eq!(
            result.observed_checks[0].detail,
            "Vertex live validation was skipped because `VERTEX_PROJECT_ID` still resolves from the environment later"
        );
        assert_eq!(
            result.observed_checks[0].remediation.as_deref(),
            Some(
                "set `VERTEX_PROJECT_ID` in the same shell and run `cara verify --outcome local-chat` once it is available"
            )
        );
        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.provider_validation_calls, 0);
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_configure_provider_interactive_vertex_validation_failure_can_abort() {
        let mut env_guard = ScopedEnv::new();
        env_guard.unset("VERTEX_PROJECT_ID");
        env_guard.unset("VERTEX_LOCATION");
        env_guard.unset("VERTEX_MODEL");
        let _guard = install_setup_interactive_harness(SetupInteractiveTestHarness {
            visible_inputs: VecDeque::from(vec![
                "my-project".to_string(),
                "us-central1".to_string(),
                "default-route".to_string(),
                "gemini-2.5-flash".to_string(),
                "y".to_string(),
                "n".to_string(),
            ]),
            provider_validation_results: VecDeque::from(vec![Err(
                "Vertex rejected access to the configured project".to_string(),
            )]),
            ..Default::default()
        });
        let mut config = serde_json::json!({});

        let result =
            configure_provider_interactive(&mut config, SetupProvider::Vertex, false, None, None);
        assert!(
            result.is_err(),
            "setup should abort after validation failure"
        );
        assert_eq!(
            result.expect_err("expected setup abort").to_string(),
            "setup aborted after provider configuration validation failure"
        );
        assert_eq!(
            config,
            serde_json::json!({}),
            "aborting setup should leave the in-memory config untouched"
        );
        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.provider_validation_calls, 1);
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_handle_setup_interactive_selects_ollama_provider() {
        let mut env_guard = ScopedEnv::new();
        let env = setup_interactive_test_env(
            &mut env_guard,
            SetupInteractiveTestHarness {
                force_interactive: Some(true),
                visible_inputs: VecDeque::from(vec![
                    "y".to_string(),
                    "ollama".to_string(),
                    "".to_string(),
                    "".to_string(),
                    "".to_string(),
                    "".to_string(),
                    "".to_string(),
                    "".to_string(),
                    "".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                ]),
                ..Default::default()
            },
        );
        env_guard.set("OLLAMA_BASE_URL", "http://127.0.0.1:11434");

        let result = handle_setup(true, None, None, Some(TEST_MODEL_OLLAMA_BARE));
        assert!(result.is_ok(), "interactive Ollama setup should succeed");

        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.provider_validation_calls, 0);
        assert!(state.visible_inputs.is_empty());

        let content = std::fs::read_to_string(&env.config_path).unwrap();
        let parsed: serde_json::Value = json5::from_str(&content).unwrap();
        assert_eq!(
            parsed["providers"]["ollama"]["baseUrl"],
            "${OLLAMA_BASE_URL}"
        );
        assert_eq!(parsed["agents"]["defaults"]["model"], TEST_MODEL_OLLAMA);
    }

    #[test]
    fn test_handle_setup_interactive_bare_model_skips_confirmation_when_provider_matches() {
        let mut env_guard = ScopedEnv::new();
        let env = setup_interactive_test_env(
            &mut env_guard,
            SetupInteractiveTestHarness {
                force_interactive: Some(true),
                visible_inputs: VecDeque::from(vec![
                    // Hide sensitive input? no.
                    "n".to_string(),
                    // Pick the provider implied by the bare OpenAI-shaped model id.
                    "openai".to_string(),
                    // Pick the OpenAI API-key setup variant.
                    "api-key".to_string(),
                    // OpenAI API key prompt. If a confirmation prompt appears, this
                    // value is consumed there and the test fails later.
                    "sk-openai-bare-provider-match".to_string(),
                    // Skip live provider validation.
                    "n".to_string(),
                    // Gateway auth mode: token.
                    "token".to_string(),
                    // Generate gateway token automatically.
                    "y".to_string(),
                    // Gateway bind, port, first-run outcome: defaults.
                    "".to_string(),
                    "".to_string(),
                    "".to_string(),
                    // Hooks and Control UI disabled.
                    "n".to_string(),
                    "n".to_string(),
                    // Do not run post-setup commands from the test.
                    "n".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                ]),
                ..Default::default()
            },
        );

        let result = handle_setup(true, None, None, Some(TEST_MODEL_OPENAI_BARE));
        assert!(
            result.is_ok(),
            "interactive setup should accept same-provider bare model without confirmation"
        );

        let content = std::fs::read_to_string(&env.config_path).unwrap();
        let parsed: serde_json::Value = json5::from_str(&content).unwrap();
        assert_eq!(parsed["agents"]["defaults"]["model"], TEST_MODEL_OPENAI);
        assert_eq!(parsed["openai"]["apiKey"], "sk-openai-bare-provider-match");

        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.provider_validation_calls, 0);
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_handle_setup_interactive_bare_model_confirmation_can_reprompt() {
        let mut env_guard = ScopedEnv::new();
        let env = setup_interactive_test_env(
            &mut env_guard,
            SetupInteractiveTestHarness {
                force_interactive: Some(true),
                visible_inputs: VecDeque::from(vec![
                    // Hide sensitive input? no.
                    "n".to_string(),
                    // Pick Anthropic after supplying a bare OpenAI-shaped model id.
                    "anthropic".to_string(),
                    // The cross-prefixed `anthropic:gpt-5.5` confirmation
                    // requires an explicit answer because `gpt-5.5` looks
                    // OpenAI-shaped. Blank input re-prompts.
                    "".to_string(),
                    // Decline the cross-provider model.
                    "n".to_string(),
                    // Enter an Anthropic model after the confirmation declines.
                    "claude-sonnet-4-6".to_string(),
                    // Anthropic API key prompt.
                    "sk-ant-cross-provider".to_string(),
                    // Skip live provider validation.
                    "n".to_string(),
                    // Gateway auth mode: token.
                    "token".to_string(),
                    // Generate gateway token automatically.
                    "y".to_string(),
                    // Gateway bind, port, first-run outcome: defaults.
                    "".to_string(),
                    "".to_string(),
                    "".to_string(),
                    // Hooks and Control UI disabled.
                    "n".to_string(),
                    "n".to_string(),
                    // Do not run post-setup commands from the test.
                    "n".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                ]),
                ..Default::default()
            },
        );

        let result = handle_setup(
            true,
            None,
            Some(SetupAuthModeSelection::ApiKey),
            Some(TEST_MODEL_OPENAI_BARE),
        );
        assert!(
            result.is_ok(),
            "interactive setup should let users reject the cross-prefixed bare model"
        );

        let content = std::fs::read_to_string(&env.config_path).unwrap();
        let parsed: serde_json::Value = json5::from_str(&content).unwrap();
        assert_eq!(parsed["agents"]["defaults"]["model"], TEST_MODEL_ANTHROPIC);
        assert_eq!(parsed["anthropic"]["apiKey"], "sk-ant-cross-provider");

        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.provider_validation_calls, 0);
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_handle_setup_interactive_bedrock_native_model_reprompts_for_non_bedrock_provider() {
        let mut env_guard = ScopedEnv::new();
        let env = setup_interactive_test_env(
            &mut env_guard,
            SetupInteractiveTestHarness {
                force_interactive: Some(true),
                visible_inputs: VecDeque::from(vec![
                    // Hide sensitive input? no.
                    "n".to_string(),
                    // Pick Anthropic after supplying a Bedrock native model ID.
                    "anthropic".to_string(),
                    // Re-prompted Anthropic model after the Bedrock mismatch.
                    "claude-sonnet-4-6".to_string(),
                    // Anthropic API key prompt.
                    "sk-ant-bedrock-mismatch".to_string(),
                    // Skip live provider validation.
                    "n".to_string(),
                    // Gateway auth mode: token.
                    "token".to_string(),
                    // Generate gateway token automatically.
                    "y".to_string(),
                    // Gateway bind, port, first-run outcome: defaults.
                    "".to_string(),
                    "".to_string(),
                    "".to_string(),
                    // Hooks and Control UI disabled.
                    "n".to_string(),
                    "n".to_string(),
                    // Do not run post-setup commands from the test.
                    "n".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                ]),
                ..Default::default()
            },
        );

        let result = handle_setup(
            true,
            None,
            Some(SetupAuthModeSelection::ApiKey),
            Some("anthropic.claude-v1:0"),
        );
        assert!(
            result.is_ok(),
            "interactive setup should re-prompt instead of aborting on Bedrock/native provider mismatch: {:?}",
            result.err().map(|e| e.to_string())
        );

        let content = std::fs::read_to_string(&env.config_path).unwrap();
        let parsed: serde_json::Value = json5::from_str(&content).unwrap();
        assert_eq!(parsed["agents"]["defaults"]["model"], TEST_MODEL_ANTHROPIC);
        assert_eq!(parsed["anthropic"]["apiKey"], "sk-ant-bedrock-mismatch");

        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.provider_validation_calls, 0);
        assert_eq!(
            state.visible_prompt_count, 15,
            "script should consume the replacement model prompt without aborting"
        );
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_handle_setup_interactive_unknown_bare_model_requires_explicit_confirmation() {
        let mut env_guard = ScopedEnv::new();
        let env = setup_interactive_test_env(
            &mut env_guard,
            SetupInteractiveTestHarness {
                force_interactive: Some(true),
                visible_inputs: VecDeque::from(vec![
                    // Hide sensitive input? no.
                    "n".to_string(),
                    // Pick Anthropic after supplying a bare model id that the
                    // heuristic cannot classify.
                    "anthropic".to_string(),
                    // Unknown models have no default; blank input re-prompts.
                    "".to_string(),
                    // Explicitly confirm the unknown provider-native model ID.
                    "y".to_string(),
                    // Anthropic API key prompt.
                    "sk-ant-unknown-model".to_string(),
                    // Skip live provider validation.
                    "n".to_string(),
                    // Gateway auth mode: token.
                    "token".to_string(),
                    // Generate gateway token automatically.
                    "y".to_string(),
                    // Gateway bind, port, first-run outcome: defaults.
                    "".to_string(),
                    "".to_string(),
                    "".to_string(),
                    // Hooks and Control UI disabled.
                    "n".to_string(),
                    "n".to_string(),
                    // Do not run post-setup commands from the test.
                    "n".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                ]),
                ..Default::default()
            },
        );

        let result = handle_setup(
            true,
            None,
            Some(SetupAuthModeSelection::ApiKey),
            Some("custom-model"),
        );
        assert!(
            result.is_ok(),
            "interactive setup should keep unknown explicit bare --model after explicit confirmation"
        );

        let content = std::fs::read_to_string(&env.config_path).unwrap();
        let parsed: serde_json::Value = json5::from_str(&content).unwrap();
        assert_eq!(
            parsed["agents"]["defaults"]["model"],
            "anthropic:custom-model"
        );
        assert_eq!(parsed["anthropic"]["apiKey"], "sk-ant-unknown-model");

        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.provider_validation_calls, 0);
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_handle_setup_interactive_unknown_bare_model_decline_reprompts() {
        let mut env_guard = ScopedEnv::new();
        let env = setup_interactive_test_env(
            &mut env_guard,
            SetupInteractiveTestHarness {
                force_interactive: Some(true),
                visible_inputs: VecDeque::from(vec![
                    // Hide sensitive input? no.
                    "n".to_string(),
                    // Pick Anthropic after supplying a bare model id that the
                    // heuristic cannot classify.
                    "anthropic".to_string(),
                    // Unknown models require an explicit y/n; decline it.
                    "n".to_string(),
                    // Re-prompted Anthropic model after the decline.
                    "claude-sonnet-4-6".to_string(),
                    // Anthropic API key prompt.
                    "sk-ant-unknown-decline".to_string(),
                    // Skip live provider validation.
                    "n".to_string(),
                    // Gateway auth mode: token.
                    "token".to_string(),
                    // Generate gateway token automatically.
                    "y".to_string(),
                    // Gateway bind, port, first-run outcome: defaults.
                    "".to_string(),
                    "".to_string(),
                    "".to_string(),
                    // Hooks and Control UI disabled.
                    "n".to_string(),
                    "n".to_string(),
                    // Do not run post-setup commands from the test.
                    "n".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                ]),
                ..Default::default()
            },
        );

        let result = handle_setup(
            true,
            None,
            Some(SetupAuthModeSelection::ApiKey),
            Some("custom-model"),
        );
        assert!(
            result.is_ok(),
            "interactive setup should re-prompt after declining an unknown bare model"
        );

        let content = std::fs::read_to_string(&env.config_path).unwrap();
        let parsed: serde_json::Value = json5::from_str(&content).unwrap();
        assert_eq!(parsed["agents"]["defaults"]["model"], TEST_MODEL_ANTHROPIC);
        assert_eq!(parsed["anthropic"]["apiKey"], "sk-ant-unknown-decline");

        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.provider_validation_calls, 0);
        assert_eq!(
            state.visible_prompt_count, 16,
            "script should consume the explicit decline and replacement model prompt"
        );
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_handle_setup_interactive_hidden_input_skips_telegram_validation_on_blank_token() {
        let mut env_guard = ScopedEnv::new();
        let env = setup_interactive_test_env(
            &mut env_guard,
            SetupInteractiveTestHarness {
                force_interactive: Some(true),
                visible_inputs: VecDeque::from(vec![
                    "y".to_string(),
                    "openai".to_string(),
                    "api-key".to_string(),
                    "".to_string(),
                    "n".to_string(),
                    "".to_string(),
                    "".to_string(),
                    "telegram".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                ]),
                hidden_inputs: VecDeque::from(vec![
                    "".to_string(),
                    "hidden-token-123".to_string(),
                    "".to_string(),
                ]),
                ..Default::default()
            },
        );

        let result = handle_setup(true, None, None, Some(TEST_MODEL_OPENAI_BARE));
        assert!(result.is_ok(), "interactive setup should succeed");

        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.hidden_prompt_count, 3);
        assert_eq!(state.channel_validation_calls, 0);
        assert_eq!(state.provider_validation_calls, 0);
        assert!(state.visible_inputs.is_empty());
        assert!(state.hidden_inputs.is_empty());

        let content = std::fs::read_to_string(&env.config_path).unwrap();
        let parsed: serde_json::Value = json5::from_str(&content).unwrap();
        assert_eq!(parsed["gateway"]["auth"]["token"], "hidden-token-123");
        assert!(
            parsed.get("telegram").is_none(),
            "telegram config should be absent when token is blank"
        );
    }

    #[test]
    fn test_handle_setup_interactive_visible_input_validates_telegram_token() {
        let mut env_guard = ScopedEnv::new();
        let env = setup_interactive_test_env(
            &mut env_guard,
            SetupInteractiveTestHarness {
                force_interactive: Some(true),
                visible_inputs: VecDeque::from(vec![
                    "n".to_string(),
                    "openai".to_string(),
                    "api-key".to_string(),
                    "sk-openai-visible".to_string(),
                    "n".to_string(),
                    "".to_string(),
                    "".to_string(),
                    "".to_string(),
                    "".to_string(),
                    "telegram".to_string(),
                    "12345:abc".to_string(),
                    "y".to_string(),
                    "".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                ]),
                channel_validation_results: VecDeque::from(vec![Ok(())]),
                ..Default::default()
            },
        );

        let result = handle_setup(true, None, None, Some(TEST_MODEL_OPENAI_BARE));
        assert!(result.is_ok(), "interactive setup should succeed");

        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.hidden_prompt_count, 0);
        assert_eq!(state.channel_validation_calls, 1);
        assert_eq!(state.provider_validation_calls, 0);
        assert!(state.channel_validation_results.is_empty());
        assert!(state.visible_inputs.is_empty());

        let content = std::fs::read_to_string(&env.config_path).unwrap();
        let parsed: serde_json::Value = json5::from_str(&content).unwrap();
        assert_eq!(parsed["telegram"]["enabled"], true);
        assert_eq!(parsed["telegram"]["botToken"], "12345:abc");
    }

    #[test]
    fn test_handle_setup_interactive_telegram_validation_failure_aborts_when_user_declines_continue(
    ) {
        let mut env_guard = ScopedEnv::new();
        let env = setup_interactive_test_env(
            &mut env_guard,
            SetupInteractiveTestHarness {
                force_interactive: Some(true),
                visible_inputs: VecDeque::from(vec![
                    "n".to_string(),
                    "openai".to_string(),
                    "api-key".to_string(),
                    "sk-openai-visible".to_string(),
                    "n".to_string(),
                    "".to_string(),
                    "".to_string(),
                    "".to_string(),
                    "".to_string(),
                    "telegram".to_string(),
                    "12345:abc".to_string(),
                    "y".to_string(),
                    "n".to_string(),
                ]),
                channel_validation_results: VecDeque::from(vec![Err(
                    "telegram token rejected".to_string()
                )]),
                ..Default::default()
            },
        );

        let result = handle_setup(true, None, None, Some(TEST_MODEL_OPENAI_BARE));
        assert!(
            result.is_err(),
            "setup should abort after validation failure"
        );
        let err = result.unwrap_err().to_string();
        assert!(
            err.contains("setup aborted after credential validation failure"),
            "unexpected setup error"
        );
        assert!(
            !env.config_path.exists(),
            "config file should not be written when setup aborts"
        );

        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.hidden_prompt_count, 0);
        assert_eq!(state.channel_validation_calls, 1);
        assert_eq!(state.provider_validation_calls, 0);
        assert!(state.channel_validation_results.is_empty());
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_handle_setup_interactive_hidden_input_skips_discord_validation_on_blank_token() {
        let mut env_guard = ScopedEnv::new();
        let env = setup_interactive_test_env(
            &mut env_guard,
            SetupInteractiveTestHarness {
                force_interactive: Some(true),
                visible_inputs: VecDeque::from(vec![
                    "y".to_string(),
                    "openai".to_string(),
                    "api-key".to_string(),
                    "".to_string(),
                    "n".to_string(),
                    "".to_string(),
                    "".to_string(),
                    "discord".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                    "n".to_string(),
                ]),
                hidden_inputs: VecDeque::from(vec![
                    "".to_string(),
                    "hidden-token-123".to_string(),
                    "".to_string(),
                ]),
                ..Default::default()
            },
        );

        let result = handle_setup(true, None, None, Some(TEST_MODEL_OPENAI_BARE));
        assert!(result.is_ok(), "interactive setup should succeed");

        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.hidden_prompt_count, 3);
        assert_eq!(state.channel_validation_calls, 0);
        assert_eq!(state.provider_validation_calls, 0);
        assert!(state.visible_inputs.is_empty());
        assert!(state.hidden_inputs.is_empty());

        let content = std::fs::read_to_string(&env.config_path).unwrap();
        let parsed: serde_json::Value = json5::from_str(&content).unwrap();
        assert_eq!(parsed["gateway"]["auth"]["token"], "hidden-token-123");
        assert!(
            parsed.get("discord").is_none(),
            "discord config should be absent when token is blank"
        );
    }

    #[test]
    fn test_handle_setup_interactive_discord_validation_failure_aborts_when_user_declines_continue()
    {
        let mut env_guard = ScopedEnv::new();
        let env = setup_interactive_test_env(
            &mut env_guard,
            SetupInteractiveTestHarness {
                force_interactive: Some(true),
                visible_inputs: VecDeque::from(vec![
                    "n".to_string(),
                    "openai".to_string(),
                    "api-key".to_string(),
                    "sk-openai-visible".to_string(),
                    "n".to_string(),
                    "".to_string(),
                    "".to_string(),
                    "".to_string(),
                    "".to_string(),
                    "discord".to_string(),
                    "discord-bot-token".to_string(),
                    "y".to_string(),
                    "n".to_string(),
                ]),
                channel_validation_results: VecDeque::from(vec![Err(
                    "discord token rejected".to_string()
                )]),
                ..Default::default()
            },
        );

        let result = handle_setup(true, None, None, Some(TEST_MODEL_OPENAI_BARE));
        assert!(
            result.is_err(),
            "setup should abort after validation failure"
        );
        let err = result.unwrap_err().to_string();
        assert!(
            err.contains("setup aborted after credential validation failure"),
            "unexpected setup error"
        );
        assert!(
            !env.config_path.exists(),
            "config file should not be written when setup aborts"
        );

        let state = setup_interactive_test_harness_snapshot().expect("harness snapshot");
        assert_eq!(state.hidden_prompt_count, 0);
        assert_eq!(state.channel_validation_calls, 1);
        assert_eq!(state.provider_validation_calls, 0);
        assert!(state.channel_validation_results.is_empty());
        assert!(state.visible_inputs.is_empty());
    }

    #[test]
    fn test_handle_setup_validation_failure_uses_provider_configuration_abort_message() {
        let _guard = install_setup_interactive_harness(SetupInteractiveTestHarness {
            visible_inputs: VecDeque::from(vec!["n".to_string()]),
            ..Default::default()
        });

        let result = handle_setup_validation_failure(
            SetupProvider::Gemini,
            Some(SetupAuthModeSelection::ApiKey),
            crate::agent::AgentError::InvalidBaseUrl("bad".to_string()),
            None,
        );

        assert!(result.is_err());
        assert_eq!(
            result.unwrap_err().to_string(),
            "setup aborted after provider configuration validation failure"
        );
    }

    #[test]
    fn test_handle_setup_validation_failure_remediation_notes_model_placeholder() {
        let _guard = install_setup_interactive_harness(SetupInteractiveTestHarness {
            visible_inputs: VecDeque::from(vec!["y".to_string()]),
            ..Default::default()
        });

        let result = handle_setup_validation_failure(
            SetupProvider::Gemini,
            Some(SetupAuthModeSelection::ApiKey),
            crate::agent::AgentError::InvalidBaseUrl("bad".to_string()),
            None,
        )
        .expect("continued setup should return a failed validation check");

        assert_eq!(
            result.remediation.as_deref(),
            Some(
                "fix the value and rerun `cara setup --force --provider gemini --auth-mode api-key --model gemini:<model-id>` (replace `<model-id>` with your chosen model before running the command)"
            )
        );
    }

    #[test]
    fn test_handle_setup_validation_failure_remediation_uses_resolved_model() {
        let _guard = install_setup_interactive_harness(SetupInteractiveTestHarness {
            visible_inputs: VecDeque::from(vec!["y".to_string()]),
            ..Default::default()
        });

        let result = handle_setup_validation_failure(
            SetupProvider::Gemini,
            Some(SetupAuthModeSelection::ApiKey),
            crate::agent::AgentError::InvalidBaseUrl("bad".to_string()),
            Some(TEST_MODEL_GEMINI),
        )
        .expect("continued setup should return a failed validation check");

        assert_eq!(
            result.remediation.as_deref(),
            Some(
                "fix the value and rerun `cara setup --force --provider gemini --auth-mode api-key --model gemini:gemini-2.5-flash`"
            )
        );
    }

    // -----------------------------------------------------------------------
    // Pair subcommand tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_cli_pair_basic() {
        let cli = Cli::try_parse_from(["cara", "pair", "https://gateway.local:3001"]).unwrap();
        match cli.command {
            Some(Command::Pair {
                ref url,
                ref name,
                trust,
            }) => {
                assert_eq!(url, "https://gateway.local:3001");
                assert!(name.is_none());
                assert!(!trust);
            }
            other => panic!("Expected Pair, got {:?}", other),
        }
    }

    #[test]
    fn test_cli_pair_with_name_and_trust() {
        let cli = Cli::try_parse_from([
            "cara",
            "pair",
            "https://gateway.local:3001",
            "--name",
            "my-node",
            "--trust",
        ])
        .unwrap();
        match cli.command {
            Some(Command::Pair {
                ref url,
                ref name,
                trust,
            }) => {
                assert_eq!(url, "https://gateway.local:3001");
                assert_eq!(name.as_deref(), Some("my-node"));
                assert!(trust);
            }
            other => panic!("Expected Pair, got {:?}", other),
        }
    }

    #[test]
    fn test_pair_url_validation_https() {
        // Valid https URL should not trigger the URL validation error.
        let url = "https://gateway.local:3001";
        assert!(
            url.starts_with("http://") || url.starts_with("https://"),
            "https URL should be valid"
        );
    }

    #[test]
    fn test_pair_url_validation_http() {
        // Valid http URL should not trigger the URL validation error.
        let url = "http://gateway.local:3001";
        assert!(
            url.starts_with("http://") || url.starts_with("https://"),
            "http URL should be valid"
        );
    }

    #[test]
    fn test_pair_url_validation_invalid() {
        let url = "ftp://gateway.local:3001";
        assert!(
            !(url.starts_with("http://") || url.starts_with("https://")),
            "ftp URL should be invalid"
        );

        let url2 = "gateway.local:3001";
        assert!(
            !(url2.starts_with("http://") || url2.starts_with("https://")),
            "URL without scheme should be invalid"
        );
    }

    #[test]
    fn test_pair_generates_valid_uuid_token() {
        let token = uuid::Uuid::new_v4().to_string();
        // UUID v4 format: 8-4-4-4-12 hex chars.
        assert_eq!(token.len(), 36, "UUID should be 36 characters");
        assert_eq!(
            token.chars().filter(|c| *c == '-').count(),
            4,
            "UUID should have 4 dashes"
        );
        // Verify it parses back as a valid UUID.
        assert!(
            uuid::Uuid::parse_str(&token).is_ok(),
            "Generated token should be a valid UUID"
        );
    }

    #[test]
    fn test_pair_device_name_explicit() {
        let name = Some("my-device");
        let device_name = match name {
            Some(n) => n.to_string(),
            None => {
                crate::config::read_process_env("HOSTNAME").unwrap_or_else(|| "unknown".to_string())
            }
        };
        assert_eq!(device_name, "my-device");
    }

    #[test]
    fn test_pair_device_name_fallback() {
        let name: Option<&str> = None;
        let device_name = match name {
            Some(n) => n.to_string(),
            None => {
                crate::config::read_process_env("HOSTNAME").unwrap_or_else(|| "unknown".to_string())
            }
        };
        // Should be either the hostname or "unknown", both are acceptable.
        assert!(
            !device_name.is_empty(),
            "Fallback device name should not be empty"
        );
    }

    // -----------------------------------------------------------------------
    // Update subcommand tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_cli_update_check() {
        let cli = Cli::try_parse_from(["cara", "update", "--check"]).unwrap();
        match cli.command {
            Some(Command::Update { check, version }) => {
                assert!(check);
                assert!(version.is_none());
            }
            other => panic!("Expected Update, got {:?}", other),
        }
    }

    #[test]
    fn test_cli_update_with_version() {
        let cli = Cli::try_parse_from(["cara", "update", "--version", "0.2.0"]).unwrap();
        match cli.command {
            Some(Command::Update { check, version }) => {
                assert!(!check);
                assert_eq!(version.as_deref(), Some("0.2.0"));
            }
            other => panic!("Expected Update, got {:?}", other),
        }
    }

    #[test]
    fn test_update_version_comparison_up_to_date() {
        let current = "0.1.0";
        let latest = "0.1.0";
        assert_eq!(current, latest, "Same versions should be equal");
    }

    // ====================================================================
    // TLS subcommand parsing tests
    // ====================================================================

    #[test]
    fn test_cli_tls_init_ca() {
        let cli = Cli::try_parse_from(["cara", "tls", "init-ca"]).unwrap();
        match cli.command {
            Some(Command::Tls(TlsCommand::InitCa { output })) => {
                assert!(output.is_none());
            }
            other => panic!("Expected Tls(InitCa), got {:?}", other),
        }
    }

    #[test]
    fn test_cli_tls_init_ca_with_output() {
        let cli = Cli::try_parse_from(["cara", "tls", "init-ca", "--output", "/tmp/ca"]).unwrap();
        match cli.command {
            Some(Command::Tls(TlsCommand::InitCa { output })) => {
                assert_eq!(output.as_deref(), Some("/tmp/ca"));
            }
            other => panic!("Expected Tls(InitCa), got {:?}", other),
        }
    }

    #[test]
    fn test_cli_tls_issue_cert() {
        let cli = Cli::try_parse_from(["cara", "tls", "issue-cert", "node-east-1"]).unwrap();
        match cli.command {
            Some(Command::Tls(TlsCommand::IssueCert {
                node_id,
                ca_dir,
                output,
            })) => {
                assert_eq!(node_id, "node-east-1");
                assert!(ca_dir.is_none());
                assert!(output.is_none());
            }
            other => panic!("Expected Tls(IssueCert), got {:?}", other),
        }
    }

    #[test]
    fn test_cli_tls_issue_cert_with_options() {
        let cli = Cli::try_parse_from([
            "cara",
            "tls",
            "issue-cert",
            "node-west-2",
            "--ca-dir",
            "/etc/carapace/ca",
            "--output",
            "/etc/carapace/nodes",
        ])
        .unwrap();
        match cli.command {
            Some(Command::Tls(TlsCommand::IssueCert {
                node_id,
                ca_dir,
                output,
            })) => {
                assert_eq!(node_id, "node-west-2");
                assert_eq!(ca_dir.as_deref(), Some("/etc/carapace/ca"));
                assert_eq!(output.as_deref(), Some("/etc/carapace/nodes"));
            }
            other => panic!("Expected Tls(IssueCert), got {:?}", other),
        }
    }

    #[test]
    fn test_cli_tls_revoke_cert() {
        let cli = Cli::try_parse_from([
            "cara",
            "tls",
            "revoke-cert",
            "AA:BB:CC:DD",
            "--node-id",
            "node-1",
        ])
        .unwrap();
        match cli.command {
            Some(Command::Tls(TlsCommand::RevokeCert {
                fingerprint,
                node_id,
                ca_dir,
                reason,
            })) => {
                assert_eq!(fingerprint, "AA:BB:CC:DD");
                assert_eq!(node_id, "node-1");
                assert!(ca_dir.is_none());
                assert!(reason.is_none());
            }
            other => panic!("Expected Tls(RevokeCert), got {:?}", other),
        }
    }

    #[test]
    fn test_cli_tls_revoke_cert_with_reason() {
        let cli = Cli::try_parse_from([
            "cara",
            "tls",
            "revoke-cert",
            "AA:BB:CC:DD",
            "--node-id",
            "node-1",
            "--reason",
            "key compromised",
        ])
        .unwrap();
        match cli.command {
            Some(Command::Tls(TlsCommand::RevokeCert {
                fingerprint,
                node_id,
                ca_dir,
                reason,
            })) => {
                assert_eq!(fingerprint, "AA:BB:CC:DD");
                assert_eq!(node_id, "node-1");
                assert!(ca_dir.is_none());
                assert_eq!(reason.as_deref(), Some("key compromised"));
            }
            other => panic!("Expected Tls(RevokeCert), got {:?}", other),
        }
    }

    #[test]
    fn test_cli_tls_show_ca() {
        let cli = Cli::try_parse_from(["cara", "tls", "show-ca"]).unwrap();
        match cli.command {
            Some(Command::Tls(TlsCommand::ShowCa { ca_dir })) => {
                assert!(ca_dir.is_none());
            }
            other => panic!("Expected Tls(ShowCa), got {:?}", other),
        }
    }

    #[test]
    fn test_cli_tls_show_ca_with_dir() {
        let cli = Cli::try_parse_from(["cara", "tls", "show-ca", "--ca-dir", "/tmp/ca"]).unwrap();
        match cli.command {
            Some(Command::Tls(TlsCommand::ShowCa { ca_dir })) => {
                assert_eq!(ca_dir.as_deref(), Some("/tmp/ca"));
            }
            other => panic!("Expected Tls(ShowCa), got {:?}", other),
        }
    }

    /// `ensure_no_running_daemon_for_rekey` short-circuits on every
    /// state where it cannot prove the daemon is alive: missing PID
    /// file, empty PID file, unparseable PID, and structurally
    /// invalid PIDs (`<= 1`). The last guard exists because
    /// `kill(0, 0)` (process group) and `kill(-1, 0)` (every
    /// reachable process) typically return 0, which would refuse the
    /// rekey forever on a corrupt PID of `0` or `-1`.
    #[test]
    fn test_ensure_no_running_daemon_for_rekey_skips_garbage_pids() {
        let temp = tempfile::tempdir().expect("tempdir");

        // No PID file → pass.
        ensure_no_running_daemon_for_rekey(temp.path())
            .expect("missing PID file must not block rekey");

        let pid_path = temp.path().join("daemon.pid");

        for garbage in ["", "   ", "\n\n", "not-a-pid", "9999999999999999999"] {
            std::fs::write(&pid_path, garbage).expect("write garbage PID");
            ensure_no_running_daemon_for_rekey(temp.path()).unwrap_or_else(|err| {
                panic!("garbage PID {garbage:?} must not block rekey, got: {err}")
            });
        }

        for structural_garbage in ["0", "-1", "1", "  -42  "] {
            std::fs::write(&pid_path, structural_garbage).expect("write structural garbage");
            ensure_no_running_daemon_for_rekey(temp.path()).unwrap_or_else(|err| {
                panic!("structural garbage PID {structural_garbage:?} must not block rekey, got: {err}")
            });
        }
    }

    /// On Unix, `rekey_pid_is_alive` distinguishes ESRCH (process
    /// gone) from EPERM (process exists, signal denied). EPERM is
    /// security-load-bearing: the daemon may run as a different user,
    /// and treating EPERM as "dead" would let `rekey-store --new`
    /// proceed while another user's daemon holds the SQLite stores
    /// open. We pin both ends — a definitely-alive PID (the test
    /// process itself) returns true; a definitely-dead PID returns
    /// false. We can't synthesize EPERM portably without spawning a
    /// privileged process, so this test pins the public contract via
    /// the cases we *can* construct.
    #[cfg(unix)]
    #[test]
    fn test_rekey_pid_is_alive_unix_contract() {
        let self_pid = std::process::id() as i32;
        assert!(
            rekey_pid_is_alive(self_pid),
            "current process must be detected as alive"
        );

        // A high PID that almost certainly does not exist on the
        // host. The kernel's PID space is bounded
        // (`/proc/sys/kernel/pid_max` ≈ 4194304 on Linux); 999_999
        // is well within that bound, so `kill(pid, 0)` reports ESRCH
        // rather than EINVAL. Run a quick sanity check first to
        // skip the assertion if the test happens to coincide with a
        // live PID.
        let dead_pid: i32 = 999_999;
        // SAFETY: signal 0 is a no-op probe — never delivers.
        let alive_now = unsafe { libc::kill(dead_pid, 0) } == 0;
        if !alive_now {
            assert!(
                !rekey_pid_is_alive(dead_pid),
                "PID {dead_pid} must be reported dead when ESRCH"
            );
        }
    }

    /// On Windows, `rekey_pid_is_alive` uses `OpenProcess` to probe
    /// the PID. Pin both ends:
    /// - Current process is detected as alive.
    /// - A definitely-dead high PID is detected as dead, unblocking
    ///   `cara matrix rekey-store --new` against a stale `daemon.pid`
    ///   left by an unclean daemon shutdown.
    /// - Structurally invalid PIDs (`<= 1`) report dead so the
    ///   guard's load path can short-circuit identically to Unix
    ///   (PID 0 = System Idle Process, PID 1 ≈ System).
    ///
    /// The dead-PID branch must NOT be gated on the very function
    /// under test — that would silently skip the assertion when
    /// the function is broken (which is exactly the round-17 PID
    /// stub failure mode that round 20 was supposed to fix). Probe
    /// the kernel directly via `OpenProcess` to obtain an
    /// independent ground truth, then assert the wrapper agrees.
    #[cfg(windows)]
    #[test]
    fn test_rekey_pid_is_alive_windows_contract() {
        assert!(!rekey_pid_is_alive(0));
        assert!(!rekey_pid_is_alive(-1));
        assert!(!rekey_pid_is_alive(1), "PID 1 (System) is reserved garbage");

        let self_pid = std::process::id() as i32;
        assert!(
            rekey_pid_is_alive(self_pid),
            "current process must be detected as alive"
        );

        // Pick a high PID and probe the kernel directly to verify
        // it's actually free, then assert our wrapper agrees. Don't
        // gate the assert on the wrapper's own answer — that would
        // hide a `return true` regression. If the picked PID happens
        // to be live, skip with a note rather than fail spuriously
        // (test environments differ).
        use windows_sys::Win32::Foundation::CloseHandle;
        use windows_sys::Win32::System::Threading::{
            OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
        };
        let dead_pid: u32 = 999_999_993;
        // SAFETY: pure FFI probe; any returned handle is closed.
        let h = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, dead_pid) };
        if h.is_null() {
            // Independent ground-truth: PID is genuinely free.
            assert!(
                !rekey_pid_is_alive(dead_pid as i32),
                "wrapper must report dead for a kernel-confirmed-free PID"
            );
        } else {
            // SAFETY: handle came from the OpenProcess call above.
            unsafe {
                CloseHandle(h);
            }
            // PID happened to be live in this test run; cannot pin
            // dead-branch behaviour, but the wrapper should agree
            // it's alive.
            assert!(
                rekey_pid_is_alive(dead_pid as i32),
                "wrapper must agree with kernel-confirmed-alive PID"
            );
        }
    }

    /// On targets without `kill(pid, 0)` or `OpenProcess`, the
    /// fallback trusts the PID file's existence — always true. Pin
    /// that contract so a future refactor doesn't silently flip the
    /// answer and let rekey proceed against a live daemon.
    #[cfg(not(any(unix, windows)))]
    #[test]
    fn test_rekey_pid_is_alive_other_target_always_alive() {
        assert!(rekey_pid_is_alive(1));
        assert!(rekey_pid_is_alive(99999));
        assert!(rekey_pid_is_alive(0));
    }

    /// `send_control_request_with_client_and_auth` must refuse to
    /// transmit a bearer credential over plaintext HTTP unless the
    /// target is loopback. This is the exposed end of the loopback
    /// guard — a future refactor that moves the check could
    /// silently drop the protection if no test pins the behaviour
    /// at the public boundary. We exercise the three branches that
    /// matter:
    /// - credential + http + non-loopback → refused
    /// - credential + http + loopback     → allowed (no scheme error)
    /// - credential + https + non-loopback → allowed
    /// - no credential + http + non-loopback → allowed (nothing to leak)
    ///
    /// We can't actually issue the request in a unit test, so we rely
    /// on the fact that the loopback refusal is the only synchronous
    /// validation: any other branch returns an error from `reqwest`
    /// (DNS, connect refused, etc.) — distinguishable by message.
    #[tokio::test]
    async fn test_send_control_request_refuses_bearer_over_plaintext_to_remote() {
        let client = reqwest::Client::builder()
            .timeout(std::time::Duration::from_millis(50))
            .build()
            .expect("client");
        let auth = GatewayAuth {
            token: Some("test-token".to_string()),
            password: None,
        };

        // (1) Plaintext HTTP to a non-loopback host with a bearer
        // credential — must refuse synchronously, before any network
        // I/O.
        let url = Url::parse("http://matrix.example.com:18789/control/status").expect("url");
        let err = send_control_request_with_client_and_auth(
            &client,
            &auth,
            reqwest::Method::GET,
            url,
            None,
        )
        .await
        .expect_err("must refuse bearer-over-plaintext to non-loopback");
        assert!(
            err.to_string().contains("plaintext HTTP"),
            "expected refusal message, got: {err}"
        );

        // (2) Plaintext HTTP to a loopback host with a bearer credential
        // — must NOT trip the refusal. Connection will fail because
        // nothing is listening, but the error must NOT be the bearer
        // refusal.
        let url = Url::parse("http://127.0.0.1:1/control/status").expect("url");
        let err = send_control_request_with_client_and_auth(
            &client,
            &auth,
            reqwest::Method::GET,
            url,
            None,
        )
        .await
        .expect_err("connect must fail (port 1)");
        assert!(
            !err.to_string().contains("plaintext HTTP"),
            "loopback bearer must not be refused, got: {err}"
        );

        // (3) No credential present + plaintext + non-loopback — no
        // refusal because there's nothing to leak.
        let no_auth = GatewayAuth {
            token: None,
            password: None,
        };
        let url = Url::parse("http://matrix.example.com:1/control/status").expect("url");
        let err = send_control_request_with_client_and_auth(
            &client,
            &no_auth,
            reqwest::Method::GET,
            url,
            None,
        )
        .await
        .expect_err("connect/dns must fail");
        assert!(
            !err.to_string().contains("plaintext HTTP"),
            "no-credential request must not be refused, got: {err}"
        );

        // (4) Wildcard 0.0.0.0 must also refuse — it's not loopback,
        // even though some operators expect it to be.
        let url = Url::parse("http://0.0.0.0:18789/control/status").expect("url");
        let err = send_control_request_with_client_and_auth(
            &client,
            &auth,
            reqwest::Method::GET,
            url,
            None,
        )
        .await
        .expect_err("must refuse bearer-over-plaintext to 0.0.0.0");
        assert!(
            err.to_string().contains("plaintext HTTP"),
            "0.0.0.0 must be refused, got: {err}"
        );

        // (5) IPv6 wildcard `[::]` — same operator-misconfig as
        // `0.0.0.0`. Must be refused so a bearer credential cannot
        // leak via an unbracketed-form parser drift.
        let url = Url::parse("http://[::]:18789/control/status").expect("url");
        let err = send_control_request_with_client_and_auth(
            &client,
            &auth,
            reqwest::Method::GET,
            url,
            None,
        )
        .await
        .expect_err("must refuse bearer-over-plaintext to [::]");
        assert!(
            err.to_string().contains("plaintext HTTP"),
            "[::] must be refused, got: {err}"
        );

        // (6) IPv6 non-loopback (`[2001:db8::1]`) — bracketed form per
        // `Url::host_str` semantics. The `is_loopback_host` helper
        // strips brackets internally; the e2e test pins that the
        // refusal triggers correctly when `host_str()` returns the
        // non-bracketless form for the parser.
        let url = Url::parse("http://[2001:db8::1]:18789/control/status").expect("url");
        let err = send_control_request_with_client_and_auth(
            &client,
            &auth,
            reqwest::Method::GET,
            url,
            None,
        )
        .await
        .expect_err("must refuse bearer-over-plaintext to non-loopback IPv6");
        assert!(
            err.to_string().contains("plaintext HTTP"),
            "[2001:db8::1] must be refused, got: {err}"
        );

        // (7) IPv6 loopback (`[::1]`) — must NOT be refused. Connect
        // will still fail (port 1 likely closed), but the error must
        // not be the bearer refusal.
        let url = Url::parse("http://[::1]:1/control/status").expect("url");
        let err = send_control_request_with_client_and_auth(
            &client,
            &auth,
            reqwest::Method::GET,
            url,
            None,
        )
        .await
        .expect_err("connect must fail (port 1)");
        assert!(
            !err.to_string().contains("plaintext HTTP"),
            "loopback IPv6 must not be refused, got: {err}"
        );
    }
}
