Skip to content
Open
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
24 changes: 18 additions & 6 deletions app/src/ai/agent_sdk/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ pub struct AgentDriverOptions {
/// Selected execution harness for this run.
pub selected_harness: Harness,
/// Model ID for the selected harness. Only used for non-Oz harnesses.
pub harness_model_id: Option<String>,
pub third_party_harness_model_id: Option<String>,
/// Whether to skip end-of-run snapshot upload.
pub snapshot_disabled: Option<bool>,
/// End-of-run snapshot upload timeout override.
Expand Down Expand Up @@ -300,6 +300,7 @@ pub struct AgentDriver {
/// conversation's `parent_agent_id` field at register time so the
/// streamer recognizes the child role in driver-hosted processes.
parent_run_id: Option<String>,
third_party_harness_model_id: Option<String>,
}

pub(crate) enum SDKConversationOutputStatus {
Expand Down Expand Up @@ -490,7 +491,7 @@ impl AgentDriver {
cloud_providers,
environment,
selected_harness,
harness_model_id,
third_party_harness_model_id,
snapshot_disabled,
snapshot_upload_timeout,
snapshot_script_timeout,
Expand Down Expand Up @@ -616,7 +617,7 @@ impl AgentDriver {
));
env_vars.extend(harness_model_env_vars(
selected_harness,
harness_model_id.as_deref(),
third_party_harness_model_id.as_deref(),
));

// Signal to third-party harnesses (e.g. Claude Code) that we're in a sandbox
Expand Down Expand Up @@ -671,6 +672,7 @@ impl AgentDriver {
resume_payload,
run_conversation_id,
parent_run_id: parent_run_id_for_self,
third_party_harness_model_id,
})
}

Expand Down Expand Up @@ -1569,11 +1571,21 @@ impl AgentDriver {
};

// Prepare harness config files (onboarding, trust dialog, API-key approval, etc.).
let secrets = foreground
.spawn(|me, _| Arc::clone(&me.secrets))
let (secrets, third_party_harness_model_id) = foreground
.spawn(|me, _| {
(
Arc::clone(&me.secrets),
me.third_party_harness_model_id.clone(),
)
})
.await
.map_err(|_| AgentDriverError::InvalidRuntimeState)?;
harness.prepare_environment_config(&working_dir, system_prompt.as_deref(), &secrets)?;
harness.prepare_environment_config(
&working_dir,
system_prompt.as_deref(),
&secrets,
third_party_harness_model_id.as_deref(),
)?;

// Pull the resume payload off the driver so the harness runner can rehydrate any
// existing session/conversation state before launching its CLI. The payload variant
Expand Down
1 change: 1 addition & 0 deletions app/src/ai/agent_sdk/driver/harness/claude_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ impl ThirdPartyHarness for ClaudeHarness {
working_dir: &Path,
_system_prompt: Option<&str>,
secrets: &HashMap<String, ManagedSecretValue>,
_third_party_harness_model_id: Option<&str>,
) -> Result<(), AgentDriverError> {
prepare_claude_environment_config(working_dir, secrets).map_err(|error| {
AgentDriverError::HarnessConfigSetupFailed {
Expand Down
83 changes: 76 additions & 7 deletions app/src/ai/agent_sdk/driver/harness/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,17 @@ impl ThirdPartyHarness for CodexHarness {
working_dir: &Path,
system_prompt: Option<&str>,
secrets: &HashMap<String, ManagedSecretValue>,
third_party_harness_model_id: Option<&str>,
) -> Result<(), AgentDriverError> {
prepare_codex_environment_config(working_dir, system_prompt, secrets).map_err(|error| {
AgentDriverError::HarnessConfigSetupFailed {
harness: self.cli_agent().command_prefix().to_owned(),
error,
}
prepare_codex_environment_config(
working_dir,
system_prompt,
secrets,
third_party_harness_model_id,
)
.map_err(|error| AgentDriverError::HarnessConfigSetupFailed {
harness: self.cli_agent().command_prefix().to_owned(),
error,
})
}

Expand Down Expand Up @@ -226,6 +231,15 @@ const CODEX_TRUST_LEVEL_TRUSTED: &str = "trusted";
/// Top-level config key codex reads to override the built-in `openai` provider's base URL
/// (codex `core/src/config/mod.rs`).
const CODEX_OPENAI_BASE_URL_KEY: &str = "openai_base_url";
const CODEX_MODEL_KEY: &str = "model";
/// Target model for the `[notice.model_migrations]` table that suppresses Codex's
/// "choose a newer model" upgrade prompt at session launch. We stamp this for any
/// pinned model id (even when it already matches the target) so the unattended
/// cloud run never blocks on the prompt.
///
/// TODO: Ideally, we would make this server-driven so we don't depend on a client
/// release to change this.
const CODEX_MODEL_MIGRATIONS_TARGET: &str = "gpt-5.4";
/// US data-residency endpoint. Our OpenAI keys are issued under a US-residency project,
/// which rejects requests to the global host with `401 incorrect_hostname`.
/// TODO(REMOTE-1509): plumb a region-tagged auth secret instead of hardcoding the URL.
Expand All @@ -235,6 +249,7 @@ fn prepare_codex_environment_config(
working_dir: &Path,
system_prompt: Option<&str>,
secrets: &HashMap<String, ManagedSecretValue>,
third_party_harness_model_id: Option<&str>,
) -> Result<()> {
let home_dir =
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?;
Expand All @@ -249,7 +264,11 @@ fn prepare_codex_environment_config(
None => log::info!("No OPENAI_API_KEY available; skipping Codex auth.json seed"),
}

prepare_codex_config_toml(&codex_dir.join(CODEX_CONFIG_TOML_FILE_NAME), working_dir)?;
prepare_codex_config_toml(
&codex_dir.join(CODEX_CONFIG_TOML_FILE_NAME),
working_dir,
third_party_harness_model_id,
)?;
Ok(())
}

Expand Down Expand Up @@ -353,7 +372,14 @@ fn resolve_openai_api_key(secrets: &HashMap<String, ManagedSecretValue>) -> Opti
/// set the projects to `trusted`.
/// - base URL: set `openai_base_url = "<US data-residency endpoint>"` so we
/// hit the regional host our API keys require.
fn prepare_codex_config_toml(config_toml_path: &Path, working_dir: &Path) -> Result<()> {
/// - model override: when a non-default `third_party_harness_model_id` is
/// supplied, write the top-level `model` key so Codex pins the chosen model
/// for new sessions.
fn prepare_codex_config_toml(
config_toml_path: &Path,
working_dir: &Path,
third_party_harness_model_id: Option<&str>,
) -> Result<()> {
let existing = match fs::read_to_string(config_toml_path) {
Ok(content) => content,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Expand All @@ -372,6 +398,7 @@ fn prepare_codex_config_toml(config_toml_path: &Path, working_dir: &Path) -> Res
})?;

set_codex_openai_base_url(&mut doc, CODEX_OPENAI_BASE_URL);
set_codex_model(&mut doc, third_party_harness_model_id);

let canonical = working_dir.canonicalize().with_context(|| {
format!(
Expand Down Expand Up @@ -408,6 +435,48 @@ fn set_codex_openai_base_url(doc: &mut toml_edit::DocumentMut, base_url: &str) {
doc[CODEX_OPENAI_BASE_URL_KEY] = toml_edit::value(base_url);
}

fn set_codex_model(
doc: &mut toml_edit::DocumentMut,
third_party_harness_model_id: Option<&str>,
) {
let Some(model_id) =
third_party_harness_model_id.filter(|id| !id.is_empty() && *id != "default")
else {
return;
};
doc[CODEX_MODEL_KEY] = toml_edit::value(model_id);

// Codex's TUI prompts the user to upgrade older models on session launch even when
// a `model` key has been pinned. Stamping a migration entry keyed on the chosen
// model id suppresses that prompt for the unattended cloud run. We do this
// unconditionally rather than enumerating a list of "old" models on the client:
// mapping the migration target to itself (e.g. `gpt-5.4 = "gpt-5.4"`) is a no-op
// for Codex, and keeping the client free of model-version knowledge means we
// don't have to ship a client update every time Anthropic/OpenAI ages out a model.
set_codex_model_migration(doc, model_id, CODEX_MODEL_MIGRATIONS_TARGET);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ [IMPORTANT] This assumes every selected model's migration notice target is gpt-5.4; Codex acknowledgements are target-specific, so older supported models with a different target can still show the prompt and block the unattended run. Use the selected model's actual target mapping or a server-driven disable/acknowledgement value instead of stamping all models to one constant.

}

fn set_codex_model_migration(
doc: &mut toml_edit::DocumentMut,
from_model_id: &str,
to_model_id: &str,
) {
if !doc.contains_table("notice") {
let mut notice_tbl = toml_edit::Table::new();
notice_tbl.set_implicit(true);
doc.insert("notice", toml_edit::Item::Table(notice_tbl));
}
let migrations_tbl = doc["notice"]
.as_table_mut()
.expect("notice table inserted above")
.entry("model_migrations")
.or_insert_with(toml_edit::table)
.as_table_mut()
.expect("model_migrations entry is a table");
migrations_tbl.set_implicit(false);
migrations_tbl[from_model_id] = toml_edit::value(to_model_id);
}

/// Return immediate subdirectories of `dir` that contain a `.git`.
fn find_child_git_repos(dir: &Path) -> Vec<std::path::PathBuf> {
let Ok(entries) = fs::read_dir(dir) else {
Expand Down
99 changes: 92 additions & 7 deletions app/src/ai/agent_sdk/driver/harness/codex_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ fn prepare_codex_config_toml_writes_fresh_config() {
let working_dir = tmp.path().join("workspace/proj");
fs::create_dir_all(&working_dir).unwrap();

prepare_codex_config_toml(&config_path, &working_dir).unwrap();
prepare_codex_config_toml(&config_path, &working_dir, None).unwrap();

let canonical = working_dir.canonicalize().unwrap();
let key = canonical.to_string_lossy().into_owned();
Expand All @@ -196,7 +196,8 @@ fn prepare_codex_config_toml_preserves_unrelated_keys() {
)
.unwrap();

prepare_codex_config_toml(&config_path, &working_dir).unwrap();
// Pass `None` for the model id so the helper preserves the user's existing `model = ...`.
prepare_codex_config_toml(&config_path, &working_dir, None).unwrap();

let canonical = working_dir.canonicalize().unwrap();
let key = canonical.to_string_lossy().into_owned();
Expand All @@ -219,9 +220,9 @@ fn prepare_codex_config_toml_is_idempotent() {
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();

prepare_codex_config_toml(&config_path, &working_dir).unwrap();
prepare_codex_config_toml(&config_path, &working_dir, None).unwrap();
let after_first = fs::read_to_string(&config_path).unwrap();
prepare_codex_config_toml(&config_path, &working_dir).unwrap();
prepare_codex_config_toml(&config_path, &working_dir, None).unwrap();
let after_second = fs::read_to_string(&config_path).unwrap();

assert_eq!(after_first, after_second);
Expand Down Expand Up @@ -250,7 +251,7 @@ fn prepare_codex_config_toml_upgrades_untrusted_entry() {
)
.unwrap();

prepare_codex_config_toml(&config_path, &working_dir).unwrap();
prepare_codex_config_toml(&config_path, &working_dir, None).unwrap();

let cfg = read_codex_config(&config_path);
assert_eq!(
Expand All @@ -269,7 +270,7 @@ fn prepare_codex_config_toml_trusts_multiple_child_repos() {
fs::create_dir_all(repo_a.join(".git")).unwrap();
fs::create_dir_all(repo_b.join(".git")).unwrap();

prepare_codex_config_toml(&config_path, &working_dir).unwrap();
prepare_codex_config_toml(&config_path, &working_dir, None).unwrap();

let cfg = read_codex_config(&config_path);
let projects = cfg["projects"].as_table().unwrap();
Expand Down Expand Up @@ -297,12 +298,96 @@ fn prepare_codex_config_toml_overwrites_stale_openai_base_url() {
)
.unwrap();

prepare_codex_config_toml(&config_path, &working_dir).unwrap();
prepare_codex_config_toml(&config_path, &working_dir, None).unwrap();

let cfg = read_codex_config(&config_path);
assert_eq!(cfg["openai_base_url"].as_str(), Some(CODEX_OPENAI_BASE_URL));
}

#[test]
fn prepare_codex_config_toml_writes_model_when_specified() {
// A non-default model id is written to the top-level `model` key so Codex pins it
// for new sessions launched from this `~/.codex/config.toml`. Even for the
// current target model, we stamp a self-referential migration entry so the
// upgrade prompt is suppressed regardless of what the user selected.
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();

prepare_codex_config_toml(&config_path, &working_dir, Some("gpt-5.5")).unwrap();

let cfg = read_codex_config(&config_path);
assert_eq!(cfg["model"].as_str(), Some("gpt-5.5"));
assert_eq!(
cfg["notice"]["model_migrations"]["gpt-5.5"].as_str(),
Some(CODEX_MODEL_MIGRATIONS_TARGET),
);
}

#[test]
fn prepare_codex_config_toml_writes_model_migration_for_older_model() {
// For an older model id, the migration entry maps it to the current target
// so Codex's "choose a newer model" prompt is suppressed at session launch.
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();

prepare_codex_config_toml(&config_path, &working_dir, Some("gpt-5.2")).unwrap();

let cfg = read_codex_config(&config_path);
assert_eq!(cfg["model"].as_str(), Some("gpt-5.2"));
assert_eq!(
cfg["notice"]["model_migrations"]["gpt-5.2"].as_str(),
Some(CODEX_MODEL_MIGRATIONS_TARGET),
);
}

#[test]
fn prepare_codex_config_toml_skips_model_for_default_sentinel() {
// The literal "default" sentinel means "let Codex pick its own default model";
// we should NOT write a `model` key (or a migration entry) in that case.
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();

prepare_codex_config_toml(&config_path, &working_dir, Some("default")).unwrap();

let cfg = read_codex_config(&config_path);
assert!(
cfg.get("model").is_none(),
"`model` should not be written for the default sentinel"
);
assert!(
cfg.get("notice").is_none(),
"`[notice]` table should not be written without a pinned model id"
);
}

#[test]
fn prepare_codex_config_toml_skips_model_when_none() {
// No model id supplied means the user didn't pick one; we should not write a
// `model` key or any `[notice.model_migrations]` entries.
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();

prepare_codex_config_toml(&config_path, &working_dir, None).unwrap();

let cfg = read_codex_config(&config_path);
assert!(
cfg.get("model").is_none(),
"`model` should not be written when no override is supplied"
);
assert!(
cfg.get("notice").is_none(),
"`[notice]` table should not be written without a pinned model id"
);
}

#[test]
fn find_child_git_repos_returns_only_repo_children() {
let tmp = TempDir::new().unwrap();
Expand Down
1 change: 1 addition & 0 deletions app/src/ai/agent_sdk/driver/harness/gemini.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ impl ThirdPartyHarness for GeminiHarness {
working_dir: &Path,
system_prompt: Option<&str>,
_secrets: &HashMap<String, ManagedSecretValue>,
_third_party_harness_model_id: Option<&str>,
) -> Result<(), AgentDriverError> {
prepare_gemini_environment_config(working_dir, system_prompt).map_err(|error| {
AgentDriverError::HarnessConfigSetupFailed {
Expand Down
7 changes: 5 additions & 2 deletions app/src/ai/agent_sdk/driver/harness/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ pub(crate) trait ThirdPartyHarness: Send + Sync {
_working_dir: &Path,
_system_prompt: Option<&str>,
_secrets: &HashMap<String, ManagedSecretValue>,
_third_party_harness_model_id: Option<&str>,
) -> Result<(), AgentDriverError> {
Ok(())
}
Expand Down Expand Up @@ -317,10 +318,12 @@ pub(crate) fn task_env_vars(
/// Returns an empty map for Oz or when no model is specified.
pub(crate) fn harness_model_env_vars(
selected_harness: Harness,
harness_model_id: Option<&str>,
third_party_harness_model_id: Option<&str>,
) -> HashMap<OsString, OsString> {
let mut env_vars = HashMap::new();
let Some(model_id) = harness_model_id.filter(|id| !id.is_empty() && *id != "default") else {
let Some(model_id) =
third_party_harness_model_id.filter(|id| !id.is_empty() && *id != "default")
else {
return env_vars;
};

Expand Down
Loading