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 9e06959515..4c89f78e1c 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 @@ -8908,6 +8908,12 @@ }, "ConfigRequirements": { "properties": { + "additionalDeveloperInstructions": { + "type": [ + "string", + "null" + ] + }, "allowAppshots": { "type": [ "boolean", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index f40e3177b5..0514ad4ad7 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -4966,6 +4966,12 @@ }, "ConfigRequirements": { "properties": { + "additionalDeveloperInstructions": { + "type": [ + "string", + "null" + ] + }, "allowAppshots": { "type": [ "boolean", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index 6f32d3ce34..aef399e799 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -115,6 +115,12 @@ }, "ConfigRequirements": { "properties": { + "additionalDeveloperInstructions": { + "type": [ + "string", + "null" + ] + }, "allowAppshots": { "type": [ "boolean", diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 4c8be3a061..8b5cafce9a 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 7ecfeadaca..90f598fc41 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts index 3e4e73209c..badb7db303 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts @@ -15,4 +15,4 @@ import type { ResidencyRequirement } from "./ResidencyRequirement"; import type { SandboxMode } from "./SandboxMode"; import type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode"; -export type ConfigRequirements = {cliAuthCredentialsStore: CliAuthCredentialsStoreMode | null, chatgptBaseUrl: string | null, allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWindowsSandboxImplementations: Array | null, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, allowRemoteControl: boolean | null, computerUse: ComputerUseRequirements | null, browserUse: BrowserUseRequirements | null, inAppBrowser: InAppBrowserRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null, autoReview: AutoReviewRequirements | null, models: ModelsRequirements | null, sqliteHome: PathUri | null, logDir: PathUri | null, modelCatalogJson: PathUri | null, checkForUpdateOnStartup: boolean | null, allowLoginShell: boolean | null, feedback: FeedbackRequirements | null, windowsSandboxPrivateDesktop: boolean | null}; +export type ConfigRequirements = {cliAuthCredentialsStore: CliAuthCredentialsStoreMode | null, chatgptBaseUrl: string | null, additionalDeveloperInstructions: string | null, allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWindowsSandboxImplementations: Array | null, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, allowRemoteControl: boolean | null, computerUse: ComputerUseRequirements | null, browserUse: BrowserUseRequirements | null, inAppBrowser: InAppBrowserRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null, autoReview: AutoReviewRequirements | null, models: ModelsRequirements | null, sqliteHome: PathUri | null, logDir: PathUri | null, modelCatalogJson: PathUri | null, checkForUpdateOnStartup: boolean | null, allowLoginShell: boolean | null, feedback: FeedbackRequirements | null, windowsSandboxPrivateDesktop: boolean | null}; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index f87c2c55aa..f6c1777627 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -385,6 +385,7 @@ pub struct ConfigReadResponse { pub struct ConfigRequirements { pub cli_auth_credentials_store: Option, pub chatgpt_base_url: Option, + pub additional_developer_instructions: Option, #[experimental(nested)] pub allowed_approval_policies: Option>, #[experimental("configRequirements/read.allowedApprovalsReviewers")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 6ac1814fd9..2db87b4fef 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2042,6 +2042,7 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() crate::experimental_api::ExperimentalApi::experimental_reason(&ConfigRequirements { cli_auth_credentials_store: None, chatgpt_base_url: None, + additional_developer_instructions: None, allowed_approval_policies: Some(vec![AskForApproval::Granular { sandbox_approval: true, rules: true, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 4f5b471d04..68b5bfc3d9 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -288,7 +288,7 @@ Example with notification opt-out: - `externalAgentConfig/import/readHistories` — read completed import histories and connector candidates detected from successfully imported session histories. Successful session entries include the original imported title when one was available. Connector candidates include a normalized display `name`, the number of imported sessions that used the connector, and the source metadata field used for detection. - `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface. Writes that overlap a managed requirement are rejected with `configRequirementReadonly`. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits. Session-static model, reasoning-effort, Plan-mode reasoning-effort, service-tier, and personality defaults do not reload existing threads. -- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including exact managed values (`cliAuthCredentialsStore`, `chatgptBaseUrl`, `sqliteHome`, `logDir`, `modelCatalogJson`, `checkForUpdateOnStartup`, `allowLoginShell`, `feedback.enabled`, and `windowsSandboxPrivateDesktop`), allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), Browser Use policy (`browserUse.disableAutoReview`), interactive browser import policy (`inAppBrowser.allowExternalBrowserSettingsImport`), pinned feature values (`featureRequirements`, including the default-allowed `in_app_updates` policy that administrators can set to `false`), managed lifecycle hooks (`hooks`, including command handlers with optional `additionalContextLimit` and `mcp_tool` handlers with `server`, `tool`, `input`, `timeoutSec`, and `statusMessage`), `enforceResidency`, managed automatic review (`autoReview.requiredOnModels` and `autoReview.ignoreRules`), model defaults (`models.newThread.model`, `models.newThread.modelReasoningEffort`, and `models.newThread.serviceTier`), and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. +- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including exact managed values (`cliAuthCredentialsStore`, `chatgptBaseUrl`, `sqliteHome`, `logDir`, `modelCatalogJson`, `checkForUpdateOnStartup`, `allowLoginShell`, `feedback.enabled`, and `windowsSandboxPrivateDesktop`), requirements-only developer instructions (`additionalDeveloperInstructions`, supplied independently of ordinary developer instructions), allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), Browser Use policy (`browserUse.disableAutoReview`), interactive browser import policy (`inAppBrowser.allowExternalBrowserSettingsImport`), pinned feature values (`featureRequirements`, including the default-allowed `in_app_updates` policy that administrators can set to `false`), managed lifecycle hooks (`hooks`, including command handlers with optional `additionalContextLimit` and `mcp_tool` handlers with `server`, `tool`, `input`, `timeoutSec`, and `statusMessage`), `enforceResidency`, managed automatic review (`autoReview.requiredOnModels` and `autoReview.ignoreRules`), model defaults (`models.newThread.model`, `models.newThread.modelReasoningEffort`, and `models.newThread.serviceTier`), and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. ### Example: Start or resume a thread diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 053edf2912..52a05e5ae9 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -371,6 +371,7 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR }, ), chatgpt_base_url: requirements.chatgpt_base_url, + additional_developer_instructions: requirements.additional_developer_instructions, allowed_approval_policies: requirements.allowed_approval_policies.map(|policies| { policies .into_iter() diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index c0b0a63a44..3eefad5308 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -278,12 +278,17 @@ in_app_updates = false } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn config_requirements_read_includes_model_auto_review_and_new_thread_defaults() -> Result<()> -{ +async fn config_requirements_read_includes_managed_model_policy_and_instructions() -> Result<()> { let codex_home = TempDir::new()?; + write_config( + &codex_home, + "developer_instructions = \"ordinary instructions\"\n", + )?; std::fs::write( codex_home.path().join("requirements.toml"), r#" +additional_developer_instructions = "Follow the managed policy.\nPreserve its formatting." + [auto_review] required_on_models = ["gpt-protected", "gpt-sensitive"] ignore_rules = ["gpt-protected"] @@ -305,6 +310,10 @@ service_tier = "fast" timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; let requirements = response.requirements.expect("managed requirements"); + assert_eq!( + requirements.additional_developer_instructions.as_deref(), + Some("Follow the managed policy.\nPreserve its formatting.") + ); let auto_review = requirements .auto_review .expect("managed automatic-review requirements"); @@ -327,6 +336,25 @@ service_tier = "fast" Some(ReasoningEffort::Medium) ); assert_eq!(defaults.service_tier.as_deref(), Some("fast")); + + let config_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await?; + let config: ConfigReadResponse = + timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(config_id)).await??; + assert_eq!( + ( + config.config.developer_instructions.as_deref(), + config + .config + .additional + .get("additional_developer_instructions"), + ), + (Some("ordinary instructions"), None), + ); Ok(()) } diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index bf10a161d8..81c082bb2c 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -187,6 +187,8 @@ pub struct ConfigRequirements { pub network: Option>, /// Managed filesystem constraints derived from requirements. pub filesystem: Option>, + /// Managed instructions included independently of ordinary developer instructions. + pub additional_developer_instructions: Option>, /// Source for the managed guardian policy config, when one is configured. pub guardian_policy_config_source: Option, } @@ -242,6 +244,7 @@ impl Default for ConfigRequirements { ), network: None, filesystem: None, + additional_developer_instructions: None, guardian_policy_config_source: None, } } @@ -959,6 +962,7 @@ pub struct ConfigRequirementsToml { pub permissions: Option, pub auto_review: Option, pub models: Option, + pub additional_developer_instructions: Option, pub guardian_policy_config: Option, } @@ -1059,6 +1063,7 @@ pub struct ConfigRequirementsWithSources { pub permissions: Option>, pub auto_review: Option>, pub models: Option>, + pub additional_developer_instructions: Option>, pub guardian_policy_config: Option>, } @@ -1117,6 +1122,7 @@ impl ConfigRequirementsWithSources { permissions: _, auto_review: _, models: _, + additional_developer_instructions: _, guardian_policy_config: _, } = &other; @@ -1166,6 +1172,7 @@ impl ConfigRequirementsWithSources { network, permissions, models, + additional_developer_instructions, guardian_policy_config, } ); @@ -1248,6 +1255,7 @@ impl ConfigRequirementsWithSources { permissions, auto_review, models, + additional_developer_instructions, guardian_policy_config, } = self; ConfigRequirementsToml { @@ -1287,6 +1295,8 @@ impl ConfigRequirementsWithSources { permissions: permissions.map(|sourced| sourced.value), auto_review: auto_review.map(|sourced| sourced.value), models: models.map(|sourced| sourced.value), + additional_developer_instructions: additional_developer_instructions + .map(|sourced| sourced.value), guardian_policy_config: guardian_policy_config.map(|sourced| sourced.value), } } @@ -1431,6 +1441,7 @@ impl ConfigRequirementsToml { .models .as_ref() .is_none_or(ModelsRequirementsToml::is_empty) + && self.additional_developer_instructions.is_none() && self .guardian_policy_config .as_deref() @@ -1606,6 +1617,7 @@ impl TryFrom for ConfigRequirements { permissions, auto_review, models: _, + additional_developer_instructions, guardian_policy_config, } = toml; @@ -1962,6 +1974,7 @@ impl TryFrom for ConfigRequirements { enforce_residency, network, filesystem, + additional_developer_instructions, guardian_policy_config_source, }) } @@ -2134,6 +2147,7 @@ mod tests { permissions, auto_review, models, + additional_developer_instructions, guardian_policy_config, } = toml; ConfigRequirementsWithSources { @@ -2191,6 +2205,8 @@ mod tests { permissions: permissions.map(|value| Sourced::new(value, RequirementSource::Unknown)), auto_review: auto_review.map(|value| Sourced::new(value, RequirementSource::Unknown)), models: models.map(|value| Sourced::new(value, RequirementSource::Unknown)), + additional_developer_instructions: additional_developer_instructions + .map(|value| Sourced::new(value, RequirementSource::Unknown)), guardian_policy_config: guardian_policy_config .map(|value| Sourced::new(value, RequirementSource::Unknown)), } @@ -2445,6 +2461,7 @@ mod tests { }; let enforce_residency = ResidencyRequirement::Us; let enforce_source = source.clone(); + let additional_developer_instructions = "Follow the company policy.".to_string(); let guardian_policy_config = "Use the company-managed guardian policy.".to_string(); // Intentionally constructed without `..Default::default()` so adding a new field to @@ -2486,6 +2503,7 @@ mod tests { permissions: None, auto_review: Some(auto_review.clone()), models: Some(models.clone()), + additional_developer_instructions: Some(additional_developer_instructions.clone()), guardian_policy_config: Some(guardian_policy_config.clone()), }; @@ -2565,6 +2583,10 @@ mod tests { permissions: None, auto_review: Some(Sourced::new(auto_review, source.clone())), models: Some(Sourced::new(models, source.clone())), + additional_developer_instructions: Some(Sourced::new( + additional_developer_instructions, + source.clone(), + )), guardian_policy_config: Some(Sourced::new(guardian_policy_config, source)), } ); diff --git a/codex-rs/config/src/requirements_layers/stack.rs b/codex-rs/config/src/requirements_layers/stack.rs index 9c91fd9ca8..9da3fca305 100644 --- a/codex-rs/config/src/requirements_layers/stack.rs +++ b/codex-rs/config/src/requirements_layers/stack.rs @@ -247,6 +247,7 @@ fn populate_merged_regular_fields_with_sources( permissions, auto_review, models, + additional_developer_instructions, guardian_policy_config, } = requirements; @@ -292,6 +293,10 @@ fn populate_merged_regular_fields_with_sources( set_sourced!(network, &["experimental_network"]); set_sourced!(permissions, &["permissions"]); set_sourced!(models, &["models"]); + set_sourced!( + additional_developer_instructions, + &["additional_developer_instructions"] + ); if let Some(guardian_policy_config) = guardian_policy_config.filter(|value| !value.trim().is_empty()) diff --git a/codex-rs/config/src/requirements_layers/stack_tests.rs b/codex-rs/config/src/requirements_layers/stack_tests.rs index fa9f07f425..dadd362bd9 100644 --- a/codex-rs/config/src/requirements_layers/stack_tests.rs +++ b/codex-rs/config/src/requirements_layers/stack_tests.rs @@ -96,6 +96,7 @@ allowed_approval_policies = ["on-request"] allowed_sandbox_modes = ["workspace-write"] default_permissions = ":workspace" allow_remote_control = true +additional_developer_instructions = "Lower-priority instructions." [allowed_permission_profiles] ":read-only" = true @@ -110,6 +111,7 @@ allowed_approval_policies = ["never"] allowed_sandbox_modes = ["read-only"] default_permissions = ":read-only" allow_remote_control = false +additional_developer_instructions = "" [allowed_permission_profiles] ":danger-full-access" = false @@ -128,6 +130,7 @@ allowed_approval_policies = ["never"] allowed_sandbox_modes = ["read-only"] default_permissions = ":read-only" allow_remote_control = false +additional_developer_instructions = "" [allowed_permission_profiles] ":danger-full-access" = false diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index 8b2955034f..e18e760f1e 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -4,6 +4,7 @@ use crate::agent::role::apply_role_to_config; use crate::config::PermissionProfileSnapshot; use crate::context::ContextualUserFragment; use crate::context::CurrentTimeReminder; +use crate::context::ManagedDeveloperInstructions; use crate::context::MultiAgentModeInstructions; use crate::context::MultiAgentRoleInstructions; use crate::session::multi_agents::resolve_usage_hints; @@ -737,16 +738,28 @@ impl AgentControl { return false; } - if let Some(parent_developer_instructions) = parent_developer_instructions.as_ref() - && let Some(subagent_developer_instructions) = - subagent_developer_instructions.as_ref() - && let ResponseItem::Message { role, content, .. } = response_item + if let ResponseItem::Message { role, content, .. } = response_item && role == "developer" { content.retain_mut(|content_item| { let ContentItem::InputText { text } = content_item else { return true; }; + if ManagedDeveloperInstructions::matches_text(text) { + // If the child will rebuild its initial context, drop the inherited + // managed instructions; startup will add the current requirements once. + return preserve_reference_context_item; + } + let ( + Some(parent_developer_instructions), + Some(subagent_developer_instructions), + ) = ( + parent_developer_instructions.as_ref(), + subagent_developer_instructions.as_ref(), + ) + else { + return true; + }; // TODO(anp) track better message fragment provenance in rollouts. if !text.contains(parent_developer_instructions) { return true; diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index c0cc479ea8..4cbc4a46d5 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -9,6 +9,7 @@ use crate::config::AgentRoleConfig; use crate::config::Config; use crate::config::ConfigBuilder; use crate::context::ContextualUserFragment; +use crate::context::ManagedDeveloperInstructions; use crate::context::MultiAgentRoleInstructions; use crate::context::SubagentNotification; use crate::init_state_db; @@ -1306,6 +1307,7 @@ async fn spawn_agent_numeric_fork_from_compacted_paginated_parent_clamps_to_prov #[tokio::test] async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { + let managed_fragment = "\nParent developer instructions.\n"; let harness = AgentControlHarness::new().await; let mut parent_config = harness.config.clone(); let _ = parent_config.features.enable(Feature::MultiAgentV2); @@ -1388,6 +1390,9 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { ContentItem::InputText { text: "Preserved developer context.".to_string(), }, + ContentItem::InputText { + text: managed_fragment.to_string(), + }, ], phase: None, internal_chat_message_metadata_passthrough: None, @@ -1471,6 +1476,9 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { ContentItem::InputText { text: "Preserved developer context.".to_string(), }, + ContentItem::InputText { + text: managed_fragment.to_string(), + }, ], phase: None, internal_chat_message_metadata_passthrough: None, @@ -1554,10 +1562,14 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { assert!( !history_contains_text( no_hint_history.raw_items(), - "Parent developer instructions." + "Developer context before.\nParent developer instructions." ), "empty child developer instructions should remove parent developer instructions" ); + assert!( + history_contains_text(no_hint_history.raw_items(), managed_fragment), + "clearing child instructions must preserve an overlapping managed policy" + ); assert!( history_contains_text( no_hint_history.raw_items(), @@ -1934,6 +1946,12 @@ async fn spawn_agent_full_fork_restores_instructions_after_compaction_discards_p /// rebuild configured developer instructions exactly once. #[tokio::test] async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_once() { + let managed_policy = "Managed policy for every agent."; + let current_managed_fragment = format!( + "\n{managed_policy}\n" + ); + let stale_managed_fragment = + "\nOld managed policy.\n"; for (case, parent_developer_instructions) in [ ("without parent instructions", None), ( @@ -1945,6 +1963,23 @@ async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_onc let mut parent_config = harness.config.clone(); let _ = parent_config.features.enable(Feature::MultiAgentV2); parent_config.developer_instructions = parent_developer_instructions.map(str::to_string); + let mut requirements = parent_config.config_layer_stack.requirements().clone(); + requirements.additional_developer_instructions = Some(codex_config::Sourced::new( + managed_policy.to_string(), + codex_config::RequirementSource::Unknown, + )); + let mut requirements_toml = parent_config.config_layer_stack.requirements_toml().clone(); + requirements_toml.additional_developer_instructions = Some(managed_policy.to_string()); + parent_config.config_layer_stack = codex_config::ConfigLayerStack::new( + parent_config + .config_layer_stack + .all_layers_low_to_high() + .cloned() + .collect(), + requirements, + requirements_toml, + ) + .expect("managed requirements stack"); let mut child_config = parent_config.clone(); child_config.developer_instructions = Some("Child developer instructions.".to_string()); child_config.multi_agent_v2.subagent_developer_instructions = @@ -2004,6 +2039,15 @@ async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_onc internal_chat_message_metadata_passthrough: None, })); } + rollout_items.push(rollout_response_item(ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: stale_managed_fragment.to_string(), + }], + phase: None, + internal_chat_message_metadata_passthrough: None, + })); rollout_items.push(RolloutItem::TurnContext( turn_context.to_turn_context_item(), )); @@ -2060,6 +2104,7 @@ async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_onc } let history = child_thread.session.clone_history().await; let mut instruction_count = 0; + let mut managed_instructions = Vec::new(); for item in history.raw_items() { let ResponseItem::Message { role, content, .. } = item else { continue; @@ -2068,16 +2113,18 @@ async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_onc continue; } for content_item in content { - if let ContentItem::InputText { text } = content_item - && text == "Child developer instructions." - { - instruction_count += 1; + if let ContentItem::InputText { text } = content_item { + instruction_count += usize::from(text == "Child developer instructions."); + if ManagedDeveloperInstructions::matches_text(text) { + managed_instructions.push(text.as_str()); + } } } } assert_eq!( - instruction_count, 1, - "{case}: canonical context reconstruction must not duplicate child developer instructions" + (instruction_count, managed_instructions), + (1, vec![current_managed_fragment.as_str()]), + "{case}: canonical context reconstruction must keep only the current child and managed developer instructions" ); let _ = harness diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 9c37647560..916e3d7565 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -9858,6 +9858,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() permissions: None, auto_review: None, models: None, + additional_developer_instructions: None, guardian_policy_config: None, }; let requirement_source = codex_config::RequirementSource::Unknown; @@ -10518,6 +10519,38 @@ async fn test_untrusted_project_gets_unless_trusted_approval_policy() -> anyhow: Ok(()) } +#[tokio::test] +async fn oversized_managed_developer_instructions_are_rejected_during_config_load() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + // The policy text alone fits; rendering its required context markers must not. + let instructions = "x".repeat(codex_utils_string::approx_bytes_for_tokens( + /*tokens*/ 10_000, + )); + + let error = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement(format!( + "additional_developer_instructions = {instructions:?}" + )), + ) + .build() + .await + .expect_err("oversized managed instructions must fail during config loading"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + let message = error.to_string(); + assert!( + message.starts_with( + "`additional_developer_instructions` from enterprise-managed requirements Base requirements (req_1) exceeds the model-context limit of 10000 estimated tokens" + ), + "unexpected config-load error: {message}" + ); + Ok(()) +} + #[tokio::test] async fn requirements_disallowing_default_sandbox_falls_back_to_required_default() -> std::io::Result<()> { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 2f74cf41bc..d5c2f18404 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1,5 +1,6 @@ use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; +use crate::context::world_state::validate_managed_developer_instructions; use crate::guardian::BUNDLED_GUARDIAN_POLICY; use crate::path_utils::normalize_for_native_workdir; use crate::unified_exec::DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS; @@ -3108,6 +3109,12 @@ impl Config { std::io::Error::new(std::io::ErrorKind::InvalidInput, message) })?; } + validate_managed_developer_instructions( + config_layer_stack + .requirements() + .additional_developer_instructions + .as_ref(), + )?; let orchestrator = cfg.orchestrator.as_ref(); let orchestrator_skills_enabled = resolve_orchestrator_feature_enabled(orchestrator.and_then(|value| value.skills.as_ref())); @@ -3156,6 +3163,7 @@ impl Config { enforce_residency, network: network_requirements, filesystem: filesystem_requirements, + additional_developer_instructions: _, guardian_policy_config_source: _, } = config_layer_stack.requirements().clone(); diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index ebd5a99c54..c493340b56 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -93,3 +93,4 @@ pub(crate) use token_budget_context::TokenBudgetReminder; pub(crate) use turn_aborted::TurnAborted; pub(crate) use user_instructions::UserInstructions; pub(crate) use user_shell_command::UserShellCommand; +pub(crate) use world_state::ManagedDeveloperInstructions; diff --git a/codex-rs/core/src/context/world_state/managed_developer_instructions.rs b/codex-rs/core/src/context/world_state/managed_developer_instructions.rs new file mode 100644 index 0000000000..797ec28f05 --- /dev/null +++ b/codex-rs/core/src/context/world_state/managed_developer_instructions.rs @@ -0,0 +1,152 @@ +use super::PreviousSectionState; +use super::WorldStateHash; +use super::WorldStateSection; +use crate::context::ContextualUserFragment; +use codex_config::Sourced; +use codex_utils_string::approx_bytes_for_tokens; +use codex_utils_string::approx_tokens_from_byte_count; +use serde::Deserialize; +use serde::Serialize; +use std::io; + +const MAX_MANAGED_DEVELOPER_INSTRUCTIONS_TOKENS: usize = 10_000; +const REPLACEMENT_NOTICE: &str = "These managed developer instructions replace all previously provided managed developer instructions."; +const REMOVAL_NOTICE: &str = + "The previously provided managed developer instructions no longer apply."; + +/// A requirements-owned developer message, kept separate from client instructions. +#[derive(Clone, Debug)] +pub(crate) struct ManagedDeveloperInstructions { + instructions: String, +} + +impl ContextualUserFragment for ManagedDeveloperInstructions { + fn role(&self) -> &'static str { + "developer" + } + + fn requires_separate_message(&self) -> bool { + true + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ( + "", + "", + ) + } + + fn body(&self) -> String { + format!("\n{}\n", self.instructions) + } +} + +/// Reject oversized policy rather than silently dropping part of its instructions. +pub(crate) fn validate_managed_developer_instructions( + requirement: Option<&Sourced>, +) -> io::Result<()> { + let Some(requirement) = requirement else { + return Ok(()); + }; + // Reserve the notice used when an existing conversation receives a new policy. + let overhead = ManagedDeveloperInstructions { + instructions: format!("{REPLACEMENT_NOTICE}\n\n"), + } + .render() + .len(); + let rendered_bytes = requirement.value.len().saturating_add(overhead); + if rendered_bytes > approx_bytes_for_tokens(MAX_MANAGED_DEVELOPER_INSTRUCTIONS_TOKENS) { + let estimated_tokens = approx_tokens_from_byte_count(rendered_bytes); + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "`additional_developer_instructions` from {} exceeds the model-context limit of {MAX_MANAGED_DEVELOPER_INSTRUCTIONS_TOKENS} estimated tokens ({estimated_tokens} including context markers)", + requirement.source, + ), + )); + } + Ok(()) +} + +/// The managed policy currently visible in retained model history. +#[derive(Clone, Debug)] +pub(crate) struct ManagedDeveloperInstructionsState { + instructions: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct ManagedDeveloperInstructionsSnapshot { + instructions: Option, +} + +impl ManagedDeveloperInstructionsState { + pub(crate) fn new(requirement: Option<&Sourced>) -> Self { + Self { + instructions: requirement + .filter(|requirement| !requirement.value.is_empty()) + .map(|requirement| ManagedDeveloperInstructions { + instructions: requirement.value.clone(), + }), + } + } +} + +impl WorldStateSection for ManagedDeveloperInstructionsState { + const ID: &'static str = "managed_developer_instructions"; + type Snapshot = ManagedDeveloperInstructionsSnapshot; + + fn snapshot(&self) -> Self::Snapshot { + ManagedDeveloperInstructionsSnapshot { + instructions: self + .instructions + .as_ref() + .map(WorldStateHash::from_fragment), + } + } + + fn matches_legacy_fragment(role: &str, text: &str) -> bool { + role == "developer" && ManagedDeveloperInstructions::matches_text(text) + } + + fn has_retained_fragment_matcher() -> bool { + true + } + + fn matches_retained_fragment(role: &str, text: &str) -> bool { + Self::matches_legacy_fragment(role, text) + } + + fn render_diff( + &self, + previous: PreviousSectionState<'_, Self::Snapshot>, + ) -> Option> { + if matches!(previous, PreviousSectionState::Known(previous) if previous == &self.snapshot()) + { + return None; + } + let previous_had_instructions = match previous { + PreviousSectionState::Absent => false, + PreviousSectionState::Unknown => true, + PreviousSectionState::Known(previous) => previous.instructions.is_some(), + }; + let fragment = match (&self.instructions, previous_had_instructions) { + (Some(instructions), false) => instructions.clone(), + (Some(instructions), true) => ManagedDeveloperInstructions { + instructions: format!("{REPLACEMENT_NOTICE}\n\n{}", instructions.instructions), + }, + (None, true) => ManagedDeveloperInstructions { + instructions: REMOVAL_NOTICE.to_string(), + }, + (None, false) => return None, + }; + Some(Box::new(fragment)) + } +} + +#[cfg(test)] +#[path = "managed_developer_instructions_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/context/world_state/managed_developer_instructions_tests.rs b/codex-rs/core/src/context/world_state/managed_developer_instructions_tests.rs new file mode 100644 index 0000000000..4df1a8ed9b --- /dev/null +++ b/codex-rs/core/src/context/world_state/managed_developer_instructions_tests.rs @@ -0,0 +1,93 @@ +use super::*; +use crate::context::world_state::WorldState; +use codex_config::RequirementSource; +use codex_protocol::models::ResponseItem; +use pretty_assertions::assert_eq; + +#[test] +fn managed_policy_updates_and_removal_are_emitted_once() { + let mut history: Vec = Vec::new(); + let mut previous = None; + + for (instructions, expected) in [ + (None, None), + (Some("initial policy"), Some("initial policy".to_string())), + ( + Some("updated policy"), + Some(format!("{REPLACEMENT_NOTICE}\n\nupdated policy")), + ), + (Some(""), Some(REMOVAL_NOTICE.to_string())), + (None, None), + ] { + let requirement = + instructions.map(|text| Sourced::new(text.to_string(), RequirementSource::Unknown)); + let mut world_state = WorldState::default(); + world_state.add_section(ManagedDeveloperInstructionsState::new(requirement.as_ref())); + let updates = world_state + .render_history_diff(previous.as_ref(), &history) + .into_iter() + .map(ContextualUserFragment::into_boxed_response_item) + .collect::>(); + assert_eq!( + updates, + expected + .map(|instructions| { + ContextualUserFragment::into(ManagedDeveloperInstructions { instructions }) + }) + .into_iter() + .collect::>() + ); + history.extend(updates); + previous = Some(world_state.snapshot()); + assert!( + world_state + .render_history_diff(previous.as_ref(), &history) + .is_empty() + ); + } + + let legacy_policy = ContextualUserFragment::into(ManagedDeveloperInstructions { + instructions: "legacy policy".to_string(), + }); + for (instructions, expected) in [ + ( + Some("current policy"), + format!("{REPLACEMENT_NOTICE}\n\ncurrent policy"), + ), + (None, REMOVAL_NOTICE.to_string()), + ] { + let requirement = + instructions.map(|text| Sourced::new(text.to_string(), RequirementSource::Unknown)); + let mut world_state = WorldState::default(); + world_state.add_section(ManagedDeveloperInstructionsState::new(requirement.as_ref())); + assert_eq!( + world_state + .render_history_diff(/*previous*/ None, std::slice::from_ref(&legacy_policy)) + .into_iter() + .map(ContextualUserFragment::into_boxed_response_item) + .collect::>(), + vec![ContextualUserFragment::into(ManagedDeveloperInstructions { + instructions: expected, + })] + ); + } +} + +#[test] +fn managed_policy_limit_includes_replacement_notice_and_markers() -> io::Result<()> { + let overhead = ManagedDeveloperInstructions { + instructions: format!("{REPLACEMENT_NOTICE}\n\n"), + } + .render() + .len(); + let mut requirement = Sourced::new( + "x".repeat(approx_bytes_for_tokens(MAX_MANAGED_DEVELOPER_INSTRUCTIONS_TOKENS) - overhead), + RequirementSource::Unknown, + ); + validate_managed_developer_instructions(Some(&requirement))?; + requirement.value.push('x'); + let error = validate_managed_developer_instructions(Some(&requirement)) + .expect_err("oversized policy must not be silently truncated"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + Ok(()) +} diff --git a/codex-rs/core/src/context/world_state/mod.rs b/codex-rs/core/src/context/world_state/mod.rs index ac90fc9f55..9cfafeaa37 100644 --- a/codex-rs/core/src/context/world_state/mod.rs +++ b/codex-rs/core/src/context/world_state/mod.rs @@ -5,6 +5,7 @@ mod compact_permissions; mod context_window_guidance; mod environment; mod environments_instructions; +mod managed_developer_instructions; mod model; mod multi_agent_mode; mod multi_agent_usage_hint; @@ -40,6 +41,9 @@ pub(crate) use compact_permissions::CompactPermissionsState; pub(crate) use context_window_guidance::ContextWindowGuidanceState; pub(crate) use environment::EnvironmentsState; pub(crate) use environments_instructions::EnvironmentsInstructionsState; +pub(crate) use managed_developer_instructions::ManagedDeveloperInstructions; +pub(crate) use managed_developer_instructions::ManagedDeveloperInstructionsState; +pub(crate) use managed_developer_instructions::validate_managed_developer_instructions; pub(crate) use model::ModelInstructionsState; pub(crate) use multi_agent_mode::MultiAgentModeState; pub(crate) use multi_agent_usage_hint::MultiAgentUsageHintState; diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 666c708ef7..fa03ecb925 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -1193,6 +1193,9 @@ fn drop_last_n_user_turns_trims_context_updates_above_rolled_back_turn() { assistant_msg("session prefix item"), user_input_text_msg("turn 1 user"), assistant_msg("turn 1 assistant"), + developer_msg( + "\nROLLED_BACK_MANAGED_INSTRUCTIONS\n", + ), developer_msg(&format!( "{APPS_INSTRUCTIONS_OPEN_TAG}\nROLLED_BACK_APPS_INSTRUCTIONS" )), diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 12f0cfd6c5..7682c219eb 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -41,6 +41,7 @@ const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[ "", APPROVED_COMMAND_PREFIX_SAVED_MESSAGE_PREFIX, "", + "", APPS_INSTRUCTIONS_OPEN_TAG, COLLABORATION_MODE_OPEN_TAG, "", diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 513e7c03eb..e9b573b67c 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -21,6 +21,7 @@ use crate::compact::CompactedHistoryMetadata; use crate::config::ManagedFeatures; use crate::config::resolve_tool_suggest_config_from_layer_stack; use crate::context::ContextualUserFragment; +use crate::context::ManagedDeveloperInstructions; use crate::context::ModelSwitchInstructions; use crate::context::MultiAgentRoleInstructions; use crate::context::NetworkRuleSaved; @@ -3646,6 +3647,7 @@ impl Session { } // Render the active mode after the usage hint so it can override that hint. let mut initial_multi_agent_mode = None; + let mut managed_developer_instructions = None; for fragment in world_state.render_full() { match fragment.role() { "developer" @@ -3657,6 +3659,11 @@ impl Session { "developer" if fragment.markers().0 == MULTI_AGENT_MODE_OPEN_TAG => { initial_multi_agent_mode = Some(fragment); } + "developer" + if fragment.markers().0 == ManagedDeveloperInstructions::type_markers().0 => + { + managed_developer_instructions = Some(fragment); + } "developer" if fragment.markers().0 == MultiAgentRoleInstructions::type_markers().0 => { @@ -3706,6 +3713,9 @@ impl Session { { items.push(guardian_developer_message); } + if let Some(managed_developer_instructions) = managed_developer_instructions { + items.push(managed_developer_instructions.into_boxed_response_item()); + } // New context windows and compaction install these items directly into replacement history. for item in &mut items { item.set_turn_id_if_missing(&turn_context.sub_id); diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index 62a9efae26..90a5328a87 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -12,6 +12,7 @@ use crate::context::world_state::CompactPermissionsState; use crate::context::world_state::ContextWindowGuidanceState; use crate::context::world_state::EnvironmentsInstructionsState; use crate::context::world_state::EnvironmentsState; +use crate::context::world_state::ManagedDeveloperInstructionsState; use crate::context::world_state::ModelInstructionsState; use crate::context::world_state::MultiAgentModeState; use crate::context::world_state::MultiAgentUsageHintState; @@ -284,6 +285,14 @@ impl Session { world_state.add_section(usage_hint); } world_state.add_section(multi_agent_mode); + world_state.add_section(ManagedDeveloperInstructionsState::new( + turn_context + .config + .config_layer_stack + .requirements() + .additional_developer_instructions + .as_ref(), + )); Ok(world_state) } } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index daaf811f1d..3a70fff441 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1,3 +1,4 @@ +use codex_config::test_support::CloudConfigBundleFixture; use codex_config::types::AuthCredentialsStoreMode; use codex_core::ModelClient; use codex_core::NewThread; @@ -72,6 +73,7 @@ use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::sse_failed; +use core_test_support::responses::start_mock_server; use core_test_support::responses::strip_metadata_from_json; use core_test_support::responses::strip_response_item_ids_from_json; use core_test_support::responses_metadata as test_responses_metadata; @@ -2893,6 +2895,48 @@ async fn includes_developer_instructions_message_in_request() { ); } +#[tokio::test] +async fn includes_managed_developer_instructions_once_per_request() -> anyhow::Result<()> { + const CLIENT_INSTRUCTIONS: &str = "client developer instructions"; + const MANAGED_INSTRUCTIONS: &str = "managed requirements instructions"; + + let server = start_mock_server().await; + let mut builder = test_codex() + .with_cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement(format!( + "additional_developer_instructions = {MANAGED_INSTRUCTIONS:?}" + )), + ) + .with_config(|config| { + config.developer_instructions = Some(CLIENT_INSTRUCTIONS.to_string()); + }); + let test = builder.build_with_auto_env(&server).await?; + let managed_message = format!( + "\n{MANAGED_INSTRUCTIONS}\n" + ); + + for (response_id, prompt) in [("resp-1", "first turn"), ("resp-2", "second turn")] { + let response = mount_sse_once(&server, sse(vec![ev_completed(response_id)])).await; + test.submit_text_turn(prompt).await?; + + let developer_messages = response.single_request().message_input_texts("developer"); + assert!( + developer_messages + .iter() + .any(|message| message.contains(CLIENT_INSTRUCTIONS)) + ); + assert_eq!( + developer_messages + .iter() + .filter(|message| message.contains("")) + .collect::>(), + vec![&managed_message] + ); + } + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn azure_responses_request_does_not_store_and_preserves_prefixed_item_ids() { skip_if_no_network!(); diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 2012d1a5f5..8c7dd119b1 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -6,6 +6,7 @@ use anyhow::Context; use anyhow::Result; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use codex_config::test_support::CloudConfigBundleFixture; use codex_core::StartThreadOptions; use codex_core::TurnInputRequest; use codex_core::X_CODEX_ROUTING_HINT_HEADER; @@ -3154,9 +3155,14 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res let server = wiremock::MockServer::start().await; let stale_developer_message = "STALE_DEVELOPER_INSTRUCTIONS_SHOULD_BE_REMOVED"; + let managed_requirements = CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"additional_developer_instructions = "MANAGED_DEVELOPER_INSTRUCTIONS""#, + ); + let managed_message = "\nMANAGED_DEVELOPER_INSTRUCTIONS\n"; - let mut start_builder = - test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let mut start_builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_cloud_config_bundle(managed_requirements.clone()); let initial = start_builder.build(&server).await?; let home = initial.home.clone(); let rollout_path = initial @@ -3233,8 +3239,9 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res }) .await; - let mut resume_builder = - test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let mut resume_builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_cloud_config_bundle(managed_requirements); let resumed = resume_builder.resume(&server, home, rollout_path).await?; resumed @@ -3249,6 +3256,17 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res assert_eq!(compact_mock.requests().len(), 1); let requests = responses_mock.requests(); assert_eq!(requests.len(), 3, "expected three model requests"); + for request in &requests { + assert_eq!( + request + .message_input_texts("developer") + .into_iter() + .filter(|message| message.contains("")) + .collect::>(), + vec![managed_message.to_string()], + "Each model request should contain exactly one managed developer-instructions block, including after compaction and resume." + ); + } let after_compact_request = &requests[1]; let after_resume_request = &requests[2]; diff --git a/codex-rs/thread-store/src/local/rollout_migration/rollback.rs b/codex-rs/thread-store/src/local/rollout_migration/rollback.rs index 7f6d992e0c..7a0a5c108e 100644 --- a/codex-rs/thread-store/src/local/rollout_migration/rollback.rs +++ b/codex-rs/thread-store/src/local/rollout_migration/rollback.rs @@ -88,6 +88,7 @@ fn is_known_contextual_developer_message_content(content: &[ContentItem]) -> boo [ "", "", + "", "", "", "", diff --git a/codex-rs/thread-store/src/local/rollout_migration_tests.rs b/codex-rs/thread-store/src/local/rollout_migration_tests.rs index 1fd9af7691..ab7cbf6307 100644 --- a/codex-rs/thread-store/src/local/rollout_migration_tests.rs +++ b/codex-rs/thread-store/src/local/rollout_migration_tests.rs @@ -716,6 +716,10 @@ async fn migration_rolls_back_response_and_inter_agent_user_boundaries() { SessionSource::Cli, vec![ rollout_response_item(input_response_message("user", "keep first boundary")), + rollout_response_item(input_response_message( + "developer", + "context only", + )), rollout_response_item(input_response_message( "developer", "context only", diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 00369b7ecb..e0d76deb01 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -960,6 +960,7 @@ interrupt_message = false allowed_sandbox_implementations: None, sandbox_private_desktop: Some(false), }), + additional_developer_instructions: None, guardian_policy_config: Some("Use the managed guardian policy.".to_string()), feature_requirements: Some(FeatureRequirementsToml { entries: BTreeMap::from([("guardian_approval".to_string(), true)]),