diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f11e199a03..d276705c2d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1343,6 +1343,7 @@ dependencies = [ "codex-core", "codex-exec", "codex-execpolicy", + "codex-keyring-store", "codex-login", "codex-mcp-server", "codex-protocol", @@ -1452,7 +1453,6 @@ dependencies = [ name = "codex-core" version = "0.0.0" dependencies = [ - "age", "anyhow", "arc-swap", "assert_cmd", @@ -1477,6 +1477,7 @@ dependencies = [ "codex-otel", "codex-protocol", "codex-rmcp-client", + "codex-secrets", "codex-state", "codex-utils-absolute-path", "codex-utils-cargo-bin", @@ -1944,6 +1945,24 @@ dependencies = [ "which", ] +[[package]] +name = "codex-secrets" +version = "0.0.0" +dependencies = [ + "age", + "anyhow", + "base64 0.22.1", + "codex-keyring-store", + "pretty_assertions", + "rand 0.9.2", + "schemars 0.8.22", + "serde", + "serde_json", + "sha2", + "tempfile", + "tracing", +] + [[package]] name = "codex-state" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 68b8df77a0..99a80ae9f8 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -16,6 +16,7 @@ members = [ "cli", "common", "core", + "secrets", "exec", "exec-server", "execpolicy", @@ -76,6 +77,7 @@ codex-cli = { path = "cli"} codex-client = { path = "codex-client" } codex-common = { path = "common" } codex-core = { path = "core" } +codex-secrets = { path = "secrets" } codex-exec = { path = "exec" } codex-execpolicy = { path = "execpolicy" } codex-feedback = { path = "feedback" } diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 2e8f74a279..107e640bd0 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -57,6 +57,7 @@ codex_windows_sandbox = { package = "codex-windows-sandbox", path = "../windows- [dev-dependencies] assert_cmd = { workspace = true } assert_matches = { workspace = true } +codex-keyring-store = { workspace = true } codex-utils-cargo-bin = { workspace = true } predicates = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/cli/src/secrets_cmd.rs b/codex-rs/cli/src/secrets_cmd.rs index 238d1d24fe..b5a786382c 100644 --- a/codex-rs/cli/src/secrets_cmd.rs +++ b/codex-rs/cli/src/secrets_cmd.rs @@ -31,6 +31,8 @@ pub struct SecretsCli { pub enum SecretsSubcommand { /// Store or update a secret value. Set(SecretsSetArgs), + /// Update an existing secret value. + Edit(SecretsEditArgs), /// List secret names (values are never displayed). List(SecretsListArgs), /// Delete a secret value. @@ -61,6 +63,19 @@ pub struct SecretsSetArgs { pub scope: SecretsScopeArgs, } +#[derive(Debug, Parser)] +pub struct SecretsEditArgs { + /// Secret name (A-Z, 0-9, underscore only). + pub name: String, + + /// New secret value. Prefer piping via stdin to avoid shell history. + #[arg(long)] + pub value: Option, + + #[clap(flatten)] + pub scope: SecretsScopeArgs, +} + #[derive(Debug, Parser)] pub struct SecretsListArgs { #[clap(flatten)] @@ -82,6 +97,7 @@ impl SecretsCli { let manager = SecretsManager::new(config.codex_home.clone(), config.secrets_backend); match self.subcommand { SecretsSubcommand::Set(args) => run_set(&config, &manager, args), + SecretsSubcommand::Edit(args) => run_edit(&config, &manager, args), SecretsSubcommand::List(args) => run_list(&config, &manager, args), SecretsSubcommand::Delete(args) => run_delete(&config, &manager, args), } @@ -100,12 +116,28 @@ async fn load_config(cli_config_overrides: CliConfigOverrides) -> Result fn run_set(config: &Config, manager: &SecretsManager, args: SecretsSetArgs) -> Result<()> { let name = SecretName::new(&args.name)?; let scope = resolve_scope(config, &args.scope)?; - let value = resolve_value(&name, args.value)?; + let value = resolve_value(&args.name, args.value)?; manager.set(&scope, &name, &value)?; println!("Stored {name} in {}.", scope_label(&scope)); Ok(()) } +fn run_edit(config: &Config, manager: &SecretsManager, args: SecretsEditArgs) -> Result<()> { + let name = SecretName::new(&args.name)?; + let scope = resolve_scope(config, &args.scope)?; + let exists = manager.get(&scope, &name)?.is_some(); + if !exists { + bail!( + "No secret named {name} found in {}. Use `codex secrets set {name}` to create it.", + scope_label(&scope) + ); + } + let value = resolve_value(&args.name, args.value)?; + manager.set(&scope, &name, &value)?; + println!("Updated {name} in {}.", scope_label(&scope)); + Ok(()) +} + fn run_list(config: &Config, manager: &SecretsManager, args: SecretsListArgs) -> Result<()> { let default_scope = default_environment_scope(config)?; let scope_filter = match (args.scope.global, args.scope.environment_id.as_deref()) { @@ -165,13 +197,13 @@ fn default_environment_scope(config: &Config) -> Result { SecretScope::environment(environment_id) } -fn resolve_value(name: &SecretName, explicit: Option) -> Result { +fn resolve_value(display_name: &str, explicit: Option) -> Result { if let Some(value) = explicit { return Ok(value); } if std::io::stdin().is_terminal() { - return prompt_secret_value(name); + return prompt_secret_value(display_name); } let mut buf = String::new(); @@ -183,8 +215,8 @@ fn resolve_value(name: &SecretName, explicit: Option) -> Result Ok(trimmed) } -fn prompt_secret_value(name: &SecretName) -> Result { - print!("Enter value for {name}: "); +fn prompt_secret_value(display_name: &str) -> Result { + print!("Enter value for {display_name}: "); std::io::stdout() .flush() .context("failed to flush stdout before prompt")?; @@ -250,3 +282,90 @@ fn scope_label(scope: &SecretScope) -> String { SecretScope::Environment(env_id) => format!("env/{env_id}"), } } + +#[cfg(test)] +mod tests { + use super::*; + use codex_core::config::ConfigBuilder; + use codex_core::config::ConfigOverrides; + use codex_keyring_store::tests::MockKeyringStore; + use pretty_assertions::assert_eq; + use std::sync::Arc; + + async fn test_config(codex_home: &std::path::Path, cwd: &std::path::Path) -> Result { + let overrides = ConfigOverrides { + cwd: Some(cwd.to_path_buf()), + ..Default::default() + }; + Ok(ConfigBuilder::default() + .codex_home(codex_home.to_path_buf()) + .harness_overrides(overrides) + .build() + .await?) + } + + #[tokio::test] + async fn edit_updates_existing_secret() -> Result<()> { + let codex_home = tempfile::tempdir().context("temp codex home")?; + let cwd = tempfile::tempdir().context("temp cwd")?; + let config = test_config(codex_home.path(), cwd.path()).await?; + let keyring = Arc::new(MockKeyringStore::default()); + let manager = SecretsManager::new_with_keyring_store( + config.codex_home.clone(), + config.secrets_backend, + keyring, + ); + + let scope = SecretScope::Global; + let name = SecretName::new("TEST_SECRET")?; + manager.set(&scope, &name, "before")?; + + run_edit( + &config, + &manager, + SecretsEditArgs { + name: "TEST_SECRET".to_string(), + value: Some("after".to_string()), + scope: SecretsScopeArgs { + global: true, + environment_id: None, + }, + }, + )?; + + assert_eq!(manager.get(&scope, &name)?, Some("after".to_string())); + Ok(()) + } + + #[tokio::test] + async fn edit_missing_secret_errors() -> Result<()> { + let codex_home = tempfile::tempdir().context("temp codex home")?; + let cwd = tempfile::tempdir().context("temp cwd")?; + let config = test_config(codex_home.path(), cwd.path()).await?; + let keyring = Arc::new(MockKeyringStore::default()); + let manager = SecretsManager::new_with_keyring_store( + config.codex_home.clone(), + config.secrets_backend, + keyring, + ); + + let err = run_edit( + &config, + &manager, + SecretsEditArgs { + name: "TEST_SECRET".to_string(), + value: Some("after".to_string()), + scope: SecretsScopeArgs { + global: true, + environment_id: None, + }, + }, + ) + .expect_err("edit should fail when secret is missing"); + + let message = err.to_string(); + assert!(message.contains("No secret named TEST_SECRET found in global.")); + assert!(message.contains("codex secrets set TEST_SECRET")); + Ok(()) + } +} diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 4b6110fba0..cafa1081c8 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -17,7 +17,6 @@ path = "src/bin/config_schema.rs" workspace = true [dependencies] -age = { workspace = true } anyhow = { workspace = true } arc-swap = "1.8.0" async-channel = { workspace = true } @@ -38,6 +37,7 @@ codex-keyring-store = { workspace = true } codex-otel = { workspace = true } codex-protocol = { workspace = true } codex-rmcp-client = { workspace = true } +codex-secrets = { workspace = true } codex-state = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-pty = { workspace = true } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 2b7bbb1920..c251805272 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -155,7 +155,6 @@ use crate::rollout::RolloutRecorder; use crate::rollout::RolloutRecorderParams; use crate::rollout::map_session_init_error; use crate::rollout::metadata; -use crate::secrets::resolve_skill_env_dependencies; use crate::shell; use crate::shell_snapshot::ShellSnapshot; use crate::skills::SkillError; @@ -1936,41 +1935,6 @@ impl Session { state.record_mcp_dependency_prompted(names); } - pub(crate) async fn skill_secret_prompted(&self) -> HashSet { - let state = self.state.lock().await; - state.skill_secret_prompted() - } - - pub(crate) async fn record_skill_secret_prompted(&self, keys: I) - where - I: IntoIterator, - { - let mut state = self.state.lock().await; - state.record_skill_secret_prompted(keys); - } - - pub(crate) async fn set_skill_env_overrides( - &self, - sub_id: String, - overrides: HashMap, - ) { - let mut state = self.state.lock().await; - state.set_skill_env_overrides(sub_id, overrides); - } - - pub(crate) async fn skill_env_overrides( - &self, - sub_id: &str, - ) -> Option> { - let state = self.state.lock().await; - state.skill_env_overrides(sub_id) - } - - pub(crate) async fn clear_skill_env_overrides(&self, sub_id: &str) { - let mut state = self.state.lock().await; - state.clear_skill_env_overrides(sub_id); - } - pub(crate) async fn set_server_reasoning_included(&self, included: bool) { let mut state = self.state.lock().await; state.set_server_reasoning_included(included); @@ -3214,20 +3178,6 @@ pub(crate) async fn run_turn( ) .await; - let skill_secrets = resolve_skill_env_dependencies( - sess.as_ref(), - turn_context.as_ref(), - &cancellation_token, - &mentioned_skills, - ) - .await; - sess.set_skill_env_overrides(turn_context.sub_id.clone(), skill_secrets.overrides) - .await; - for message in skill_secrets.warnings { - sess.send_event(&turn_context, EventMsg::Warning(WarningEvent { message })) - .await; - } - let otel_manager = turn_context.client.get_otel_manager(); let SkillInjections { items: skill_items, diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index c763755af5..9091d3ab4a 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -236,6 +236,7 @@ fn candidate_filenames<'a>(config: &'a Config) -> Vec<&'a str> { mod tests { use super::*; use crate::config::ConfigBuilder; + use crate::config::ConfigOverrides; use crate::skills::load_skills; use std::fs; use std::path::PathBuf; @@ -248,13 +249,16 @@ mod tests { /// been configured. async fn make_config(root: &TempDir, limit: usize, instructions: Option<&str>) -> Config { let codex_home = TempDir::new().unwrap(); + let overrides = ConfigOverrides { + cwd: Some(root.path().to_path_buf()), + ..Default::default() + }; let mut config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) + .harness_overrides(overrides) .build() .await .expect("defaults for test should always succeed"); - - config.cwd = root.path().to_path_buf(); config.project_doc_max_bytes = limit; config.user_instructions = instructions.map(ToOwned::to_owned); diff --git a/codex-rs/core/src/secrets/mod.rs b/codex-rs/core/src/secrets/mod.rs index c18bc41e3b..20221d416f 100644 --- a/codex-rs/core/src/secrets/mod.rs +++ b/codex-rs/core/src/secrets/mod.rs @@ -1,211 +1,7 @@ -use std::fmt; -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; - -use anyhow::Result; -use codex_keyring_store::DefaultKeyringStore; -use codex_keyring_store::KeyringStore; -use schemars::JsonSchema; -use serde::Deserialize; -use serde::Serialize; -use sha2::Digest; -use sha2::Sha256; - -use crate::git_info::get_git_repo_root; - -mod local; -mod skill_dependencies; - -pub use local::LocalSecretsBackend; -pub(crate) use skill_dependencies::resolve_skill_env_dependencies; - -const KEYRING_SERVICE: &str = "codex"; - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct SecretName(String); - -impl SecretName { - pub fn new(raw: &str) -> Result { - let trimmed = raw.trim(); - anyhow::ensure!(!trimmed.is_empty(), "secret name must not be empty"); - anyhow::ensure!( - trimmed - .chars() - .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_'), - "secret name must contain only A-Z, 0-9, or _" - ); - Ok(Self(trimmed.to_string())) - } - - pub fn as_str(&self) -> &str { - self.0.as_str() - } -} - -impl fmt::Display for SecretName { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum SecretScope { - Global, - Environment(String), -} - -impl SecretScope { - pub fn environment(environment_id: impl Into) -> Result { - let env_id = environment_id.into(); - let trimmed = env_id.trim(); - anyhow::ensure!(!trimmed.is_empty(), "environment id must not be empty"); - Ok(Self::Environment(trimmed.to_string())) - } - - pub fn canonical_key(&self, name: &SecretName) -> String { - match self { - Self::Global => format!("global/{}", name.as_str()), - Self::Environment(environment_id) => { - format!("env/{environment_id}/{}", name.as_str()) - } - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SecretListEntry { - pub scope: SecretScope, - pub name: SecretName, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "lowercase")] -pub enum SecretsBackendKind { - Local, -} - -impl Default for SecretsBackendKind { - fn default() -> Self { - Self::Local - } -} - -#[derive(Debug, Clone)] -pub struct SecretsManager { - backend: Arc, -} - -impl SecretsManager { - pub fn new(codex_home: PathBuf, backend_kind: SecretsBackendKind) -> Self { - let keyring_store: Arc = Arc::new(DefaultKeyringStore); - Self::new_with_keyring_store(codex_home, backend_kind, keyring_store) - } - - pub fn new_with_keyring_store( - codex_home: PathBuf, - backend_kind: SecretsBackendKind, - keyring_store: Arc, - ) -> Self { - match backend_kind { - SecretsBackendKind::Local => Self { - backend: Arc::new(LocalSecretsBackend::new(codex_home, keyring_store)), - }, - } - } - - pub fn set(&self, scope: &SecretScope, name: &SecretName, value: &str) -> Result<()> { - self.backend.set(scope, name, value) - } - - pub fn get(&self, scope: &SecretScope, name: &SecretName) -> Result> { - self.backend.get(scope, name) - } - - pub fn delete(&self, scope: &SecretScope, name: &SecretName) -> Result { - self.backend.delete(scope, name) - } - - pub fn list(&self, scope_filter: Option<&SecretScope>) -> Result> { - self.backend.list(scope_filter) - } -} - -pub fn environment_id_from_cwd(cwd: &Path) -> String { - if let Some(repo_root) = get_git_repo_root(cwd) - && let Some(name) = repo_root.file_name() - { - let name = name.to_string_lossy().trim().to_string(); - if !name.is_empty() { - return name; - } - } - - let canonical = cwd - .canonicalize() - .unwrap_or_else(|_| cwd.to_path_buf()) - .to_string_lossy() - .into_owned(); - let mut hasher = Sha256::new(); - hasher.update(canonical.as_bytes()); - let digest = hasher.finalize(); - let hex = format!("{digest:x}"); - let short = hex.get(..12).unwrap_or(hex.as_str()); - format!("cwd-{short}") -} - -pub(crate) fn compute_keyring_account(codex_home: &Path) -> String { - let canonical = codex_home - .canonicalize() - .unwrap_or_else(|_| codex_home.to_path_buf()) - .to_string_lossy() - .into_owned(); - let mut hasher = Sha256::new(); - hasher.update(canonical.as_bytes()); - let digest = hasher.finalize(); - let hex = format!("{digest:x}"); - let short = hex.get(..16).unwrap_or(hex.as_str()); - format!("secrets|{short}") -} - -pub(crate) fn keyring_service() -> &'static str { - KEYRING_SERVICE -} - -#[cfg(test)] -mod tests { - use super::*; - use codex_keyring_store::tests::MockKeyringStore; - use pretty_assertions::assert_eq; - - #[test] - fn environment_id_fallback_has_cwd_prefix() { - let dir = tempfile::tempdir().expect("tempdir"); - let env_id = environment_id_from_cwd(dir.path()); - assert!(env_id.starts_with("cwd-")); - } - - #[test] - fn manager_round_trips_local_backend() -> Result<()> { - let codex_home = tempfile::tempdir().expect("tempdir"); - let keyring = Arc::new(MockKeyringStore::default()); - let manager = SecretsManager::new_with_keyring_store( - codex_home.path().to_path_buf(), - SecretsBackendKind::Local, - keyring, - ); - let scope = SecretScope::Global; - let name = SecretName::new("GITHUB_TOKEN")?; - - manager.set(&scope, &name, "token-1")?; - assert_eq!(manager.get(&scope, &name)?, Some("token-1".to_string())); - - let listed = manager.list(None)?; - assert_eq!(listed.len(), 1); - assert_eq!(listed[0].name, name); - - assert!(manager.delete(&scope, &name)?); - assert_eq!(manager.get(&scope, &name)?, None); - Ok(()) - } -} +pub use codex_secrets::LocalSecretsBackend; +pub use codex_secrets::SecretListEntry; +pub use codex_secrets::SecretName; +pub use codex_secrets::SecretScope; +pub use codex_secrets::SecretsBackendKind; +pub use codex_secrets::SecretsManager; +pub use codex_secrets::environment_id_from_cwd; diff --git a/codex-rs/core/src/secrets/skill_dependencies.rs b/codex-rs/core/src/secrets/skill_dependencies.rs deleted file mode 100644 index 7928aaddfb..0000000000 --- a/codex-rs/core/src/secrets/skill_dependencies.rs +++ /dev/null @@ -1,382 +0,0 @@ -use std::collections::BTreeMap; -use std::collections::HashMap; - -use codex_protocol::request_user_input::RequestUserInputAnswer; -use codex_protocol::request_user_input::RequestUserInputArgs; -use codex_protocol::request_user_input::RequestUserInputQuestion; -use codex_protocol::request_user_input::RequestUserInputResponse; -use tokio_util::sync::CancellationToken; -use tracing::warn; - -use crate::codex::Session; -use crate::codex::TurnContext; -use crate::secrets::SecretName; -use crate::secrets::SecretScope; -use crate::secrets::SecretsManager; -use crate::secrets::environment_id_from_cwd; -use crate::skills::SkillMetadata; -use crate::skills::model::SkillToolDependency; - -const SKILL_SECRET_PROMPT_PREFIX: &str = "skill-secret"; - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct SkillSecretsOutcome { - pub overrides: HashMap, - pub warnings: Vec, -} - -#[derive(Debug, Clone)] -struct SecretDependencyContext { - name: SecretName, - skills: Vec, - description: Option, -} - -#[derive(Debug, Clone)] -struct MissingSecret { - context: SecretDependencyContext, - canonical_key: String, -} - -pub(crate) async fn resolve_skill_env_dependencies( - sess: &Session, - turn_context: &TurnContext, - cancellation_token: &CancellationToken, - mentioned_skills: &[SkillMetadata], -) -> SkillSecretsOutcome { - if mentioned_skills.is_empty() { - return SkillSecretsOutcome::default(); - } - - let config = turn_context.client.config(); - let manager = SecretsManager::new(config.codex_home.clone(), config.secrets_backend); - - let env_scope = match SecretScope::environment(environment_id_from_cwd(&turn_context.cwd)) { - Ok(scope) => scope, - Err(err) => { - return SkillSecretsOutcome { - overrides: HashMap::new(), - warnings: vec![format!("failed to resolve environment scope: {err}")], - }; - } - }; - let global_scope = SecretScope::Global; - - let mut outcome = SkillSecretsOutcome::default(); - let dependencies = collect_env_var_dependencies(mentioned_skills, &mut outcome.warnings); - if dependencies.is_empty() { - return outcome; - } - - let mut missing = Vec::new(); - - for context in dependencies { - match resolve_secret_value( - &manager, - &env_scope, - &global_scope, - &context, - &mut outcome.warnings, - ) { - Some(value) => { - outcome - .overrides - .insert(context.name.as_str().to_string(), value); - } - None => { - missing.push(MissingSecret { - canonical_key: env_scope.canonical_key(&context.name), - context, - }); - } - } - } - - if missing.is_empty() { - return outcome; - } - - let prompted = sess.skill_secret_prompted().await; - let mut already_prompted = Vec::new(); - let mut unprompted_missing = Vec::new(); - for entry in missing { - if prompted.contains(&entry.canonical_key) { - already_prompted.push(entry); - } else { - unprompted_missing.push(entry); - } - } - - for entry in already_prompted { - outcome - .warnings - .push(missing_secret_warning(&entry.context)); - } - - if unprompted_missing.is_empty() { - return outcome; - } - - let responses = prompt_for_missing_secrets( - sess, - turn_context, - cancellation_token, - &env_scope, - &unprompted_missing, - ) - .await; - - let prompted_keys = unprompted_missing - .iter() - .map(|entry| entry.canonical_key.clone()); - sess.record_skill_secret_prompted(prompted_keys).await; - - let mut resolved_from_prompt = HashMap::new(); - for entry in &unprompted_missing { - let Some(answer) = responses.answers.get(&entry.canonical_key) else { - outcome - .warnings - .push(missing_secret_warning(&entry.context)); - continue; - }; - let Some(value) = first_non_empty_answer(answer) else { - outcome - .warnings - .push(missing_secret_warning(&entry.context)); - continue; - }; - - if let Err(err) = manager.set(&env_scope, &entry.context.name, &value) { - outcome.warnings.push(format!( - "failed to persist secret {}: {err}", - entry.context.name - )); - } - - resolved_from_prompt.insert(entry.context.name.as_str().to_string(), value); - } - - outcome.overrides.extend(resolved_from_prompt); - outcome -} - -fn collect_env_var_dependencies( - mentioned_skills: &[SkillMetadata], - warnings: &mut Vec, -) -> Vec { - let mut contexts: BTreeMap = BTreeMap::new(); - - for skill in mentioned_skills { - let Some(dependencies) = skill.dependencies.as_ref() else { - continue; - }; - - for tool in &dependencies.tools { - if !tool.r#type.eq_ignore_ascii_case("env_var") { - continue; - } - add_dependency_context(&mut contexts, skill, tool, warnings); - } - } - - contexts - .into_values() - .map(|mut context| { - context.skills.sort(); - context.skills.dedup(); - context - }) - .collect() -} - -fn add_dependency_context( - contexts: &mut BTreeMap, - skill: &SkillMetadata, - tool: &SkillToolDependency, - warnings: &mut Vec, -) { - let name = match SecretName::new(&tool.value) { - Ok(name) => name, - Err(err) => { - warnings.push(format!( - "skill {} declares invalid env_var dependency {}: {err}", - skill.name, tool.value - )); - return; - } - }; - - let key = name.as_str().to_string(); - let description = tool.description.clone(); - contexts - .entry(key) - .and_modify(|context| { - context.skills.push(skill.name.clone()); - if context.description.is_none() { - context.description = description.clone(); - } - }) - .or_insert_with(|| SecretDependencyContext { - name, - skills: vec![skill.name.clone()], - description, - }); -} - -fn resolve_secret_value( - manager: &SecretsManager, - env_scope: &SecretScope, - global_scope: &SecretScope, - context: &SecretDependencyContext, - warnings: &mut Vec, -) -> Option { - if let Some(env_value) = read_non_empty_env(context.name.as_str()) { - return Some(env_value); - } - - match manager.get(env_scope, &context.name) { - Ok(Some(value)) if !value.trim().is_empty() => return Some(value), - Ok(Some(_)) | Ok(None) => {} - Err(err) => warnings.push(format!( - "failed to read secret {} from env scope: {err}", - context.name - )), - } - - match manager.get(global_scope, &context.name) { - Ok(Some(value)) if !value.trim().is_empty() => Some(value), - Ok(Some(_)) | Ok(None) => None, - Err(err) => { - warnings.push(format!( - "failed to read secret {} from global scope: {err}", - context.name - )); - None - } - } -} - -fn read_non_empty_env(name: &str) -> Option { - match std::env::var(name) { - Ok(value) if !value.trim().is_empty() => Some(value), - Ok(_) => None, - Err(std::env::VarError::NotPresent) => None, - Err(std::env::VarError::NotUnicode(_)) => { - warn!("environment variable {name} contains invalid unicode; treating as missing"); - None - } - } -} - -async fn prompt_for_missing_secrets( - sess: &Session, - turn_context: &TurnContext, - cancellation_token: &CancellationToken, - env_scope: &SecretScope, - missing: &[MissingSecret], -) -> RequestUserInputResponse { - let questions = missing - .iter() - .map(|entry| build_secret_question(env_scope, entry)) - .collect(); - let args = RequestUserInputArgs { questions }; - let call_id = format!("{SKILL_SECRET_PROMPT_PREFIX}-{}", turn_context.sub_id); - let response_fut = sess.request_user_input(turn_context, call_id, args); - let sub_id = turn_context.sub_id.as_str(); - - tokio::select! { - biased; - _ = cancellation_token.cancelled() => { - let empty = RequestUserInputResponse { answers: HashMap::new() }; - sess.notify_user_input_response(sub_id, empty.clone()).await; - empty - } - response = response_fut => response.unwrap_or_else(|| RequestUserInputResponse { answers: HashMap::new() }), - } -} - -fn build_secret_question( - env_scope: &SecretScope, - missing: &MissingSecret, -) -> RequestUserInputQuestion { - let scope_label = match env_scope { - SecretScope::Global => "global".to_string(), - SecretScope::Environment(env_id) => format!("env/{env_id}"), - }; - let skills = missing.context.skills.join(", "); - let description = missing - .context - .description - .as_deref() - .map(|text| format!(" {text}")) - .unwrap_or_default(); - RequestUserInputQuestion { - id: missing.canonical_key.clone(), - header: format!("Missing secret: {}", missing.context.name), - question: format!( - "Skill(s) {skills} require {}.{description} Enter a value to store for {scope_label}.", - missing.context.name, - ), - is_other: false, - options: None, - } -} - -fn first_non_empty_answer(answer: &RequestUserInputAnswer) -> Option { - answer - .answers - .iter() - .find(|value| !value.trim().is_empty()) - .map(|value| value.to_string()) -} - -fn missing_secret_warning(context: &SecretDependencyContext) -> String { - let skills = context.skills.join(", "); - format!( - "Required secret {} is missing for skill(s) {skills}. Use `codex secrets set {}` to configure it.", - context.name, context.name - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use anyhow::Context; - use pretty_assertions::assert_eq; - use std::path::PathBuf; - - fn skill_with_env(name: &str, env_var: &str) -> SkillMetadata { - SkillMetadata { - name: name.to_string(), - description: name.to_string(), - short_description: None, - interface: None, - dependencies: Some(crate::skills::model::SkillDependencies { - tools: vec![crate::skills::model::SkillToolDependency { - r#type: "env_var".to_string(), - value: env_var.to_string(), - description: Some("token".to_string()), - transport: None, - command: None, - url: None, - }], - }), - path: PathBuf::from(name), - scope: codex_protocol::protocol::SkillScope::User, - } - } - - #[tokio::test] - async fn resolves_from_environment_without_prompting() -> anyhow::Result<()> { - let (session, turn) = crate::codex::make_session_and_context().await; - let home = std::env::var("HOME").context("HOME must be set for tests")?; - let skills = vec![skill_with_env("skill", "HOME")]; - let outcome = - resolve_skill_env_dependencies(&session, &turn, &CancellationToken::new(), &skills) - .await; - - assert_eq!(outcome.warnings, Vec::::new()); - assert_eq!(outcome.overrides.get("HOME"), Some(&home)); - Ok(()) - } -} diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index b6b7647f6c..9d3d96db99 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -1,7 +1,6 @@ //! Session-wide mutable state. use codex_protocol::models::ResponseItem; -use std::collections::HashMap; use std::collections::HashSet; use crate::codex::SessionConfiguration; @@ -18,8 +17,6 @@ pub(crate) struct SessionState { pub(crate) latest_rate_limits: Option, pub(crate) server_reasoning_included: bool, pub(crate) mcp_dependency_prompted: HashSet, - pub(crate) skill_secret_prompted: HashSet, - pub(crate) skill_env_overrides: HashMap>, /// Whether the session's initial context has been seeded into history. /// /// TODO(owen): This is a temporary solution to avoid updating a thread's updated_at @@ -37,8 +34,6 @@ impl SessionState { latest_rate_limits: None, server_reasoning_included: false, mcp_dependency_prompted: HashSet::new(), - skill_secret_prompted: HashSet::new(), - skill_env_overrides: HashMap::new(), initial_context_seeded: false, } } @@ -117,37 +112,6 @@ impl SessionState { pub(crate) fn mcp_dependency_prompted(&self) -> HashSet { self.mcp_dependency_prompted.clone() } - - pub(crate) fn record_skill_secret_prompted(&mut self, keys: I) - where - I: IntoIterator, - { - self.skill_secret_prompted.extend(keys); - } - - pub(crate) fn skill_secret_prompted(&self) -> HashSet { - self.skill_secret_prompted.clone() - } - - pub(crate) fn set_skill_env_overrides( - &mut self, - sub_id: String, - overrides: HashMap, - ) { - if overrides.is_empty() { - self.skill_env_overrides.remove(&sub_id); - return; - } - self.skill_env_overrides.insert(sub_id, overrides); - } - - pub(crate) fn skill_env_overrides(&self, sub_id: &str) -> Option> { - self.skill_env_overrides.get(sub_id).cloned() - } - - pub(crate) fn clear_skill_env_overrides(&mut self, sub_id: &str) { - self.skill_env_overrides.remove(sub_id); - } } // Sometimes new snapshots don't include credits or plan information. diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 981a96c5a8..08d23f7987 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -191,7 +191,6 @@ impl Session { false }; drop(active); - self.clear_skill_env_overrides(&turn_context.sub_id).await; if should_close_processes { self.close_unified_exec_processes().await; } diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index c4671c3d03..daa0e1b097 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -101,10 +101,7 @@ impl SessionTask for UserShellCommandTask { ) .await; - let mut env = create_env(&turn_context.shell_environment_policy); - if let Some(overrides) = session.skill_env_overrides(&turn_context.sub_id).await { - env.extend(overrides); - } + let env = create_env(&turn_context.shell_environment_policy); let exec_env = ExecEnv { command: exec_command.clone(), cwd: cwd.clone(), diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 75e1c38fb9..dbfb0364bf 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -216,7 +216,7 @@ impl ShellHandler { async fn run_exec_like(args: RunExecLikeArgs) -> Result { let RunExecLikeArgs { tool_name, - mut exec_params, + exec_params, prefix_rule, session, turn, @@ -233,10 +233,6 @@ impl ShellHandler { None }; - if let Some(overrides) = session.skill_env_overrides(&turn.sub_id).await { - exec_params.env.extend(overrides); - } - // Approval policy guard for explicit escalation in non-OnRequest modes. if exec_params .sandbox_permissions diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index d01bcff31c..5b3e83a5dc 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -483,14 +483,7 @@ impl UnifiedExecProcessManager { cwd: PathBuf, context: &UnifiedExecContext, ) -> Result { - let mut env = apply_unified_exec_env(create_env(&context.turn.shell_environment_policy)); - if let Some(overrides) = context - .session - .skill_env_overrides(&context.turn.sub_id) - .await - { - env.extend(overrides); - } + let env = apply_unified_exec_env(create_env(&context.turn.shell_environment_policy)); let features = context.session.features(); let mut orchestrator = ToolOrchestrator::new(); let mut runtime = UnifiedExecRuntime::new(self); diff --git a/codex-rs/secrets/Cargo.toml b/codex-rs/secrets/Cargo.toml new file mode 100644 index 0000000000..f55c983d9d --- /dev/null +++ b/codex-rs/secrets/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "codex-secrets" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +age = { workspace = true } +anyhow = { workspace = true } +base64 = { workspace = true } +codex-keyring-store = { workspace = true } +rand = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/secrets/src/lib.rs b/codex-rs/secrets/src/lib.rs new file mode 100644 index 0000000000..cdd6fe87c8 --- /dev/null +++ b/codex-rs/secrets/src/lib.rs @@ -0,0 +1,223 @@ +use std::fmt; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::Result; +use codex_keyring_store::DefaultKeyringStore; +use codex_keyring_store::KeyringStore; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use sha2::Digest; +use sha2::Sha256; + +mod local; + +pub use local::LocalSecretsBackend; + +const KEYRING_SERVICE: &str = "codex"; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SecretName(String); + +impl SecretName { + pub fn new(raw: &str) -> Result { + let trimmed = raw.trim(); + anyhow::ensure!(!trimmed.is_empty(), "secret name must not be empty"); + anyhow::ensure!( + trimmed + .chars() + .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_'), + "secret name must contain only A-Z, 0-9, or _" + ); + Ok(Self(trimmed.to_string())) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl fmt::Display for SecretName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum SecretScope { + Global, + Environment(String), +} + +impl SecretScope { + pub fn environment(environment_id: impl Into) -> Result { + let env_id = environment_id.into(); + let trimmed = env_id.trim(); + anyhow::ensure!(!trimmed.is_empty(), "environment id must not be empty"); + Ok(Self::Environment(trimmed.to_string())) + } + + pub fn canonical_key(&self, name: &SecretName) -> String { + match self { + Self::Global => format!("global/{}", name.as_str()), + Self::Environment(environment_id) => { + format!("env/{environment_id}/{}", name.as_str()) + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SecretListEntry { + pub scope: SecretScope, + pub name: SecretName, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum SecretsBackendKind { + Local, +} + +impl Default for SecretsBackendKind { + fn default() -> Self { + Self::Local + } +} + +#[derive(Debug, Clone)] +pub struct SecretsManager { + backend: Arc, +} + +impl SecretsManager { + pub fn new(codex_home: PathBuf, backend_kind: SecretsBackendKind) -> Self { + let keyring_store: Arc = Arc::new(DefaultKeyringStore); + Self::new_with_keyring_store(codex_home, backend_kind, keyring_store) + } + + pub fn new_with_keyring_store( + codex_home: PathBuf, + backend_kind: SecretsBackendKind, + keyring_store: Arc, + ) -> Self { + match backend_kind { + SecretsBackendKind::Local => Self { + backend: Arc::new(LocalSecretsBackend::new(codex_home, keyring_store)), + }, + } + } + + pub fn set(&self, scope: &SecretScope, name: &SecretName, value: &str) -> Result<()> { + self.backend.set(scope, name, value) + } + + pub fn get(&self, scope: &SecretScope, name: &SecretName) -> Result> { + self.backend.get(scope, name) + } + + pub fn delete(&self, scope: &SecretScope, name: &SecretName) -> Result { + self.backend.delete(scope, name) + } + + pub fn list(&self, scope_filter: Option<&SecretScope>) -> Result> { + self.backend.list(scope_filter) + } +} + +pub fn environment_id_from_cwd(cwd: &Path) -> String { + if let Some(repo_root) = get_git_repo_root(cwd) + && let Some(name) = repo_root.file_name() + { + let name = name.to_string_lossy().trim().to_string(); + if !name.is_empty() { + return name; + } + } + + let canonical = cwd + .canonicalize() + .unwrap_or_else(|_| cwd.to_path_buf()) + .to_string_lossy() + .into_owned(); + let mut hasher = Sha256::new(); + hasher.update(canonical.as_bytes()); + let digest = hasher.finalize(); + let hex = format!("{digest:x}"); + let short = hex.get(..12).unwrap_or(hex.as_str()); + format!("cwd-{short}") +} + +fn get_git_repo_root(base_dir: &Path) -> Option { + let mut dir = base_dir.to_path_buf(); + + loop { + if dir.join(".git").exists() { + return Some(dir); + } + + if !dir.pop() { + break; + } + } + + None +} + +pub(crate) fn compute_keyring_account(codex_home: &Path) -> String { + let canonical = codex_home + .canonicalize() + .unwrap_or_else(|_| codex_home.to_path_buf()) + .to_string_lossy() + .into_owned(); + let mut hasher = Sha256::new(); + hasher.update(canonical.as_bytes()); + let digest = hasher.finalize(); + let hex = format!("{digest:x}"); + let short = hex.get(..16).unwrap_or(hex.as_str()); + format!("secrets|{short}") +} + +pub(crate) fn keyring_service() -> &'static str { + KEYRING_SERVICE +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_keyring_store::tests::MockKeyringStore; + use pretty_assertions::assert_eq; + + #[test] + fn environment_id_fallback_has_cwd_prefix() { + let dir = tempfile::tempdir().expect("tempdir"); + let env_id = environment_id_from_cwd(dir.path()); + assert!(env_id.starts_with("cwd-")); + } + + #[test] + fn manager_round_trips_local_backend() -> Result<()> { + let codex_home = tempfile::tempdir().expect("tempdir"); + let keyring = Arc::new(MockKeyringStore::default()); + let manager = SecretsManager::new_with_keyring_store( + codex_home.path().to_path_buf(), + SecretsBackendKind::Local, + keyring, + ); + let scope = SecretScope::Global; + let name = SecretName::new("GITHUB_TOKEN")?; + + manager.set(&scope, &name, "token-1")?; + assert_eq!(manager.get(&scope, &name)?, Some("token-1".to_string())); + + let listed = manager.list(None)?; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].name, name); + + assert!(manager.delete(&scope, &name)?); + assert_eq!(manager.get(&scope, &name)?, None); + Ok(()) + } +} diff --git a/codex-rs/core/src/secrets/local.rs b/codex-rs/secrets/src/local.rs similarity index 97% rename from codex-rs/core/src/secrets/local.rs rename to codex-rs/secrets/src/local.rs index fc087b901b..53fd81340c 100644 --- a/codex-rs/core/src/secrets/local.rs +++ b/codex-rs/secrets/src/local.rs @@ -205,10 +205,8 @@ fn parse_canonical_key(canonical_key: &str) -> Option { return None; } let name = SecretName::new(name).ok()?; - Some(SecretListEntry { - scope: SecretScope::Environment(environment_id.to_string()), - name, - }) + let scope = SecretScope::environment(environment_id.to_string()).ok()?; + Some(SecretListEntry { scope, name }) } _ => None, } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 3a3f5d407b..34e3335ffc 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -671,6 +671,10 @@ impl ChatComposer { self.footer_hint_override = items; } + pub(crate) fn set_mask_input(&mut self, mask_input: bool) { + self.textarea.set_mask_input(mask_input); + } + #[cfg(test)] pub(crate) fn show_footer_flash(&mut self, line: Line<'static>, duration: Duration) { let expires_at = Instant::now() diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index 979499835d..bac4a45e4a 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -32,6 +32,7 @@ use crate::render::renderable::Renderable; use codex_core::protocol::Op; use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputEvent; +use codex_protocol::request_user_input::RequestUserInputQuestion; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::TextElement; use unicode_width::UnicodeWidthStr; @@ -375,6 +376,10 @@ impl RequestUserInputOverlay { } fn restore_current_draft(&mut self) { + let mask_input = self + .current_question() + .is_some_and(Self::is_secret_question); + self.composer.set_mask_input(mask_input); self.composer .set_placeholder_text(self.notes_placeholder().to_string()); self.composer.set_footer_hint_override(Some(Vec::new())); @@ -401,6 +406,18 @@ impl RequestUserInputOverlay { } } + fn is_secret_question(question: &RequestUserInputQuestion) -> bool { + let has_options = question + .options + .as_ref() + .is_some_and(|options| !options.is_empty()); + if has_options { + return false; + } + let id = question.id.as_str(); + id.starts_with("global/") || id.starts_with("env/") + } + fn sync_composer_placeholder(&mut self) { self.composer .set_placeholder_text(self.notes_placeholder().to_string()); @@ -708,9 +725,14 @@ impl RequestUserInputOverlay { }; let selected_label = selected_idx .and_then(|selected_idx| Self::option_label_for_index(question, selected_idx)); + let has_options = options.is_some_and(|opts| !opts.is_empty()); let mut answer_list = selected_label.into_iter().collect::>(); if !notes.is_empty() { - answer_list.push(format!("user_note: {notes}")); + if has_options { + answer_list.push(format!("user_note: {notes}")); + } else { + answer_list.push(notes); + } } answers.insert( question.id.clone(), @@ -1976,6 +1998,61 @@ mod tests { assert_eq!(answer.answers, Vec::::new()); } + #[test] + fn freeform_submission_does_not_prefix_user_note() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_without_options("q1", "Secret")]), + tx, + true, + false, + false, + ); + + overlay + .composer + .set_text_content("super-secret".to_string(), Vec::new(), Vec::new()); + overlay.composer.move_cursor_to_end(); + let draft = overlay.capture_composer_draft(); + if let Some(answer) = overlay.current_answer_mut() { + answer.draft = draft; + answer.answer_committed = true; + } + + overlay.submit_answers(); + + let event = rx.try_recv().expect("expected AppEvent"); + let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + panic!("expected UserInputAnswer"); + }; + let answer = response.answers.get("q1").expect("answer missing"); + assert_eq!(answer.answers, vec!["super-secret".to_string()]); + } + + #[test] + fn secret_questions_mask_rendered_input() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![question_without_options("global/TEST_SECRET", "Secret")], + ), + tx, + true, + false, + false, + ); + + overlay + .composer + .set_text_content("super-secret".to_string(), Vec::new(), Vec::new()); + overlay.composer.move_cursor_to_end(); + + let snapshot = render_snapshot(&overlay, Rect::new(0, 0, 80, 12)); + assert!(!snapshot.contains("super-secret")); + assert!(snapshot.contains("************")); + } + #[test] fn notes_are_captured_for_selected_option() { let (tx, mut rx) = test_sender(); diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 0235f7a9c1..6e86e63bdf 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -36,6 +36,7 @@ pub(crate) struct TextArea { preferred_col: Option, elements: Vec, kill_buffer: String, + mask_input: bool, } #[derive(Debug, Clone)] @@ -59,6 +60,7 @@ impl TextArea { preferred_col: None, elements: Vec::new(), kill_buffer: String::new(), + mask_input: false, } } @@ -72,6 +74,10 @@ impl TextArea { self.set_text_inner(text, Some(elements)); } + pub fn set_mask_input(&mut self, mask_input: bool) { + self.mask_input = mask_input; + } + fn set_text_inner(&mut self, text: &str, elements: Option<&[UserTextElement]>) { // Stage 1: replace the raw text and keep the cursor in a safe byte range. self.text = text.to_string(); @@ -1157,8 +1163,15 @@ impl TextArea { let r = &lines[idx]; let y = area.y + row as u16; let line_range = r.start..r.end - 1; + let line_slice = &self.text[line_range.clone()]; + if self.mask_input { + let masked_len = line_slice.graphemes(true).count(); + let masked = "*".repeat(masked_len); + buf.set_string(area.x, y, masked, Style::default()); + continue; + } // Draw base line with default style. - buf.set_string(area.x, y, &self.text[line_range.clone()], Style::default()); + buf.set_string(area.x, y, line_slice, Style::default()); // Overlay styled segments for elements that intersect this line. for elem in &self.elements {