mirror of
https://github.com/openai/codex.git
synced 2026-09-16 12:13:30 +00:00
Override session requirements.toml per thread
This commit is contained in:
@@ -63,7 +63,7 @@ Use the thread APIs to create, list, or archive conversations. Drive a conversat
|
||||
- Initialize once per connection: Immediately after opening a transport connection, send an `initialize` request with your client metadata, then emit an `initialized` notification. Any other request on that connection before this handshake gets rejected.
|
||||
- Start (or resume) a thread: Call `thread/start` to open a fresh conversation. The response returns the thread object and you’ll also get a `thread/started` notification. If you’re continuing an existing conversation, call `thread/resume` with its ID instead. If you want to branch from an existing conversation, call `thread/fork` to create a new thread id with copied history.
|
||||
The returned `thread.ephemeral` flag tells you whether the session is intentionally in-memory only; when it is `true`, `thread.path` is `null`.
|
||||
`requirementsToml` is available on `thread/start`, `thread/resume`, and `thread/fork` to apply an additional session-scoped requirements file without changing persisted config.
|
||||
`requirementsToml` is available on `thread/start`, `thread/resume`, and `thread/fork` to apply a session-scoped requirements file without changing persisted config. When the same requirement field is present in both the session file and an earlier requirement layer, the session file wins for that thread.
|
||||
- Begin a turn: To send user input, call `turn/start` with the target `threadId` and the user's input. Optional fields let you override model, cwd, sandbox policy, etc. This immediately returns the new turn object and triggers a `turn/started` notification.
|
||||
- Stream events: After `turn/start`, keep reading JSON-RPC notifications on stdout. You’ll see `item/started`, `item/completed`, deltas like `item/agentMessage/delta`, tool progress, etc. These represent streaming model output plus any side effects (commands, tool calls, reasoning notes).
|
||||
- Finish the turn: When the model is done (or the turn is interrupted via making the `turn/interrupt` call), the server sends `turn/completed` with the final turn state and token usage.
|
||||
|
||||
@@ -273,18 +273,40 @@ pub struct ConfigRequirementsWithSources {
|
||||
pub network: Option<Sourced<NetworkRequirementsToml>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum MergeBehavior {
|
||||
FillUnset,
|
||||
OverwriteExisting,
|
||||
}
|
||||
|
||||
impl ConfigRequirementsWithSources {
|
||||
pub fn merge_unset_fields(&mut self, source: RequirementSource, other: ConfigRequirementsToml) {
|
||||
// For every field in `other` that is `Some`, if the corresponding field
|
||||
// in `self` is `None`, copy the value from `other` into `self`.
|
||||
macro_rules! fill_missing_take {
|
||||
self.merge_fields(source, other, MergeBehavior::FillUnset);
|
||||
}
|
||||
|
||||
pub fn merge_overwrite_fields(
|
||||
&mut self,
|
||||
source: RequirementSource,
|
||||
other: ConfigRequirementsToml,
|
||||
) {
|
||||
self.merge_fields(source, other, MergeBehavior::OverwriteExisting);
|
||||
}
|
||||
|
||||
fn merge_fields(
|
||||
&mut self,
|
||||
source: RequirementSource,
|
||||
other: ConfigRequirementsToml,
|
||||
merge_behavior: MergeBehavior,
|
||||
) {
|
||||
macro_rules! merge_take {
|
||||
($base:expr, $other:expr, $source:expr, { $($field:ident),+ $(,)? }) => {
|
||||
// Destructure without `..` so adding fields to `ConfigRequirementsToml`
|
||||
// forces this merge logic to be updated.
|
||||
let ConfigRequirementsToml { $($field: _,)+ } = &$other;
|
||||
|
||||
$(
|
||||
if $base.$field.is_none()
|
||||
if (matches!(merge_behavior, MergeBehavior::OverwriteExisting)
|
||||
|| $base.$field.is_none())
|
||||
&& let Some(value) = $other.$field.take()
|
||||
{
|
||||
$base.$field = Some(Sourced::new(value, $source.clone()));
|
||||
@@ -294,7 +316,7 @@ impl ConfigRequirementsWithSources {
|
||||
}
|
||||
|
||||
let mut other = other;
|
||||
fill_missing_take!(
|
||||
merge_take!(
|
||||
self,
|
||||
other,
|
||||
source,
|
||||
@@ -732,6 +754,46 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_overwrite_fields_replaces_existing_values() -> Result<()> {
|
||||
let existing_source = RequirementSource::LegacyManagedConfigTomlFromMdm;
|
||||
let mut populated_target = ConfigRequirementsWithSources::default();
|
||||
let populated_requirements: ConfigRequirementsToml = from_str(
|
||||
r#"
|
||||
allowed_approval_policies = ["never"]
|
||||
"#,
|
||||
)?;
|
||||
populated_target.merge_unset_fields(existing_source, populated_requirements);
|
||||
|
||||
let source: ConfigRequirementsToml = from_str(
|
||||
r#"
|
||||
allowed_approval_policies = ["on-request"]
|
||||
"#,
|
||||
)?;
|
||||
let source_location = RequirementSource::MdmManagedPreferences {
|
||||
domain: "com.codex".to_string(),
|
||||
key: "allowed_approval_policies".to_string(),
|
||||
};
|
||||
populated_target.merge_overwrite_fields(source_location.clone(), source);
|
||||
|
||||
assert_eq!(
|
||||
populated_target,
|
||||
ConfigRequirementsWithSources {
|
||||
allowed_approval_policies: Some(Sourced::new(
|
||||
vec![AskForApproval::OnRequest],
|
||||
source_location,
|
||||
)),
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
mcp_servers: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
network: None,
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constraint_error_includes_requirement_source() -> Result<()> {
|
||||
let source: ConfigRequirementsToml = from_str(
|
||||
|
||||
@@ -77,15 +77,19 @@ pub(crate) async fn first_layer_config_error_from_entries(
|
||||
.await
|
||||
}
|
||||
|
||||
/// To build up the set of admin-enforced constraints, we build up from multiple
|
||||
/// configuration layers in the following order, but a constraint defined in an
|
||||
/// earlier layer cannot be overridden by a later layer:
|
||||
/// To build up the set of baseline constraints, we merge the managed and
|
||||
/// system-provided requirement layers in the following order, but a constraint
|
||||
/// defined in an earlier layer cannot be overridden by a later one within this
|
||||
/// baseline stack:
|
||||
///
|
||||
/// - cloud: managed cloud requirements
|
||||
/// - admin: managed preferences (*)
|
||||
/// - system `/etc/codex/requirements.toml` (Unix) or
|
||||
/// `%ProgramData%\OpenAI\Codex\requirements.toml` (Windows)
|
||||
///
|
||||
/// If a session-scoped `requirements.toml` file is provided via
|
||||
/// `LoaderOverrides`, we apply it afterward as a per-thread override layer.
|
||||
///
|
||||
/// For backwards compatibility, we also load from
|
||||
/// `managed_config.toml` and map it to `requirements.toml`.
|
||||
///
|
||||
@@ -355,6 +359,7 @@ async fn load_requirements_toml(
|
||||
config_requirements_toml,
|
||||
requirements_toml_file,
|
||||
MissingRequirementsTomlBehavior::Ignore,
|
||||
RequirementsMergeBehavior::FillUnset,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -367,6 +372,7 @@ async fn load_session_requirements_toml(
|
||||
config_requirements_toml,
|
||||
requirements_toml_file,
|
||||
MissingRequirementsTomlBehavior::Error,
|
||||
RequirementsMergeBehavior::OverwriteExisting,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -377,10 +383,17 @@ enum MissingRequirementsTomlBehavior {
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum RequirementsMergeBehavior {
|
||||
FillUnset,
|
||||
OverwriteExisting,
|
||||
}
|
||||
|
||||
async fn load_requirements_toml_with_source(
|
||||
config_requirements_toml: &mut ConfigRequirementsWithSources,
|
||||
requirements_toml_file: impl AsRef<Path>,
|
||||
missing_behavior: MissingRequirementsTomlBehavior,
|
||||
merge_behavior: RequirementsMergeBehavior,
|
||||
) -> io::Result<()> {
|
||||
let requirements_toml_file =
|
||||
AbsolutePathBuf::from_absolute_path(requirements_toml_file.as_ref())?;
|
||||
@@ -396,12 +409,17 @@ async fn load_requirements_toml_with_source(
|
||||
),
|
||||
)
|
||||
})?;
|
||||
config_requirements_toml.merge_unset_fields(
|
||||
RequirementSource::SystemRequirementsToml {
|
||||
file: requirements_toml_file.clone(),
|
||||
},
|
||||
requirements_config,
|
||||
);
|
||||
let source = RequirementSource::SystemRequirementsToml {
|
||||
file: requirements_toml_file.clone(),
|
||||
};
|
||||
match merge_behavior {
|
||||
RequirementsMergeBehavior::FillUnset => {
|
||||
config_requirements_toml.merge_unset_fields(source, requirements_config);
|
||||
}
|
||||
RequirementsMergeBehavior::OverwriteExisting => {
|
||||
config_requirements_toml.merge_overwrite_fields(source, requirements_config);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if e.kind() == io::ErrorKind::NotFound
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::config_loader::ConfigRequirementsToml;
|
||||
use crate::config_loader::ConfigRequirementsWithSources;
|
||||
use crate::config_loader::RequirementSource;
|
||||
use crate::config_loader::load_requirements_toml;
|
||||
use crate::config_loader::load_session_requirements_toml;
|
||||
use crate::config_loader::version_for_toml;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
@@ -596,6 +597,53 @@ allowed_approval_policies = ["never"]
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn session_requirements_toml_from_loader_overrides_overwrites_existing_requirements()
|
||||
-> anyhow::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let requirements_file = tmp.path().join("session-requirements.toml");
|
||||
tokio::fs::write(
|
||||
&requirements_file,
|
||||
r#"
|
||||
allowed_approval_policies = ["on-request"]
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let layers = load_config_layers_state(
|
||||
tmp.path(),
|
||||
Some(AbsolutePathBuf::try_from(tmp.path())?),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides {
|
||||
requirements_toml_file: Some(requirements_file.clone()),
|
||||
..LoaderOverrides::default()
|
||||
},
|
||||
CloudRequirementsLoader::new(async {
|
||||
Ok(Some(ConfigRequirementsToml {
|
||||
allowed_approval_policies: Some(vec![AskForApproval::Never]),
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
mcp_servers: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
network: None,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
layers.requirements_toml().allowed_approval_policies,
|
||||
Some(vec![AskForApproval::OnRequest])
|
||||
);
|
||||
assert_eq!(
|
||||
layers.requirements().approval_policy.value(),
|
||||
AskForApproval::OnRequest
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn missing_session_requirements_toml_from_loader_overrides_errors() -> anyhow::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
@@ -724,6 +772,53 @@ allowed_approval_policies = ["on-request"]
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn load_session_requirements_toml_overwrites_existing_values() -> anyhow::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let requirements_file = tmp.path().join("requirements.toml");
|
||||
tokio::fs::write(
|
||||
&requirements_file,
|
||||
r#"
|
||||
allowed_approval_policies = ["on-request"]
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut config_requirements_toml = ConfigRequirementsWithSources::default();
|
||||
config_requirements_toml.merge_unset_fields(
|
||||
RequirementSource::CloudRequirements,
|
||||
ConfigRequirementsToml {
|
||||
allowed_approval_policies: Some(vec![AskForApproval::Never]),
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
mcp_servers: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
network: None,
|
||||
},
|
||||
);
|
||||
load_session_requirements_toml(&mut config_requirements_toml, &requirements_file).await?;
|
||||
|
||||
assert_eq!(
|
||||
config_requirements_toml
|
||||
.allowed_approval_policies
|
||||
.as_ref()
|
||||
.map(|sourced| sourced.value.clone()),
|
||||
Some(vec![AskForApproval::OnRequest])
|
||||
);
|
||||
assert_eq!(
|
||||
config_requirements_toml
|
||||
.allowed_approval_policies
|
||||
.as_ref()
|
||||
.map(|sourced| sourced.source.clone()),
|
||||
Some(RequirementSource::SystemRequirementsToml {
|
||||
file: AbsolutePathBuf::from_absolute_path(requirements_file.as_path())?,
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
|
||||
@@ -118,7 +118,7 @@ const thread = codex.startThread({
|
||||
|
||||
### Session-scoped requirements
|
||||
|
||||
Use `requirementsToml` to apply an additional `requirements.toml` file to a single SDK thread without modifying saved Codex config.
|
||||
Use `requirementsToml` to apply a `requirements.toml` file to a single SDK thread without modifying saved Codex config. When the same requirement field is present in both this file and an earlier requirement layer, the session file wins for that thread.
|
||||
|
||||
```typescript
|
||||
const thread = codex.startThread({
|
||||
|
||||
Reference in New Issue
Block a user