From 7d6cb71ec01d3cc8b3c2c56a6b8f4c8c80b3f0a9 Mon Sep 17 00:00:00 2001 From: MJR Date: Fri, 27 Feb 2026 08:43:17 -0500 Subject: [PATCH] add requirements.toml commandline option --- codex-rs/README.md | 10 +++ .../schema/json/ClientRequest.json | 18 +++++ .../codex_app_server_protocol.schemas.json | 18 +++++ .../schema/json/v2/ThreadForkParams.json | 6 ++ .../schema/json/v2/ThreadResumeParams.json | 6 ++ .../schema/json/v2/ThreadStartParams.json | 6 ++ .../typescript/NewConversationParams.ts | 2 +- .../schema/typescript/v2/ThreadForkParams.ts | 2 +- .../typescript/v2/ThreadResumeParams.ts | 2 +- .../schema/typescript/v2/ThreadStartParams.ts | 2 +- .../src/protocol/common.rs | 2 + .../app-server-protocol/src/protocol/v1.rs | 1 + .../app-server-protocol/src/protocol/v2.rs | 6 ++ codex-rs/app-server/README.md | 12 ++- .../app-server/src/codex_message_processor.rs | 76 +++++++++++++++++-- codex-rs/app-server/src/message_processor.rs | 1 + .../app-server/tests/suite/v2/thread_fork.rs | 55 +++++++++++++- .../tests/suite/v2/thread_resume.rs | 55 +++++++++++++- .../app-server/tests/suite/v2/thread_start.rs | 47 +++++++++++- .../tests/suite/v2/turn_interrupt.rs | 15 ++-- codex-rs/cli/src/main.rs | 43 +++++++++++ codex-rs/config/src/state.rs | 1 + codex-rs/core/src/config/mod.rs | 19 ++++- codex-rs/core/src/config/service.rs | 5 ++ codex-rs/core/src/config_loader/mod.rs | 45 ++++++++++- codex-rs/core/src/config_loader/tests.rs | 69 +++++++++++++++++ codex-rs/docs/codex_mcp_interface.md | 3 +- codex-rs/exec/src/cli.rs | 21 +++++ codex-rs/exec/src/lib.rs | 12 ++- codex-rs/tui/src/app.rs | 7 ++ codex-rs/tui/src/cli.rs | 4 + codex-rs/tui/src/lib.rs | 29 ++++++- sdk/typescript/README.md | 10 +++ sdk/typescript/src/exec.ts | 6 ++ sdk/typescript/src/thread.ts | 1 + sdk/typescript/src/threadOptions.ts | 1 + sdk/typescript/tests/run.test.ts | 63 +++++++++++++++ 37 files changed, 646 insertions(+), 35 deletions(-) diff --git a/codex-rs/README.md b/codex-rs/README.md index 1c93852fe2..4562e8ad93 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -89,6 +89,16 @@ codex --sandbox danger-full-access The same setting can be persisted in `~/.codex/config.toml` via the top-level `sandbox_mode = "MODE"` key, e.g. `sandbox_mode = "workspace-write"`. +### Session-scoped requirements + +To apply an additional `requirements.toml` file to just the current interactive session, pass: + +```shell +codex --requirements-toml /path/to/requirements.toml +``` + +This only affects the current session and does not modify saved config. + ## Code Organization This folder is the root of a Cargo workspace. It contains quite a bit of experimental code, but here are the key crates: diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 03e42bed45..c2fad509a2 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1923,6 +1923,12 @@ "null" ] }, + "requirementsToml": { + "type": [ + "string", + "null" + ] + }, "sandbox": { "anyOf": [ { @@ -2145,6 +2151,12 @@ } ] }, + "requirementsToml": { + "type": [ + "string", + "null" + ] + }, "sandbox": { "anyOf": [ { @@ -2284,6 +2296,12 @@ } ] }, + "requirementsToml": { + "type": [ + "string", + "null" + ] + }, "sandbox": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index edee815e08..e637871f9c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -12713,6 +12713,12 @@ "null" ] }, + "requirementsToml": { + "type": [ + "string", + "null" + ] + }, "sandbox": { "anyOf": [ { @@ -13696,6 +13702,12 @@ } ] }, + "requirementsToml": { + "type": [ + "string", + "null" + ] + }, "sandbox": { "anyOf": [ { @@ -13907,6 +13919,12 @@ } ] }, + "requirementsToml": { + "type": [ + "string", + "null" + ] + }, "sandbox": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index 2b832d5bf1..5fb7ed0e55 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -102,6 +102,12 @@ "null" ] }, + "requirementsToml": { + "type": [ + "string", + "null" + ] + }, "sandbox": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index ef7607d3ca..3c11030715 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -900,6 +900,12 @@ } ] }, + "requirementsToml": { + "type": [ + "string", + "null" + ] + }, "sandbox": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index 5f2dda11a1..f8a11749b8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -141,6 +141,12 @@ } ] }, + "requirementsToml": { + "type": [ + "string", + "null" + ] + }, "sandbox": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/NewConversationParams.ts b/codex-rs/app-server-protocol/schema/typescript/NewConversationParams.ts index e1113c27e2..7aa30e0a4d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/NewConversationParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/NewConversationParams.ts @@ -5,4 +5,4 @@ import type { AskForApproval } from "./AskForApproval"; import type { SandboxMode } from "./SandboxMode"; import type { JsonValue } from "./serde_json/JsonValue"; -export type NewConversationParams = { model: string | null, modelProvider: string | null, profile: string | null, cwd: string | null, approvalPolicy: AskForApproval | null, sandbox: SandboxMode | null, config: { [key in string]?: JsonValue } | null, baseInstructions: string | null, developerInstructions: string | null, compactPrompt: string | null, includeApplyPatchTool: boolean | null, }; +export type NewConversationParams = { model: string | null, modelProvider: string | null, profile: string | null, requirementsToml: string | null, cwd: string | null, approvalPolicy: AskForApproval | null, sandbox: SandboxMode | null, config: { [key in string]?: JsonValue } | null, baseInstructions: string | null, developerInstructions: string | null, compactPrompt: string | null, includeApplyPatchTool: boolean | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts index 742e4d7032..2387ebc047 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts @@ -21,7 +21,7 @@ export type ThreadForkParams = {threadId: string, /** path?: string | null, /** * Configuration overrides for the forked thread, if any. */ -model?: string | null, modelProvider?: string | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, /** +model?: string | null, modelProvider?: string | null, requirementsToml?: string | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, /** * If true, persist additional rollout EventMsg variants required to * reconstruct a richer thread history on subsequent resume/fork/read. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts index e79c748cc7..31615ac033 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts @@ -30,7 +30,7 @@ history?: Array | null, /** path?: string | null, /** * Configuration overrides for the resumed thread, if any. */ -model?: string | null, modelProvider?: string | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, /** +model?: string | null, modelProvider?: string | null, requirementsToml?: string | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, /** * If true, persist additional rollout EventMsg variants required to * reconstruct a richer thread history on subsequent resume/fork/read. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts index 5f34f6198c..e7ec558389 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts @@ -6,7 +6,7 @@ import type { JsonValue } from "../serde_json/JsonValue"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxMode } from "./SandboxMode"; -export type ThreadStartParams = {model?: string | null, modelProvider?: string | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, serviceName?: string | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, ephemeral?: boolean | null, /** +export type ThreadStartParams = {model?: string | null, modelProvider?: string | null, requirementsToml?: string | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, serviceName?: string | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, ephemeral?: boolean | null, /** * If true, opt into emitting raw Responses API items on the event stream. * This is for internal use only (e.g. Codex Cloud). */ diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index f63ebd37a2..5071fa1fb6 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -919,6 +919,7 @@ mod tests { model: Some("gpt-5.1-codex-max".to_string()), model_provider: None, profile: None, + requirements_toml: None, cwd: None, approval_policy: Some(AskForApproval::OnRequest), sandbox: None, @@ -937,6 +938,7 @@ mod tests { "model": "gpt-5.1-codex-max", "modelProvider": null, "profile": null, + "requirementsToml": null, "cwd": null, "approvalPolicy": "on-request", "sandbox": null, diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 719268e9c5..1e145598ad 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -70,6 +70,7 @@ pub struct NewConversationParams { pub model: Option, pub model_provider: Option, pub profile: Option, + pub requirements_toml: Option, pub cwd: Option, pub approval_policy: Option, pub sandbox: Option, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index bde0e17fa3..d9404d789c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1789,6 +1789,8 @@ pub struct ThreadStartParams { #[ts(optional = nullable)] pub model_provider: Option, #[ts(optional = nullable)] + pub requirements_toml: Option, + #[ts(optional = nullable)] pub cwd: Option, #[ts(optional = nullable)] pub approval_policy: Option, @@ -1892,6 +1894,8 @@ pub struct ThreadResumeParams { #[ts(optional = nullable)] pub model_provider: Option, #[ts(optional = nullable)] + pub requirements_toml: Option, + #[ts(optional = nullable)] pub cwd: Option, #[ts(optional = nullable)] pub approval_policy: Option, @@ -1952,6 +1956,8 @@ pub struct ThreadForkParams { #[ts(optional = nullable)] pub model_provider: Option, #[ts(optional = nullable)] + pub requirements_toml: Option, + #[ts(optional = nullable)] pub cwd: Option, #[ts(optional = nullable)] pub approval_policy: Option, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 64d0625c8d..b84bc04582 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -63,6 +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. - 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. @@ -175,6 +176,7 @@ Start a fresh thread when you need a new Codex conversation. // current config settings. "model": "gpt-5.1-codex", "cwd": "/Users/me/project", + "requirementsToml": "/Users/me/policies/session-requirements.toml", "approvalPolicy": "never", "sandbox": "workspaceWrite", "personality": "friendly", @@ -207,12 +209,13 @@ Start a fresh thread when you need a new Codex conversation. Valid `personality` values are `"friendly"`, `"pragmatic"`, and `"none"`. When `"none"` is selected, the personality placeholder is replaced with an empty string. -To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`, and no additional notifications are emitted. You can also pass the same configuration overrides supported by `thread/start`, such as `personality`: +To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`, and no additional notifications are emitted. You can also pass the same configuration overrides supported by `thread/start`, such as `personality` or `requirementsToml`: ```json { "method": "thread/resume", "id": 11, "params": { "threadId": "thr_123", - "personality": "friendly" + "personality": "friendly", + "requirementsToml": "/Users/me/policies/session-requirements.toml" } } { "id": 11, "result": { "thread": { "id": "thr_123", … } } } ``` @@ -220,7 +223,10 @@ To continue a stored session, call `thread/resume` with the `thread.id` you prev To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it: ```json -{ "method": "thread/fork", "id": 12, "params": { "threadId": "thr_123" } } +{ "method": "thread/fork", "id": 12, "params": { + "threadId": "thr_123", + "requirementsToml": "/Users/me/policies/session-requirements.toml" +} } { "id": 12, "result": { "thread": { "id": "thr_456", … } } } { "method": "thread/started", "params": { "thread": { … } } } ``` diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e65f7e888f..dc41ebc4d1 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -211,6 +211,7 @@ use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config::types::McpServerTransportConfig; use codex_core::config_loader::CloudRequirementsLoader; +use codex_core::config_loader::LoaderOverrides; use codex_core::default_client::get_codex_user_agent; use codex_core::default_client::set_default_client_residency_requirement; use codex_core::error::CodexErr; @@ -376,6 +377,7 @@ pub(crate) struct CodexMessageProcessor { config: Arc, single_client_mode: bool, cli_overrides: Vec<(String, TomlValue)>, + loader_overrides: LoaderOverrides, cloud_requirements: Arc>, active_login: Arc>>, pending_thread_unloads: Arc>>, @@ -417,6 +419,7 @@ pub(crate) struct CodexMessageProcessorArgs { pub(crate) arg0_paths: Arg0DispatchPaths, pub(crate) config: Arc, pub(crate) cli_overrides: Vec<(String, TomlValue)>, + pub(crate) loader_overrides: LoaderOverrides, pub(crate) cloud_requirements: Arc>, pub(crate) single_client_mode: bool, pub(crate) feedback: CodexFeedback, @@ -462,6 +465,7 @@ impl CodexMessageProcessor { arg0_paths, config, cli_overrides, + loader_overrides, cloud_requirements, single_client_mode, feedback, @@ -474,6 +478,7 @@ impl CodexMessageProcessor { config, single_client_mode, cli_overrides, + loader_overrides, cloud_requirements, active_login: Arc::new(Mutex::new(None)), pending_thread_unloads: Arc::new(Mutex::new(HashSet::new())), @@ -489,6 +494,8 @@ impl CodexMessageProcessor { let cloud_requirements = self.current_cloud_requirements(); let mut config = codex_core::config::ConfigBuilder::default() .cli_overrides(self.cli_overrides.clone()) + .loader_overrides(self.loader_overrides.clone()) + .fallback_cwd(Some(self.config.codex_home.clone())) .cloud_requirements(cloud_requirements) .build() .await @@ -509,6 +516,12 @@ impl CodexMessageProcessor { .unwrap_or_default() } + fn loader_overrides_for_session(&self, requirements_toml: Option) -> LoaderOverrides { + let mut loader_overrides = self.loader_overrides.clone(); + loader_overrides.requirements_toml_file = requirements_toml.map(PathBuf::from); + loader_overrides + } + /// If a client sends `developer_instructions: null` during a mode switch, /// use the built-in instructions for that mode. fn normalize_turn_start_collaboration_mode( @@ -1134,6 +1147,7 @@ impl CodexMessageProcessor { let chatgpt_base_url = self.config.chatgpt_base_url.clone(); let codex_home = self.config.codex_home.clone(); let cli_overrides = self.cli_overrides.clone(); + let loader_overrides = self.loader_overrides.clone(); let auth_url = server.auth_url.clone(); tokio::spawn(async move { let (success, error_msg) = match tokio::time::timeout( @@ -1167,10 +1181,12 @@ impl CodexMessageProcessor { cloud_requirements.as_ref(), auth_manager.clone(), chatgpt_base_url, - codex_home, + codex_home.clone(), ); sync_default_client_residency_requirement( &cli_overrides, + &codex_home, + &loader_overrides, cloud_requirements.as_ref(), ) .await; @@ -1242,6 +1258,7 @@ impl CodexMessageProcessor { let chatgpt_base_url = self.config.chatgpt_base_url.clone(); let codex_home = self.config.codex_home.clone(); let cli_overrides = self.cli_overrides.clone(); + let loader_overrides = self.loader_overrides.clone(); let auth_url = server.auth_url.clone(); tokio::spawn(async move { let (success, error_msg) = match tokio::time::timeout( @@ -1275,10 +1292,12 @@ impl CodexMessageProcessor { cloud_requirements.as_ref(), auth_manager.clone(), chatgpt_base_url, - codex_home, + codex_home.clone(), ); sync_default_client_residency_requirement( &cli_overrides, + &codex_home, + &loader_overrides, cloud_requirements.as_ref(), ) .await; @@ -1449,6 +1468,8 @@ impl CodexMessageProcessor { ); sync_default_client_residency_requirement( &self.cli_overrides, + &self.config.codex_home, + &self.loader_overrides, self.cloud_requirements.as_ref(), ) .await; @@ -1939,6 +1960,7 @@ impl CodexMessageProcessor { model, model_provider, profile, + requirements_toml, cwd, approval_policy, sandbox: sandbox_mode, @@ -1989,6 +2011,7 @@ impl CodexMessageProcessor { &self.cli_overrides, Some(request_overrides), typesafe_overrides, + self.loader_overrides_for_session(requirements_toml), &cloud_requirements, ) .await @@ -2047,6 +2070,7 @@ impl CodexMessageProcessor { let ThreadStartParams { model, model_provider, + requirements_toml, cwd, approval_policy, sandbox, @@ -2073,6 +2097,7 @@ impl CodexMessageProcessor { ); typesafe_overrides.ephemeral = ephemeral; let cli_overrides = self.cli_overrides.clone(); + let loader_overrides = self.loader_overrides_for_session(requirements_toml); let cloud_requirements = self.current_cloud_requirements(); let listener_task_context = ListenerTaskContext { thread_manager: Arc::clone(&self.thread_manager), @@ -2088,6 +2113,7 @@ impl CodexMessageProcessor { Self::thread_start_task( listener_task_context, cli_overrides, + loader_overrides, cloud_requirements, request_id, config, @@ -2105,6 +2131,7 @@ impl CodexMessageProcessor { async fn thread_start_task( listener_task_context: ListenerTaskContext, cli_overrides: Vec<(String, TomlValue)>, + loader_overrides: LoaderOverrides, cloud_requirements: CloudRequirementsLoader, request_id: ConnectionRequestId, config_overrides: Option>, @@ -2118,6 +2145,7 @@ impl CodexMessageProcessor { &cli_overrides, config_overrides, typesafe_overrides, + loader_overrides, &cloud_requirements, ) .await @@ -3023,6 +3051,7 @@ impl CodexMessageProcessor { path, model, model_provider, + requirements_toml, cwd, approval_policy, sandbox, @@ -3070,6 +3099,7 @@ impl CodexMessageProcessor { request_overrides, typesafe_overrides, history_cwd, + self.loader_overrides_for_session(requirements_toml), &cloud_requirements, ) .await @@ -3190,6 +3220,16 @@ impl CodexMessageProcessor { .await; return true; } + if params.requirements_toml.is_some() { + self.send_invalid_request_error( + request_id, + format!( + "cannot resume running thread {existing_thread_id} with requirementsToml while it is already running" + ), + ) + .await; + return true; + } let rollout_path = if let Some(path) = existing_thread.rollout_path() { if path.exists() { @@ -3460,6 +3500,7 @@ impl CodexMessageProcessor { path, model, model_provider, + requirements_toml, cwd, approval_policy, sandbox, @@ -3554,6 +3595,7 @@ impl CodexMessageProcessor { request_overrides, typesafe_overrides, history_cwd, + self.loader_overrides_for_session(requirements_toml), &cloud_requirements, ) .await @@ -4478,12 +4520,13 @@ impl CodexMessageProcessor { }; let history_cwd = thread_history.session_cwd(); - let (typesafe_overrides, request_overrides) = match overrides { + let (typesafe_overrides, request_overrides, loader_overrides) = match overrides { Some(overrides) => { let NewConversationParams { model, model_provider, profile, + requirements_toml, cwd, approval_policy, sandbox: sandbox_mode, @@ -4529,7 +4572,11 @@ impl CodexMessageProcessor { include_apply_patch_tool, ..Default::default() }; - (typesafe_overrides, Some(request_overrides)) + ( + typesafe_overrides, + Some(request_overrides), + self.loader_overrides_for_session(requirements_toml), + ) } None => ( ConfigOverrides { @@ -4538,6 +4585,7 @@ impl CodexMessageProcessor { ..Default::default() }, None, + self.loader_overrides.clone(), ), }; @@ -4547,6 +4595,7 @@ impl CodexMessageProcessor { request_overrides, typesafe_overrides, history_cwd, + loader_overrides, &cloud_requirements, ) .await @@ -4670,12 +4719,13 @@ impl CodexMessageProcessor { read_history_cwd_from_state_db(&self.config, source_thread_id, rollout_path.as_path()) .await; - let (typesafe_overrides, request_overrides) = match overrides { + let (typesafe_overrides, request_overrides, loader_overrides) = match overrides { Some(overrides) => { let NewConversationParams { model, model_provider, profile, + requirements_toml, cwd, approval_policy, sandbox: sandbox_mode, @@ -4727,7 +4777,11 @@ impl CodexMessageProcessor { ..Default::default() }; - (overrides, request_overrides) + ( + overrides, + request_overrides, + self.loader_overrides_for_session(requirements_toml), + ) } None => ( ConfigOverrides { @@ -4736,6 +4790,7 @@ impl CodexMessageProcessor { ..Default::default() }, None, + self.loader_overrides.clone(), ), }; @@ -4745,6 +4800,7 @@ impl CodexMessageProcessor { request_overrides, typesafe_overrides, history_cwd, + loader_overrides, &cloud_requirements, ) .await @@ -7426,6 +7482,8 @@ fn replace_cloud_requirements_loader( async fn sync_default_client_residency_requirement( cli_overrides: &[(String, TomlValue)], + codex_home: &Path, + loader_overrides: &LoaderOverrides, cloud_requirements: &RwLock, ) { let loader = cloud_requirements @@ -7434,6 +7492,8 @@ async fn sync_default_client_residency_requirement( .unwrap_or_default(); match codex_core::config::ConfigBuilder::default() .cli_overrides(cli_overrides.to_vec()) + .loader_overrides(loader_overrides.clone()) + .fallback_cwd(Some(codex_home.to_path_buf())) .cloud_requirements(loader) .build() .await @@ -7460,6 +7520,7 @@ async fn derive_config_from_params( cli_overrides: &[(String, TomlValue)], request_overrides: Option>, typesafe_overrides: ConfigOverrides, + loader_overrides: LoaderOverrides, cloud_requirements: &CloudRequirementsLoader, ) -> std::io::Result { let merged_cli_overrides = cli_overrides @@ -7476,6 +7537,7 @@ async fn derive_config_from_params( codex_core::config::ConfigBuilder::default() .cli_overrides(merged_cli_overrides) .harness_overrides(typesafe_overrides) + .loader_overrides(loader_overrides) .cloud_requirements(cloud_requirements.clone()) .build() .await @@ -7486,6 +7548,7 @@ async fn derive_config_for_cwd( request_overrides: Option>, typesafe_overrides: ConfigOverrides, cwd: Option, + loader_overrides: LoaderOverrides, cloud_requirements: &CloudRequirementsLoader, ) -> std::io::Result { let merged_cli_overrides = cli_overrides @@ -7502,6 +7565,7 @@ async fn derive_config_for_cwd( codex_core::config::ConfigBuilder::default() .cli_overrides(merged_cli_overrides) .harness_overrides(typesafe_overrides) + .loader_overrides(loader_overrides) .fallback_cwd(cwd) .cloud_requirements(cloud_requirements.clone()) .build() diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 2c3034aa1a..cc66185a06 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -198,6 +198,7 @@ impl MessageProcessor { arg0_paths, config: Arc::clone(&config), cli_overrides: cli_overrides.clone(), + loader_overrides: loader_overrides.clone(), cloud_requirements: cloud_requirements.clone(), single_client_mode, feedback, diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index a6f9942171..ce21f11b4b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -3,6 +3,7 @@ use app_test_support::McpProcess; use app_test_support::create_fake_rollout; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; +use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; @@ -191,15 +192,67 @@ async fn thread_fork_rejects_unmaterialized_thread() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_fork_applies_requirements_toml_per_session() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml_with_approval_policy(codex_home.path(), &server.uri(), "on-request")?; + + let requirements_toml = codex_home.path().join("session-requirements.toml"); + std::fs::write( + &requirements_toml, + "allowed_approval_policies = [\"never\"]\n", + )?; + + let conversation_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some("mock_provider"), + None, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: conversation_id, + requirements_toml: Some(requirements_toml.display().to_string()), + ..Default::default() + }) + .await?; + let fork_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(fork_id)), + ) + .await??; + let ThreadForkResponse { + approval_policy, .. + } = to_response::(fork_resp)?; + + assert_eq!(approval_policy, AskForApproval::Never); + Ok(()) +} + // Helper to create a config.toml pointing at the mock model server. fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + create_config_toml_with_approval_policy(codex_home, server_uri, "never") +} + +fn create_config_toml_with_approval_policy( + codex_home: &Path, + server_uri: &str, + approval_policy: &str, +) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); std::fs::write( config_toml, format!( r#" model = "mock-model" -approval_policy = "never" +approval_policy = "{approval_policy}" sandbox_mode = "read-only" model_provider = "mock_provider" diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 7ae56761c1..7e11dd6023 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -173,6 +173,51 @@ async fn thread_resume_returns_rollout_history() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_resume_applies_requirements_toml_per_session() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml_with_approval_policy(codex_home.path(), &server.uri(), "on-request")?; + + let requirements_toml = codex_home.path().join("session-requirements.toml"); + std::fs::write( + &requirements_toml, + "allowed_approval_policies = [\"never\"]\n", + )?; + + let conversation_id = create_fake_rollout_with_text_elements( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Vec::new(), + Some("mock_provider"), + None, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: conversation_id, + requirements_toml: Some(requirements_toml.display().to_string()), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + approval_policy, .. + } = to_response::(resume_resp)?; + + assert_eq!(approval_policy, AskForApproval::Never); + Ok(()) +} + #[tokio::test] async fn thread_resume_without_overrides_does_not_change_updated_at_or_mtime() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -1361,13 +1406,21 @@ async fn thread_resume_accepts_personality_override() -> Result<()> { // Helper to create a config.toml pointing at the mock model server. fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { + create_config_toml_with_approval_policy(codex_home, server_uri, "never") +} + +fn create_config_toml_with_approval_policy( + codex_home: &std::path::Path, + server_uri: &str, + approval_policy: &str, +) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); std::fs::write( config_toml, format!( r#" model = "gpt-5.2-codex" -approval_policy = "never" +approval_policy = "{approval_policy}" sandbox_mode = "read-only" model_provider = "mock_provider" diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 7871f58351..ede685a846 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -2,6 +2,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; +use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; @@ -190,6 +191,42 @@ async fn thread_start_accepts_metrics_service_name() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_start_applies_requirements_toml_per_session() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_with_approval_policy(codex_home.path(), &server.uri(), "on-request")?; + + let requirements_toml = codex_home.path().join("session-requirements.toml"); + std::fs::write( + &requirements_toml, + "allowed_approval_policies = [\"never\"]\n", + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + requirements_toml: Some(requirements_toml.display().to_string()), + ..Default::default() + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let ThreadStartResponse { + approval_policy, .. + } = to_response::(resp)?; + + assert_eq!(approval_policy, AskForApproval::Never); + Ok(()) +} + #[tokio::test] async fn thread_start_ephemeral_remains_pathless() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -273,13 +310,21 @@ async fn thread_start_fails_when_required_mcp_server_fails_to_initialize() -> Re // Helper to create a config.toml pointing at the mock model server. fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + create_config_toml_with_approval_policy(codex_home, server_uri, "never") +} + +fn create_config_toml_with_approval_policy( + codex_home: &Path, + server_uri: &str, + approval_policy: &str, +) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); std::fs::write( config_toml, format!( r#" model = "mock-model" -approval_policy = "never" +approval_policy = "{approval_policy}" sandbox_mode = "read-only" model_provider = "mock_provider" diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 8f944dca07..62291f4791 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -124,14 +124,11 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { #[tokio::test] async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<()> { - #[cfg(target_os = "windows")] let shell_command = vec![ - "powershell".to_string(), - "-Command".to_string(), - "Start-Sleep -Seconds 10".to_string(), + "python3".to_string(), + "-c".to_string(), + "print(42)".to_string(), ]; - #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); @@ -143,7 +140,7 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() shell_command.clone(), Some(&working_directory), Some(10_000), - "call_sleep_approval", + "call_python_approval", )?]) .await; create_config_toml(&codex_home, &server.uri(), "untrusted")?; @@ -168,7 +165,7 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), input: vec![V2UserInput::Text { - text: "run sleep".to_string(), + text: "run python".to_string(), text_elements: Vec::new(), }], cwd: Some(working_directory), @@ -190,7 +187,7 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() let ServerRequest::CommandExecutionRequestApproval { request_id, params } = request else { panic!("expected CommandExecutionRequestApproval request"); }; - assert_eq!(params.item_id, "call_sleep_approval"); + assert_eq!(params.item_id, "call_python_approval"); assert_eq!(params.thread_id, thread.id); assert_eq!(params.turn_id, turn.id); diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index f818b743e9..26f4b784da 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1070,6 +1070,9 @@ fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) if let Some(cwd) = subcommand_cli.cwd { interactive.cwd = Some(cwd); } + if let Some(requirements_toml) = subcommand_cli.requirements_toml { + interactive.requirements_toml = Some(requirements_toml); + } if subcommand_cli.web_search { interactive.web_search = true; } @@ -1379,6 +1382,26 @@ mod tests { assert_eq!(interactive.resume_session_id.as_deref(), Some("sid")); } + #[test] + fn resume_subcommand_requirements_toml_overrides_root_value() { + let interactive = finalize_resume_from_args( + [ + "codex", + "--requirements-toml", + "/tmp/root.toml", + "resume", + "--requirements-toml", + "/tmp/resume.toml", + ] + .as_ref(), + ); + + assert_eq!( + interactive.requirements_toml.as_deref(), + Some(std::path::Path::new("/tmp/resume.toml")) + ); + } + #[test] fn resume_merges_dangerously_bypass_flag() { let interactive = finalize_resume_from_args( @@ -1429,6 +1452,26 @@ mod tests { assert!(interactive.fork_show_all); } + #[test] + fn fork_subcommand_requirements_toml_overrides_root_value() { + let interactive = finalize_fork_from_args( + [ + "codex", + "--requirements-toml", + "/tmp/root.toml", + "fork", + "--requirements-toml", + "/tmp/fork.toml", + ] + .as_ref(), + ); + + assert_eq!( + interactive.requirements_toml.as_deref(), + Some(std::path::Path::new("/tmp/fork.toml")) + ); + } + #[test] fn app_server_analytics_default_disabled_without_flag() { let app_server = app_server_from_args(["codex", "app-server"].as_ref()); diff --git a/codex-rs/config/src/state.rs b/codex-rs/config/src/state.rs index 98769f44b2..cde4113027 100644 --- a/codex-rs/config/src/state.rs +++ b/codex-rs/config/src/state.rs @@ -17,6 +17,7 @@ use toml::Value as TomlValue; #[derive(Debug, Default, Clone)] pub struct LoaderOverrides { pub managed_config_path: Option, + pub requirements_toml_file: Option, //TODO(gt): Add a macos_ prefix to this field and remove the target_os check. #[cfg(target_os = "macos")] pub managed_preferences_base64: Option, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index b5520a2f08..1f6ab9b64e 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -665,12 +665,27 @@ pub async fn load_config_as_toml_with_cli_overrides( codex_home: &Path, cwd: &AbsolutePathBuf, cli_overrides: Vec<(String, TomlValue)>, +) -> std::io::Result { + load_config_as_toml_with_cli_overrides_and_loader_overrides( + codex_home, + cwd, + cli_overrides, + LoaderOverrides::default(), + ) + .await +} + +pub async fn load_config_as_toml_with_cli_overrides_and_loader_overrides( + codex_home: &Path, + cwd: &AbsolutePathBuf, + cli_overrides: Vec<(String, TomlValue)>, + loader_overrides: LoaderOverrides, ) -> std::io::Result { let config_layer_stack = load_config_layers_state( codex_home, Some(cwd.clone()), &cli_overrides, - LoaderOverrides::default(), + loader_overrides, CloudRequirementsLoader::default(), ) .await?; @@ -3488,6 +3503,7 @@ profile = "project" let overrides = LoaderOverrides { managed_config_path: Some(managed_path.clone()), + requirements_toml_file: None, #[cfg(target_os = "macos")] managed_preferences_base64: None, macos_managed_config_requirements_base64: None, @@ -3619,6 +3635,7 @@ profile = "project" let overrides = LoaderOverrides { managed_config_path: Some(managed_path), + requirements_toml_file: None, #[cfg(target_os = "macos")] managed_preferences_base64: None, macos_managed_config_requirements_base64: None, diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index da675d2389..60cccb52c8 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -880,6 +880,7 @@ personality = true vec![], LoaderOverrides { managed_config_path: Some(managed_path.clone()), + requirements_toml_file: None, #[cfg(target_os = "macos")] managed_preferences_base64: None, macos_managed_config_requirements_base64: None, @@ -955,6 +956,7 @@ personality = true vec![], LoaderOverrides { managed_config_path: Some(managed_path.clone()), + requirements_toml_file: None, #[cfg(target_os = "macos")] managed_preferences_base64: None, macos_managed_config_requirements_base64: None, @@ -1060,6 +1062,7 @@ personality = true vec![], LoaderOverrides { managed_config_path: Some(managed_path.clone()), + requirements_toml_file: None, #[cfg(target_os = "macos")] managed_preferences_base64: None, macos_managed_config_requirements_base64: None, @@ -1109,6 +1112,7 @@ personality = true cli_overrides, LoaderOverrides { managed_config_path: Some(managed_path.clone()), + requirements_toml_file: None, #[cfg(target_os = "macos")] managed_preferences_base64: None, macos_managed_config_requirements_base64: None, @@ -1167,6 +1171,7 @@ personality = true vec![], LoaderOverrides { managed_config_path: Some(managed_path.clone()), + requirements_toml_file: None, #[cfg(target_os = "macos")] managed_preferences_base64: None, macos_managed_config_requirements_base64: None, diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 5ce059a9fc..42e9ce8334 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -134,6 +134,10 @@ pub async fn load_config_layers_state( // Honor the system requirements.toml location. let requirements_toml_file = system_requirements_toml_file()?; load_requirements_toml(&mut config_requirements_toml, requirements_toml_file).await?; + if let Some(requirements_toml_file) = overrides.requirements_toml_file.as_ref() { + load_session_requirements_toml(&mut config_requirements_toml, requirements_toml_file) + .await?; + } // Make a best-effort to support the legacy `managed_config.toml` as a // requirements specification. @@ -346,6 +350,37 @@ async fn load_config_toml_for_required_layer( async fn load_requirements_toml( config_requirements_toml: &mut ConfigRequirementsWithSources, requirements_toml_file: impl AsRef, +) -> io::Result<()> { + load_requirements_toml_with_source( + config_requirements_toml, + requirements_toml_file, + MissingRequirementsTomlBehavior::Ignore, + ) + .await +} + +async fn load_session_requirements_toml( + config_requirements_toml: &mut ConfigRequirementsWithSources, + requirements_toml_file: impl AsRef, +) -> io::Result<()> { + load_requirements_toml_with_source( + config_requirements_toml, + requirements_toml_file, + MissingRequirementsTomlBehavior::Error, + ) + .await +} + +#[derive(Clone, Copy)] +enum MissingRequirementsTomlBehavior { + Ignore, + Error, +} + +async fn load_requirements_toml_with_source( + config_requirements_toml: &mut ConfigRequirementsWithSources, + requirements_toml_file: impl AsRef, + missing_behavior: MissingRequirementsTomlBehavior, ) -> io::Result<()> { let requirements_toml_file = AbsolutePathBuf::from_absolute_path(requirements_toml_file.as_ref())?; @@ -369,7 +404,14 @@ async fn load_requirements_toml( ); } Err(e) => { - if e.kind() != io::ErrorKind::NotFound { + if e.kind() == io::ErrorKind::NotFound + && matches!(missing_behavior, MissingRequirementsTomlBehavior::Ignore) + { + return Ok(()); + } + if e.kind() != io::ErrorKind::NotFound + || matches!(missing_behavior, MissingRequirementsTomlBehavior::Error) + { return Err(io::Error::new( e.kind(), format!( @@ -380,7 +422,6 @@ async fn load_requirements_toml( } } } - Ok(()) } diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 05d5fa4c20..dcb954675f 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -203,6 +203,7 @@ extra = true let overrides = LoaderOverrides { managed_config_path: Some(managed_path), + requirements_toml_file: None, #[cfg(target_os = "macos")] managed_preferences_base64: None, macos_managed_config_requirements_base64: None, @@ -240,6 +241,7 @@ async fn returns_empty_when_all_layers_missing() { let overrides = LoaderOverrides { managed_config_path: Some(managed_path), + requirements_toml_file: None, #[cfg(target_os = "macos")] // Force managed preferences to resolve as empty so this test does not // inherit non-empty machine-specific managed state. @@ -338,6 +340,7 @@ flag = false let overrides = LoaderOverrides { managed_config_path: Some(managed_path), + requirements_toml_file: None, managed_preferences_base64: Some( base64::prelude::BASE64_STANDARD.encode(raw_managed_preferences.as_bytes()), ), @@ -392,6 +395,7 @@ async fn managed_preferences_requirements_are_applied() -> anyhow::Result<()> { &[] as &[(String, TomlValue)], LoaderOverrides { managed_config_path: Some(tmp.path().join("managed_config.toml")), + requirements_toml_file: None, managed_preferences_base64: Some(String::new()), macos_managed_config_requirements_base64: Some( base64::prelude::BASE64_STANDARD.encode( @@ -455,6 +459,7 @@ async fn managed_preferences_requirements_take_precedence() -> anyhow::Result<() &[] as &[(String, TomlValue)], LoaderOverrides { managed_config_path: Some(managed_path), + requirements_toml_file: None, managed_preferences_base64: Some(String::new()), macos_managed_config_requirements_base64: Some( base64::prelude::BASE64_STANDARD.encode( @@ -555,6 +560,69 @@ enforce_residency = "us" Ok(()) } +#[tokio::test(flavor = "current_thread")] +async fn load_requirements_toml_from_loader_overrides() -> anyhow::Result<()> { + let tmp = tempdir()?; + let requirements_file = tmp.path().join("session-requirements.toml"); + tokio::fs::write( + &requirements_file, + r#" +allowed_approval_policies = ["never"] +"#, + ) + .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::default(), + ) + .await?; + + assert_eq!( + layers.requirements_toml().allowed_approval_policies, + Some(vec![AskForApproval::Never]) + ); + assert_eq!( + layers.requirements().approval_policy.value(), + AskForApproval::Never + ); + + Ok(()) +} + +#[tokio::test(flavor = "current_thread")] +async fn missing_session_requirements_toml_from_loader_overrides_errors() -> anyhow::Result<()> { + let tmp = tempdir()?; + let requirements_file = tmp.path().join("missing-requirements.toml"); + + let err = 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::default(), + ) + .await + .expect_err("missing session requirements should fail"); + + assert_eq!(err.kind(), std::io::ErrorKind::NotFound); + assert!( + err.to_string() + .contains(&requirements_file.display().to_string()) + ); + + Ok(()) +} + #[cfg(target_os = "macos")] #[tokio::test] async fn cloud_requirements_take_precedence_over_mdm_requirements() -> anyhow::Result<()> { @@ -566,6 +634,7 @@ async fn cloud_requirements_take_precedence_over_mdm_requirements() -> anyhow::R Some(AbsolutePathBuf::try_from(tmp.path())?), &[] as &[(String, TomlValue)], LoaderOverrides { + requirements_toml_file: None, macos_managed_config_requirements_base64: Some( base64::prelude::BASE64_STANDARD.encode( r#" diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md index 763f2602ad..b90170950b 100644 --- a/codex-rs/docs/codex_mcp_interface.md +++ b/codex-rs/docs/codex_mcp_interface.md @@ -58,6 +58,7 @@ Request `newConversation` params (subset): - `model`: string model id (e.g. "o3", "gpt-5.1", "gpt-5.1-codex") - `profile`: optional named profile +- `requirementsToml`: optional path to a session-scoped `requirements.toml` - `cwd`: optional working directory - `approvalPolicy`: `untrusted` | `on-request` | `on-failure` (deprecated) | `never` - `sandbox`: `read-only` | `workspace-write` | `external-sandbox` (honors `networkAccess` restricted/enabled) | `danger-full-access` @@ -164,7 +165,7 @@ For the complete request/response shapes and flow examples, see the [“Auth end ## Example: start and send a message ```json -{ "jsonrpc": "2.0", "id": 1, "method": "newConversation", "params": { "model": "gpt-5.1", "approvalPolicy": "on-request" } } +{ "jsonrpc": "2.0", "id": 1, "method": "newConversation", "params": { "model": "gpt-5.1", "approvalPolicy": "on-request", "requirementsToml": "/path/to/requirements.toml" } } ``` Server responds: diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 6cda7e4082..6b346a5519 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -63,6 +63,15 @@ pub struct Cli { #[clap(long = "cd", short = 'C', value_name = "DIR")] pub cwd: Option, + /// Apply an additional requirements.toml file to this session only. + #[arg( + long = "requirements-toml", + value_name = "FILE", + value_hint = clap::ValueHint::FilePath, + global = true + )] + pub requirements_toml: Option, + /// Allow running Codex outside a Git repository. #[arg(long = "skip-git-repo-check", global = true, default_value_t = false)] pub skip_git_repo_check: bool, @@ -273,6 +282,8 @@ mod tests { "--json", "--model", "gpt-5.2-codex", + "--requirements-toml", + "/tmp/session-requirements.toml", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", "--ephemeral", @@ -280,6 +291,10 @@ mod tests { ]); assert!(cli.ephemeral); + assert_eq!( + cli.requirements_toml, + Some(PathBuf::from("/tmp/session-requirements.toml")) + ); let Some(Command::Resume(args)) = cli.command else { panic!("expected resume command"); }; @@ -300,6 +315,8 @@ mod tests { "codex-exec", "resume", "session-123", + "--requirements-toml", + "/tmp/resume-output-requirements.toml", "-o", "/tmp/resume-output.md", PROMPT, @@ -309,6 +326,10 @@ mod tests { cli.last_message_file, Some(PathBuf::from("/tmp/resume-output.md")) ); + assert_eq!( + cli.requirements_toml, + Some(PathBuf::from("/tmp/resume-output-requirements.toml")) + ); let Some(Command::Resume(args)) = cli.command else { panic!("expected resume command"); }; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index c0a559e067..75a4778e02 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -26,9 +26,10 @@ use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; -use codex_core::config::load_config_as_toml_with_cli_overrides; +use codex_core::config::load_config_as_toml_with_cli_overrides_and_loader_overrides; use codex_core::config::resolve_oss_provider; use codex_core::config_loader::ConfigLoadError; +use codex_core::config_loader::LoaderOverrides; use codex_core::config_loader::format_config_error_with_source; use codex_core::format_exec_policy_error_with_source; use codex_core::git_info::get_git_repo_root; @@ -109,6 +110,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result full_auto, dangerously_bypass_approvals_and_sandbox, cwd, + requirements_toml, skip_git_repo_check, add_dir, ephemeral, @@ -185,6 +187,10 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result Some(path) => AbsolutePathBuf::from_absolute_path(path.canonicalize()?)?, None => AbsolutePathBuf::current_dir()?, }; + let loader_overrides = LoaderOverrides { + requirements_toml_file: requirements_toml.clone(), + ..Default::default() + }; // we load config.toml here to determine project state. #[allow(clippy::print_stderr)] @@ -197,10 +203,11 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result }; #[allow(clippy::print_stderr)] - let config_toml = match load_config_as_toml_with_cli_overrides( + let config_toml = match load_config_as_toml_with_cli_overrides_and_loader_overrides( &codex_home, &config_cwd, cli_kv_overrides.clone(), + loader_overrides.clone(), ) .await { @@ -294,6 +301,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result let config = ConfigBuilder::default() .cli_overrides(cli_kv_overrides) .harness_overrides(overrides) + .loader_overrides(loader_overrides) .cloud_requirements(cloud_requirements) .build() .await?; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 52aefb4727..404a057b54 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -48,6 +48,7 @@ use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config::types::ModelAvailabilityNuxConfig; use codex_core::config_loader::ConfigLayerStackOrdering; +use codex_core::config_loader::LoaderOverrides; use codex_core::features::Feature; use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_core::models_manager::manager::RefreshStrategy; @@ -635,6 +636,7 @@ pub(crate) struct App { pub(crate) active_profile: Option, cli_kv_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, + loader_overrides: LoaderOverrides, runtime_approval_policy_override: Option, runtime_sandbox_policy_override: Option, @@ -751,6 +753,7 @@ impl App { .codex_home(self.config.codex_home.clone()) .cli_overrides(self.cli_kv_overrides.clone()) .harness_overrides(overrides) + .loader_overrides(self.loader_overrides.clone()) .build() .await .wrap_err_with(|| format!("Failed to rebuild config for cwd {cwd_display}")) @@ -1495,6 +1498,7 @@ impl App { mut config: Config, cli_kv_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, + loader_overrides: LoaderOverrides, active_profile: Option, initial_prompt: Option, initial_images: Vec, @@ -1704,6 +1708,7 @@ impl App { active_profile, cli_kv_overrides, harness_overrides, + loader_overrides, runtime_approval_policy_override: None, runtime_sandbox_policy_override: None, file_search, @@ -4364,6 +4369,7 @@ mod tests { active_profile: None, cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), + loader_overrides: LoaderOverrides::default(), runtime_approval_policy_override: None, runtime_sandbox_policy_override: None, file_search, @@ -4424,6 +4430,7 @@ mod tests { active_profile: None, cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), + loader_overrides: LoaderOverrides::default(), runtime_approval_policy_override: None, runtime_sandbox_policy_override: None, file_search, diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index 86bea97abe..863f77dc68 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -94,6 +94,10 @@ pub struct Cli { #[clap(long = "cd", short = 'C', value_name = "DIR")] pub cwd: Option, + /// Load session-scoped requirements from the specified TOML file. + #[arg(long = "requirements-toml", value_name = "FILE", value_hint = ValueHint::FilePath)] + pub requirements_toml: Option, + /// Enable live web search. When enabled, the native Responses `web_search` tool is available to the model (no per‑call approval). #[arg(long = "search", default_value_t = false)] pub web_search: bool, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 5d36ec441d..3d752a18c7 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -20,10 +20,11 @@ use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; -use codex_core::config::load_config_as_toml_with_cli_overrides; +use codex_core::config::load_config_as_toml_with_cli_overrides_and_loader_overrides; use codex_core::config::resolve_oss_provider; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::ConfigLoadError; +use codex_core::config_loader::LoaderOverrides; use codex_core::config_loader::format_config_error_with_source; use codex_core::default_client::set_default_client_residency_requirement; use codex_core::find_thread_path_by_id_str; @@ -278,16 +279,21 @@ pub async fn run_main(mut cli: Cli, arg0_paths: Arg0DispatchPaths) -> std::io::R }; let cwd = cli.cwd.clone(); + let loader_overrides = LoaderOverrides { + requirements_toml_file: cli.requirements_toml.clone(), + ..Default::default() + }; let config_cwd = match cwd.as_deref() { Some(path) => AbsolutePathBuf::from_absolute_path(path.canonicalize()?)?, None => AbsolutePathBuf::current_dir()?, }; #[allow(clippy::print_stderr)] - let config_toml = match load_config_as_toml_with_cli_overrides( + let config_toml = match load_config_as_toml_with_cli_overrides_and_loader_overrides( &codex_home, &config_cwd, cli_kv_overrides.clone(), + loader_overrides.clone(), ) .await { @@ -386,6 +392,7 @@ pub async fn run_main(mut cli: Cli, arg0_paths: Arg0DispatchPaths) -> std::io::R let config = load_config_or_exit( cli_kv_overrides.clone(), overrides.clone(), + loader_overrides.clone(), cloud_requirements.clone(), ) .await; @@ -522,6 +529,7 @@ pub async fn run_main(mut cli: Cli, arg0_paths: Arg0DispatchPaths) -> std::io::R config, overrides, cli_kv_overrides, + loader_overrides, cloud_requirements, feedback, ) @@ -534,6 +542,7 @@ async fn run_ratatui_app( initial_config: Config, overrides: ConfigOverrides, cli_kv_overrides: Vec<(String, toml::Value)>, + loader_overrides: LoaderOverrides, mut cloud_requirements: CloudRequirementsLoader, feedback: codex_feedback::CodexFeedback, ) -> color_eyre::Result { @@ -634,6 +643,7 @@ async fn run_ratatui_app( load_config_or_exit( cli_kv_overrides.clone(), overrides.clone(), + loader_overrides.clone(), cloud_requirements.clone(), ) .await @@ -885,6 +895,7 @@ async fn run_ratatui_app( load_config_or_exit_with_fallback_cwd( cli_kv_overrides.clone(), overrides.clone(), + loader_overrides.clone(), cloud_requirements.clone(), fallback_cwd, ) @@ -926,6 +937,7 @@ async fn run_ratatui_app( config, cli_kv_overrides.clone(), overrides.clone(), + loader_overrides.clone(), active_profile, prompt, images, @@ -1119,15 +1131,23 @@ fn get_login_status(config: &Config) -> LoginStatus { async fn load_config_or_exit( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, + loader_overrides: LoaderOverrides, cloud_requirements: CloudRequirementsLoader, ) -> Config { - load_config_or_exit_with_fallback_cwd(cli_kv_overrides, overrides, cloud_requirements, None) - .await + load_config_or_exit_with_fallback_cwd( + cli_kv_overrides, + overrides, + loader_overrides, + cloud_requirements, + None, + ) + .await } async fn load_config_or_exit_with_fallback_cwd( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, + loader_overrides: LoaderOverrides, cloud_requirements: CloudRequirementsLoader, fallback_cwd: Option, ) -> Config { @@ -1135,6 +1155,7 @@ async fn load_config_or_exit_with_fallback_cwd( match ConfigBuilder::default() .cli_overrides(cli_kv_overrides) .harness_overrides(overrides) + .loader_overrides(loader_overrides) .cloud_requirements(cloud_requirements) .fallback_cwd(fallback_cwd) .build() diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 09886c061c..58a576ff31 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -116,6 +116,16 @@ 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. + +```typescript +const thread = codex.startThread({ + requirementsToml: "/path/to/session-requirements.toml", +}); +``` + ### Controlling the Codex CLI environment By default, the Codex CLI inherits the Node.js process environment. Provide the optional `env` parameter when instantiating the diff --git a/sdk/typescript/src/exec.ts b/sdk/typescript/src/exec.ts index ea85a6a276..5299fdbb02 100644 --- a/sdk/typescript/src/exec.ts +++ b/sdk/typescript/src/exec.ts @@ -19,6 +19,8 @@ export type CodexExecArgs = { sandboxMode?: SandboxMode; // --cd workingDirectory?: string; + // --requirements-toml + requirementsToml?: string; // --add-dir additionalDirectories?: string[]; // --skip-git-repo-check @@ -90,6 +92,10 @@ export class CodexExec { commandArgs.push("--cd", args.workingDirectory); } + if (args.requirementsToml) { + commandArgs.push("--requirements-toml", args.requirementsToml); + } + if (args.additionalDirectories?.length) { for (const dir of args.additionalDirectories) { commandArgs.push("--add-dir", dir); diff --git a/sdk/typescript/src/thread.ts b/sdk/typescript/src/thread.ts index 9ab425e652..3c9d8e5f51 100644 --- a/sdk/typescript/src/thread.ts +++ b/sdk/typescript/src/thread.ts @@ -83,6 +83,7 @@ export class Thread { model: options?.model, sandboxMode: options?.sandboxMode, workingDirectory: options?.workingDirectory, + requirementsToml: options?.requirementsToml, skipGitRepoCheck: options?.skipGitRepoCheck, outputSchemaFile: schemaPath, modelReasoningEffort: options?.modelReasoningEffort, diff --git a/sdk/typescript/src/threadOptions.ts b/sdk/typescript/src/threadOptions.ts index 7bde5e4d78..ff432071eb 100644 --- a/sdk/typescript/src/threadOptions.ts +++ b/sdk/typescript/src/threadOptions.ts @@ -10,6 +10,7 @@ export type ThreadOptions = { model?: string; sandboxMode?: SandboxMode; workingDirectory?: string; + requirementsToml?: string; skipGitRepoCheck?: boolean; modelReasoningEffort?: ModelReasoningEffort; networkAccessEnabled?: boolean; diff --git a/sdk/typescript/tests/run.test.ts b/sdk/typescript/tests/run.test.ts index 410bf50267..52f1616379 100644 --- a/sdk/typescript/tests/run.test.ts +++ b/sdk/typescript/tests/run.test.ts @@ -410,6 +410,37 @@ describe("Codex", () => { } }); + it("passes requirementsToml to exec", async () => { + const { url, close } = await startResponsesTestProxy({ + statusCode: 200, + responseBodies: [ + sse( + responseStarted("response_1"), + assistantMessage("Requirements applied", "item_1"), + responseCompleted("response_1"), + ), + ], + }); + + const { args: spawnArgs, restore } = codexExecSpy(); + + try { + const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); + + const thread = client.startThread({ + requirementsToml: "/tmp/session-requirements.toml", + }); + await thread.run("test requirements"); + + const commandArgs = spawnArgs[0]; + expect(commandArgs).toBeDefined(); + expectPair(commandArgs, ["--requirements-toml", "/tmp/session-requirements.toml"]); + } finally { + restore(); + await close(); + } + }); + it("passes CodexOptions config overrides as TOML --config flags", async () => { const { url, close } = await startResponsesTestProxy({ statusCode: 200, @@ -774,6 +805,38 @@ describe("Codex", () => { } }); + it("passes requirementsToml when resuming a thread", async () => { + const { url, close } = await startResponsesTestProxy({ + statusCode: 200, + responseBodies: [ + sse( + responseStarted("response_1"), + assistantMessage("Requirements applied", "item_1"), + responseCompleted("response_1"), + ), + ], + }); + + const { args: spawnArgs, restore } = codexExecSpy(); + + try { + const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); + + const thread = client.resumeThread("thread_123", { + requirementsToml: "/tmp/session-requirements.toml", + }); + await thread.run("resume with requirements"); + + const commandArgs = spawnArgs[0]; + expect(commandArgs).toBeDefined(); + expectPair(commandArgs, ["--requirements-toml", "/tmp/session-requirements.toml"]); + expectPair(commandArgs, ["resume", "thread_123"]); + } finally { + restore(); + await close(); + } + }); + it("sets the codex sdk originator header", async () => { const { url, close, requests } = await startResponsesTestProxy({ statusCode: 200,