Merge c7ce17671a into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-02-02 17:19:08 -08:00
committed by GitHub
27 changed files with 876 additions and 52 deletions
+4 -3
View File
@@ -38,9 +38,10 @@ jobs:
- If applicable, add one of the following labels to specify which sub-product or product surface the issue relates to.
1. CLI — the Codex command line interface.
2. extension — VS Code (or other IDE) extension-specific issues.
3. codex-web — Issues targeting the Codex web UI/Cloud experience.
4. github-action — Issues with the Codex GitHub action.
5. iOS — Issues with the Codex iOS app.
3. app - Issues related to the Codex desktop application.
4. codex-web — Issues targeting the Codex web UI/Cloud experience.
5. github-action — Issues with the Codex GitHub action.
6. iOS — Issues with the Codex iOS app.
- Additionally add zero or more of the following labels that are relevant to the issue content. Prefer a small set of precise labels over many broad ones.
1. windows-os — Bugs or friction specific to Windows environments (always when PowerShell is mentioned, path handling, copy/paste, OS-specific auth or tooling failures).
+1
View File
@@ -1420,6 +1420,7 @@ dependencies = [
"core-foundation 0.9.4",
"core_test_support",
"ctor 0.6.3",
"dirs",
"dunce",
"encoding_rs",
"env-flags",
+105 -12
View File
@@ -1,7 +1,9 @@
use std::collections::VecDeque;
use std::fs;
use std::io::BufRead;
use std::io::BufReader;
use std::io::Write;
use std::path::Path;
use std::process::Child;
use std::process::ChildStdin;
use std::process::ChildStdout;
@@ -24,6 +26,7 @@ use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::CommandExecutionApprovalDecision;
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
use codex_app_server_protocol::DynamicToolSpec;
use codex_app_server_protocol::FileChangeApprovalDecision;
use codex_app_server_protocol::FileChangeRequestApprovalParams;
use codex_app_server_protocol::FileChangeRequestApprovalResponse;
@@ -83,6 +86,15 @@ struct Cli {
)]
config_overrides: Vec<String>,
/// JSON array of dynamic tool specs or a single tool object.
/// Prefix a filename with '@' to read from a file.
///
/// Example:
/// --dynamic-tools '[{"name":"demo","description":"Demo","inputSchema":{"type":"object"}}]'
/// --dynamic-tools @/path/to/tools.json
#[arg(long, value_name = "json-or-@file", global = true)]
dynamic_tools: Option<String>,
#[command(subcommand)]
command: CliCommand,
}
@@ -140,23 +152,29 @@ fn main() -> Result<()> {
let Cli {
codex_bin,
config_overrides,
dynamic_tools,
command,
} = Cli::parse();
let dynamic_tools = parse_dynamic_tools_arg(&dynamic_tools)?;
match command {
CliCommand::SendMessage { user_message } => {
ensure_dynamic_tools_unused(&dynamic_tools, "send-message")?;
send_message(&codex_bin, &config_overrides, user_message)
}
CliCommand::SendMessageV2 { user_message } => {
send_message_v2(&codex_bin, &config_overrides, user_message)
send_message_v2(&codex_bin, &config_overrides, user_message, &dynamic_tools)
}
CliCommand::TriggerCmdApproval { user_message } => {
trigger_cmd_approval(&codex_bin, &config_overrides, user_message)
trigger_cmd_approval(&codex_bin, &config_overrides, user_message, &dynamic_tools)
}
CliCommand::TriggerPatchApproval { user_message } => {
trigger_patch_approval(&codex_bin, &config_overrides, user_message)
trigger_patch_approval(&codex_bin, &config_overrides, user_message, &dynamic_tools)
}
CliCommand::NoTriggerCmdApproval => {
no_trigger_cmd_approval(&codex_bin, &config_overrides, &dynamic_tools)
}
CliCommand::NoTriggerCmdApproval => no_trigger_cmd_approval(&codex_bin, &config_overrides),
CliCommand::SendFollowUpV2 {
first_message,
follow_up_message,
@@ -165,10 +183,20 @@ fn main() -> Result<()> {
&config_overrides,
first_message,
follow_up_message,
&dynamic_tools,
),
CliCommand::TestLogin => test_login(&codex_bin, &config_overrides),
CliCommand::GetAccountRateLimits => get_account_rate_limits(&codex_bin, &config_overrides),
CliCommand::ModelList => model_list(&codex_bin, &config_overrides),
CliCommand::TestLogin => {
ensure_dynamic_tools_unused(&dynamic_tools, "test-login")?;
test_login(&codex_bin, &config_overrides)
}
CliCommand::GetAccountRateLimits => {
ensure_dynamic_tools_unused(&dynamic_tools, "get-account-rate-limits")?;
get_account_rate_limits(&codex_bin, &config_overrides)
}
CliCommand::ModelList => {
ensure_dynamic_tools_unused(&dynamic_tools, "model-list")?;
model_list(&codex_bin, &config_overrides)
}
}
}
@@ -198,14 +226,23 @@ fn send_message_v2(
codex_bin: &str,
config_overrides: &[String],
user_message: String,
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
) -> Result<()> {
send_message_v2_with_policies(codex_bin, config_overrides, user_message, None, None)
send_message_v2_with_policies(
codex_bin,
config_overrides,
user_message,
None,
None,
dynamic_tools,
)
}
fn trigger_cmd_approval(
codex_bin: &str,
config_overrides: &[String],
user_message: Option<String>,
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
) -> Result<()> {
let default_prompt =
"Run `touch /tmp/should-trigger-approval` so I can confirm the file exists.";
@@ -216,6 +253,7 @@ fn trigger_cmd_approval(
message,
Some(AskForApproval::OnRequest),
Some(SandboxPolicy::ReadOnly),
dynamic_tools,
)
}
@@ -223,6 +261,7 @@ fn trigger_patch_approval(
codex_bin: &str,
config_overrides: &[String],
user_message: Option<String>,
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
) -> Result<()> {
let default_prompt =
"Create a file named APPROVAL_DEMO.txt containing a short hello message using apply_patch.";
@@ -233,12 +272,24 @@ fn trigger_patch_approval(
message,
Some(AskForApproval::OnRequest),
Some(SandboxPolicy::ReadOnly),
dynamic_tools,
)
}
fn no_trigger_cmd_approval(codex_bin: &str, config_overrides: &[String]) -> Result<()> {
fn no_trigger_cmd_approval(
codex_bin: &str,
config_overrides: &[String],
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
) -> Result<()> {
let prompt = "Run `touch should_not_trigger_approval.txt`";
send_message_v2_with_policies(codex_bin, config_overrides, prompt.to_string(), None, None)
send_message_v2_with_policies(
codex_bin,
config_overrides,
prompt.to_string(),
None,
None,
dynamic_tools,
)
}
fn send_message_v2_with_policies(
@@ -247,13 +298,17 @@ fn send_message_v2_with_policies(
user_message: String,
approval_policy: Option<AskForApproval>,
sandbox_policy: Option<SandboxPolicy>,
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
) -> Result<()> {
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let thread_response = client.thread_start(ThreadStartParams::default())?;
let thread_response = client.thread_start(ThreadStartParams {
dynamic_tools: dynamic_tools.clone(),
..Default::default()
})?;
println!("< thread/start response: {thread_response:?}");
let mut turn_params = TurnStartParams {
thread_id: thread_response.thread.id.clone(),
@@ -280,13 +335,17 @@ fn send_follow_up_v2(
config_overrides: &[String],
first_message: String,
follow_up_message: String,
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
) -> Result<()> {
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let thread_response = client.thread_start(ThreadStartParams::default())?;
let thread_response = client.thread_start(ThreadStartParams {
dynamic_tools: dynamic_tools.clone(),
..Default::default()
})?;
println!("< thread/start response: {thread_response:?}");
let first_turn_params = TurnStartParams {
@@ -372,6 +431,40 @@ fn model_list(codex_bin: &str, config_overrides: &[String]) -> Result<()> {
Ok(())
}
fn ensure_dynamic_tools_unused(
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
command: &str,
) -> Result<()> {
if dynamic_tools.is_some() {
bail!(
"dynamic tools are only supported for v2 thread/start; remove --dynamic-tools for {command} or use send-message-v2"
);
}
Ok(())
}
fn parse_dynamic_tools_arg(dynamic_tools: &Option<String>) -> Result<Option<Vec<DynamicToolSpec>>> {
let Some(raw_arg) = dynamic_tools.as_deref() else {
return Ok(None);
};
let raw_json = if let Some(path) = raw_arg.strip_prefix('@') {
fs::read_to_string(Path::new(path))
.with_context(|| format!("read dynamic tools file {path}"))?
} else {
raw_arg.to_string()
};
let value: Value = serde_json::from_str(&raw_json).context("parse dynamic tools JSON")?;
let tools = match value {
Value::Array(_) => serde_json::from_value(value).context("decode dynamic tools array")?,
Value::Object(_) => vec![serde_json::from_value(value).context("decode dynamic tool")?],
_ => bail!("dynamic tools JSON must be an object or array"),
};
Ok(Some(tools))
}
struct CodexClient {
child: Child,
stdin: Option<ChildStdin>,
+1
View File
@@ -45,6 +45,7 @@ codex-utils-pty = { workspace = true }
codex-utils-readiness = { workspace = true }
codex-utils-string = { workspace = true }
codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" }
dirs = { workspace = true }
dunce = { workspace = true }
encoding_rs = { workspace = true }
env-flags = { workspace = true }
+64 -2
View File
@@ -1,10 +1,13 @@
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::OnceLock;
use std::sync::RwLock;
use crate::api_bridge::CoreAuthProvider;
use crate::api_bridge::auth_provider_from_auth;
use crate::api_bridge::map_api_error;
use crate::auth::UnauthorizedRecovery;
use crate::turn_metadata::build_turn_metadata_header;
use codex_api::AggregateStreamExt;
use codex_api::ChatClient as ApiChatClient;
use codex_api::CompactClient as ApiCompactClient;
@@ -72,6 +75,13 @@ use crate::transport_manager::TransportManager;
pub const WEB_SEARCH_ELIGIBLE_HEADER: &str = "x-oai-web-search-eligible";
pub const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state";
pub const X_CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata";
#[derive(Debug, Default)]
struct TurnMetadataCache {
cwd: Option<PathBuf>,
header: Option<HeaderValue>,
}
#[derive(Debug)]
struct ModelClientState {
@@ -85,6 +95,7 @@ struct ModelClientState {
summary: ReasoningSummaryConfig,
session_source: SessionSource,
transport_manager: TransportManager,
turn_metadata_cache: Arc<RwLock<TurnMetadataCache>>,
}
#[derive(Debug, Clone)]
@@ -136,11 +147,13 @@ impl ModelClient {
summary,
session_source,
transport_manager,
turn_metadata_cache: Arc::new(RwLock::new(TurnMetadataCache::default())),
}),
}
}
pub fn new_session(&self) -> ModelClientSession {
pub fn new_session(&self, turn_metadata_cwd: Option<PathBuf>) -> ModelClientSession {
self.prewarm_turn_metadata_header(turn_metadata_cwd);
ModelClientSession {
state: Arc::clone(&self.state),
connection: None,
@@ -149,6 +162,38 @@ impl ModelClient {
turn_state: Arc::new(OnceLock::new()),
}
}
/// Refresh turn metadata in the background and update a cached header that request
/// builders can read without blocking.
fn prewarm_turn_metadata_header(&self, turn_metadata_cwd: Option<PathBuf>) {
let turn_metadata_cwd =
turn_metadata_cwd.map(|cwd| std::fs::canonicalize(&cwd).unwrap_or(cwd));
if let Ok(mut cache) = self.state.turn_metadata_cache.write()
&& cache.cwd != turn_metadata_cwd
{
cache.cwd = turn_metadata_cwd.clone();
cache.header = None;
}
let Some(cwd) = turn_metadata_cwd else {
return;
};
let turn_metadata_cache = Arc::clone(&self.state.turn_metadata_cache);
if let Ok(handle) = tokio::runtime::Handle::try_current() {
let _task = handle.spawn(async move {
let header = build_turn_metadata_header(cwd.as_path())
.await
.and_then(|value| HeaderValue::from_str(value.as_str()).ok());
if let Ok(mut cache) = turn_metadata_cache.write()
&& cache.cwd.as_ref() == Some(&cwd)
{
cache.header = header;
}
});
}
}
}
impl ModelClient {
@@ -257,6 +302,14 @@ impl ModelClient {
}
impl ModelClientSession {
fn turn_metadata_header(&self) -> Option<HeaderValue> {
self.state
.turn_metadata_cache
.try_read()
.ok()
.and_then(|cache| cache.header.clone())
}
/// Streams a single model turn using either the Responses or Chat
/// Completions wire API, depending on the configured provider.
///
@@ -332,6 +385,7 @@ impl ModelClientSession {
prompt: &Prompt,
compression: Compression,
) -> ApiResponsesOptions {
let turn_metadata_header = self.turn_metadata_header();
let model_info = &self.state.model_info;
let default_reasoning_effort = model_info.default_reasoning_level;
@@ -380,7 +434,11 @@ impl ModelClientSession {
store_override: None,
conversation_id: Some(conversation_id),
session_source: Some(self.state.session_source.clone()),
extra_headers: build_responses_headers(&self.state.config, Some(&self.turn_state)),
extra_headers: build_responses_headers(
&self.state.config,
Some(&self.turn_state),
turn_metadata_header.as_ref(),
),
compression,
turn_state: Some(Arc::clone(&self.turn_state)),
}
@@ -713,6 +771,7 @@ fn experimental_feature_headers(config: &Config) -> ApiHeaderMap {
fn build_responses_headers(
config: &Config,
turn_state: Option<&Arc<OnceLock<String>>>,
turn_metadata_header: Option<&HeaderValue>,
) -> ApiHeaderMap {
let mut headers = experimental_feature_headers(config);
headers.insert(
@@ -731,6 +790,9 @@ fn build_responses_headers(
{
headers.insert(X_CODEX_TURN_STATE_HEADER, header_value);
}
if let Some(header_value) = turn_metadata_header {
headers.insert(X_CODEX_TURN_METADATA_HEADER, header_value.clone());
}
headers
}
+33 -6
View File
@@ -118,6 +118,7 @@ use crate::error::Result as CodexResult;
use crate::exec::StreamOutput;
use crate::exec_policy::ExecPolicyUpdateError;
use crate::feedback_tags;
use crate::git_info::get_git_repo_root;
use crate::instructions::UserInstructions;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp::auth::compute_auth_statuses;
@@ -332,9 +333,36 @@ impl Codex {
.clone()
.or_else(|| conversation_history.get_base_instructions().map(|s| s.text))
.unwrap_or_else(|| model_info.get_model_instructions(config.personality));
// Respect explicit thread-start tools; fall back to persisted tools when resuming a thread.
// Respect thread-start tools. When missing (resumed/forked threads), read from the db
// first, then fall back to rollout-file tools.
let persisted_tools = if dynamic_tools.is_empty()
&& config.features.enabled(Feature::Sqlite)
{
let thread_id = match &conversation_history {
InitialHistory::Resumed(resumed) => Some(resumed.conversation_id),
InitialHistory::Forked(_) => conversation_history.forked_from_id(),
InitialHistory::New => None,
};
match thread_id {
Some(thread_id) => {
let state_db_ctx = state_db::open_if_present(
config.codex_home.as_path(),
config.model_provider_id.as_str(),
)
.await;
state_db::get_dynamic_tools(state_db_ctx.as_deref(), thread_id, "codex_spawn")
.await
}
None => None,
}
} else {
None
};
let dynamic_tools = if dynamic_tools.is_empty() {
conversation_history.get_dynamic_tools().unwrap_or_default()
persisted_tools
.or_else(|| conversation_history.get_dynamic_tools())
.unwrap_or_default()
} else {
dynamic_tools
};
@@ -503,7 +531,6 @@ pub(crate) struct TurnContext {
pub(crate) truncation_policy: TruncationPolicy,
pub(crate) dynamic_tools: Vec<DynamicToolSpec>,
}
impl TurnContext {
pub(crate) fn resolve_path(&self, path: Option<String>) -> PathBuf {
path.as_ref()
@@ -3384,7 +3411,9 @@ pub(crate) async fn run_turn(
// many turns, from the perspective of the user, it is a single turn.
let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
let mut client_session = turn_context.client.new_session();
let mut client_session = turn_context
.client
.new_session(Some(turn_context.cwd.clone()));
loop {
// Note that pending_input would be something like a message the user
@@ -4447,8 +4476,6 @@ pub(super) fn get_last_assistant_message_from_turn(responses: &[ResponseItem]) -
#[cfg(test)]
pub(crate) use tests::make_session_and_context;
use crate::git_info::get_git_repo_root;
#[cfg(test)]
pub(crate) use tests::make_session_and_context_with_rx;
+3 -1
View File
@@ -335,7 +335,9 @@ async fn drain_to_completed(
turn_context: &TurnContext,
prompt: &Prompt,
) -> CodexResult<()> {
let mut client_session = turn_context.client.new_session();
let mut client_session = turn_context
.client
.new_session(Some(turn_context.cwd.clone()));
let mut stream = client_session.stream(prompt).await?;
loop {
let maybe_event = stream.next().await;
+1
View File
@@ -28,6 +28,7 @@ impl EnvironmentContext {
cwd,
// should compare all fields except shell
shell: _,
..
} = other;
self.cwd == *cwd
+71 -5
View File
@@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
@@ -109,6 +110,73 @@ pub async fn collect_git_info(cwd: &Path) -> Option<GitInfo> {
Some(git_info)
}
/// Collect fetch remotes in a multi-root-friendly format: {"origin": "https://..."}.
pub async fn get_git_remote_urls(cwd: &Path) -> Option<BTreeMap<String, String>> {
let is_git_repo = run_git_command_with_timeout(&["rev-parse", "--git-dir"], cwd)
.await?
.status
.success();
if !is_git_repo {
return None;
}
get_git_remote_urls_assume_git_repo(cwd).await
}
/// Collect fetch remotes without checking whether `cwd` is in a git repo.
pub async fn get_git_remote_urls_assume_git_repo(cwd: &Path) -> Option<BTreeMap<String, String>> {
let output = run_git_command_with_timeout(&["remote", "-v"], cwd).await?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8(output.stdout).ok()?;
parse_git_remote_urls(stdout.as_str())
}
/// Return the current HEAD commit hash without checking whether `cwd` is in a git repo.
pub async fn get_head_commit_hash(cwd: &Path) -> Option<String> {
let output = run_git_command_with_timeout(&["rev-parse", "HEAD"], cwd).await?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8(output.stdout).ok()?;
let hash = stdout.trim();
if hash.is_empty() {
None
} else {
Some(hash.to_string())
}
}
fn parse_git_remote_urls(stdout: &str) -> Option<BTreeMap<String, String>> {
let mut remotes = BTreeMap::new();
for line in stdout.lines() {
let Some(fetch_line) = line.strip_suffix(" (fetch)") else {
continue;
};
let Some((name, url_part)) = fetch_line
.split_once('\t')
.or_else(|| fetch_line.split_once(' '))
else {
continue;
};
let url = url_part.trim_start();
if !url.is_empty() {
remotes.insert(name.to_string(), url.to_string());
}
}
if remotes.is_empty() {
None
} else {
Some(remotes)
}
}
/// A minimal commit summary entry used for pickers (subject + timestamp + sha).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CommitLogEntry {
@@ -185,11 +253,9 @@ pub async fn git_diff_to_remote(cwd: &Path) -> Option<GitDiffToRemote> {
/// Run a git command with a timeout to prevent blocking on large repositories
async fn run_git_command_with_timeout(args: &[&str], cwd: &Path) -> Option<std::process::Output> {
let result = timeout(
GIT_COMMAND_TIMEOUT,
Command::new("git").args(args).current_dir(cwd).output(),
)
.await;
let mut command = Command::new("git");
command.args(args).current_dir(cwd).kill_on_drop(true);
let result = timeout(GIT_COMMAND_TIMEOUT, command.output()).await;
match result {
Ok(Ok(output)) => Some(output),
+2
View File
@@ -101,6 +101,7 @@ pub mod state_db;
pub mod terminal;
mod tools;
pub mod turn_diff_tracker;
mod turn_metadata;
pub use rollout::ARCHIVED_SESSIONS_SUBDIR;
pub use rollout::INTERACTIVE_SESSION_SOURCES;
pub use rollout::RolloutRecorder;
@@ -131,6 +132,7 @@ pub mod util;
pub use apply_patch::CODEX_APPLY_PATCH_ARG1;
pub use client::WEB_SEARCH_ELIGIBLE_HEADER;
pub use client::X_CODEX_TURN_METADATA_HEADER;
pub use command_safety::is_dangerous_command;
pub use command_safety::is_safe_command;
pub use exec_policy::ExecPolicyError;
+23
View File
@@ -187,6 +187,29 @@ pub(crate) async fn backfill_sessions(
warn!("failed to upsert rollout {}: {err}", path.display());
} else {
stats.upserted = stats.upserted.saturating_add(1);
if let Ok(meta_line) = rollout::list::read_session_meta_line(&path).await {
if let Err(err) = runtime
.persist_dynamic_tools(
meta_line.meta.id,
meta_line.meta.dynamic_tools.as_deref(),
)
.await
{
if let Some(otel) = otel {
otel.counter(
DB_ERROR_METRIC,
1,
&[("stage", "backfill_dynamic_tools")],
);
}
warn!("failed to backfill dynamic tools {}: {err}", path.display());
}
} else {
warn!(
"failed to read session meta for dynamic tools {}",
path.display()
);
}
}
}
Err(err) => {
+84 -8
View File
@@ -13,6 +13,7 @@ use crate::skills::model::SkillToolDependency;
use crate::skills::system::system_cache_root_dir;
use codex_app_server_protocol::ConfigLayerSource;
use codex_protocol::protocol::SkillScope;
use dirs::home_dir;
use dunce::canonicalize as canonicalize_path;
use serde::Deserialize;
use std::collections::HashSet;
@@ -164,7 +165,10 @@ where
outcome
}
fn skill_roots_from_layer_stack_inner(config_layer_stack: &ConfigLayerStack) -> Vec<SkillRoot> {
fn skill_roots_from_layer_stack_inner(
config_layer_stack: &ConfigLayerStack,
home_dir: Option<&Path>,
) -> Vec<SkillRoot> {
let mut roots = Vec::new();
for layer in
@@ -182,12 +186,21 @@ fn skill_roots_from_layer_stack_inner(config_layer_stack: &ConfigLayerStack) ->
});
}
ConfigLayerSource::User { .. } => {
// `$CODEX_HOME/skills` (user-installed skills).
// Deprecated user skills location (`$CODEX_HOME/skills`), kept for backward
// compatibility.
roots.push(SkillRoot {
path: config_folder.as_path().join(SKILLS_DIR_NAME),
scope: SkillScope::User,
});
// `$HOME/.agents/skills` (user-installed skills).
if let Some(home_dir) = home_dir {
roots.push(SkillRoot {
path: home_dir.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME),
scope: SkillScope::User,
});
}
// Embedded system skills are cached under `$CODEX_HOME/skills/.system` and are a
// special case (not a config layer).
roots.push(SkillRoot {
@@ -220,15 +233,16 @@ fn skill_roots(config: &Config) -> Vec<SkillRoot> {
#[cfg(test)]
pub(crate) fn skill_roots_from_layer_stack(
config_layer_stack: &ConfigLayerStack,
home_dir: Option<&Path>,
) -> Vec<SkillRoot> {
skill_roots_from_layer_stack_inner(config_layer_stack)
skill_roots_from_layer_stack_inner(config_layer_stack, home_dir)
}
pub(crate) fn skill_roots_from_layer_stack_with_agents(
config_layer_stack: &ConfigLayerStack,
cwd: &Path,
) -> Vec<SkillRoot> {
let mut roots = skill_roots_from_layer_stack_inner(config_layer_stack);
let mut roots = skill_roots_from_layer_stack_inner(config_layer_stack, home_dir().as_deref());
roots.extend(repo_agents_skill_roots(config_layer_stack, cwd));
dedupe_skill_roots_by_path(&mut roots);
roots
@@ -829,7 +843,8 @@ mod tests {
let tmp = tempfile::tempdir()?;
let system_folder = tmp.path().join("etc/codex");
let user_folder = tmp.path().join("home/codex");
let home_folder = tmp.path().join("home");
let user_folder = home_folder.join("codex");
fs::create_dir_all(&system_folder)?;
fs::create_dir_all(&user_folder)?;
@@ -853,7 +868,7 @@ mod tests {
ConfigRequirementsToml::default(),
)?;
let got = skill_roots_from_layer_stack(&stack)
let got = skill_roots_from_layer_stack(&stack, Some(&home_folder))
.into_iter()
.map(|root| (root.scope, root.path))
.collect::<Vec<_>>();
@@ -862,6 +877,10 @@ mod tests {
got,
vec![
(SkillScope::User, user_folder.join("skills")),
(
SkillScope::User,
home_folder.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME)
),
(
SkillScope::System,
user_folder.join("skills").join(".system")
@@ -877,7 +896,8 @@ mod tests {
fn skill_roots_from_layer_stack_includes_disabled_project_layers() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let user_folder = tmp.path().join("home/codex");
let home_folder = tmp.path().join("home");
let user_folder = home_folder.join("codex");
fs::create_dir_all(&user_folder)?;
let project_root = tmp.path().join("repo");
@@ -906,7 +926,7 @@ mod tests {
ConfigRequirementsToml::default(),
)?;
let got = skill_roots_from_layer_stack(&stack)
let got = skill_roots_from_layer_stack(&stack, Some(&home_folder))
.into_iter()
.map(|root| (root.scope, root.path))
.collect::<Vec<_>>();
@@ -916,6 +936,10 @@ mod tests {
vec![
(SkillScope::Repo, dot_codex.join("skills")),
(SkillScope::User, user_folder.join("skills")),
(
SkillScope::User,
home_folder.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME)
),
(
SkillScope::System,
user_folder.join("skills").join(".system")
@@ -926,6 +950,55 @@ mod tests {
Ok(())
}
#[test]
fn loads_skills_from_home_agents_dir_for_user_scope() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let home_folder = tmp.path().join("home");
let user_folder = home_folder.join("codex");
fs::create_dir_all(&user_folder)?;
let user_file = AbsolutePathBuf::from_absolute_path(user_folder.join("config.toml"))?;
let layers = vec![ConfigLayerEntry::new(
ConfigLayerSource::User { file: user_file },
TomlValue::Table(toml::map::Map::new()),
)];
let stack = ConfigLayerStack::new(
layers,
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)?;
let skill_path = write_skill_at(
&home_folder.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME),
"agents-home",
"agents-home-skill",
"from home agents",
);
let outcome =
load_skills_from_roots(skill_roots_from_layer_stack(&stack, Some(&home_folder)));
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(
outcome.skills,
vec![SkillMetadata {
name: "agents-home-skill".to_string(),
description: "from home agents".to_string(),
short_description: None,
interface: None,
dependencies: None,
path: normalized(&skill_path),
scope: SkillScope::User,
}]
);
Ok(())
}
fn write_skill(codex_home: &TempDir, dir: &str, name: &str, description: &str) -> PathBuf {
write_skill_at(&codex_home.path().join("skills"), dir, name, description)
}
@@ -2136,6 +2209,9 @@ interface:
.map(|root| root.scope)
.collect();
let mut expected = vec![SkillScope::User, SkillScope::System];
if home_dir().is_some() {
expected.insert(1, SkillScope::User);
}
if cfg!(unix) {
expected.push(SkillScope::Admin);
}
+47
View File
@@ -9,6 +9,7 @@ use chrono::Timelike;
use chrono::Utc;
use codex_otel::OtelManager;
use codex_protocol::ThreadId;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionSource;
use codex_state::DB_METRIC_COMPARE_ERROR;
@@ -196,6 +197,37 @@ pub async fn find_rollout_path_by_id(
})
}
/// Get dynamic tools for a thread id using SQLite.
pub async fn get_dynamic_tools(
context: Option<&codex_state::StateRuntime>,
thread_id: ThreadId,
stage: &str,
) -> Option<Vec<DynamicToolSpec>> {
let ctx = context?;
match ctx.get_dynamic_tools(thread_id).await {
Ok(tools) => tools,
Err(err) => {
warn!("state db get_dynamic_tools failed during {stage}: {err}");
None
}
}
}
/// Persist dynamic tools for a thread id using SQLite, if none exist yet.
pub async fn persist_dynamic_tools(
context: Option<&codex_state::StateRuntime>,
thread_id: ThreadId,
tools: Option<&[DynamicToolSpec]>,
stage: &str,
) {
let Some(ctx) = context else {
return;
};
if let Err(err) = ctx.persist_dynamic_tools(thread_id, tools).await {
warn!("state db persist_dynamic_tools failed during {stage}: {err}");
}
}
/// Reconcile rollout items into SQLite, falling back to scanning the rollout file.
pub async fn reconcile_rollout(
context: Option<&codex_state::StateRuntime>,
@@ -235,6 +267,21 @@ pub async fn reconcile_rollout(
"state db reconcile_rollout upsert failed {}: {err}",
rollout_path.display()
);
return;
}
if let Ok(meta_line) = crate::rollout::list::read_session_meta_line(rollout_path).await {
persist_dynamic_tools(
Some(ctx),
meta_line.meta.id,
meta_line.meta.dynamic_tools.as_deref(),
"reconcile_rollout",
)
.await;
} else {
warn!(
"state db reconcile_rollout missing session meta {}",
rollout_path.display()
);
}
}
+43
View File
@@ -0,0 +1,43 @@
use std::collections::BTreeMap;
use std::path::Path;
use serde::Serialize;
use crate::git_info::get_git_remote_urls_assume_git_repo;
use crate::git_info::get_git_repo_root;
use crate::git_info::get_head_commit_hash;
#[derive(Serialize)]
struct TurnMetadataWorkspace {
#[serde(skip_serializing_if = "Option::is_none")]
associated_remote_urls: Option<BTreeMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
latest_git_commit_hash: Option<String>,
}
#[derive(Serialize)]
struct TurnMetadata {
workspaces: BTreeMap<String, TurnMetadataWorkspace>,
}
pub(crate) async fn build_turn_metadata_header(cwd: &Path) -> Option<String> {
let repo_root = get_git_repo_root(cwd)?;
let (latest_git_commit_hash, associated_remote_urls) = tokio::join!(
get_head_commit_hash(cwd),
get_git_remote_urls_assume_git_repo(cwd)
);
if latest_git_commit_hash.is_none() && associated_remote_urls.is_none() {
return None;
}
let mut workspaces = BTreeMap::new();
workspaces.insert(
repo_root.to_string_lossy().into_owned(),
TurnMetadataWorkspace {
associated_remote_urls,
latest_git_commit_hash,
},
);
serde_json::to_string(&TurnMetadata { workspaces }).ok()
}
+19
View File
@@ -65,6 +65,25 @@ pub fn elevated_setup_failure_details(_err: &anyhow::Error) -> Option<(String, S
None
}
#[cfg(target_os = "windows")]
pub fn elevated_setup_failure_metric_name(err: &anyhow::Error) -> &'static str {
if codex_windows_sandbox::extract_setup_failure(err).is_some_and(|failure| {
matches!(
failure.code,
codex_windows_sandbox::SetupErrorCode::OrchestratorHelperLaunchCanceled
)
}) {
"codex.windows_sandbox.elevated_setup_canceled"
} else {
"codex.windows_sandbox.elevated_setup_failure"
}
}
#[cfg(not(target_os = "windows"))]
pub fn elevated_setup_failure_metric_name(_err: &anyhow::Error) -> &'static str {
panic!("elevated_setup_failure_metric_name is only supported on Windows")
}
#[cfg(target_os = "windows")]
pub fn run_elevated_setup(
policy: &SandboxPolicy,
@@ -102,7 +102,7 @@ async fn run_request(input: Vec<ResponseItem>) -> Value {
SessionSource::Exec,
TransportManager::new(),
)
.new_session();
.new_session(None);
let mut prompt = Prompt::default();
prompt.input = input;
+1 -1
View File
@@ -103,7 +103,7 @@ async fn run_stream_with_bytes(sse_body: &[u8]) -> Vec<ResponseEvent> {
SessionSource::Exec,
TransportManager::new(),
)
.new_session();
.new_session(None);
let mut prompt = Prompt::default();
prompt.input = vec![ResponseItem::Message {
+198 -3
View File
@@ -1,3 +1,4 @@
use std::process::Command;
use std::sync::Arc;
use codex_app_server_protocol::AuthMode;
@@ -23,6 +24,7 @@ use core_test_support::load_default_config_for_test;
use core_test_support::responses;
use core_test_support::test_codex::test_codex;
use futures::StreamExt;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use wiremock::matchers::header;
@@ -98,7 +100,7 @@ async fn responses_stream_includes_subagent_header_on_review() {
session_source,
TransportManager::new(),
)
.new_session();
.new_session(None);
let mut prompt = Prompt::default();
prompt.input = vec![ResponseItem::Message {
@@ -197,7 +199,7 @@ async fn responses_stream_includes_subagent_header_on_other() {
session_source,
TransportManager::new(),
)
.new_session();
.new_session(None);
let mut prompt = Prompt::default();
prompt.input = vec![ResponseItem::Message {
@@ -354,7 +356,7 @@ async fn responses_respects_model_info_overrides_from_config() {
session_source,
TransportManager::new(),
)
.new_session();
.new_session(None);
let mut prompt = Prompt::default();
prompt.input = vec![ResponseItem::Message {
@@ -393,3 +395,196 @@ async fn responses_respects_model_info_overrides_from_config() {
Some("detailed")
);
}
#[tokio::test]
async fn responses_stream_includes_turn_metadata_header_for_git_workspace_e2e() {
core_test_support::skip_if_no_network!();
let server = responses::start_mock_server().await;
let response_body = responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_completed("resp-1"),
]);
let provider = ModelProviderInfo {
name: "mock".into(),
base_url: Some(format!("{}/v1", server.uri())),
env_key: None,
env_key_instructions: None,
experimental_bearer_token: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: None,
env_http_headers: None,
request_max_retries: Some(0),
stream_max_retries: Some(0),
stream_idle_timeout_ms: Some(5_000),
requires_openai_auth: false,
supports_websockets: false,
};
let codex_home = TempDir::new().expect("failed to create TempDir");
let mut config = load_default_config_for_test(&codex_home).await;
config.model_provider_id = provider.name.clone();
config.model_provider = provider.clone();
let effort = config.model_reasoning_effort;
let summary = config.model_reasoning_summary;
let model = ModelsManager::get_model_offline(config.model.as_deref());
config.model = Some(model.clone());
let config = Arc::new(config);
let conversation_id = ThreadId::new();
let auth_mode = AuthMode::Chatgpt;
let session_source =
SessionSource::SubAgent(SubAgentSource::Other("turn-metadata-e2e".to_string()));
let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config);
let otel_manager = OtelManager::new(
conversation_id,
model.as_str(),
model_info.slug.as_str(),
None,
Some("test@test.com".to_string()),
Some(auth_mode),
false,
"test".to_string(),
session_source.clone(),
);
let client = ModelClient::new(
Arc::clone(&config),
None,
model_info,
otel_manager,
provider,
effort,
summary,
conversation_id,
session_source,
TransportManager::new(),
);
let workspace = TempDir::new().expect("workspace tempdir");
let cwd = workspace.path();
let mut prompt = Prompt::default();
prompt.input = vec![ResponseItem::Message {
id: None,
role: "user".into(),
content: vec![ContentItem::InputText {
text: "hello".into(),
}],
end_turn: None,
}];
let first_request = responses::mount_sse_once(&server, response_body.clone()).await;
let mut first_session = client.new_session(Some(cwd.to_path_buf()));
let mut first_stream = first_session
.stream(&prompt)
.await
.expect("stream first turn");
while let Some(event) = first_stream.next().await {
if matches!(event, Ok(ResponseEvent::Completed { .. })) {
break;
}
}
assert_eq!(
first_request
.single_request()
.header("x-codex-turn-metadata"),
None
);
let git_config_global = cwd.join("empty-git-config");
std::fs::write(&git_config_global, "").expect("write empty git config");
let run_git = |args: &[&str]| {
let output = Command::new("git")
.env("GIT_CONFIG_GLOBAL", &git_config_global)
.env("GIT_CONFIG_NOSYSTEM", "1")
.args(args)
.current_dir(cwd)
.output()
.expect("git command should run");
assert!(
output.status.success(),
"git {:?} failed: stdout={} stderr={}",
args,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
output
};
run_git(&["init"]);
run_git(&["config", "user.name", "Test User"]);
run_git(&["config", "user.email", "test@example.com"]);
std::fs::write(cwd.join("README.md"), "hello").expect("write README");
run_git(&["add", "."]);
run_git(&["commit", "-m", "initial commit"]);
run_git(&[
"remote",
"add",
"origin",
"https://github.com/openai/codex.git",
]);
let expected_head = String::from_utf8(run_git(&["rev-parse", "HEAD"]).stdout)
.expect("git rev-parse output should be valid UTF-8")
.trim()
.to_string();
let expected_origin = String::from_utf8(run_git(&["remote", "get-url", "origin"]).stdout)
.expect("git remote get-url output should be valid UTF-8")
.trim()
.to_string();
let repo_root = std::fs::canonicalize(cwd)
.unwrap_or_else(|_| cwd.to_path_buf())
.to_string_lossy()
.into_owned();
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
let request_recorder = responses::mount_sse_once(&server, response_body.clone()).await;
let mut session = client.new_session(Some(cwd.to_path_buf()));
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let mut stream = session.stream(&prompt).await.expect("stream post-git turn");
while let Some(event) = stream.next().await {
if matches!(event, Ok(ResponseEvent::Completed { .. })) {
break;
}
}
let maybe_header = request_recorder
.single_request()
.header("x-codex-turn-metadata");
if let Some(header_value) = maybe_header {
let parsed: serde_json::Value = serde_json::from_str(&header_value)
.expect("x-codex-turn-metadata should be valid JSON");
let workspace = parsed
.get("workspaces")
.and_then(serde_json::Value::as_object)
.and_then(|workspaces| workspaces.get(&repo_root))
.expect("metadata should include cwd repo root workspace entry");
assert_eq!(
workspace
.get("latest_git_commit_hash")
.and_then(serde_json::Value::as_str),
Some(expected_head.as_str())
);
assert_eq!(
workspace
.get("associated_remote_urls")
.and_then(serde_json::Value::as_object)
.and_then(|remotes| remotes.get("origin"))
.and_then(serde_json::Value::as_str),
Some(expected_origin.as_str())
);
return;
}
if tokio::time::Instant::now() >= deadline {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
panic!("x-codex-turn-metadata was never observed within 5s after git setup");
}
+1 -1
View File
@@ -1190,7 +1190,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() {
SessionSource::Exec,
TransportManager::new(),
)
.new_session();
.new_session(None);
let mut prompt = Prompt::default();
prompt.input.push(ResponseItem::Reasoning {
@@ -53,7 +53,7 @@ async fn responses_websocket_streams_request() {
.await;
let harness = websocket_harness(&server).await;
let mut session = harness.client.new_session();
let mut session = harness.client.new_session(None);
let prompt = prompt_with_input(vec![message_item("hello")]);
stream_until_complete(&mut session, &prompt).await;
@@ -83,7 +83,7 @@ async fn responses_websocket_emits_websocket_telemetry_events() {
let harness = websocket_harness(&server).await;
harness.otel_manager.reset_runtime_metrics();
let mut session = harness.client.new_session();
let mut session = harness.client.new_session(None);
let prompt = prompt_with_input(vec![message_item("hello")]);
stream_until_complete(&mut session, &prompt).await;
@@ -113,7 +113,7 @@ async fn responses_websocket_emits_reasoning_included_event() {
.await;
let harness = websocket_harness(&server).await;
let mut session = harness.client.new_session();
let mut session = harness.client.new_session(None);
let prompt = prompt_with_input(vec![message_item("hello")]);
let mut stream = session
@@ -147,7 +147,7 @@ async fn responses_websocket_appends_on_prefix() {
.await;
let harness = websocket_harness(&server).await;
let mut session = harness.client.new_session();
let mut session = harness.client.new_session(None);
let prompt_one = prompt_with_input(vec![message_item("hello")]);
let prompt_two = prompt_with_input(vec![message_item("hello"), message_item("second")]);
@@ -183,7 +183,7 @@ async fn responses_websocket_creates_on_non_prefix() {
.await;
let harness = websocket_harness(&server).await;
let mut session = harness.client.new_session();
let mut session = harness.client.new_session(None);
let prompt_one = prompt_with_input(vec![message_item("hello")]);
let prompt_two = prompt_with_input(vec![message_item("different")]);
+35 -2
View File
@@ -1,6 +1,7 @@
use anyhow::Result;
use codex_core::features::Feature;
use codex_protocol::ThreadId;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
@@ -74,6 +75,28 @@ async fn backfill_scans_existing_rollouts() -> Result<()> {
let rollout_rel_path = format!("sessions/2026/01/27/rollout-2026-01-27T12-00-00-{uuid}.jsonl");
let rollout_rel_path_for_hook = rollout_rel_path.clone();
let dynamic_tools = vec![
DynamicToolSpec {
name: "geo_lookup".to_string(),
description: "lookup a city".to_string(),
input_schema: json!({
"type": "object",
"required": ["city"],
"properties": { "city": { "type": "string" } }
}),
},
DynamicToolSpec {
name: "weather_lookup".to_string(),
description: "lookup weather".to_string(),
input_schema: json!({
"type": "object",
"required": ["zip"],
"properties": { "zip": { "type": "string" } }
}),
},
];
let dynamic_tools_for_hook = dynamic_tools.clone();
let mut builder = test_codex()
.with_pre_build_hook(move |codex_home| {
let rollout_path = codex_home.join(&rollout_rel_path_for_hook);
@@ -81,7 +104,6 @@ async fn backfill_scans_existing_rollouts() -> Result<()> {
.parent()
.expect("rollout path should have parent");
fs::create_dir_all(parent).expect("should create rollout directory");
let session_meta_line = SessionMetaLine {
meta: SessionMeta {
id: thread_id,
@@ -93,7 +115,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> {
source: SessionSource::default(),
model_provider: None,
base_instructions: None,
dynamic_tools: None,
dynamic_tools: Some(dynamic_tools_for_hook),
},
git: None,
};
@@ -155,6 +177,17 @@ async fn backfill_scans_existing_rollouts() -> Result<()> {
assert_eq!(metadata.model_provider, default_provider);
assert!(metadata.has_user_event);
let mut stored_tools = None;
for _ in 0..40 {
stored_tools = db.get_dynamic_tools(thread_id).await?;
if stored_tools.is_some() {
break;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
let stored_tools = stored_tools.expect("dynamic tools should be stored");
assert_eq!(stored_tools, dynamic_tools);
Ok(())
}
@@ -0,0 +1,11 @@
CREATE TABLE thread_dynamic_tools (
thread_id TEXT NOT NULL,
position INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
input_schema TEXT NOT NULL,
PRIMARY KEY(thread_id, position),
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
);
CREATE INDEX idx_thread_dynamic_tools_thread ON thread_dynamic_tools(thread_id);
+109
View File
@@ -16,8 +16,10 @@ use chrono::DateTime;
use chrono::Utc;
use codex_otel::OtelManager;
use codex_protocol::ThreadId;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::protocol::RolloutItem;
use log::LevelFilter;
use serde_json::Value;
use sqlx::ConnectOptions;
use sqlx::QueryBuilder;
use sqlx::Row;
@@ -117,6 +119,38 @@ WHERE id = ?
.transpose()
}
/// Get dynamic tools for a thread, if present.
pub async fn get_dynamic_tools(
&self,
thread_id: ThreadId,
) -> anyhow::Result<Option<Vec<DynamicToolSpec>>> {
let rows = sqlx::query(
r#"
SELECT name, description, input_schema
FROM thread_dynamic_tools
WHERE thread_id = ?
ORDER BY position ASC
"#,
)
.bind(thread_id.to_string())
.fetch_all(self.pool.as_ref())
.await?;
if rows.is_empty() {
return Ok(None);
}
let mut tools = Vec::with_capacity(rows.len());
for row in rows {
let input_schema: String = row.try_get("input_schema")?;
let input_schema = serde_json::from_str::<Value>(input_schema.as_str())?;
tools.push(DynamicToolSpec {
name: row.try_get("name")?,
description: row.try_get("description")?,
input_schema,
});
}
Ok(Some(tools))
}
/// Find a rollout path by thread id using the underlying database.
pub async fn find_rollout_path_by_id(
&self,
@@ -369,6 +403,58 @@ ON CONFLICT(id) DO UPDATE SET
Ok(())
}
/// Persist dynamic tools for a thread if none have been stored yet.
///
/// Dynamic tools are defined at thread start and should not change afterward.
/// This only writes the first time we see tools for a given thread.
pub async fn persist_dynamic_tools(
&self,
thread_id: ThreadId,
tools: Option<&[DynamicToolSpec]>,
) -> anyhow::Result<()> {
let Some(tools) = tools else {
return Ok(());
};
if tools.is_empty() {
return Ok(());
}
let mut tx = self.pool.begin().await?;
let thread_id = thread_id.to_string();
let existing: Option<i64> =
sqlx::query_scalar("SELECT 1 FROM thread_dynamic_tools WHERE thread_id = ? LIMIT 1")
.bind(thread_id.as_str())
.fetch_optional(&mut *tx)
.await?;
if existing.is_some() {
tx.commit().await?;
return Ok(());
}
for (idx, tool) in tools.iter().enumerate() {
let position = i64::try_from(idx).unwrap_or(i64::MAX);
let input_schema = serde_json::to_string(&tool.input_schema)?;
sqlx::query(
r#"
INSERT INTO thread_dynamic_tools (
thread_id,
position,
name,
description,
input_schema
) VALUES (?, ?, ?, ?, ?)
"#,
)
.bind(thread_id.as_str())
.bind(position)
.bind(tool.name.as_str())
.bind(tool.description.as_str())
.bind(input_schema)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
/// Apply rollout items incrementally using the underlying database.
pub async fn apply_rollout_items(
&self,
@@ -390,12 +476,25 @@ ON CONFLICT(id) DO UPDATE SET
if let Some(updated_at) = file_modified_time_utc(builder.rollout_path.as_path()).await {
metadata.updated_at = updated_at;
}
// Keep the thread upsert before dynamic tools to satisfy the foreign key constraint:
// thread_dynamic_tools.thread_id -> threads.id.
if let Err(err) = self.upsert_thread(&metadata).await {
if let Some(otel) = otel {
otel.counter(DB_ERROR_METRIC, 1, &[("stage", "apply_rollout_items")]);
}
return Err(err);
}
let dynamic_tools = extract_dynamic_tools(items);
if let Some(dynamic_tools) = dynamic_tools
&& let Err(err) = self
.persist_dynamic_tools(builder.id, dynamic_tools.as_deref())
.await
{
if let Some(otel) = otel {
otel.counter(DB_ERROR_METRIC, 1, &[("stage", "persist_dynamic_tools")]);
}
return Err(err);
}
Ok(())
}
@@ -507,6 +606,16 @@ fn push_like_filters<'a>(
builder.push(")");
}
fn extract_dynamic_tools(items: &[RolloutItem]) -> Option<Option<Vec<DynamicToolSpec>>> {
items.iter().find_map(|item| match item {
RolloutItem::SessionMeta(meta_line) => Some(meta_line.meta.dynamic_tools.clone()),
RolloutItem::ResponseItem(_)
| RolloutItem::Compacted(_)
| RolloutItem::TurnContext(_)
| RolloutItem::EventMsg(_) => None,
})
}
async fn open_sqlite(path: &Path) -> anyhow::Result<SqlitePool> {
let options = SqliteConnectOptions::new()
.filename(path)
+3 -1
View File
@@ -1700,7 +1700,9 @@ impl App {
tags.push(("message", message));
}
otel_manager.counter(
"codex.windows_sandbox.elevated_setup_failure",
codex_core::windows_sandbox::elevated_setup_failure_metric_name(
&err,
),
1,
&tags,
);
+1
View File
@@ -9,6 +9,7 @@ Use /status to see the current model, approvals, and token usage.
Use /fork to branch the current chat into a new thread.
Use /init to create an AGENTS.md with project-specific guidance.
Use /mcp to list configured MCP tools.
Use /personality to customize how Codex communicates.
Use /rename to rename your threads for easier thread resuming.
Use the OpenAI docs MCP for API questions; enable it with `codex mcp add openaiDeveloperDocs --url https://developers.openai.com/mcp`.
Join the OpenAI community Discord: http://discord.gg/openai
@@ -22,6 +22,8 @@ pub enum SetupErrorCode {
OrchestratorPayloadSerializeFailed,
/// Failed to launch the setup helper process (spawn or ShellExecuteExW).
OrchestratorHelperLaunchFailed,
/// User canceled the UAC prompt while launching the helper.
OrchestratorHelperLaunchCanceled,
/// Helper exited non-zero and no structured report was available.
OrchestratorHelperExitNonzero,
/// Helper exited non-zero and reading `setup_error.json` failed.
@@ -72,6 +74,7 @@ impl SetupErrorCode {
Self::OrchestratorElevationCheckFailed => "orchestrator_elevation_check_failed",
Self::OrchestratorPayloadSerializeFailed => "orchestrator_payload_serialize_failed",
Self::OrchestratorHelperLaunchFailed => "orchestrator_helper_launch_failed",
Self::OrchestratorHelperLaunchCanceled => "orchestrator_helper_launch_canceled",
Self::OrchestratorHelperExitNonzero => "orchestrator_helper_exit_nonzero",
Self::OrchestratorHelperReportReadFailed => "orchestrator_helper_report_read_failed",
Self::HelperRequestArgsFailed => "helper_request_args_failed",
@@ -34,6 +34,7 @@ use windows_sys::Win32::Security::SECURITY_NT_AUTHORITY;
pub const SETUP_VERSION: u32 = 5;
pub const OFFLINE_USERNAME: &str = "CodexSandboxOffline";
pub const ONLINE_USERNAME: &str = "CodexSandboxOnline";
const ERROR_CANCELLED: u32 = 1223;
const SECURITY_BUILTIN_DOMAIN_RID: u32 = 0x0000_0020;
const DOMAIN_ALIAS_RID_ADMINS: u32 = 0x0000_0220;
@@ -411,8 +412,13 @@ fn run_setup_exe(
let ok = unsafe { ShellExecuteExW(&mut sei) };
if ok == 0 || sei.hProcess == 0 {
let last_error = unsafe { GetLastError() };
let code = if last_error == ERROR_CANCELLED {
SetupErrorCode::OrchestratorHelperLaunchCanceled
} else {
SetupErrorCode::OrchestratorHelperLaunchFailed
};
return Err(failure(
SetupErrorCode::OrchestratorHelperLaunchFailed,
code,
format!("ShellExecuteExW failed to launch setup helper: {last_error}"),
));
}