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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions codex-rs/app-server-protocol/src/protocol/v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2532,6 +2532,8 @@ pub struct ToolRequestUserInputQuestion {
pub question: String,
#[serde(default)]
pub is_other: bool,
#[serde(default)]
pub is_secret: bool,
pub options: Option<Vec<ToolRequestUserInputOption>>,
}

Expand Down
1 change: 1 addition & 0 deletions codex-rs/app-server/src/bespoke_event_handling.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ pub(crate) async fn apply_bespoke_event_handling(
header: question.header,
question: question.question,
is_other: question.is_other,
is_secret: question.is_secret,
options: question.options.map(|options| {
options
.into_iter()
Expand Down
6 changes: 6 additions & 0 deletions codex-rs/core/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,9 @@
"shell_tool": {
"type": "boolean"
},
"skill_env_var_dependency_prompt": {
"type": "boolean"
},
"skill_mcp_dependency_install": {
"type": "boolean"
},
Expand Down Expand Up @@ -1231,6 +1234,9 @@
"shell_tool": {
"type": "boolean"
},
"skill_env_var_dependency_prompt": {
"type": "boolean"
},
"skill_mcp_dependency_install": {
"type": "boolean"
},
Expand Down
21 changes: 21 additions & 0 deletions codex-rs/core/src/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,12 @@ use crate::skills::SkillInjections;
use crate::skills::SkillMetadata;
use crate::skills::SkillsManager;
use crate::skills::build_skill_injections;
use crate::skills::collect_env_var_dependencies;
use crate::skills::collect_explicit_skill_mentions;
use crate::skills::injection::ToolMentionKind;
use crate::skills::injection::app_id_from_path;
use crate::skills::injection::tool_kind_for_path;
use crate::skills::resolve_skill_dependencies_for_turn;
use crate::state::ActiveTurn;
use crate::state::SessionServices;
use crate::state::SessionState;
Expand Down Expand Up @@ -1948,6 +1950,16 @@ impl Session {
state.record_mcp_dependency_prompted(names);
}

pub async fn dependency_env(&self) -> HashMap<String, String> {
let state = self.state.lock().await;
state.dependency_env()
}

pub async fn set_dependency_env(&self, values: HashMap<String, String>) {
let mut state = self.state.lock().await;
state.set_dependency_env(values);
}

pub(crate) async fn set_server_reasoning_included(&self, included: bool) {
let mut state = self.state.lock().await;
state.set_server_reasoning_included(included);
Expand Down Expand Up @@ -3212,6 +3224,15 @@ pub(crate) async fn run_turn(
});
let explicit_app_paths = collect_explicit_app_paths(&input);

let config = turn_context.client.config();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this happen before we spin up missing mcp servers?

if config
.features
.enabled(Feature::SkillEnvVarDependencyPrompt)
{
let env_var_dependencies = collect_env_var_dependencies(&mentioned_skills);
resolve_skill_dependencies_for_turn(&sess, &turn_context, &env_var_dependencies).await;
}

maybe_prompt_and_install_mcp_dependencies(
sess.as_ref(),
turn_context.as_ref(),
Expand Down
8 changes: 8 additions & 0 deletions codex-rs/core/src/features.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ pub enum Feature {
Apps,
/// Allow prompting and installing missing MCP dependencies.
SkillMcpDependencyInstall,
/// Prompt for missing skill env var dependencies.
SkillEnvVarDependencyPrompt,
/// Steer feature flag - when enabled, Enter submits immediately instead of queuing.
Steer,
/// Enable collaboration modes (Plan, Code, Pair Programming, Execute).
Expand Down Expand Up @@ -533,6 +535,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::SkillEnvVarDependencyPrompt,
key: "skill_env_var_dependency_prompt",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::Steer,
key: "steer",
Expand Down
1 change: 1 addition & 0 deletions codex-rs/core/src/mcp/skill_dependencies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ async fn should_install_mcp_dependencies(
"The following MCP servers are required by the selected skills but are not installed yet: {server_list}. Install them now?"
),
is_other: false,
is_secret: false,
options: Some(vec![
RequestUserInputQuestionOption {
label: MCP_DEPENDENCY_OPTION_INSTALL.to_string(),
Expand Down
162 changes: 162 additions & 0 deletions codex-rs/core/src/skills/env_var_dependencies.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::env;
use std::sync::Arc;

use codex_protocol::request_user_input::RequestUserInputArgs;
use codex_protocol::request_user_input::RequestUserInputQuestion;
use codex_protocol::request_user_input::RequestUserInputResponse;
use tracing::warn;

use crate::codex::Session;
use crate::codex::TurnContext;
use crate::skills::SkillMetadata;

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SkillDependencyInfo {
pub(crate) skill_name: String,
pub(crate) name: String,
pub(crate) description: Option<String>,
}

/// Resolve required dependency values (session cache, then env vars),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:new line between imports and function signature?

/// and prompt the UI for any missing ones.
pub(crate) async fn resolve_skill_dependencies_for_turn(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
dependencies: &[SkillDependencyInfo],
) {
if dependencies.is_empty() {
return;
}

let existing_env = sess.dependency_env().await;
let mut loaded_values = HashMap::new();
let mut missing = Vec::new();
let mut seen_names = HashSet::new();

for dependency in dependencies {
let name = dependency.name.clone();
if !seen_names.insert(name.clone()) {
continue;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you use Hashmap.difference method?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

difference only gives you set membership and drops per‑item context; here we must keep each dependency’s metadata (skill name/description)

if existing_env.contains_key(&name) {
continue;
}
match env::var(&name) {
Ok(value) => {
loaded_values.insert(name.clone(), value);
continue;
Comment on lines +46 to +49
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Respect shell env policy when auto-loading deps

When the feature is enabled, this reads env::var directly and caches the value even if it would be excluded by ShellEnvironmentPolicy (e.g., the default *KEY*/*TOKEN* filters). Because ShellHandler later merges dependency_env into exec_params.env, those secrets get injected into every shell run without prompting, bypassing the policy and potentially leaking credentials. Consider only auto-loading vars that would pass create_env (or prompt if excluded) so the policy continues to gate secret propagation.

Useful? React with 👍 / 👎.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's OK for now.

Comment on lines +46 to +49
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat empty env vars as missing dependencies

resolve_skill_dependencies_for_turn treats any Ok(value) from env::var as satisfied, so a dependency like API_KEY set to the empty string will be cached and skip the prompt. In shells/CI it’s common to export empty placeholders, and downstream tools typically treat empty values as “missing,” so this will silently proceed with an unusable credential. Consider treating empty/whitespace-only values as missing and prompting instead.

Useful? React with 👍 / 👎.

}
Err(env::VarError::NotPresent) => {}
Err(err) => {
warn!("failed to read env var {name}: {err}");
}
}
missing.push(dependency.clone());
}

if !loaded_values.is_empty() {
sess.set_dependency_env(loaded_values).await;
}

if !missing.is_empty() {
request_skill_dependencies(sess, turn_context, &missing).await;
}
}

pub(crate) fn collect_env_var_dependencies(
mentioned_skills: &[SkillMetadata],
) -> Vec<SkillDependencyInfo> {
let mut dependencies = Vec::new();
for skill in mentioned_skills {
let Some(skill_dependencies) = &skill.dependencies else {
continue;
};
for tool in &skill_dependencies.tools {
if tool.r#type != "env_var" {
continue;
}
if tool.value.is_empty() {
continue;
}
dependencies.push(SkillDependencyInfo {
skill_name: skill.name.clone(),
name: tool.value.clone(),
description: tool.description.clone(),
});
}
}
dependencies
}

/// Prompt via request_user_input to gather missing env vars.
pub(crate) async fn request_skill_dependencies(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
dependencies: &[SkillDependencyInfo],
) {
let questions = dependencies
.iter()
.map(|dep| {
let requirement = dep.description.as_ref().map_or_else(
|| format!("The skill \"{}\" requires \"{}\" to be set.", dep.skill_name, dep.name),
|description| {
format!(
"The skill \"{}\" requires \"{}\" to be set ({}).",
dep.skill_name, dep.name, description
)
},
);
let question = format!(
"{requirement} This is an experimental internal feature. The value is stored in memory for this session only.",
);
RequestUserInputQuestion {
id: dep.name.clone(),
header: "Skill requires environment variable".to_string(),
question,
is_other: false,
is_secret: true,
Comment on lines +118 to +119
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve isSecret in app-server request_user_input

is_secret: true is now set for env-var dependency prompts, but the app-server v2 protocol (ToolRequestUserInputQuestion) doesn’t carry isSecret, and bespoke_event_handling drops any extra fields. In app-server clients the flag will be lost, so secret prompts will render as plain text even though core intends masking. This should be forwarded through the app-server protocol/mapping so non-TUI clients can respect secret inputs.

Useful? React with 👍 / 👎.

options: None,
}
})
.collect::<Vec<_>>();

if questions.is_empty() {
return;
}

let args = RequestUserInputArgs { questions };
let call_id = format!("skill-deps-{}", turn_context.sub_id);
let response = sess
.request_user_input(turn_context, call_id, args)
.await
.unwrap_or_else(|| RequestUserInputResponse {
answers: HashMap::new(),
});

if response.answers.is_empty() {
return;
}

let mut values = HashMap::new();
for (name, answer) in response.answers {
let mut user_note = None;
for entry in &answer.answers {
if let Some(note) = entry.strip_prefix("user_note: ")
&& !note.trim().is_empty()
{
user_note = Some(note.trim().to_string());
}
}
if let Some(value) = user_note {
values.insert(name, value);
}
}

if values.is_empty() {
return;
}

sess.set_dependency_env(values).await;
}
3 changes: 3 additions & 0 deletions codex-rs/core/src/skills/mod.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
mod env_var_dependencies;
pub mod injection;
pub mod loader;
pub mod manager;
pub mod model;
pub mod render;
pub mod system;

pub(crate) use env_var_dependencies::collect_env_var_dependencies;
pub(crate) use env_var_dependencies::resolve_skill_dependencies_for_turn;
pub(crate) use injection::SkillInjections;
pub(crate) use injection::build_skill_injections;
pub(crate) use injection::collect_explicit_skill_mentions;
Expand Down
13 changes: 13 additions & 0 deletions codex-rs/core/src/state/session.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Session-wide mutable state.

use codex_protocol::models::ResponseItem;
use std::collections::HashMap;
use std::collections::HashSet;

use crate::codex::SessionConfiguration;
Expand All @@ -16,6 +17,7 @@ pub(crate) struct SessionState {
pub(crate) history: ContextManager,
pub(crate) latest_rate_limits: Option<RateLimitSnapshot>,
pub(crate) server_reasoning_included: bool,
pub(crate) dependency_env: HashMap<String, String>,
pub(crate) mcp_dependency_prompted: HashSet<String>,
/// Whether the session's initial context has been seeded into history.
///
Expand All @@ -33,6 +35,7 @@ impl SessionState {
history,
latest_rate_limits: None,
server_reasoning_included: false,
dependency_env: HashMap::new(),
mcp_dependency_prompted: HashSet::new(),
initial_context_seeded: false,
}
Expand Down Expand Up @@ -112,6 +115,16 @@ impl SessionState {
pub(crate) fn mcp_dependency_prompted(&self) -> HashSet<String> {
self.mcp_dependency_prompted.clone()
}

pub(crate) fn set_dependency_env(&mut self, values: HashMap<String, String>) {
for (key, value) in values {
self.dependency_env.insert(key, value);
}
}

pub(crate) fn dependency_env(&self) -> HashMap<String, String> {
self.dependency_env.clone()
}
}

// Sometimes new snapshots don't include credits or plan information.
Expand Down
6 changes: 6 additions & 0 deletions codex-rs/core/src/tools/handlers/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,12 @@ impl ShellHandler {
None
};

let mut exec_params = exec_params;
let dependency_env = session.dependency_env().await;
if !dependency_env.is_empty() {
exec_params.env.extend(dependency_env);
}

// Approval policy guard for explicit escalation in non-OnRequest modes.
if exec_params
.sandbox_permissions
Expand Down
4 changes: 4 additions & 0 deletions codex-rs/protocol/src/request_user_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ pub struct RequestUserInputQuestion {
#[schemars(rename = "isOther")]
#[ts(rename = "isOther")]
pub is_other: bool,
#[serde(rename = "isSecret", default)]
#[schemars(rename = "isSecret")]
#[ts(rename = "isSecret")]
pub is_secret: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub options: Option<Vec<RequestUserInputQuestionOption>>,
}
Expand Down
13 changes: 12 additions & 1 deletion codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2854,6 +2854,12 @@ impl Renderable for ChatComposer {
}

fn render(&self, area: Rect, buf: &mut Buffer) {
self.render_with_mask(area, buf, None);
}
}

impl ChatComposer {
pub(crate) fn render_with_mask(&self, area: Rect, buf: &mut Buffer, mask_char: Option<char>) {
let [composer_rect, textarea_rect, popup_rect] = self.layout_areas(area);
match &self.active_popup {
ActivePopup::Command(popup) => {
Expand Down Expand Up @@ -3018,7 +3024,12 @@ impl Renderable for ChatComposer {
}

let mut state = self.textarea_state.borrow_mut();
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
if let Some(mask_char) = mask_char {
self.textarea
.render_ref_masked(textarea_rect, buf, &mut state, mask_char);
} else {
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
}
if self.textarea.text().is_empty() {
let text = if self.input_enabled {
self.placeholder_text.as_str().to_string()
Expand Down
Loading
Loading