mirror of
https://github.com/openai/codex.git
synced 2026-10-25 18:28:38 +00:00
Merge 8e53b89280 into sapling-pr-archive-bolinfest
This commit is contained in:
Generated
+2
@@ -2985,6 +2985,7 @@ dependencies = [
|
||||
"codex-protocol",
|
||||
"codex-state",
|
||||
"codex-tools",
|
||||
"codex-utils-template",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3590,6 +3591,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"zstd 0.13.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -160,11 +160,13 @@ fn sample_thread_with_metadata(
|
||||
ephemeral: bool,
|
||||
source: AppServerSessionSource,
|
||||
thread_source: Option<AppServerThreadSource>,
|
||||
parent_thread_id: Option<String>,
|
||||
) -> Thread {
|
||||
Thread {
|
||||
id: thread_id.to_string(),
|
||||
session_id: format!("session-{thread_id}"),
|
||||
forked_from_id: None,
|
||||
parent_thread_id,
|
||||
preview: "first prompt".to_string(),
|
||||
ephemeral,
|
||||
model_provider: "openai".to_string(),
|
||||
@@ -195,6 +197,7 @@ fn sample_thread_start_response(
|
||||
ephemeral,
|
||||
AppServerSessionSource::Exec,
|
||||
Some(AppServerThreadSource::User),
|
||||
/*parent_thread_id*/ None,
|
||||
),
|
||||
model: model.to_string(),
|
||||
model_provider: "openai".to_string(),
|
||||
@@ -240,6 +243,7 @@ fn sample_thread_resume_response(
|
||||
model,
|
||||
AppServerSessionSource::Exec,
|
||||
Some(AppServerThreadSource::User),
|
||||
/*parent_thread_id*/ None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -249,9 +253,16 @@ fn sample_thread_resume_response_with_source(
|
||||
model: &str,
|
||||
source: AppServerSessionSource,
|
||||
thread_source: Option<AppServerThreadSource>,
|
||||
parent_thread_id: Option<String>,
|
||||
) -> ClientResponsePayload {
|
||||
ClientResponsePayload::ThreadResume(ThreadResumeResponse {
|
||||
thread: sample_thread_with_metadata(thread_id, ephemeral, source, thread_source),
|
||||
thread: sample_thread_with_metadata(
|
||||
thread_id,
|
||||
ephemeral,
|
||||
source,
|
||||
thread_source,
|
||||
parent_thread_id,
|
||||
),
|
||||
model: model.to_string(),
|
||||
model_provider: "openai".to_string(),
|
||||
service_tier: None,
|
||||
@@ -1755,6 +1766,7 @@ async fn compaction_event_ingests_custom_fact() {
|
||||
agent_role: None,
|
||||
}),
|
||||
Some(AppServerThreadSource::Subagent),
|
||||
Some(parent_thread_id.to_string()),
|
||||
)),
|
||||
},
|
||||
&mut events,
|
||||
@@ -2456,7 +2468,7 @@ fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() {
|
||||
SubAgentThreadStartedInput {
|
||||
session_id: "session-root".to_string(),
|
||||
thread_id: "thread-spawn".to_string(),
|
||||
parent_thread_id: None,
|
||||
parent_thread_id: Some(parent_thread_id.to_string()),
|
||||
product_client_id: "codex-tui".to_string(),
|
||||
client_name: "codex-tui".to_string(),
|
||||
client_version: "1.0.0".to_string(),
|
||||
@@ -2534,11 +2546,14 @@ fn subagent_thread_started_other_serializes_expected_shape() {
|
||||
|
||||
#[test]
|
||||
fn subagent_thread_started_other_serializes_explicit_parent_thread_id() {
|
||||
let parent_thread_id =
|
||||
codex_protocol::ThreadId::from_string("33333333-3333-4333-8333-333333333333")
|
||||
.expect("valid thread id");
|
||||
let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request(
|
||||
SubAgentThreadStartedInput {
|
||||
session_id: "session-root".to_string(),
|
||||
thread_id: "thread-guardian".to_string(),
|
||||
parent_thread_id: Some("parent-thread-guardian".to_string()),
|
||||
parent_thread_id: Some(parent_thread_id.to_string()),
|
||||
product_client_id: "codex-tui".to_string(),
|
||||
client_name: "codex-tui".to_string(),
|
||||
client_version: "1.0.0".to_string(),
|
||||
@@ -2553,7 +2568,7 @@ fn subagent_thread_started_other_serializes_explicit_parent_thread_id() {
|
||||
assert_eq!(payload["event_params"]["subagent_source"], "guardian");
|
||||
assert_eq!(
|
||||
payload["event_params"]["parent_thread_id"],
|
||||
"parent-thread-guardian"
|
||||
"33333333-3333-4333-8333-333333333333"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2642,7 +2657,7 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() {
|
||||
SubAgentThreadStartedInput {
|
||||
session_id: "session-root".to_string(),
|
||||
thread_id: "thread-review".to_string(),
|
||||
parent_thread_id: None,
|
||||
parent_thread_id: Some(parent_thread_id.to_string()),
|
||||
product_client_id: "parent-client".to_string(),
|
||||
client_name: "parent-client".to_string(),
|
||||
client_version: "1.0.0".to_string(),
|
||||
|
||||
@@ -124,6 +124,7 @@ fn sample_thread(thread_id: &str) -> Thread {
|
||||
id: thread_id.to_string(),
|
||||
session_id: format!("session-{thread_id}"),
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
preview: "first prompt".to_string(),
|
||||
ephemeral: false,
|
||||
model_provider: "openai".to_string(),
|
||||
|
||||
@@ -1012,6 +1012,7 @@ fn analytics_hook_source(source: HookSource) -> &'static str {
|
||||
HookSource::SessionFlags => "session_flags",
|
||||
HookSource::Plugin => "plugin",
|
||||
HookSource::CloudRequirements => "cloud_requirements",
|
||||
HookSource::CloudManagedConfig => "cloud_managed_config",
|
||||
HookSource::LegacyManagedConfigFile => "legacy_managed_config_file",
|
||||
HookSource::LegacyManagedConfigMdm => "legacy_managed_config_mdm",
|
||||
HookSource::Unknown => "unknown",
|
||||
@@ -1047,9 +1048,7 @@ pub(crate) fn subagent_thread_started_event_request(
|
||||
thread_source: Some(ThreadSource::Subagent),
|
||||
initialization_mode: ThreadInitializationMode::New,
|
||||
subagent_source: Some(subagent_source_name(&input.subagent_source)),
|
||||
parent_thread_id: input
|
||||
.parent_thread_id
|
||||
.or_else(|| subagent_parent_thread_id(&input.subagent_source)),
|
||||
parent_thread_id: input.parent_thread_id,
|
||||
created_at: input.created_at,
|
||||
};
|
||||
ThreadInitializedEvent {
|
||||
@@ -1059,19 +1058,7 @@ pub(crate) fn subagent_thread_started_event_request(
|
||||
}
|
||||
|
||||
pub(crate) fn subagent_source_name(subagent_source: &SubAgentSource) -> String {
|
||||
match subagent_source {
|
||||
SubAgentSource::Review => "review".to_string(),
|
||||
SubAgentSource::Compact => "compact".to_string(),
|
||||
SubAgentSource::ThreadSpawn { .. } => "thread_spawn".to_string(),
|
||||
SubAgentSource::MemoryConsolidation => "memory_consolidation".to_string(),
|
||||
SubAgentSource::Other(other) => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option<String> {
|
||||
subagent_source
|
||||
.parent_thread_id()
|
||||
.map(|parent_thread_id| parent_thread_id.to_string())
|
||||
subagent_source.kind().to_string()
|
||||
}
|
||||
|
||||
fn analytics_hook_status(status: HookRunStatus) -> HookRunStatus {
|
||||
|
||||
@@ -55,7 +55,6 @@ use crate::events::codex_hook_run_metadata;
|
||||
use crate::events::codex_plugin_metadata;
|
||||
use crate::events::codex_plugin_used_metadata;
|
||||
use crate::events::plugin_state_event_type;
|
||||
use crate::events::subagent_parent_thread_id;
|
||||
use crate::events::subagent_source_name;
|
||||
use crate::events::subagent_thread_started_event_request;
|
||||
use crate::facts::AnalyticsFact;
|
||||
@@ -267,20 +266,18 @@ impl ThreadMetadataState {
|
||||
session_id: String,
|
||||
session_source: &SessionSource,
|
||||
thread_source: Option<ThreadSource>,
|
||||
parent_thread_id: Option<String>,
|
||||
initialization_mode: ThreadInitializationMode,
|
||||
) -> Self {
|
||||
let (subagent_source, parent_thread_id) = match session_source {
|
||||
SessionSource::SubAgent(subagent_source) => (
|
||||
Some(subagent_source_name(subagent_source)),
|
||||
subagent_parent_thread_id(subagent_source),
|
||||
),
|
||||
let subagent_source = match session_source {
|
||||
SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)),
|
||||
SessionSource::Cli
|
||||
| SessionSource::VSCode
|
||||
| SessionSource::Exec
|
||||
| SessionSource::Mcp
|
||||
| SessionSource::Custom(_)
|
||||
| SessionSource::Internal(_)
|
||||
| SessionSource::Unknown => (None, None),
|
||||
| SessionSource::Unknown => None,
|
||||
};
|
||||
Self {
|
||||
session_id,
|
||||
@@ -516,10 +513,7 @@ impl AnalyticsReducer {
|
||||
input: SubAgentThreadStartedInput,
|
||||
out: &mut Vec<TrackEventRequest>,
|
||||
) {
|
||||
let parent_thread_id = input
|
||||
.parent_thread_id
|
||||
.clone()
|
||||
.or_else(|| subagent_parent_thread_id(&input.subagent_source));
|
||||
let parent_thread_id = input.parent_thread_id.clone();
|
||||
let parent_connection_id = parent_thread_id
|
||||
.as_ref()
|
||||
.and_then(|parent_thread_id| self.threads.get(parent_thread_id))
|
||||
@@ -1238,6 +1232,7 @@ impl AnalyticsReducer {
|
||||
let session_source: SessionSource = thread.source.into();
|
||||
let session_id = thread.session_id;
|
||||
let thread_id = thread.id;
|
||||
let parent_thread_id = thread.parent_thread_id;
|
||||
let Some(connection_state) = self.connections.get(&connection_id) else {
|
||||
return;
|
||||
};
|
||||
@@ -1245,6 +1240,7 @@ impl AnalyticsReducer {
|
||||
session_id.clone(),
|
||||
&session_source,
|
||||
thread.thread_source.map(Into::into),
|
||||
parent_thread_id,
|
||||
initialization_mode,
|
||||
);
|
||||
self.threads.insert(
|
||||
|
||||
@@ -2002,6 +2002,7 @@
|
||||
"sessionFlags",
|
||||
"plugin",
|
||||
"cloudRequirements",
|
||||
"cloudManagedConfig",
|
||||
"legacyManagedConfigFile",
|
||||
"legacyManagedConfigMdm",
|
||||
"unknown"
|
||||
@@ -3365,6 +3366,13 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"parentThreadId": {
|
||||
"description": "The ID of the parent thread. This will only be set if this thread is a subagent.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"description": "[UNSTABLE] Path to the thread on disk.",
|
||||
"type": [
|
||||
|
||||
+35
@@ -7603,6 +7603,33 @@
|
||||
"title": "SystemConfigLayerSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Enterprise-managed config layer delivered by the cloud config bundle.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Stable identifier for the delivered layer.",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "Admin-facing name for the delivered layer. This is surfaced in diagnostics so users know which cloud layer needs administrator attention.",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"enterpriseManaged"
|
||||
],
|
||||
"title": "EnterpriseManagedConfigLayerSourceType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"type"
|
||||
],
|
||||
"title": "EnterpriseManagedConfigLayerSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory",
|
||||
"properties": {
|
||||
@@ -10091,6 +10118,7 @@
|
||||
"sessionFlags",
|
||||
"plugin",
|
||||
"cloudRequirements",
|
||||
"cloudManagedConfig",
|
||||
"legacyManagedConfigFile",
|
||||
"legacyManagedConfigMdm",
|
||||
"unknown"
|
||||
@@ -15485,6 +15513,13 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"parentThreadId": {
|
||||
"description": "The ID of the parent thread. This will only be set if this thread is a subagent.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"description": "[UNSTABLE] Path to the thread on disk.",
|
||||
"type": [
|
||||
|
||||
+35
@@ -3972,6 +3972,33 @@
|
||||
"title": "SystemConfigLayerSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Enterprise-managed config layer delivered by the cloud config bundle.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Stable identifier for the delivered layer.",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "Admin-facing name for the delivered layer. This is surfaced in diagnostics so users know which cloud layer needs administrator attention.",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"enterpriseManaged"
|
||||
],
|
||||
"title": "EnterpriseManagedConfigLayerSourceType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"type"
|
||||
],
|
||||
"title": "EnterpriseManagedConfigLayerSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory",
|
||||
"properties": {
|
||||
@@ -6571,6 +6598,7 @@
|
||||
"sessionFlags",
|
||||
"plugin",
|
||||
"cloudRequirements",
|
||||
"cloudManagedConfig",
|
||||
"legacyManagedConfigFile",
|
||||
"legacyManagedConfigMdm",
|
||||
"unknown"
|
||||
@@ -13309,6 +13337,13 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"parentThreadId": {
|
||||
"description": "The ID of the parent thread. This will only be set if this thread is a subagent.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"description": "[UNSTABLE] Path to the thread on disk.",
|
||||
"type": [
|
||||
|
||||
@@ -498,6 +498,33 @@
|
||||
"title": "SystemConfigLayerSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Enterprise-managed config layer delivered by the cloud config bundle.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Stable identifier for the delivered layer.",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "Admin-facing name for the delivered layer. This is surfaced in diagnostics so users know which cloud layer needs administrator attention.",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"enterpriseManaged"
|
||||
],
|
||||
"title": "EnterpriseManagedConfigLayerSourceType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"type"
|
||||
],
|
||||
"title": "EnterpriseManagedConfigLayerSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory",
|
||||
"properties": {
|
||||
|
||||
@@ -73,6 +73,33 @@
|
||||
"title": "SystemConfigLayerSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Enterprise-managed config layer delivered by the cloud config bundle.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Stable identifier for the delivered layer.",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "Admin-facing name for the delivered layer. This is surfaced in diagnostics so users know which cloud layer needs administrator attention.",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"enterpriseManaged"
|
||||
],
|
||||
"title": "EnterpriseManagedConfigLayerSourceType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"type"
|
||||
],
|
||||
"title": "EnterpriseManagedConfigLayerSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory",
|
||||
"properties": {
|
||||
|
||||
@@ -166,6 +166,7 @@
|
||||
"sessionFlags",
|
||||
"plugin",
|
||||
"cloudRequirements",
|
||||
"cloudManagedConfig",
|
||||
"legacyManagedConfigFile",
|
||||
"legacyManagedConfigMdm",
|
||||
"unknown"
|
||||
|
||||
@@ -166,6 +166,7 @@
|
||||
"sessionFlags",
|
||||
"plugin",
|
||||
"cloudRequirements",
|
||||
"cloudManagedConfig",
|
||||
"legacyManagedConfigFile",
|
||||
"legacyManagedConfigMdm",
|
||||
"unknown"
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
"sessionFlags",
|
||||
"plugin",
|
||||
"cloudRequirements",
|
||||
"cloudManagedConfig",
|
||||
"legacyManagedConfigFile",
|
||||
"legacyManagedConfigMdm",
|
||||
"unknown"
|
||||
|
||||
@@ -1036,6 +1036,13 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"parentThreadId": {
|
||||
"description": "The ID of the parent thread. This will only be set if this thread is a subagent.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"description": "[UNSTABLE] Path to the thread on disk.",
|
||||
"type": [
|
||||
|
||||
@@ -851,6 +851,13 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"parentThreadId": {
|
||||
"description": "The ID of the parent thread. This will only be set if this thread is a subagent.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"description": "[UNSTABLE] Path to the thread on disk.",
|
||||
"type": [
|
||||
|
||||
+7
@@ -851,6 +851,13 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"parentThreadId": {
|
||||
"description": "The ID of the parent thread. This will only be set if this thread is a subagent.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"description": "[UNSTABLE] Path to the thread on disk.",
|
||||
"type": [
|
||||
|
||||
@@ -851,6 +851,13 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"parentThreadId": {
|
||||
"description": "The ID of the parent thread. This will only be set if this thread is a subagent.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"description": "[UNSTABLE] Path to the thread on disk.",
|
||||
"type": [
|
||||
|
||||
@@ -1036,6 +1036,13 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"parentThreadId": {
|
||||
"description": "The ID of the parent thread. This will only be set if this thread is a subagent.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"description": "[UNSTABLE] Path to the thread on disk.",
|
||||
"type": [
|
||||
|
||||
@@ -851,6 +851,13 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"parentThreadId": {
|
||||
"description": "The ID of the parent thread. This will only be set if this thread is a subagent.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"description": "[UNSTABLE] Path to the thread on disk.",
|
||||
"type": [
|
||||
|
||||
@@ -1036,6 +1036,13 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"parentThreadId": {
|
||||
"description": "The ID of the parent thread. This will only be set if this thread is a subagent.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"description": "[UNSTABLE] Path to the thread on disk.",
|
||||
"type": [
|
||||
|
||||
@@ -851,6 +851,13 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"parentThreadId": {
|
||||
"description": "The ID of the parent thread. This will only be set if this thread is a subagent.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"description": "[UNSTABLE] Path to the thread on disk.",
|
||||
"type": [
|
||||
|
||||
@@ -851,6 +851,13 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"parentThreadId": {
|
||||
"description": "The ID of the parent thread. This will only be set if this thread is a subagent.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"description": "[UNSTABLE] Path to the thread on disk.",
|
||||
"type": [
|
||||
|
||||
@@ -8,7 +8,17 @@ export type ConfigLayerSource = { "type": "mdm", domain: string, key: string, }
|
||||
* This is the path to the system config.toml file, though it is not
|
||||
* guaranteed to exist.
|
||||
*/
|
||||
file: AbsolutePathBuf, } | { "type": "user",
|
||||
file: AbsolutePathBuf, } | { "type": "enterpriseManaged",
|
||||
/**
|
||||
* Stable identifier for the delivered layer.
|
||||
*/
|
||||
id: string,
|
||||
/**
|
||||
* Admin-facing name for the delivered layer. This is surfaced in
|
||||
* diagnostics so users know which cloud layer needs administrator
|
||||
* attention.
|
||||
*/
|
||||
name: string, } | { "type": "user",
|
||||
/**
|
||||
* This is the path to the user's config.toml file, though it is not
|
||||
* guaranteed to exist.
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type HookSource = "system" | "user" | "project" | "mdm" | "sessionFlags" | "plugin" | "cloudRequirements" | "legacyManagedConfigFile" | "legacyManagedConfigMdm" | "unknown";
|
||||
export type HookSource = "system" | "user" | "project" | "mdm" | "sessionFlags" | "plugin" | "cloudRequirements" | "cloudManagedConfig" | "legacyManagedConfigFile" | "legacyManagedConfigMdm" | "unknown";
|
||||
|
||||
@@ -17,6 +17,10 @@ sessionId: string,
|
||||
* Source thread id when this thread was created by forking another thread.
|
||||
*/
|
||||
forkedFromId: string | null,
|
||||
/**
|
||||
* The ID of the parent thread. This will only be set if this thread is a subagent.
|
||||
*/
|
||||
parentThreadId: string | null,
|
||||
/**
|
||||
* Usually the first user message in the thread, if available.
|
||||
*/
|
||||
|
||||
@@ -2328,6 +2328,7 @@ mod tests {
|
||||
id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(),
|
||||
session_id: "67e55044-10b1-426f-9247-bb680e5fe0c7".to_string(),
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
preview: "first prompt".to_string(),
|
||||
ephemeral: true,
|
||||
model_provider: "openai".to_string(),
|
||||
@@ -2370,6 +2371,7 @@ mod tests {
|
||||
"id": "67e55044-10b1-426f-9247-bb680e5fe0c8",
|
||||
"sessionId": "67e55044-10b1-426f-9247-bb680e5fe0c7",
|
||||
"forkedFromId": null,
|
||||
"parentThreadId": null,
|
||||
"preview": "first prompt",
|
||||
"ephemeral": true,
|
||||
"modelProvider": "openai",
|
||||
|
||||
@@ -43,6 +43,19 @@ pub enum ConfigLayerSource {
|
||||
file: AbsolutePathBuf,
|
||||
},
|
||||
|
||||
/// Enterprise-managed config layer delivered by the cloud config bundle.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
EnterpriseManaged {
|
||||
/// Stable identifier for the delivered layer.
|
||||
id: String,
|
||||
|
||||
/// Admin-facing name for the delivered layer. This is surfaced in
|
||||
/// diagnostics so users know which cloud layer needs administrator
|
||||
/// attention.
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// User config layer from $CODEX_HOME/config.toml. This layer is special
|
||||
/// in that it is expected to be:
|
||||
/// - writable by the user
|
||||
@@ -90,6 +103,7 @@ impl ConfigLayerSource {
|
||||
match self {
|
||||
ConfigLayerSource::Mdm { .. } => 0,
|
||||
ConfigLayerSource::System { .. } => 10,
|
||||
ConfigLayerSource::EnterpriseManaged { .. } => 15,
|
||||
ConfigLayerSource::User { profile, .. } => {
|
||||
if profile.is_some() {
|
||||
21
|
||||
|
||||
@@ -48,6 +48,7 @@ v2_enum_from_core!(
|
||||
SessionFlags,
|
||||
Plugin,
|
||||
CloudRequirements,
|
||||
CloudManagedConfig,
|
||||
LegacyManagedConfigFile,
|
||||
LegacyManagedConfigMdm,
|
||||
Unknown,
|
||||
|
||||
@@ -142,6 +142,7 @@ fn thread_resume_response_round_trips_initial_turns_page() {
|
||||
id: "thr_123".to_string(),
|
||||
session_id: "thr_123".to_string(),
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
preview: String::new(),
|
||||
ephemeral: false,
|
||||
model_provider: "openai".to_string(),
|
||||
@@ -3581,6 +3582,7 @@ fn thread_lifecycle_responses_default_missing_optional_fields() {
|
||||
let fork: ThreadForkResponse = serde_json::from_value(response).expect("thread/fork response");
|
||||
|
||||
assert_eq!(start.instruction_sources, Vec::<AbsolutePathBuf>::new());
|
||||
assert_eq!(start.thread.parent_thread_id, None);
|
||||
assert_eq!(resume.instruction_sources, Vec::<AbsolutePathBuf>::new());
|
||||
assert_eq!(fork.instruction_sources, Vec::<AbsolutePathBuf>::new());
|
||||
assert_eq!(start.active_permission_profile, None);
|
||||
|
||||
@@ -108,6 +108,8 @@ pub struct Thread {
|
||||
pub session_id: String,
|
||||
/// Source thread id when this thread was created by forking another thread.
|
||||
pub forked_from_id: Option<String>,
|
||||
/// The ID of the parent thread. This will only be set if this thread is a subagent.
|
||||
pub parent_thread_id: Option<String>,
|
||||
/// Usually the first user message in the thread, if available.
|
||||
pub preview: String,
|
||||
/// Whether the thread is ephemeral and should not be materialized on disk.
|
||||
|
||||
@@ -134,7 +134,7 @@ Example with notification opt-out:
|
||||
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`.
|
||||
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`.
|
||||
- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known.
|
||||
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
|
||||
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate control/spawn parent is known.
|
||||
- `thread/loaded/list` — list the thread ids currently loaded in memory.
|
||||
- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
|
||||
- `thread/turns/list` — experimental; page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `itemsView`, `nextCursor`, and `backwardsCursor`.
|
||||
@@ -424,7 +424,7 @@ Later, after the idle unload timeout:
|
||||
|
||||
### Example: Read a thread
|
||||
|
||||
Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want thread history loaded into `thread.turns`. The returned thread includes `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available.
|
||||
Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want thread history loaded into `thread.turns`. The returned thread includes `parentThreadId`, `agentNickname`, and `agentRole` for subagent threads when available.
|
||||
|
||||
```json
|
||||
{ "method": "thread/read", "id": 22, "params": { "threadId": "thr_123" } }
|
||||
|
||||
@@ -2170,6 +2170,7 @@ mod tests {
|
||||
thread_id,
|
||||
rollout_path: None,
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
preview: "fallback preview".to_string(),
|
||||
name: Some("Rollback thread".to_string()),
|
||||
model_provider: "openai".to_string(),
|
||||
|
||||
@@ -624,6 +624,9 @@ fn override_message(layer: &ConfigLayerSource) -> String {
|
||||
ConfigLayerSource::System { file } => {
|
||||
format!("Overridden by managed config (system): {}", file.display())
|
||||
}
|
||||
ConfigLayerSource::EnterpriseManaged { id: _, name } => {
|
||||
format!("Overridden by enterprise-managed config: {name}")
|
||||
}
|
||||
ConfigLayerSource::Project { dot_codex_folder } => format!(
|
||||
"Overridden by project config: {}/{CONFIG_TOML_FILE}",
|
||||
dot_codex_folder.display(),
|
||||
|
||||
@@ -4015,6 +4015,7 @@ pub(crate) fn thread_from_stored_thread(
|
||||
id: thread_id.clone(),
|
||||
session_id: thread_id,
|
||||
forked_from_id: thread.forked_from_id.map(|id| id.to_string()),
|
||||
parent_thread_id: thread.parent_thread_id.map(|id| id.to_string()),
|
||||
preview: thread.preview,
|
||||
ephemeral: false,
|
||||
model_provider: if thread.model_provider.is_empty() {
|
||||
@@ -4223,6 +4224,7 @@ fn build_thread_from_snapshot(
|
||||
id: thread_id.to_string(),
|
||||
session_id,
|
||||
forked_from_id: None,
|
||||
parent_thread_id: config_snapshot.parent_thread_id.map(|id| id.to_string()),
|
||||
preview: String::new(),
|
||||
ephemeral: config_snapshot.ephemeral,
|
||||
model_provider: config_snapshot.model_provider_id.clone(),
|
||||
|
||||
@@ -395,6 +395,7 @@ mod thread_processor_behavior_tests {
|
||||
thread_id,
|
||||
rollout_path: Some(PathBuf::from("/tmp/thread.jsonl")),
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
preview: "preview".to_string(),
|
||||
name: None,
|
||||
model_provider: "openai".to_string(),
|
||||
@@ -681,6 +682,7 @@ mod thread_processor_behavior_tests {
|
||||
},
|
||||
},
|
||||
session_source: SessionSource::Cli,
|
||||
parent_thread_id: None,
|
||||
thread_source: None,
|
||||
};
|
||||
|
||||
|
||||
@@ -172,6 +172,7 @@ mod tests {
|
||||
id: "thread-1".to_string(),
|
||||
session_id: "session-1".to_string(),
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
preview: "preview".to_string(),
|
||||
ephemeral: false,
|
||||
model_provider: "mock_provider".to_string(),
|
||||
|
||||
@@ -305,6 +305,7 @@ pub(crate) fn summary_to_thread(
|
||||
id: thread_id.clone(),
|
||||
session_id: thread_id,
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
preview,
|
||||
ephemeral: false,
|
||||
model_provider,
|
||||
|
||||
@@ -891,6 +891,7 @@ mod tests {
|
||||
id: thread_id.to_string(),
|
||||
session_id: thread_id.to_string(),
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
preview: String::new(),
|
||||
ephemeral: false,
|
||||
model_provider: "mock-provider".to_string(),
|
||||
|
||||
@@ -38,6 +38,7 @@ pub use responses::create_final_assistant_message_sse_response;
|
||||
pub use responses::create_request_permissions_sse_response;
|
||||
pub use responses::create_request_user_input_sse_response;
|
||||
pub use responses::create_shell_command_sse_response;
|
||||
pub use rollout::create_fake_parented_rollout_with_source;
|
||||
pub use rollout::create_fake_rollout;
|
||||
pub use rollout::create_fake_rollout_with_source;
|
||||
pub use rollout::create_fake_rollout_with_text_elements;
|
||||
|
||||
@@ -118,6 +118,53 @@ pub fn create_fake_rollout_with_source(
|
||||
model_provider: Option<&str>,
|
||||
git_info: Option<GitInfo>,
|
||||
source: SessionSource,
|
||||
) -> Result<String> {
|
||||
create_fake_rollout_with_source_and_parent_thread_id(
|
||||
codex_home,
|
||||
filename_ts,
|
||||
meta_rfc3339,
|
||||
preview,
|
||||
model_provider,
|
||||
git_info,
|
||||
source,
|
||||
/*parent_thread_id*/ None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a minimal rollout file with an explicit session source and control parent.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn create_fake_parented_rollout_with_source(
|
||||
codex_home: &Path,
|
||||
filename_ts: &str,
|
||||
meta_rfc3339: &str,
|
||||
preview: &str,
|
||||
model_provider: Option<&str>,
|
||||
git_info: Option<GitInfo>,
|
||||
source: SessionSource,
|
||||
parent_thread_id: ThreadId,
|
||||
) -> Result<String> {
|
||||
create_fake_rollout_with_source_and_parent_thread_id(
|
||||
codex_home,
|
||||
filename_ts,
|
||||
meta_rfc3339,
|
||||
preview,
|
||||
model_provider,
|
||||
git_info,
|
||||
source,
|
||||
Some(parent_thread_id),
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn create_fake_rollout_with_source_and_parent_thread_id(
|
||||
codex_home: &Path,
|
||||
filename_ts: &str,
|
||||
meta_rfc3339: &str,
|
||||
preview: &str,
|
||||
model_provider: Option<&str>,
|
||||
git_info: Option<GitInfo>,
|
||||
source: SessionSource,
|
||||
parent_thread_id: Option<ThreadId>,
|
||||
) -> Result<String> {
|
||||
let uuid = Uuid::new_v4();
|
||||
let uuid_str = uuid.to_string();
|
||||
@@ -133,6 +180,7 @@ pub fn create_fake_rollout_with_source(
|
||||
let meta = SessionMeta {
|
||||
id: conversation_id,
|
||||
forked_from_id: None,
|
||||
parent_thread_id,
|
||||
timestamp: meta_rfc3339.to_string(),
|
||||
cwd: PathBuf::from("/"),
|
||||
originator: "codex".to_string(),
|
||||
@@ -217,6 +265,7 @@ pub fn create_fake_rollout_with_text_elements(
|
||||
let meta = SessionMeta {
|
||||
id: conversation_id,
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
timestamp: meta_rfc3339.to_string(),
|
||||
cwd: PathBuf::from("/"),
|
||||
originator: "codex".to_string(),
|
||||
|
||||
@@ -124,6 +124,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() ->
|
||||
.create_thread(CreateThreadParams {
|
||||
thread_id,
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
source: SessionSource::Cli,
|
||||
thread_source: None,
|
||||
base_instructions: BaseInstructions::default(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use anyhow::Result;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::create_fake_parented_rollout_with_source;
|
||||
use app_test_support::create_fake_rollout;
|
||||
use app_test_support::create_fake_rollout_with_source;
|
||||
use app_test_support::to_response;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
@@ -9,6 +9,7 @@ use codex_app_server_protocol::ReviewDelivery;
|
||||
use codex_app_server_protocol::ReviewStartParams;
|
||||
use codex_app_server_protocol::ReviewStartResponse;
|
||||
use codex_app_server_protocol::ReviewTarget;
|
||||
use codex_app_server_protocol::SessionSource as ApiSessionSource;
|
||||
use codex_app_server_protocol::ThreadForkParams;
|
||||
use codex_app_server_protocol::ThreadForkResponse;
|
||||
use codex_app_server_protocol::ThreadResumeParams;
|
||||
@@ -198,7 +199,7 @@ async fn turn_start_sends_fork_lineage_in_turn_metadata_for_thread_fork_v2() ->
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn review_start_sends_fork_lineage_in_turn_metadata_for_thread_fork_v2() -> Result<()> {
|
||||
async fn review_start_sends_parent_lineage_in_turn_metadata_for_thread_fork_v2() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let review_payload = serde_json::json!({
|
||||
@@ -276,8 +277,9 @@ async fn review_start_sends_fork_lineage_in_turn_metadata_for_thread_fork_v2() -
|
||||
request.header("x-openai-subagent").as_deref(),
|
||||
Some("review")
|
||||
);
|
||||
assert!(metadata.get("forked_from_thread_id").is_none());
|
||||
assert_eq!(
|
||||
metadata["forked_from_thread_id"].as_str(),
|
||||
metadata["parent_thread_id"].as_str(),
|
||||
Some(review_thread_id.as_str())
|
||||
);
|
||||
let review_request_thread_id = metadata["thread_id"]
|
||||
@@ -297,7 +299,7 @@ async fn review_start_sends_fork_lineage_in_turn_metadata_for_thread_fork_v2() -
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_start_sends_subagent_lineage_after_cold_thread_resume_v2() -> Result<()> {
|
||||
async fn turn_start_sends_other_subagent_lineage_after_cold_thread_resume_v2() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
@@ -320,20 +322,15 @@ async fn turn_start_sends_subagent_lineage_after_cold_thread_resume_v2() -> Resu
|
||||
|
||||
let parent_thread_id = CoreThreadId::new();
|
||||
let parent_thread_id_str = parent_thread_id.to_string();
|
||||
let subagent_thread_id = create_fake_rollout_with_source(
|
||||
let subagent_thread_id = create_fake_parented_rollout_with_source(
|
||||
codex_home.path(),
|
||||
"2025-01-05T12-00-00",
|
||||
"2025-01-05T12:00:00Z",
|
||||
"Saved subagent message",
|
||||
Some("mock_provider"),
|
||||
/*git_info*/ None,
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
}),
|
||||
SessionSource::SubAgent(SubAgentSource::Other("guardian".to_string())),
|
||||
parent_thread_id,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
@@ -352,6 +349,11 @@ async fn turn_start_sends_subagent_lineage_after_cold_thread_resume_v2() -> Resu
|
||||
.await??;
|
||||
let ThreadResumeResponse { thread, .. } = to_response::<ThreadResumeResponse>(resume_resp)?;
|
||||
assert_eq!(thread.id, subagent_thread_id);
|
||||
assert_eq!(thread.parent_thread_id, Some(parent_thread_id_str.clone()));
|
||||
assert_eq!(
|
||||
thread.source,
|
||||
ApiSessionSource::SubAgent(SubAgentSource::Other("guardian".to_string()))
|
||||
);
|
||||
|
||||
let turn_req = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
@@ -386,7 +388,7 @@ async fn turn_start_sends_subagent_lineage_after_cold_thread_resume_v2() -> Resu
|
||||
metadata["parent_thread_id"].as_str(),
|
||||
Some(parent_thread_id_str.as_str())
|
||||
);
|
||||
assert_eq!(metadata["subagent_kind"].as_str(), Some("thread_spawn"));
|
||||
assert_eq!(metadata["subagent_kind"].as_str(), Some("guardian"));
|
||||
assert_eq!(metadata["thread_id"].as_str(), Some(thread.id.as_str()));
|
||||
assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str()));
|
||||
assert!(metadata.get("forked_from_thread_id").is_none());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::create_fake_parented_rollout_with_source;
|
||||
use app_test_support::create_fake_rollout;
|
||||
use app_test_support::create_fake_rollout_with_source;
|
||||
use app_test_support::create_final_assistant_message_sse_response;
|
||||
@@ -1049,7 +1050,7 @@ async fn thread_list_filters_by_subagent_variant() -> Result<()> {
|
||||
|
||||
let parent_thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?;
|
||||
|
||||
let review_id = create_fake_rollout_with_source(
|
||||
let review_id = create_fake_parented_rollout_with_source(
|
||||
codex_home.path(),
|
||||
"2025-02-02T09-00-00",
|
||||
"2025-02-02T09:00:00Z",
|
||||
@@ -1057,6 +1058,7 @@ async fn thread_list_filters_by_subagent_variant() -> Result<()> {
|
||||
Some("mock_provider"),
|
||||
/*git_info*/ None,
|
||||
CoreSessionSource::SubAgent(SubAgentSource::Review),
|
||||
parent_thread_id,
|
||||
)?;
|
||||
let compact_id = create_fake_rollout_with_source(
|
||||
codex_home.path(),
|
||||
@@ -1109,6 +1111,10 @@ async fn thread_list_filters_by_subagent_variant() -> Result<()> {
|
||||
.map(|thread| thread.id.as_str())
|
||||
.collect();
|
||||
assert_eq!(review_ids, vec![review_id.as_str()]);
|
||||
assert_eq!(
|
||||
review.data[0].parent_thread_id,
|
||||
Some(parent_thread_id.to_string())
|
||||
);
|
||||
|
||||
let compact = list_threads(
|
||||
&mut mcp,
|
||||
|
||||
@@ -1358,6 +1358,7 @@ async fn seed_pathless_store_thread(
|
||||
.create_thread(CreateThreadParams {
|
||||
thread_id,
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
source: ProtocolSessionSource::Cli,
|
||||
thread_source: None,
|
||||
base_instructions: BaseInstructions::default(),
|
||||
|
||||
@@ -1791,6 +1791,7 @@ stream_max_retries = 0
|
||||
let session_meta = SessionMeta {
|
||||
id: conversation_id,
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
timestamp: "2025-01-05T12:00:00Z".to_string(),
|
||||
cwd: repo_path.clone(),
|
||||
originator: "codex".to_string(),
|
||||
|
||||
@@ -211,6 +211,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> {
|
||||
.create_thread(CreateThreadParams {
|
||||
thread_id,
|
||||
forked_from_id: Some(parent_thread_id),
|
||||
parent_thread_id: None,
|
||||
source: SessionSource::Cli,
|
||||
thread_source: None,
|
||||
base_instructions: BaseInstructions::default(),
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Conversion from cloud-delivered config TOML fragments into config stack layers.
|
||||
//!
|
||||
//! Backend fragments arrive in backend priority order. This module parses each
|
||||
//! fragment, resolves relative path fields against the cloud config base
|
||||
//! directory, and returns layers in `ConfigLayerStack` order.
|
||||
|
||||
use crate::ConfigLayerEntry;
|
||||
use crate::ConfigLayerSource;
|
||||
use crate::TomlValue;
|
||||
use crate::loader::resolve_relative_paths_in_config_toml;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Config fragment delivered by the cloud config bundle.
|
||||
///
|
||||
/// The bundle orders fragments from highest precedence to lowest precedence.
|
||||
/// This module returns config layers in stack order, so callers can append the
|
||||
/// result between system and user config without re-sorting.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CloudConfigFragment {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub contents: String,
|
||||
}
|
||||
|
||||
impl CloudConfigFragment {
|
||||
fn source_ref(&self) -> CloudConfigFragmentSource {
|
||||
CloudConfigFragmentSource {
|
||||
id: self.id.clone(),
|
||||
name: self.name.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CloudConfigFragmentSource {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for CloudConfigFragmentSource {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{} ({})", self.name, self.id)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum CloudConfigLayerError {
|
||||
#[error("failed to parse cloud config fragment {fragment}: {message}")]
|
||||
Parse {
|
||||
fragment: CloudConfigFragmentSource,
|
||||
message: String,
|
||||
},
|
||||
#[error("invalid cloud config fragment {fragment}: {message}")]
|
||||
Invalid {
|
||||
fragment: CloudConfigFragmentSource,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn cloud_config_layers_from_fragments(
|
||||
fragments: impl IntoIterator<Item = CloudConfigFragment>,
|
||||
base_dir: &AbsolutePathBuf,
|
||||
) -> Result<Vec<ConfigLayerEntry>, CloudConfigLayerError> {
|
||||
let mut layers = Vec::new();
|
||||
for fragment in fragments {
|
||||
let source_ref = fragment.source_ref();
|
||||
let raw_toml = fragment.contents;
|
||||
let value: TomlValue =
|
||||
toml::from_str(&raw_toml).map_err(|err| CloudConfigLayerError::Parse {
|
||||
fragment: source_ref.clone(),
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
let resolved =
|
||||
resolve_relative_paths_in_config_toml(value, base_dir.as_path()).map_err(|err| {
|
||||
CloudConfigLayerError::Invalid {
|
||||
fragment: source_ref.clone(),
|
||||
message: err.to_string(),
|
||||
}
|
||||
})?;
|
||||
layers.push(ConfigLayerEntry::new_with_raw_toml(
|
||||
ConfigLayerSource::EnterpriseManaged {
|
||||
id: fragment.id,
|
||||
name: fragment.name,
|
||||
},
|
||||
resolved,
|
||||
raw_toml,
|
||||
base_dir.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
// Bundle fragments arrive highest-priority first, while ConfigLayerStack
|
||||
// folds lowest-priority to highest-priority.
|
||||
layers.reverse();
|
||||
Ok(layers)
|
||||
}
|
||||
|
||||
impl From<CloudConfigLayerError> for io::Error {
|
||||
fn from(error: CloudConfigLayerError) -> Self {
|
||||
io::Error::new(io::ErrorKind::InvalidData, error)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "cloud_config_layers_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,210 @@
|
||||
use super::*;
|
||||
use crate::CONFIG_TOML_FILE;
|
||||
use crate::ConfigLayerStack;
|
||||
use crate::ConfigLayerStackOrdering;
|
||||
use crate::ConfigRequirements;
|
||||
use crate::ConfigRequirementsToml;
|
||||
use crate::config_toml::ConfigToml;
|
||||
use crate::first_layer_config_error_from_entries;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::test_support::PathBufExt;
|
||||
use codex_utils_absolute_path::test_support::test_path_buf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
|
||||
fn fragment(id: &str, name: &str, contents: &str) -> CloudConfigFragment {
|
||||
CloudConfigFragment {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
contents: contents.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn toml(contents: &str) -> TomlValue {
|
||||
toml::from_str(contents).expect("test TOML should parse")
|
||||
}
|
||||
|
||||
fn base_dir() -> AbsolutePathBuf {
|
||||
test_path_buf("/var/lib/codex").abs()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_are_returned_in_stack_order() {
|
||||
let base_dir = base_dir();
|
||||
let layers = cloud_config_layers_from_fragments(
|
||||
vec![
|
||||
fragment("high", "High priority", "model = \"cloud-high\""),
|
||||
fragment("low", "Low priority", "model_provider = \"cloud-low\""),
|
||||
],
|
||||
&base_dir,
|
||||
)
|
||||
.expect("cloud config layers should compose");
|
||||
|
||||
assert_eq!(
|
||||
layers
|
||||
.iter()
|
||||
.map(|layer| layer.name.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
ConfigLayerSource::EnterpriseManaged {
|
||||
id: "low".to_string(),
|
||||
name: "Low priority".to_string(),
|
||||
},
|
||||
ConfigLayerSource::EnterpriseManaged {
|
||||
id: "high".to_string(),
|
||||
name: "High priority".to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enterprise_layers_precede_user_and_override_system() {
|
||||
let base_dir = base_dir();
|
||||
let mut layers = vec![ConfigLayerEntry::new(
|
||||
ConfigLayerSource::System {
|
||||
file: test_path_buf("/etc/codex/config.toml").abs(),
|
||||
},
|
||||
toml(
|
||||
r#"
|
||||
model = "system"
|
||||
model_provider = "system"
|
||||
review_model = "system-review"
|
||||
"#,
|
||||
),
|
||||
)];
|
||||
layers.extend(
|
||||
cloud_config_layers_from_fragments(
|
||||
vec![
|
||||
fragment("high", "High priority", "model_provider = \"cloud-high\""),
|
||||
fragment("low", "Low priority", "review_model = \"cloud-low-review\""),
|
||||
],
|
||||
&base_dir,
|
||||
)
|
||||
.expect("cloud config layers should compose"),
|
||||
);
|
||||
layers.push(ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User {
|
||||
file: test_path_buf("/home/alice/.codex/config.toml").abs(),
|
||||
profile: None,
|
||||
},
|
||||
toml("model = \"user\""),
|
||||
));
|
||||
|
||||
let stack = ConfigLayerStack::new(
|
||||
layers,
|
||||
ConfigRequirements::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("stack should be ordered");
|
||||
|
||||
assert_eq!(
|
||||
stack
|
||||
.get_layers(
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
/*include_disabled*/ false
|
||||
)
|
||||
.iter()
|
||||
.map(|layer| layer.name.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
ConfigLayerSource::System {
|
||||
file: test_path_buf("/etc/codex/config.toml").abs(),
|
||||
},
|
||||
ConfigLayerSource::EnterpriseManaged {
|
||||
id: "low".to_string(),
|
||||
name: "Low priority".to_string(),
|
||||
},
|
||||
ConfigLayerSource::EnterpriseManaged {
|
||||
id: "high".to_string(),
|
||||
name: "High priority".to_string(),
|
||||
},
|
||||
ConfigLayerSource::User {
|
||||
file: test_path_buf("/home/alice/.codex/config.toml").abs(),
|
||||
profile: None,
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
stack.effective_config(),
|
||||
toml(
|
||||
r#"
|
||||
model = "user"
|
||||
model_provider = "cloud-high"
|
||||
review_model = "cloud-low-review"
|
||||
"#,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_absolute_path_fields_resolve_against_base_dir() {
|
||||
let base_dir = base_dir();
|
||||
let layers = cloud_config_layers_from_fragments(
|
||||
vec![fragment(
|
||||
"cfg_123",
|
||||
"Base policy",
|
||||
"model_instructions_file = \"instructions.md\"",
|
||||
)],
|
||||
&base_dir,
|
||||
)
|
||||
.expect("relative paths should match existing MDM semantics");
|
||||
|
||||
let path = layers[0]
|
||||
.config
|
||||
.get("model_instructions_file")
|
||||
.and_then(TomlValue::as_str)
|
||||
.expect("path should be present");
|
||||
let expected =
|
||||
AbsolutePathBuf::resolve_path_against_base("instructions.md", base_dir.as_path());
|
||||
assert_eq!(path, expected.to_string_lossy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_relative_path_fields_are_allowed_and_resolved() {
|
||||
let base_dir = base_dir();
|
||||
let layers = cloud_config_layers_from_fragments(
|
||||
vec![fragment(
|
||||
"cfg_123",
|
||||
"Base policy",
|
||||
"model_instructions_file = \"~/instructions.md\"",
|
||||
)],
|
||||
&base_dir,
|
||||
)
|
||||
.expect("home-relative paths should be accepted");
|
||||
|
||||
let path = layers[0]
|
||||
.config
|
||||
.get("model_instructions_file")
|
||||
.and_then(TomlValue::as_str)
|
||||
.expect("path should be present");
|
||||
let expected =
|
||||
AbsolutePathBuf::resolve_path_against_base("~/instructions.md", base_dir.as_path());
|
||||
assert_eq!(path, expected.to_string_lossy());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raw_toml_diagnostics_use_enterprise_layer_name() {
|
||||
let base_dir = base_dir();
|
||||
let layers = cloud_config_layers_from_fragments(
|
||||
vec![fragment(
|
||||
"cfg_123",
|
||||
"Base policy",
|
||||
"model_instructions_file = \"instructions.md\"\nmodel = 1",
|
||||
)],
|
||||
&base_dir,
|
||||
)
|
||||
.expect("cloud config layers should parse");
|
||||
|
||||
let error = first_layer_config_error_from_entries::<ConfigToml>(&layers, CONFIG_TOML_FILE)
|
||||
.await
|
||||
.expect("invalid raw TOML should produce a layer diagnostic");
|
||||
|
||||
assert_eq!(
|
||||
error.path,
|
||||
Path::new("enterprise-managed (Base policy, cfg_123)").to_path_buf()
|
||||
);
|
||||
assert_eq!(error.range.start.line, 2);
|
||||
assert_eq!(error.range.start.column, 9);
|
||||
assert!(error.message.contains("invalid type: integer `1`"));
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
|
||||
pub fn format_config_layer_source(source: &ConfigLayerSource, config_toml_file: &str) -> String {
|
||||
match source {
|
||||
ConfigLayerSource::Mdm { domain, key } => {
|
||||
format!("MDM ({domain}:{key})")
|
||||
}
|
||||
ConfigLayerSource::System { file } => {
|
||||
format!("system ({})", file.as_path().display())
|
||||
}
|
||||
ConfigLayerSource::EnterpriseManaged { id, name } => {
|
||||
format!("enterprise-managed ({name}, {id})")
|
||||
}
|
||||
ConfigLayerSource::User { file, .. } => {
|
||||
format!("user ({})", file.as_path().display())
|
||||
}
|
||||
ConfigLayerSource::Project { dot_codex_folder } => {
|
||||
format!(
|
||||
"project ({}/{config_toml_file})",
|
||||
dot_codex_folder.as_path().display()
|
||||
)
|
||||
}
|
||||
ConfigLayerSource::SessionFlags => "session-flags".to_string(),
|
||||
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => {
|
||||
format!("legacy managed_config.toml ({})", file.as_path().display())
|
||||
}
|
||||
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => {
|
||||
"legacy managed_config.toml (MDM)".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,13 +25,61 @@ use crate::types::WindowsSandboxModeToml;
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RequirementSource {
|
||||
Unknown,
|
||||
MdmManagedPreferences { domain: String, key: String },
|
||||
MdmManagedPreferences {
|
||||
domain: String,
|
||||
key: String,
|
||||
},
|
||||
CloudRequirements,
|
||||
SystemRequirementsToml { file: AbsolutePathBuf },
|
||||
LegacyManagedConfigTomlFromFile { file: AbsolutePathBuf },
|
||||
/// Multiple requirements layers contributed to the final value. Sources are
|
||||
/// stored highest-priority first, matching the order surfaced in errors.
|
||||
Composite {
|
||||
sources: Vec<RequirementSource>,
|
||||
},
|
||||
/// A backend-delivered enterprise-managed layer. `id` is the stable backend
|
||||
/// identifier; `name` is the admin-facing display name.
|
||||
EnterpriseManaged {
|
||||
id: String,
|
||||
name: String,
|
||||
},
|
||||
SystemRequirementsToml {
|
||||
file: AbsolutePathBuf,
|
||||
},
|
||||
LegacyManagedConfigTomlFromFile {
|
||||
file: AbsolutePathBuf,
|
||||
},
|
||||
LegacyManagedConfigTomlFromMdm,
|
||||
}
|
||||
|
||||
impl RequirementSource {
|
||||
pub fn composite(sources: impl IntoIterator<Item = RequirementSource>) -> Self {
|
||||
let mut flattened = Vec::new();
|
||||
for source in sources {
|
||||
source.append_to_composite(&mut flattened);
|
||||
}
|
||||
|
||||
match flattened.len() {
|
||||
0 => RequirementSource::Unknown,
|
||||
1 => flattened.remove(0),
|
||||
_ => RequirementSource::Composite { sources: flattened },
|
||||
}
|
||||
}
|
||||
|
||||
fn append_to_composite(self, flattened: &mut Vec<RequirementSource>) {
|
||||
match self {
|
||||
RequirementSource::Composite { sources } => {
|
||||
for source in sources {
|
||||
source.append_to_composite(flattened);
|
||||
}
|
||||
}
|
||||
source => {
|
||||
if !flattened.contains(&source) {
|
||||
flattened.push(source);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for RequirementSource {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
@@ -42,6 +90,19 @@ impl fmt::Display for RequirementSource {
|
||||
RequirementSource::CloudRequirements => {
|
||||
write!(f, "cloud requirements")
|
||||
}
|
||||
RequirementSource::Composite { sources } => {
|
||||
write!(f, "requirements layers: ")?;
|
||||
for (index, source) in sources.iter().enumerate() {
|
||||
if index > 0 {
|
||||
write!(f, ", ")?;
|
||||
}
|
||||
write!(f, "{source}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
RequirementSource::EnterpriseManaged { id, name } => {
|
||||
write!(f, "enterprise-managed requirements {name} ({id})")
|
||||
}
|
||||
RequirementSource::SystemRequirementsToml { file } => {
|
||||
write!(f, "{}", file.as_path().display())
|
||||
}
|
||||
@@ -983,7 +1044,7 @@ fn hostname_matches_any_pattern(hostname: &str, patterns: &[String]) -> bool {
|
||||
|
||||
/// Currently, `external-sandbox` is not supported in config.toml, but it is
|
||||
/// supported through programmatic use.
|
||||
#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq)]
|
||||
pub enum SandboxModeRequirement {
|
||||
#[serde(rename = "read-only")]
|
||||
ReadOnly,
|
||||
@@ -1430,6 +1491,41 @@ mod tests {
|
||||
)?)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composite_requirement_source_flattens_and_deduplicates_sources() {
|
||||
let mdm_source = RequirementSource::MdmManagedPreferences {
|
||||
domain: "com.openai.codex".to_string(),
|
||||
key: "requirements_toml_base64".to_string(),
|
||||
};
|
||||
let legacy_source = RequirementSource::LegacyManagedConfigTomlFromMdm;
|
||||
|
||||
assert_eq!(
|
||||
RequirementSource::composite([
|
||||
mdm_source.clone(),
|
||||
RequirementSource::composite([legacy_source.clone(), mdm_source.clone()]),
|
||||
]),
|
||||
RequirementSource::Composite {
|
||||
sources: vec![mdm_source, legacy_source],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composite_requirement_source_display_lists_sources_in_priority_order() {
|
||||
let source = RequirementSource::composite([
|
||||
RequirementSource::MdmManagedPreferences {
|
||||
domain: "com.openai.codex".to_string(),
|
||||
key: "requirements_toml_base64".to_string(),
|
||||
},
|
||||
RequirementSource::LegacyManagedConfigTomlFromMdm,
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
source.to_string(),
|
||||
"requirements layers: MDM com.openai.codex:requirements_toml_base64, MDM managed_config.toml (legacy)"
|
||||
);
|
||||
}
|
||||
|
||||
fn with_unknown_source(toml: ConfigRequirementsToml) -> ConfigRequirementsWithSources {
|
||||
let ConfigRequirementsToml {
|
||||
allowed_approval_policies,
|
||||
@@ -2273,14 +2369,20 @@ allowed_approvals_reviewers = ["user"]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constraint_error_includes_cloud_requirements_source() -> Result<()> {
|
||||
fn constraint_error_includes_composite_requirement_source() -> Result<()> {
|
||||
let source: ConfigRequirementsToml = from_str(
|
||||
r#"
|
||||
allowed_approval_policies = ["on-request"]
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let source_location = RequirementSource::CloudRequirements;
|
||||
let source_location = RequirementSource::composite([
|
||||
RequirementSource::MdmManagedPreferences {
|
||||
domain: "com.openai.codex".to_string(),
|
||||
key: "requirements_toml_base64".to_string(),
|
||||
},
|
||||
RequirementSource::LegacyManagedConfigTomlFromMdm,
|
||||
]);
|
||||
|
||||
let mut target = ConfigRequirementsWithSources::default();
|
||||
target.merge_unset_fields(source_location.clone(), source);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
use crate::ConfigLayerEntry;
|
||||
use crate::ConfigLayerStack;
|
||||
use crate::ConfigLayerStackOrdering;
|
||||
use crate::format_config_layer_source;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use serde::de::DeserializeOwned;
|
||||
@@ -89,7 +90,6 @@ impl std::error::Error for ConfigLoadError {
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum ConfigDiagnosticSource<'a> {
|
||||
Path(&'a Path),
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
DisplayName(&'a str),
|
||||
}
|
||||
|
||||
@@ -97,7 +97,6 @@ impl ConfigDiagnosticSource<'_> {
|
||||
pub(crate) fn to_path_buf(self) -> PathBuf {
|
||||
match self {
|
||||
ConfigDiagnosticSource::Path(path) => path.to_path_buf(),
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
ConfigDiagnosticSource::DisplayName(name) => PathBuf::from(name),
|
||||
}
|
||||
}
|
||||
@@ -201,6 +200,27 @@ where
|
||||
I: IntoIterator<Item = &'a ConfigLayerEntry>,
|
||||
{
|
||||
for layer in layers {
|
||||
if let Some(contents) = layer.raw_toml() {
|
||||
let source_name = format_config_layer_source(&layer.name, config_toml_file);
|
||||
let Some(base_dir) = layer.raw_toml_base_dir() else {
|
||||
tracing::debug!(
|
||||
"Skipping raw TOML diagnostics for {source_name} because it has no base directory"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
// Match the base directory used when the raw non-file layer was
|
||||
// parsed into the runtime layer so diagnostics resolve relative
|
||||
// path fields with the same semantics.
|
||||
let _absolute_path_base = AbsolutePathBufGuard::new(base_dir.as_path());
|
||||
if let Some(error) = config_error_from_typed_toml_for_source::<T>(
|
||||
ConfigDiagnosticSource::DisplayName(&source_name),
|
||||
contents,
|
||||
) {
|
||||
return Some(error);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(path) = config_path_for_layer(layer, config_toml_file) else {
|
||||
continue;
|
||||
};
|
||||
@@ -235,6 +255,7 @@ fn config_path_for_layer(layer: &ConfigLayerEntry, config_toml_file: &str) -> Op
|
||||
}
|
||||
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => Some(file.to_path_buf()),
|
||||
ConfigLayerSource::Mdm { .. }
|
||||
| ConfigLayerSource::EnterpriseManaged { .. }
|
||||
| ConfigLayerSource::SessionFlags
|
||||
| ConfigLayerSource::LegacyManagedConfigTomlFromMdm => None,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
mod cloud_config_layers;
|
||||
mod cloud_requirements;
|
||||
mod config_layer_source;
|
||||
mod config_requirements;
|
||||
pub mod config_toml;
|
||||
mod constraint;
|
||||
@@ -18,6 +20,7 @@ mod plugin_edit;
|
||||
pub mod profile_toml;
|
||||
mod project_root_markers;
|
||||
mod requirements_exec_policy;
|
||||
mod requirements_layers;
|
||||
pub mod schema;
|
||||
mod skills_config;
|
||||
mod state;
|
||||
@@ -28,6 +31,10 @@ pub mod types;
|
||||
|
||||
pub const CONFIG_TOML_FILE: &str = "config.toml";
|
||||
|
||||
pub use cloud_config_layers::CloudConfigFragment;
|
||||
pub use cloud_config_layers::CloudConfigFragmentSource;
|
||||
pub use cloud_config_layers::CloudConfigLayerError;
|
||||
pub use cloud_config_layers::cloud_config_layers_from_fragments;
|
||||
pub use cloud_requirements::CloudRequirementsLoadError;
|
||||
pub use cloud_requirements::CloudRequirementsLoadErrorCode;
|
||||
pub use cloud_requirements::CloudRequirementsLoader;
|
||||
@@ -35,6 +42,7 @@ pub use codex_app_server_protocol::ConfigLayerSource;
|
||||
pub use codex_protocol::config_types::ProfileV2Name;
|
||||
pub use codex_protocol::config_types::ProfileV2NameParseError;
|
||||
pub use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
pub use config_layer_source::format_config_layer_source;
|
||||
pub use config_requirements::AppRequirementToml;
|
||||
pub use config_requirements::AppToolRequirementToml;
|
||||
pub use config_requirements::AppToolsRequirementsToml;
|
||||
@@ -117,6 +125,8 @@ pub use requirements_exec_policy::RequirementsExecPolicyParseError;
|
||||
pub use requirements_exec_policy::RequirementsExecPolicyPatternTokenToml;
|
||||
pub use requirements_exec_policy::RequirementsExecPolicyPrefixRuleToml;
|
||||
pub use requirements_exec_policy::RequirementsExecPolicyToml;
|
||||
pub use requirements_layers::RequirementsLayerEntry;
|
||||
pub use requirements_layers::compose_requirements;
|
||||
pub use skills_config::BundledSkillsConfig;
|
||||
pub use skills_config::SkillConfig;
|
||||
pub use skills_config::SkillsConfig;
|
||||
|
||||
@@ -366,13 +366,18 @@ pub async fn load_config_layers_state(
|
||||
// paths, starting with `./`, but a path starting with `~/` _is_ a
|
||||
// supported use case. Because resolve_relative_paths_in_config_toml()
|
||||
// relies on AbsolutePathBufGuard to resolve `~/`, we must supply a
|
||||
// value for base_dir, so codex_home is as good a value as any.
|
||||
let managed_config =
|
||||
resolve_relative_paths_in_config_toml(config.managed_config, codex_home)?;
|
||||
// value for base_dir. Preserve that same base on the layer so later
|
||||
// raw-TOML diagnostics parse with the same path semantics.
|
||||
let raw_toml_base_dir = AbsolutePathBuf::from_absolute_path(codex_home)?;
|
||||
let managed_config = resolve_relative_paths_in_config_toml(
|
||||
config.managed_config,
|
||||
raw_toml_base_dir.as_path(),
|
||||
)?;
|
||||
layers.push(ConfigLayerEntry::new_with_raw_toml(
|
||||
ConfigLayerSource::LegacyManagedConfigTomlFromMdm,
|
||||
managed_config,
|
||||
config.raw_toml,
|
||||
raw_toml_base_dir,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
//! Hook events are append-only across requirements layers. The managed hook
|
||||
//! directory is different: only one directory is usable on a given platform, so
|
||||
//! conflicting values for the active platform fail closed. The inactive platform
|
||||
//! field is first-filled to allow the same layer stack to carry OS-specific
|
||||
//! directories.
|
||||
|
||||
use crate::HookEventsToml;
|
||||
use crate::ManagedHooksRequirementsToml;
|
||||
use crate::RequirementSource;
|
||||
use crate::Sourced;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::stack::composition_conflict;
|
||||
use super::stack::merge_output_source;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(super) enum HookDirectoryField {
|
||||
#[default]
|
||||
ManagedDir,
|
||||
WindowsManagedDir,
|
||||
}
|
||||
|
||||
impl HookDirectoryField {
|
||||
pub(super) fn current_platform() -> Self {
|
||||
if cfg!(windows) {
|
||||
Self::WindowsManagedDir
|
||||
} else {
|
||||
Self::ManagedDir
|
||||
}
|
||||
}
|
||||
|
||||
fn field_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::ManagedDir => "hooks.managed_dir",
|
||||
Self::WindowsManagedDir => "hooks.windows_managed_dir",
|
||||
}
|
||||
}
|
||||
|
||||
fn inactive(self) -> Self {
|
||||
match self {
|
||||
Self::ManagedDir => Self::WindowsManagedDir,
|
||||
Self::WindowsManagedDir => Self::ManagedDir,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct HookMergeState {
|
||||
directory_field: HookDirectoryField,
|
||||
dir_sources: BTreeMap<HookDirectoryField, RequirementSource>,
|
||||
}
|
||||
|
||||
impl HookMergeState {
|
||||
pub(super) fn new(directory_field: HookDirectoryField) -> Self {
|
||||
Self {
|
||||
directory_field,
|
||||
dir_sources: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn merge(
|
||||
&mut self,
|
||||
target: &mut Option<Sourced<ManagedHooksRequirementsToml>>,
|
||||
incoming: Option<ManagedHooksRequirementsToml>,
|
||||
source: &RequirementSource,
|
||||
) -> Result<(), super::stack::RequirementsCompositionError> {
|
||||
let Some(mut incoming) = incoming.filter(|value| !value.is_empty()) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(existing) = target.as_mut() else {
|
||||
self.track_singleton_source(
|
||||
HookDirectoryField::ManagedDir,
|
||||
&incoming.managed_dir,
|
||||
source,
|
||||
);
|
||||
self.track_singleton_source(
|
||||
HookDirectoryField::WindowsManagedDir,
|
||||
&incoming.windows_managed_dir,
|
||||
source,
|
||||
);
|
||||
*target = Some(Sourced::new(incoming, source.clone()));
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let active_field = self.directory_field;
|
||||
let inactive_field = active_field.inactive();
|
||||
let incoming_active_dir = take_hook_dir(&mut incoming, active_field);
|
||||
let incoming_inactive_dir = take_hook_dir(&mut incoming, inactive_field);
|
||||
let mut changed = false;
|
||||
changed |= self.merge_active_singleton(
|
||||
active_field,
|
||||
hook_dir_mut(&mut existing.value, active_field),
|
||||
incoming_active_dir,
|
||||
source,
|
||||
)?;
|
||||
changed |= self.fill_singleton(
|
||||
inactive_field,
|
||||
hook_dir_mut(&mut existing.value, inactive_field),
|
||||
incoming_inactive_dir,
|
||||
source,
|
||||
);
|
||||
changed |= append_hook_events(&mut existing.value.hooks, incoming.hooks);
|
||||
if changed {
|
||||
merge_output_source(&mut existing.source, source);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn track_singleton_source(
|
||||
&mut self,
|
||||
field: HookDirectoryField,
|
||||
value: &Option<PathBuf>,
|
||||
source: &RequirementSource,
|
||||
) {
|
||||
if value.is_some() {
|
||||
self.dir_sources
|
||||
.entry(field)
|
||||
.or_insert_with(|| source.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_active_singleton(
|
||||
&mut self,
|
||||
field: HookDirectoryField,
|
||||
existing: &mut Option<PathBuf>,
|
||||
incoming: Option<PathBuf>,
|
||||
incoming_source: &RequirementSource,
|
||||
) -> Result<bool, super::stack::RequirementsCompositionError> {
|
||||
let Some(incoming) = incoming else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
match existing {
|
||||
Some(existing_value) if existing_value != &incoming => {
|
||||
let existing_source = self
|
||||
.dir_sources
|
||||
.get(&field)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| incoming_source.clone());
|
||||
Err(composition_conflict(
|
||||
field.field_name().to_string(),
|
||||
existing_source,
|
||||
incoming_source.clone(),
|
||||
format!(
|
||||
"`{}` conflicts with `{}`",
|
||||
existing_value.display(),
|
||||
incoming.display()
|
||||
),
|
||||
))
|
||||
}
|
||||
Some(_) => Ok(false),
|
||||
None => {
|
||||
*existing = Some(incoming);
|
||||
self.dir_sources
|
||||
.entry(field)
|
||||
.or_insert_with(|| incoming_source.clone());
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fill_singleton(
|
||||
&mut self,
|
||||
field: HookDirectoryField,
|
||||
existing: &mut Option<PathBuf>,
|
||||
incoming: Option<PathBuf>,
|
||||
incoming_source: &RequirementSource,
|
||||
) -> bool {
|
||||
if existing.is_none()
|
||||
&& let Some(incoming) = incoming
|
||||
{
|
||||
*existing = Some(incoming);
|
||||
self.dir_sources
|
||||
.entry(field)
|
||||
.or_insert_with(|| incoming_source.clone());
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn take_hook_dir(
|
||||
hooks: &mut ManagedHooksRequirementsToml,
|
||||
field: HookDirectoryField,
|
||||
) -> Option<PathBuf> {
|
||||
match field {
|
||||
HookDirectoryField::ManagedDir => hooks.managed_dir.take(),
|
||||
HookDirectoryField::WindowsManagedDir => hooks.windows_managed_dir.take(),
|
||||
}
|
||||
}
|
||||
|
||||
fn hook_dir_mut(
|
||||
hooks: &mut ManagedHooksRequirementsToml,
|
||||
field: HookDirectoryField,
|
||||
) -> &mut Option<PathBuf> {
|
||||
match field {
|
||||
HookDirectoryField::ManagedDir => &mut hooks.managed_dir,
|
||||
HookDirectoryField::WindowsManagedDir => &mut hooks.windows_managed_dir,
|
||||
}
|
||||
}
|
||||
|
||||
fn append_hook_events(existing: &mut HookEventsToml, incoming: HookEventsToml) -> bool {
|
||||
// Destructure without `..` so new hook events cannot be introduced without
|
||||
// deciding whether requirements layer merging should append them.
|
||||
let HookEventsToml {
|
||||
pre_tool_use,
|
||||
permission_request,
|
||||
post_tool_use,
|
||||
pre_compact,
|
||||
post_compact,
|
||||
session_start,
|
||||
user_prompt_submit,
|
||||
subagent_start,
|
||||
subagent_stop,
|
||||
stop,
|
||||
} = incoming;
|
||||
|
||||
let mut changed = false;
|
||||
changed |= append_vec(&mut existing.pre_tool_use, pre_tool_use);
|
||||
changed |= append_vec(&mut existing.permission_request, permission_request);
|
||||
changed |= append_vec(&mut existing.post_tool_use, post_tool_use);
|
||||
changed |= append_vec(&mut existing.pre_compact, pre_compact);
|
||||
changed |= append_vec(&mut existing.post_compact, post_compact);
|
||||
changed |= append_vec(&mut existing.session_start, session_start);
|
||||
changed |= append_vec(&mut existing.user_prompt_submit, user_prompt_submit);
|
||||
changed |= append_vec(&mut existing.subagent_start, subagent_start);
|
||||
changed |= append_vec(&mut existing.subagent_stop, subagent_stop);
|
||||
changed |= append_vec(&mut existing.stop, stop);
|
||||
changed
|
||||
}
|
||||
|
||||
fn append_vec<T>(existing: &mut Vec<T>, mut incoming: Vec<T>) -> bool {
|
||||
let changed = !incoming.is_empty();
|
||||
existing.append(&mut incoming);
|
||||
changed
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
use crate::ConfigRequirementsToml;
|
||||
use crate::ManagedHooksRequirementsToml;
|
||||
use crate::RequirementSource;
|
||||
use crate::RequirementsExecPolicyToml;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
use super::stack::RequirementsCompositionError;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RequirementsLayerEntry {
|
||||
pub(super) source: RequirementSource,
|
||||
toml: RequirementsLayerToml,
|
||||
base_dir: Option<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
impl RequirementsLayerEntry {
|
||||
pub fn from_toml(source: RequirementSource, contents: impl Into<String>) -> Self {
|
||||
Self {
|
||||
source,
|
||||
toml: RequirementsLayerToml::String(contents.into()),
|
||||
base_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_toml_value(source: RequirementSource, value: TomlValue) -> Self {
|
||||
Self {
|
||||
source,
|
||||
toml: RequirementsLayerToml::Value(value),
|
||||
base_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_base_dir(mut self, base_dir: AbsolutePathBuf) -> Self {
|
||||
self.base_dir = Some(base_dir);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum RequirementsLayerToml {
|
||||
String(String),
|
||||
Value(TomlValue),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct ComposableRequirementsLayer {
|
||||
pub(super) source: RequirementSource,
|
||||
pub(super) regular_toml: TomlValue,
|
||||
pub(super) domain_fields: DomainMergedRequirementsFields,
|
||||
}
|
||||
|
||||
impl ComposableRequirementsLayer {
|
||||
pub(super) fn from_entry(
|
||||
layer: RequirementsLayerEntry,
|
||||
hostname: Option<&str>,
|
||||
) -> Result<Self, RequirementsCompositionError> {
|
||||
let RequirementsLayerEntry {
|
||||
source,
|
||||
toml,
|
||||
base_dir,
|
||||
} = layer;
|
||||
let (mut regular_toml, mut requirements) = {
|
||||
let _guard = base_dir
|
||||
.as_ref()
|
||||
.map(|base_dir| AbsolutePathBufGuard::new(base_dir.as_path()));
|
||||
let regular_toml = parse_layer_toml(&toml, &source)?;
|
||||
let requirements = parse_layer_requirements(&toml, &source)?;
|
||||
(regular_toml, requirements)
|
||||
};
|
||||
|
||||
requirements.apply_remote_sandbox_config(hostname);
|
||||
materialize_remote_sandbox_config(&mut regular_toml, &requirements)?;
|
||||
strip_special_fields(&mut regular_toml);
|
||||
|
||||
Ok(Self {
|
||||
source,
|
||||
regular_toml,
|
||||
domain_fields: DomainMergedRequirementsFields {
|
||||
rules: requirements.rules,
|
||||
hooks: requirements.hooks,
|
||||
permissions: requirements.permissions,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct DomainMergedRequirementsFields {
|
||||
pub(super) rules: Option<RequirementsExecPolicyToml>,
|
||||
pub(super) hooks: Option<ManagedHooksRequirementsToml>,
|
||||
pub(super) permissions: Option<crate::config_requirements::PermissionsRequirementsToml>,
|
||||
}
|
||||
|
||||
fn parse_layer_toml(
|
||||
toml: &RequirementsLayerToml,
|
||||
source: &RequirementSource,
|
||||
) -> Result<TomlValue, RequirementsCompositionError> {
|
||||
match toml {
|
||||
RequirementsLayerToml::String(contents) => {
|
||||
toml::from_str(contents).map_err(|err: toml::de::Error| {
|
||||
RequirementsCompositionError::Parse {
|
||||
layer_source: source.clone(),
|
||||
message: err.to_string(),
|
||||
}
|
||||
})
|
||||
}
|
||||
RequirementsLayerToml::Value(value) => Ok(value.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_layer_requirements(
|
||||
toml: &RequirementsLayerToml,
|
||||
source: &RequirementSource,
|
||||
) -> Result<ConfigRequirementsToml, RequirementsCompositionError> {
|
||||
match toml {
|
||||
RequirementsLayerToml::String(contents) => {
|
||||
toml::from_str(contents).map_err(|err: toml::de::Error| {
|
||||
RequirementsCompositionError::Parse {
|
||||
layer_source: source.clone(),
|
||||
message: err.to_string(),
|
||||
}
|
||||
})
|
||||
}
|
||||
RequirementsLayerToml::Value(value) => {
|
||||
value.clone().try_into().map_err(|err: toml::de::Error| {
|
||||
RequirementsCompositionError::Parse {
|
||||
layer_source: source.clone(),
|
||||
message: err.to_string(),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn materialize_remote_sandbox_config(
|
||||
layer_toml: &mut TomlValue,
|
||||
requirements: &ConfigRequirementsToml,
|
||||
) -> Result<(), RequirementsCompositionError> {
|
||||
remove_top_level_field(layer_toml, "remote_sandbox_config");
|
||||
let Some(allowed_sandbox_modes) = requirements.allowed_sandbox_modes.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(table) = layer_toml.as_table_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
table.insert(
|
||||
"allowed_sandbox_modes".to_string(),
|
||||
toml_value_from_serializable(allowed_sandbox_modes)?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn toml_value_from_serializable<T: serde::Serialize>(
|
||||
value: T,
|
||||
) -> Result<TomlValue, RequirementsCompositionError> {
|
||||
TomlValue::try_from(value).map_err(|err| RequirementsCompositionError::ComposedParse {
|
||||
message: err.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn strip_special_fields(layer_toml: &mut TomlValue) {
|
||||
remove_top_level_field(layer_toml, "rules");
|
||||
remove_top_level_field(layer_toml, "hooks");
|
||||
remove_nested_field_and_prune_empty(layer_toml, &["permissions", "filesystem", "deny_read"]);
|
||||
}
|
||||
|
||||
fn remove_top_level_field(value: &mut TomlValue, key: &str) -> Option<TomlValue> {
|
||||
value.as_table_mut()?.remove(key)
|
||||
}
|
||||
|
||||
fn remove_nested_field_and_prune_empty(value: &mut TomlValue, path: &[&str]) -> Option<TomlValue> {
|
||||
let (key, remaining) = path.split_first()?;
|
||||
let table = value.as_table_mut()?;
|
||||
if remaining.is_empty() {
|
||||
return table.remove(*key);
|
||||
}
|
||||
|
||||
let removed = table
|
||||
.get_mut(*key)
|
||||
.and_then(|child| remove_nested_field_and_prune_empty(child, remaining));
|
||||
if table
|
||||
.get(*key)
|
||||
.and_then(TomlValue::as_table)
|
||||
.is_some_and(toml::map::Map::is_empty)
|
||||
{
|
||||
table.remove(*key);
|
||||
}
|
||||
removed
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
mod hooks;
|
||||
mod layer;
|
||||
mod permissions;
|
||||
mod rules;
|
||||
mod stack;
|
||||
|
||||
pub use layer::RequirementsLayerEntry;
|
||||
pub use stack::compose_requirements;
|
||||
@@ -0,0 +1,82 @@
|
||||
//! `permissions.filesystem.deny_read` is intentionally additive across
|
||||
//! requirements layers. Other `[permissions]` content stays in the regular TOML
|
||||
//! merge path so permission profile tables follow config-style precedence.
|
||||
|
||||
use crate::FilesystemDenyReadPattern;
|
||||
use crate::RequirementSource;
|
||||
use crate::Sourced;
|
||||
use crate::config_requirements::FilesystemRequirementsToml;
|
||||
use crate::config_requirements::PermissionsRequirementsToml;
|
||||
|
||||
use super::stack::merge_output_source;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct DenyReadMergeState {
|
||||
deny_read: Vec<FilesystemDenyReadPattern>,
|
||||
source: Option<RequirementSource>,
|
||||
}
|
||||
|
||||
impl DenyReadMergeState {
|
||||
pub(super) fn merge(
|
||||
&mut self,
|
||||
incoming: Option<PermissionsRequirementsToml>,
|
||||
source: &RequirementSource,
|
||||
) {
|
||||
let Some(incoming_deny_read) = incoming
|
||||
.and_then(|permissions| permissions.filesystem)
|
||||
.and_then(|filesystem| filesystem.deny_read)
|
||||
.filter(|deny_read| !deny_read.is_empty())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
for pattern in incoming_deny_read {
|
||||
if !self.deny_read.contains(&pattern) {
|
||||
self.deny_read.push(pattern);
|
||||
self.merge_source(source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn apply_to(self, target: &mut Option<Sourced<PermissionsRequirementsToml>>) {
|
||||
if self.deny_read.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let source = self.source.unwrap_or(RequirementSource::Unknown);
|
||||
let Some(existing) = target.as_mut() else {
|
||||
*target = Some(Sourced::new(
|
||||
PermissionsRequirementsToml {
|
||||
filesystem: Some(FilesystemRequirementsToml {
|
||||
deny_read: Some(self.deny_read),
|
||||
}),
|
||||
profiles: Default::default(),
|
||||
},
|
||||
source,
|
||||
));
|
||||
return;
|
||||
};
|
||||
|
||||
let filesystem = existing
|
||||
.value
|
||||
.filesystem
|
||||
.get_or_insert_with(Default::default);
|
||||
let deny_read = filesystem.deny_read.get_or_insert_with(Vec::new);
|
||||
for pattern in self.deny_read {
|
||||
if !deny_read.contains(&pattern) {
|
||||
deny_read.push(pattern);
|
||||
}
|
||||
}
|
||||
if existing.source != source {
|
||||
existing.source = RequirementSource::composite([existing.source.clone(), source]);
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_source(&mut self, source: &RequirementSource) {
|
||||
let Some(existing) = self.source.as_mut() else {
|
||||
self.source = Some(source.clone());
|
||||
return;
|
||||
};
|
||||
merge_output_source(existing, source);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! Requirements rules are additive across layers. Higher-priority rules are
|
||||
//! appended first so the final rule order keeps priority visible.
|
||||
|
||||
use crate::RequirementSource;
|
||||
use crate::RequirementsExecPolicyToml;
|
||||
use crate::Sourced;
|
||||
|
||||
use super::stack::merge_output_source;
|
||||
|
||||
pub(super) fn merge(
|
||||
target: &mut Option<Sourced<RequirementsExecPolicyToml>>,
|
||||
incoming: Option<RequirementsExecPolicyToml>,
|
||||
source: &RequirementSource,
|
||||
) {
|
||||
let Some(incoming) = incoming else {
|
||||
return;
|
||||
};
|
||||
let Some(existing) = target.as_mut() else {
|
||||
*target = Some(Sourced::new(incoming, source.clone()));
|
||||
return;
|
||||
};
|
||||
|
||||
let RequirementsExecPolicyToml { prefix_rules } = incoming;
|
||||
existing.value.prefix_rules.extend(prefix_rules);
|
||||
merge_output_source(&mut existing.source, source);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
//! Requirements layers are composed in the same order as config layers: lowest
|
||||
//! precedence first, highest precedence last. Most fields use the same
|
||||
//! TOML-level merge policy as config: lower-priority layers provide defaults,
|
||||
//! and higher-priority layers override scalar/list values while recursively
|
||||
//! extending tables.
|
||||
//!
|
||||
//! A few fields carry domain-specific meaning that raw TOML replacement would
|
||||
//! break:
|
||||
//! - `remote_sandbox_config` is evaluated within each layer before merging.
|
||||
//! - `rules.prefix_rules` append high-priority rules first.
|
||||
//! - `hooks` append high-priority event groups first while failing closed on
|
||||
//! active managed-dir conflicts.
|
||||
//! - `permissions.filesystem.deny_read` is a high-priority-first union across
|
||||
//! layers.
|
||||
|
||||
use crate::ConfigRequirementsToml;
|
||||
use crate::ConfigRequirementsWithSources;
|
||||
use crate::RequirementSource;
|
||||
use crate::Sourced;
|
||||
use crate::merge::merge_toml_values;
|
||||
use std::io;
|
||||
use thiserror::Error;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
use super::hooks::HookDirectoryField;
|
||||
use super::hooks::HookMergeState;
|
||||
use super::layer::ComposableRequirementsLayer;
|
||||
use super::layer::RequirementsLayerEntry;
|
||||
use super::permissions::DenyReadMergeState;
|
||||
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum RequirementsCompositionError {
|
||||
#[error("failed to parse requirements layer {layer_source}: {message}")]
|
||||
Parse {
|
||||
layer_source: RequirementSource,
|
||||
message: String,
|
||||
},
|
||||
#[error("failed to parse merged requirements: {message}")]
|
||||
ComposedParse { message: String },
|
||||
#[error(
|
||||
"failed to compose requirements field `{field}` between {existing_source} and {incoming_source}: {message}"
|
||||
)]
|
||||
Conflict {
|
||||
field: String,
|
||||
existing_source: RequirementSource,
|
||||
incoming_source: RequirementSource,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<RequirementsCompositionError> for io::Error {
|
||||
fn from(error: RequirementsCompositionError) -> Self {
|
||||
io::Error::new(io::ErrorKind::InvalidData, error)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compose_requirements(
|
||||
layers: impl IntoIterator<Item = RequirementsLayerEntry>,
|
||||
) -> Result<Option<ConfigRequirementsWithSources>, RequirementsCompositionError> {
|
||||
let hostname = crate::host_name();
|
||||
compose_requirements_for_hostname(layers, hostname.as_deref())
|
||||
}
|
||||
|
||||
pub(super) fn compose_requirements_for_hostname(
|
||||
layers: impl IntoIterator<Item = RequirementsLayerEntry>,
|
||||
hostname: Option<&str>,
|
||||
) -> Result<Option<ConfigRequirementsWithSources>, RequirementsCompositionError> {
|
||||
compose_requirements_for_hostname_and_hook_directory(
|
||||
layers,
|
||||
hostname,
|
||||
HookDirectoryField::current_platform(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn compose_requirements_for_hostname_and_hook_directory(
|
||||
layers: impl IntoIterator<Item = RequirementsLayerEntry>,
|
||||
hostname: Option<&str>,
|
||||
hook_directory_field: HookDirectoryField,
|
||||
) -> Result<Option<ConfigRequirementsWithSources>, RequirementsCompositionError> {
|
||||
let mut stack = RequirementsLayerStack::new(hook_directory_field);
|
||||
for layer in layers {
|
||||
stack.add_layer(layer, hostname)?;
|
||||
}
|
||||
stack.compose()
|
||||
}
|
||||
|
||||
struct RequirementsLayerStack {
|
||||
layers: Vec<ComposableRequirementsLayer>,
|
||||
hook_directory_field: HookDirectoryField,
|
||||
}
|
||||
|
||||
impl RequirementsLayerStack {
|
||||
fn new(hook_directory_field: HookDirectoryField) -> Self {
|
||||
Self {
|
||||
layers: Vec::new(),
|
||||
hook_directory_field,
|
||||
}
|
||||
}
|
||||
|
||||
fn add_layer(
|
||||
&mut self,
|
||||
layer: RequirementsLayerEntry,
|
||||
hostname: Option<&str>,
|
||||
) -> Result<(), RequirementsCompositionError> {
|
||||
self.layers
|
||||
.push(ComposableRequirementsLayer::from_entry(layer, hostname)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn compose(
|
||||
self,
|
||||
) -> Result<Option<ConfigRequirementsWithSources>, RequirementsCompositionError> {
|
||||
let Self {
|
||||
layers,
|
||||
hook_directory_field,
|
||||
} = self;
|
||||
|
||||
let mut merged_toml = TomlValue::Table(toml::map::Map::new());
|
||||
for layer in &layers {
|
||||
merge_toml_values(&mut merged_toml, &layer.regular_toml);
|
||||
}
|
||||
|
||||
let requirements: ConfigRequirementsToml =
|
||||
merged_toml.try_into().map_err(|err: toml::de::Error| {
|
||||
RequirementsCompositionError::ComposedParse {
|
||||
message: err.to_string(),
|
||||
}
|
||||
})?;
|
||||
let mut output = ConfigRequirementsWithSources::default();
|
||||
populate_merged_regular_fields_with_sources(&mut output, requirements, &layers);
|
||||
let mut rules = None;
|
||||
let mut hooks = HookMergeState::new(hook_directory_field);
|
||||
let mut hooks_output = None;
|
||||
let mut deny_read = DenyReadMergeState::default();
|
||||
// Regular TOML fields are folded low-to-high like config. These custom
|
||||
// fields append or union values, so process them high-to-low to keep
|
||||
// priority order visible in the output.
|
||||
for layer in layers.iter().rev() {
|
||||
let domain_fields = &layer.domain_fields;
|
||||
super::rules::merge(&mut rules, domain_fields.rules.clone(), &layer.source);
|
||||
hooks.merge(
|
||||
&mut hooks_output,
|
||||
domain_fields.hooks.clone(),
|
||||
&layer.source,
|
||||
)?;
|
||||
deny_read.merge(domain_fields.permissions.clone(), &layer.source);
|
||||
}
|
||||
output.rules = rules;
|
||||
output.hooks = hooks_output;
|
||||
deny_read.apply_to(&mut output.permissions);
|
||||
|
||||
let output_is_empty = output.clone().into_toml().is_empty();
|
||||
Ok((!output_is_empty).then_some(output))
|
||||
}
|
||||
}
|
||||
|
||||
fn populate_merged_regular_fields_with_sources(
|
||||
output: &mut ConfigRequirementsWithSources,
|
||||
requirements: ConfigRequirementsToml,
|
||||
layers: &[ComposableRequirementsLayer],
|
||||
) {
|
||||
macro_rules! set_sourced {
|
||||
($field:ident, $keys:expr) => {
|
||||
if let Some(value) = $field {
|
||||
output.$field = Some(Sourced::new(
|
||||
value,
|
||||
source_for_top_level_keys(layers, $keys),
|
||||
));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Destructure without `..` so every new requirements field must choose
|
||||
// whether it belongs in the regular TOML merge path or in a special merger.
|
||||
let ConfigRequirementsToml {
|
||||
allowed_approval_policies,
|
||||
allowed_approvals_reviewers,
|
||||
allowed_sandbox_modes,
|
||||
allowed_permissions,
|
||||
remote_sandbox_config: _,
|
||||
allowed_web_search_modes,
|
||||
allow_managed_hooks_only,
|
||||
allow_appshots,
|
||||
computer_use,
|
||||
windows,
|
||||
feature_requirements,
|
||||
hooks: _,
|
||||
mcp_servers,
|
||||
plugins,
|
||||
apps,
|
||||
rules: _,
|
||||
enforce_residency,
|
||||
network,
|
||||
permissions,
|
||||
guardian_policy_config,
|
||||
} = requirements;
|
||||
|
||||
set_sourced!(allowed_approval_policies, &["allowed_approval_policies"]);
|
||||
set_sourced!(
|
||||
allowed_approvals_reviewers,
|
||||
&["allowed_approvals_reviewers"]
|
||||
);
|
||||
set_sourced!(allowed_sandbox_modes, &["allowed_sandbox_modes"]);
|
||||
set_sourced!(allowed_permissions, &["allowed_permissions"]);
|
||||
set_sourced!(allowed_web_search_modes, &["allowed_web_search_modes"]);
|
||||
set_sourced!(allow_managed_hooks_only, &["allow_managed_hooks_only"]);
|
||||
set_sourced!(allow_appshots, &["allow_appshots"]);
|
||||
set_sourced!(computer_use, &["computer_use"]);
|
||||
set_sourced!(windows, &["windows"]);
|
||||
set_sourced!(feature_requirements, &["features", "feature_requirements"]);
|
||||
set_sourced!(mcp_servers, &["mcp_servers"]);
|
||||
set_sourced!(plugins, &["plugins"]);
|
||||
set_sourced!(apps, &["apps"]);
|
||||
set_sourced!(enforce_residency, &["enforce_residency"]);
|
||||
set_sourced!(network, &["experimental_network"]);
|
||||
set_sourced!(permissions, &["permissions"]);
|
||||
|
||||
if let Some(guardian_policy_config) =
|
||||
guardian_policy_config.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
output.guardian_policy_config = Some(Sourced::new(
|
||||
guardian_policy_config,
|
||||
source_for_top_level_keys(layers, &["guardian_policy_config"]),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn source_for_top_level_keys(
|
||||
layers: &[ComposableRequirementsLayer],
|
||||
keys: &[&str],
|
||||
) -> RequirementSource {
|
||||
let matching_layers = layers
|
||||
.iter()
|
||||
.filter_map(|layer| {
|
||||
top_level_value_for_keys(&layer.regular_toml, keys).map(|value| (&layer.source, value))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let Some((winning_source, winning_value)) = matching_layers.last() else {
|
||||
return RequirementSource::Unknown;
|
||||
};
|
||||
let winning_source = (*winning_source).clone();
|
||||
|
||||
if !winning_value.is_table() {
|
||||
return winning_source;
|
||||
}
|
||||
|
||||
let table_sources = matching_layers
|
||||
.into_iter()
|
||||
.rev()
|
||||
.filter_map(|(source, value)| value.is_table().then_some(source.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
if table_sources.len() > 1 {
|
||||
RequirementSource::composite(table_sources)
|
||||
} else {
|
||||
winning_source
|
||||
}
|
||||
}
|
||||
|
||||
fn top_level_value_for_keys<'a>(value: &'a TomlValue, keys: &[&str]) -> Option<&'a TomlValue> {
|
||||
let table = value.as_table()?;
|
||||
keys.iter().find_map(|key| table.get(*key))
|
||||
}
|
||||
|
||||
pub(super) fn merge_output_source(existing: &mut RequirementSource, incoming: &RequirementSource) {
|
||||
if existing != incoming {
|
||||
*existing = RequirementSource::composite([existing.clone(), incoming.clone()]);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn composition_conflict(
|
||||
field: String,
|
||||
existing_source: RequirementSource,
|
||||
incoming_source: RequirementSource,
|
||||
message: impl Into<String>,
|
||||
) -> RequirementsCompositionError {
|
||||
RequirementsCompositionError::Conflict {
|
||||
field,
|
||||
existing_source,
|
||||
incoming_source,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "stack_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,922 @@
|
||||
use super::super::RequirementsLayerEntry;
|
||||
use super::super::hooks::HookDirectoryField;
|
||||
use super::RequirementsCompositionError;
|
||||
use super::compose_requirements_for_hostname;
|
||||
use super::compose_requirements_for_hostname_and_hook_directory;
|
||||
use crate::ConfigRequirementsToml;
|
||||
use crate::ConfigRequirementsWithSources;
|
||||
use crate::RequirementSource;
|
||||
use crate::Sourced;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn layer(id: &str, name: &str, contents: &str) -> RequirementsLayerEntry {
|
||||
RequirementsLayerEntry::from_toml(
|
||||
RequirementSource::EnterpriseManaged {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
},
|
||||
contents,
|
||||
)
|
||||
}
|
||||
|
||||
fn compose(
|
||||
layers: Vec<RequirementsLayerEntry>,
|
||||
) -> Result<Option<ConfigRequirementsToml>, RequirementsCompositionError> {
|
||||
Ok(
|
||||
compose_requirements_for_hostname(layers, /*hostname*/ None)?
|
||||
.map(ConfigRequirementsWithSources::into_toml),
|
||||
)
|
||||
}
|
||||
|
||||
fn compose_with_hook_directory_field(
|
||||
layers: Vec<RequirementsLayerEntry>,
|
||||
hook_directory_field: HookDirectoryField,
|
||||
) -> Result<Option<ConfigRequirementsToml>, RequirementsCompositionError> {
|
||||
Ok(compose_requirements_for_hostname_and_hook_directory(
|
||||
layers,
|
||||
/*hostname*/ None,
|
||||
hook_directory_field,
|
||||
)?
|
||||
.map(ConfigRequirementsWithSources::into_toml))
|
||||
}
|
||||
|
||||
fn expected_requirements(contents: impl AsRef<str>) -> ConfigRequirementsToml {
|
||||
toml::from_str(contents.as_ref()).expect("parse expected requirements TOML")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_layers_compose_to_none() {
|
||||
let composed = compose(Vec::new()).expect("compose empty layers");
|
||||
assert_eq!(composed, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_values_use_toml_priority() {
|
||||
let composed = compose(vec![
|
||||
layer(
|
||||
"req_low",
|
||||
"Low",
|
||||
r#"
|
||||
allowed_approval_policies = ["on-request"]
|
||||
allowed_sandbox_modes = ["workspace-write"]
|
||||
"#,
|
||||
),
|
||||
layer(
|
||||
"req_high",
|
||||
"High",
|
||||
r#"
|
||||
allowed_approval_policies = ["never"]
|
||||
allowed_sandbox_modes = ["read-only"]
|
||||
"#,
|
||||
),
|
||||
])
|
||||
.expect("compose requirements")
|
||||
.expect("requirements present");
|
||||
|
||||
assert_eq!(
|
||||
composed,
|
||||
expected_requirements(
|
||||
r#"
|
||||
allowed_approval_policies = ["never"]
|
||||
allowed_sandbox_modes = ["read-only"]
|
||||
"#
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composition_strategy_applies_to_non_cloud_layers() {
|
||||
let mdm_source = RequirementSource::MdmManagedPreferences {
|
||||
domain: "com.openai.codex".to_string(),
|
||||
key: "requirements_toml_base64".to_string(),
|
||||
};
|
||||
let system_file = if cfg!(windows) {
|
||||
"C:\\requirements.toml"
|
||||
} else {
|
||||
"/etc/codex/requirements.toml"
|
||||
};
|
||||
let system_source = RequirementSource::SystemRequirementsToml {
|
||||
file: AbsolutePathBuf::from_absolute_path(system_file).expect("absolute path"),
|
||||
};
|
||||
let high_path = if cfg!(windows) {
|
||||
"C:\\secret"
|
||||
} else {
|
||||
"/secret"
|
||||
};
|
||||
let low_path = if cfg!(windows) {
|
||||
"C:\\other-secret"
|
||||
} else {
|
||||
"/other-secret"
|
||||
};
|
||||
|
||||
let composed = compose_requirements_for_hostname(
|
||||
vec![
|
||||
RequirementsLayerEntry::from_toml(
|
||||
system_source,
|
||||
format!(
|
||||
r#"
|
||||
allowed_approval_policies = ["on-request"]
|
||||
|
||||
[features]
|
||||
shared = false
|
||||
system = true
|
||||
|
||||
[[rules.prefix_rules]]
|
||||
pattern = [{{ token = "npm" }}]
|
||||
decision = "prompt"
|
||||
|
||||
[permissions.filesystem]
|
||||
deny_read = [{low_path:?}]
|
||||
"#
|
||||
),
|
||||
),
|
||||
RequirementsLayerEntry::from_toml(
|
||||
mdm_source.clone(),
|
||||
format!(
|
||||
r#"
|
||||
allowed_approval_policies = ["never"]
|
||||
|
||||
[features]
|
||||
shared = true
|
||||
|
||||
[[rules.prefix_rules]]
|
||||
pattern = [{{ token = "git" }}]
|
||||
decision = "forbidden"
|
||||
|
||||
[permissions.filesystem]
|
||||
deny_read = [{high_path:?}]
|
||||
"#
|
||||
),
|
||||
),
|
||||
],
|
||||
/*hostname*/ None,
|
||||
)
|
||||
.expect("compose requirements")
|
||||
.expect("requirements present");
|
||||
|
||||
assert_eq!(
|
||||
composed.clone().into_toml(),
|
||||
expected_requirements(format!(
|
||||
r#"
|
||||
allowed_approval_policies = ["never"]
|
||||
|
||||
[features]
|
||||
shared = true
|
||||
system = true
|
||||
|
||||
[[rules.prefix_rules]]
|
||||
pattern = [{{ token = "git" }}]
|
||||
decision = "forbidden"
|
||||
|
||||
[[rules.prefix_rules]]
|
||||
pattern = [{{ token = "npm" }}]
|
||||
decision = "prompt"
|
||||
|
||||
[permissions.filesystem]
|
||||
deny_read = [{high_path:?}, {low_path:?}]
|
||||
"#
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
composed.allowed_approval_policies,
|
||||
Some(Sourced::new(vec![AskForApproval::Never], mdm_source))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_regular_layer_keeps_enterprise_managed_source() {
|
||||
let composed = compose_requirements_for_hostname(
|
||||
vec![layer(
|
||||
"req_1",
|
||||
"Security baseline",
|
||||
r#"
|
||||
allow_managed_hooks_only = true
|
||||
"#,
|
||||
)],
|
||||
/*hostname*/ None,
|
||||
)
|
||||
.expect("compose requirements")
|
||||
.expect("requirements present");
|
||||
|
||||
assert_eq!(
|
||||
composed.allow_managed_hooks_only,
|
||||
Some(Sourced::new(
|
||||
/*value*/ true,
|
||||
RequirementSource::EnterpriseManaged {
|
||||
id: "req_1".to_string(),
|
||||
name: "Security baseline".to_string(),
|
||||
},
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_toml_merge_recurses_into_tables() {
|
||||
let composed = compose(vec![
|
||||
layer(
|
||||
"req_low",
|
||||
"Low",
|
||||
r#"
|
||||
[features]
|
||||
beta = false
|
||||
shared = false
|
||||
|
||||
[apps.connector_1]
|
||||
enabled = false
|
||||
|
||||
[apps.connector_1.tools.search]
|
||||
approval_mode = "prompt"
|
||||
|
||||
[apps.connector_1.tools.list]
|
||||
approval_mode = "prompt"
|
||||
"#,
|
||||
),
|
||||
layer(
|
||||
"req_high",
|
||||
"High",
|
||||
r#"
|
||||
[features]
|
||||
alpha = true
|
||||
shared = true
|
||||
|
||||
[apps.connector_1]
|
||||
enabled = true
|
||||
|
||||
[apps.connector_1.tools.search]
|
||||
approval_mode = "approve"
|
||||
"#,
|
||||
),
|
||||
])
|
||||
.expect("compose requirements")
|
||||
.expect("requirements present");
|
||||
|
||||
assert_eq!(
|
||||
composed,
|
||||
expected_requirements(
|
||||
r#"
|
||||
[features]
|
||||
alpha = true
|
||||
beta = false
|
||||
shared = true
|
||||
|
||||
[apps.connector_1]
|
||||
enabled = true
|
||||
|
||||
[apps.connector_1.tools.list]
|
||||
approval_mode = "prompt"
|
||||
|
||||
[apps.connector_1.tools.search]
|
||||
approval_mode = "approve"
|
||||
"#
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_table_source_is_composite_in_priority_order() {
|
||||
let high_source = RequirementSource::EnterpriseManaged {
|
||||
id: "req_high".to_string(),
|
||||
name: "High".to_string(),
|
||||
};
|
||||
let low_source = RequirementSource::EnterpriseManaged {
|
||||
id: "req_low".to_string(),
|
||||
name: "Low".to_string(),
|
||||
};
|
||||
let composed = compose_requirements_for_hostname(
|
||||
vec![
|
||||
RequirementsLayerEntry::from_toml(
|
||||
low_source.clone(),
|
||||
r#"
|
||||
[features]
|
||||
beta = true
|
||||
"#,
|
||||
),
|
||||
RequirementsLayerEntry::from_toml(
|
||||
high_source.clone(),
|
||||
r#"
|
||||
[features]
|
||||
alpha = true
|
||||
"#,
|
||||
),
|
||||
],
|
||||
/*hostname*/ None,
|
||||
)
|
||||
.expect("compose requirements")
|
||||
.expect("requirements present");
|
||||
|
||||
assert_eq!(
|
||||
composed.feature_requirements.expect("features"),
|
||||
Sourced::new(
|
||||
crate::FeatureRequirementsToml {
|
||||
entries: BTreeMap::from([("alpha".to_string(), true), ("beta".to_string(), true),]),
|
||||
},
|
||||
RequirementSource::composite([high_source, low_source]),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_requirements_use_regular_toml_merge() {
|
||||
let composed = compose(vec![
|
||||
layer(
|
||||
"req_low",
|
||||
"Low",
|
||||
r#"
|
||||
[mcp_servers.shared.identity]
|
||||
command = "low-mcp"
|
||||
|
||||
[mcp_servers.low.identity]
|
||||
url = "https://low.example.com/mcp"
|
||||
"#,
|
||||
),
|
||||
layer(
|
||||
"req_high",
|
||||
"High",
|
||||
r#"
|
||||
[mcp_servers.shared.identity]
|
||||
command = "high-mcp"
|
||||
"#,
|
||||
),
|
||||
])
|
||||
.expect("compose requirements")
|
||||
.expect("requirements present");
|
||||
|
||||
assert_eq!(
|
||||
composed,
|
||||
expected_requirements(
|
||||
r#"
|
||||
[mcp_servers.low.identity]
|
||||
url = "https://low.example.com/mcp"
|
||||
|
||||
[mcp_servers.shared.identity]
|
||||
command = "high-mcp"
|
||||
"#
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_maps_use_regular_toml_merge() {
|
||||
let composed = compose(vec![
|
||||
layer(
|
||||
"req_low",
|
||||
"Low",
|
||||
r#"
|
||||
[experimental_network.domains]
|
||||
"example.com" = "deny"
|
||||
"low.example.com" = "deny"
|
||||
"internal.example.com" = "allow"
|
||||
|
||||
[experimental_network.unix_sockets]
|
||||
"/tmp/shared.sock" = "deny"
|
||||
"/tmp/low.sock" = "allow"
|
||||
"/tmp/admin.sock" = "allow"
|
||||
"#,
|
||||
),
|
||||
layer(
|
||||
"req_high",
|
||||
"High",
|
||||
r#"
|
||||
[experimental_network.domains]
|
||||
"example.com" = "allow"
|
||||
"high.example.com" = "allow"
|
||||
"internal.example.com" = "deny"
|
||||
|
||||
[experimental_network.unix_sockets]
|
||||
"/tmp/shared.sock" = "allow"
|
||||
"/tmp/high.sock" = "allow"
|
||||
"/tmp/admin.sock" = "deny"
|
||||
"#,
|
||||
),
|
||||
])
|
||||
.expect("compose requirements")
|
||||
.expect("requirements present");
|
||||
|
||||
assert_eq!(
|
||||
composed,
|
||||
expected_requirements(
|
||||
r#"
|
||||
[experimental_network.domains]
|
||||
"example.com" = "allow"
|
||||
"high.example.com" = "allow"
|
||||
"internal.example.com" = "deny"
|
||||
"low.example.com" = "deny"
|
||||
|
||||
[experimental_network.unix_sockets]
|
||||
"/tmp/admin.sock" = "deny"
|
||||
"/tmp/high.sock" = "allow"
|
||||
"/tmp/low.sock" = "allow"
|
||||
"/tmp/shared.sock" = "allow"
|
||||
"#
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_requirements_use_regular_toml_merge() {
|
||||
let composed = compose(vec![
|
||||
layer(
|
||||
"req_low",
|
||||
"Low",
|
||||
r#"
|
||||
[windows]
|
||||
allowed_sandbox_implementations = ["unelevated"]
|
||||
"#,
|
||||
),
|
||||
layer(
|
||||
"req_high",
|
||||
"High",
|
||||
r#"
|
||||
[windows]
|
||||
allowed_sandbox_implementations = ["elevated"]
|
||||
"#,
|
||||
),
|
||||
])
|
||||
.expect("compose requirements")
|
||||
.expect("requirements present");
|
||||
|
||||
assert_eq!(
|
||||
composed,
|
||||
expected_requirements(
|
||||
r#"
|
||||
[windows]
|
||||
allowed_sandbox_implementations = ["elevated"]
|
||||
"#
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_sandbox_config_is_applied_per_layer() {
|
||||
let composed = compose_requirements_for_hostname(
|
||||
vec![
|
||||
layer(
|
||||
"req_low",
|
||||
"Low",
|
||||
r#"
|
||||
allowed_sandbox_modes = ["read-only"]
|
||||
"#,
|
||||
),
|
||||
layer(
|
||||
"req_high",
|
||||
"High",
|
||||
r#"
|
||||
[[remote_sandbox_config]]
|
||||
hostname_patterns = ["build-*.example.com"]
|
||||
allowed_sandbox_modes = ["workspace-write"]
|
||||
"#,
|
||||
),
|
||||
],
|
||||
Some("BUILD-01.EXAMPLE.COM."),
|
||||
)
|
||||
.expect("compose requirements")
|
||||
.expect("requirements present")
|
||||
.into_toml();
|
||||
|
||||
assert_eq!(
|
||||
composed,
|
||||
expected_requirements(
|
||||
r#"
|
||||
allowed_sandbox_modes = ["workspace-write"]
|
||||
"#
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmatched_remote_sandbox_config_does_not_shadow_lower_layers() {
|
||||
let composed = compose_requirements_for_hostname(
|
||||
vec![
|
||||
layer(
|
||||
"req_low",
|
||||
"Low",
|
||||
r#"
|
||||
allowed_sandbox_modes = ["read-only"]
|
||||
"#,
|
||||
),
|
||||
layer(
|
||||
"req_high",
|
||||
"High",
|
||||
r#"
|
||||
[[remote_sandbox_config]]
|
||||
hostname_patterns = ["mac-*.example.com"]
|
||||
allowed_sandbox_modes = ["workspace-write"]
|
||||
"#,
|
||||
),
|
||||
],
|
||||
Some("linux-01.example.com"),
|
||||
)
|
||||
.expect("compose requirements")
|
||||
.expect("requirements present")
|
||||
.into_toml();
|
||||
|
||||
assert_eq!(
|
||||
composed,
|
||||
expected_requirements(
|
||||
r#"
|
||||
allowed_sandbox_modes = ["read-only"]
|
||||
"#
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_are_appended_in_priority_order() {
|
||||
let composed = compose(vec![
|
||||
layer(
|
||||
"req_low",
|
||||
"Low",
|
||||
r#"
|
||||
[[rules.prefix_rules]]
|
||||
pattern = [{ token = "npm" }]
|
||||
decision = "prompt"
|
||||
"#,
|
||||
),
|
||||
layer(
|
||||
"req_high",
|
||||
"High",
|
||||
r#"
|
||||
[[rules.prefix_rules]]
|
||||
pattern = [{ token = "git" }]
|
||||
decision = "forbidden"
|
||||
"#,
|
||||
),
|
||||
])
|
||||
.expect("compose requirements")
|
||||
.expect("requirements present");
|
||||
|
||||
assert_eq!(
|
||||
composed,
|
||||
expected_requirements(
|
||||
r#"
|
||||
[[rules.prefix_rules]]
|
||||
pattern = [{ token = "git" }]
|
||||
decision = "forbidden"
|
||||
|
||||
[[rules.prefix_rules]]
|
||||
pattern = [{ token = "npm" }]
|
||||
decision = "prompt"
|
||||
"#
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hooks_append_groups_and_reject_conflicting_managed_dirs() {
|
||||
let composed = compose_with_hook_directory_field(
|
||||
vec![
|
||||
layer(
|
||||
"req_low",
|
||||
"Low",
|
||||
r#"
|
||||
[hooks]
|
||||
managed_dir = "/managed/hooks"
|
||||
|
||||
[[hooks.PreToolUse]]
|
||||
matcher = "Bash"
|
||||
|
||||
[[hooks.PreToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "low"
|
||||
"#,
|
||||
),
|
||||
layer(
|
||||
"req_high",
|
||||
"High",
|
||||
r#"
|
||||
[hooks]
|
||||
managed_dir = "/managed/hooks"
|
||||
|
||||
[[hooks.PreToolUse]]
|
||||
matcher = "Edit"
|
||||
|
||||
[[hooks.PreToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "high"
|
||||
"#,
|
||||
),
|
||||
],
|
||||
HookDirectoryField::ManagedDir,
|
||||
)
|
||||
.expect("compose requirements")
|
||||
.expect("requirements present");
|
||||
|
||||
assert_eq!(
|
||||
composed,
|
||||
expected_requirements(
|
||||
r#"
|
||||
[hooks]
|
||||
managed_dir = "/managed/hooks"
|
||||
|
||||
[[hooks.PreToolUse]]
|
||||
matcher = "Edit"
|
||||
|
||||
[[hooks.PreToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "high"
|
||||
|
||||
[[hooks.PreToolUse]]
|
||||
matcher = "Bash"
|
||||
|
||||
[[hooks.PreToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "low"
|
||||
"#
|
||||
)
|
||||
);
|
||||
|
||||
let err = compose_with_hook_directory_field(
|
||||
vec![
|
||||
layer(
|
||||
"req_low",
|
||||
"Low",
|
||||
r#"
|
||||
[hooks]
|
||||
managed_dir = "/managed/low"
|
||||
"#,
|
||||
),
|
||||
layer(
|
||||
"req_high",
|
||||
"High",
|
||||
r#"
|
||||
[hooks]
|
||||
managed_dir = "/managed/high"
|
||||
"#,
|
||||
),
|
||||
],
|
||||
HookDirectoryField::ManagedDir,
|
||||
)
|
||||
.expect_err("conflicting managed dirs should fail closed");
|
||||
assert!(err.to_string().contains("hooks.managed_dir"));
|
||||
assert!(err.to_string().contains("High (req_high)"));
|
||||
assert!(err.to_string().contains("Low (req_low)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_windows_managed_dir_conflicts_fail_closed() {
|
||||
let err = compose_with_hook_directory_field(
|
||||
vec![
|
||||
layer(
|
||||
"req_low",
|
||||
"Low",
|
||||
r#"
|
||||
[hooks]
|
||||
windows_managed_dir = 'C:\managed\low'
|
||||
"#,
|
||||
),
|
||||
layer(
|
||||
"req_high",
|
||||
"High",
|
||||
r#"
|
||||
[hooks]
|
||||
windows_managed_dir = 'C:\managed\high'
|
||||
"#,
|
||||
),
|
||||
],
|
||||
HookDirectoryField::WindowsManagedDir,
|
||||
)
|
||||
.expect_err("conflicting windows managed dirs should fail closed");
|
||||
|
||||
assert!(err.to_string().contains("hooks.windows_managed_dir"));
|
||||
assert!(err.to_string().contains("High (req_high)"));
|
||||
assert!(err.to_string().contains("Low (req_low)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inactive_hook_dir_conflicts_do_not_fail_composition() {
|
||||
let composed = compose_with_hook_directory_field(
|
||||
vec![
|
||||
layer(
|
||||
"req_low",
|
||||
"Low",
|
||||
r#"
|
||||
[hooks]
|
||||
managed_dir = "/managed/hooks"
|
||||
windows_managed_dir = 'C:\managed\low'
|
||||
|
||||
[[hooks.PreToolUse]]
|
||||
matcher = "Bash"
|
||||
|
||||
[[hooks.PreToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "low"
|
||||
"#,
|
||||
),
|
||||
layer(
|
||||
"req_high",
|
||||
"High",
|
||||
r#"
|
||||
[hooks]
|
||||
managed_dir = "/managed/hooks"
|
||||
windows_managed_dir = 'C:\managed\high'
|
||||
|
||||
[[hooks.PreToolUse]]
|
||||
matcher = "Edit"
|
||||
|
||||
[[hooks.PreToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "high"
|
||||
"#,
|
||||
),
|
||||
],
|
||||
HookDirectoryField::ManagedDir,
|
||||
)
|
||||
.expect("inactive windows managed dir conflict should not fail")
|
||||
.expect("requirements present");
|
||||
|
||||
assert_eq!(
|
||||
composed,
|
||||
expected_requirements(
|
||||
r#"
|
||||
[hooks]
|
||||
managed_dir = "/managed/hooks"
|
||||
windows_managed_dir = 'C:\managed\high'
|
||||
|
||||
[[hooks.PreToolUse]]
|
||||
matcher = "Edit"
|
||||
|
||||
[[hooks.PreToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "high"
|
||||
|
||||
[[hooks.PreToolUse]]
|
||||
matcher = "Bash"
|
||||
|
||||
[[hooks.PreToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "low"
|
||||
"#
|
||||
)
|
||||
);
|
||||
|
||||
let composed = compose_with_hook_directory_field(
|
||||
vec![
|
||||
layer(
|
||||
"req_low",
|
||||
"Low",
|
||||
r#"
|
||||
[hooks]
|
||||
managed_dir = "/managed/low"
|
||||
windows_managed_dir = 'C:\managed\hooks'
|
||||
|
||||
[[hooks.PreToolUse]]
|
||||
matcher = "Bash"
|
||||
|
||||
[[hooks.PreToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "low"
|
||||
"#,
|
||||
),
|
||||
layer(
|
||||
"req_high",
|
||||
"High",
|
||||
r#"
|
||||
[hooks]
|
||||
managed_dir = "/managed/high"
|
||||
windows_managed_dir = 'C:\managed\hooks'
|
||||
|
||||
[[hooks.PreToolUse]]
|
||||
matcher = "Edit"
|
||||
|
||||
[[hooks.PreToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "high"
|
||||
"#,
|
||||
),
|
||||
],
|
||||
HookDirectoryField::WindowsManagedDir,
|
||||
)
|
||||
.expect("inactive managed dir conflict should not fail")
|
||||
.expect("requirements present");
|
||||
|
||||
assert_eq!(
|
||||
composed,
|
||||
expected_requirements(
|
||||
r#"
|
||||
[hooks]
|
||||
managed_dir = "/managed/high"
|
||||
windows_managed_dir = 'C:\managed\hooks'
|
||||
|
||||
[[hooks.PreToolUse]]
|
||||
matcher = "Edit"
|
||||
|
||||
[[hooks.PreToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "high"
|
||||
|
||||
[[hooks.PreToolUse]]
|
||||
matcher = "Bash"
|
||||
|
||||
[[hooks.PreToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "low"
|
||||
"#
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permissions_deny_read_unions_while_profiles_use_regular_toml_merge() {
|
||||
let high_path = if cfg!(windows) {
|
||||
"C:\\secret"
|
||||
} else {
|
||||
"/secret"
|
||||
};
|
||||
let low_path = if cfg!(windows) {
|
||||
"C:\\other-secret"
|
||||
} else {
|
||||
"/other-secret"
|
||||
};
|
||||
let composed = compose(vec![
|
||||
layer(
|
||||
"req_low",
|
||||
"Low",
|
||||
&format!(
|
||||
r#"
|
||||
[permissions.filesystem]
|
||||
deny_read = [{high_path:?}, {low_path:?}]
|
||||
|
||||
[permissions.managed-standard]
|
||||
description = "Low profile"
|
||||
extends = ":workspace"
|
||||
"#
|
||||
),
|
||||
),
|
||||
layer(
|
||||
"req_high",
|
||||
"High",
|
||||
&format!(
|
||||
r#"
|
||||
[permissions.filesystem]
|
||||
deny_read = [{high_path:?}]
|
||||
|
||||
[permissions.managed-standard]
|
||||
description = "High profile"
|
||||
"#
|
||||
),
|
||||
),
|
||||
])
|
||||
.expect("compose requirements")
|
||||
.expect("requirements present");
|
||||
|
||||
assert_eq!(
|
||||
composed,
|
||||
expected_requirements(format!(
|
||||
r#"
|
||||
[permissions.filesystem]
|
||||
deny_read = [{high_path:?}, {low_path:?}]
|
||||
|
||||
[permissions.managed-standard]
|
||||
description = "High profile"
|
||||
extends = ":workspace"
|
||||
"#
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_read_only_layers_do_not_leave_empty_permissions_tables() {
|
||||
let path = if cfg!(windows) {
|
||||
"C:\\secret"
|
||||
} else {
|
||||
"/secret"
|
||||
};
|
||||
let composed = compose(vec![layer(
|
||||
"req_high",
|
||||
"High",
|
||||
&format!(
|
||||
r#"
|
||||
[permissions.filesystem]
|
||||
deny_read = [{path:?}]
|
||||
"#
|
||||
),
|
||||
)])
|
||||
.expect("compose requirements")
|
||||
.expect("requirements present");
|
||||
|
||||
assert_eq!(
|
||||
composed,
|
||||
expected_requirements(format!(
|
||||
r#"
|
||||
[permissions.filesystem]
|
||||
deny_read = [{path:?}]
|
||||
"#
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_error_names_layer() {
|
||||
let err = compose(vec![layer(
|
||||
"req_bad",
|
||||
"Bad layer",
|
||||
"allowed_approval_policies = [1]",
|
||||
)])
|
||||
.expect_err("invalid layer should fail");
|
||||
|
||||
assert!(err.to_string().contains("Bad layer (req_bad)"));
|
||||
assert!(err.to_string().contains("allowed_approval_policies"));
|
||||
}
|
||||
@@ -97,33 +97,47 @@ impl LoaderOverrides {
|
||||
pub struct ConfigLayerEntry {
|
||||
pub name: ConfigLayerSource,
|
||||
pub config: TomlValue,
|
||||
pub raw_toml: Option<String>,
|
||||
pub version: String,
|
||||
pub disabled_reason: Option<String>,
|
||||
raw_toml: Option<RawTomlLayer>,
|
||||
hooks_config_folder_override: Option<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct RawTomlLayer {
|
||||
contents: String,
|
||||
base_dir: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
impl ConfigLayerEntry {
|
||||
pub fn new(name: ConfigLayerSource, config: TomlValue) -> Self {
|
||||
let version = version_for_toml(&config);
|
||||
Self {
|
||||
name,
|
||||
config,
|
||||
raw_toml: None,
|
||||
version,
|
||||
disabled_reason: None,
|
||||
raw_toml: None,
|
||||
hooks_config_folder_override: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_with_raw_toml(name: ConfigLayerSource, config: TomlValue, raw_toml: String) -> Self {
|
||||
pub fn new_with_raw_toml(
|
||||
name: ConfigLayerSource,
|
||||
config: TomlValue,
|
||||
raw_toml: String,
|
||||
raw_toml_base_dir: AbsolutePathBuf,
|
||||
) -> Self {
|
||||
let version = version_for_toml(&config);
|
||||
Self {
|
||||
name,
|
||||
config,
|
||||
raw_toml: Some(raw_toml),
|
||||
version,
|
||||
disabled_reason: None,
|
||||
raw_toml: Some(RawTomlLayer {
|
||||
contents: raw_toml,
|
||||
base_dir: raw_toml_base_dir,
|
||||
}),
|
||||
hooks_config_folder_override: None,
|
||||
}
|
||||
}
|
||||
@@ -137,9 +151,9 @@ impl ConfigLayerEntry {
|
||||
Self {
|
||||
name,
|
||||
config,
|
||||
raw_toml: None,
|
||||
version,
|
||||
disabled_reason: Some(disabled_reason.into()),
|
||||
raw_toml: None,
|
||||
hooks_config_folder_override: None,
|
||||
}
|
||||
}
|
||||
@@ -149,7 +163,13 @@ impl ConfigLayerEntry {
|
||||
}
|
||||
|
||||
pub fn raw_toml(&self) -> Option<&str> {
|
||||
self.raw_toml.as_deref()
|
||||
self.raw_toml
|
||||
.as_ref()
|
||||
.map(|raw_toml| raw_toml.contents.as_str())
|
||||
}
|
||||
|
||||
pub fn raw_toml_base_dir(&self) -> Option<&AbsolutePathBuf> {
|
||||
self.raw_toml.as_ref().map(|raw_toml| &raw_toml.base_dir)
|
||||
}
|
||||
|
||||
pub(crate) fn with_hooks_config_folder_override(
|
||||
@@ -181,6 +201,7 @@ impl ConfigLayerEntry {
|
||||
match &self.name {
|
||||
ConfigLayerSource::Mdm { .. } => None,
|
||||
ConfigLayerSource::System { file } => file.parent(),
|
||||
ConfigLayerSource::EnterpriseManaged { .. } => None,
|
||||
ConfigLayerSource::User { file, .. } => file.parent(),
|
||||
ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder.clone()),
|
||||
ConfigLayerSource::SessionFlags => None,
|
||||
|
||||
@@ -349,6 +349,7 @@ fn skill_roots_from_layer_stack_inner(
|
||||
});
|
||||
}
|
||||
ConfigLayerSource::Mdm { .. }
|
||||
| ConfigLayerSource::EnterpriseManaged { .. }
|
||||
| ConfigLayerSource::SessionFlags
|
||||
| ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. }
|
||||
| ConfigLayerSource::LegacyManagedConfigTomlFromMdm => {}
|
||||
|
||||
@@ -614,6 +614,9 @@
|
||||
"unified_exec": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"unified_exec_zsh_fork": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"use_legacy_landlock": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -4731,6 +4734,9 @@
|
||||
"unified_exec": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"unified_exec_zsh_fork": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"use_legacy_landlock": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -32,5 +32,5 @@ fn register_session_root(session: &Arc<Session>, turn: &Arc<TurnContext>) {
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.register_session_root(session.conversation_id, &turn.session_source);
|
||||
.register_session_root(session.conversation_id, turn.parent_thread_id);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ pub(crate) enum SpawnAgentForkMode {
|
||||
pub(crate) struct SpawnAgentOptions {
|
||||
pub(crate) fork_parent_spawn_call_id: Option<String>,
|
||||
pub(crate) fork_mode: Option<SpawnAgentForkMode>,
|
||||
pub(crate) parent_thread_id: Option<ThreadId>,
|
||||
pub(crate) environments: Option<Vec<TurnEnvironmentSelection>>,
|
||||
}
|
||||
|
||||
@@ -262,12 +263,12 @@ impl AgentControl {
|
||||
.await?
|
||||
}
|
||||
(Some(session_source), None) => {
|
||||
let forked_from_thread_id = thread_spawn_parent_thread_id(&session_source);
|
||||
Box::pin(state.spawn_new_thread_with_source(
|
||||
config.clone(),
|
||||
self.clone(),
|
||||
session_source,
|
||||
forked_from_thread_id,
|
||||
options.parent_thread_id,
|
||||
/*forked_from_thread_id*/ None,
|
||||
/*thread_source*/ Some(ThreadSource::Subagent),
|
||||
/*persist_extended_history*/ false,
|
||||
/*metrics_service_name*/ None,
|
||||
@@ -309,6 +310,7 @@ impl AgentControl {
|
||||
}
|
||||
};
|
||||
let thread_config = new_thread.thread.codex.thread_config_snapshot().await;
|
||||
let parent_thread_id = thread_config.parent_thread_id;
|
||||
emit_subagent_session_started(
|
||||
&new_thread
|
||||
.thread
|
||||
@@ -319,7 +321,7 @@ impl AgentControl {
|
||||
client_metadata,
|
||||
new_thread.thread.codex.session.session_id(),
|
||||
new_thread.thread_id,
|
||||
/*parent_thread_id*/ None,
|
||||
parent_thread_id,
|
||||
thread_config,
|
||||
subagent_source.clone(),
|
||||
);
|
||||
@@ -473,6 +475,7 @@ impl AgentControl {
|
||||
}
|
||||
if preserve_reference_context_item
|
||||
&& config.features.enabled(Feature::MultiAgentV2)
|
||||
&& config.multi_agent_v2.usage_hint_enabled
|
||||
&& let Some(subagent_usage_hint_text) =
|
||||
config.multi_agent_v2.subagent_usage_hint_text.clone()
|
||||
&& let Some(subagent_usage_hint_message) =
|
||||
@@ -490,6 +493,7 @@ impl AgentControl {
|
||||
self.clone(),
|
||||
session_source,
|
||||
/*thread_source*/ Some(ThreadSource::Subagent),
|
||||
/*parent_thread_id*/ Some(parent_thread_id),
|
||||
/*forked_from_thread_id*/ Some(parent_thread_id),
|
||||
/*persist_extended_history*/ false,
|
||||
inherited_shell_snapshot,
|
||||
@@ -638,6 +642,7 @@ impl AgentControl {
|
||||
.history
|
||||
.ok_or_else(|| CodexErr::ThreadNotFound(thread_id))?
|
||||
.items;
|
||||
let parent_thread_id = stored_thread.parent_thread_id;
|
||||
|
||||
let resumed_thread = state
|
||||
.resume_thread_with_history_with_source(ResumeThreadWithHistoryOptions {
|
||||
@@ -649,6 +654,7 @@ impl AgentControl {
|
||||
}),
|
||||
agent_control: self.clone(),
|
||||
session_source,
|
||||
parent_thread_id,
|
||||
inherited_shell_snapshot,
|
||||
inherited_exec_policy,
|
||||
})
|
||||
@@ -841,9 +847,9 @@ impl AgentControl {
|
||||
pub(crate) fn register_session_root(
|
||||
&self,
|
||||
current_thread_id: ThreadId,
|
||||
current_session_source: &SessionSource,
|
||||
current_parent_thread_id: Option<ThreadId>,
|
||||
) {
|
||||
if thread_spawn_parent_thread_id(current_session_source).is_none() {
|
||||
if current_parent_thread_id.is_none() {
|
||||
self.state.register_root_thread(current_thread_id);
|
||||
}
|
||||
}
|
||||
@@ -1218,7 +1224,8 @@ impl AgentControl {
|
||||
child_thread_id: ThreadId,
|
||||
session_source: Option<&SessionSource>,
|
||||
) {
|
||||
let Some(parent_thread_id) = session_source.and_then(thread_spawn_parent_thread_id) else {
|
||||
let Some(parent_thread_id) = session_source.and_then(SessionSource::parent_thread_id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(state_db_ctx) = thread.state_db() else {
|
||||
@@ -1263,10 +1270,6 @@ impl AgentControl {
|
||||
}
|
||||
}
|
||||
|
||||
fn thread_spawn_parent_thread_id(session_source: &SessionSource) -> Option<ThreadId> {
|
||||
session_source.parent_thread_id()
|
||||
}
|
||||
|
||||
fn agent_matches_prefix(agent_path: Option<&AgentPath>, prefix: &AgentPath) -> bool {
|
||||
if prefix.is_root() {
|
||||
return true;
|
||||
|
||||
@@ -74,6 +74,15 @@ fn assistant_message(text: &str, phase: Option<MessagePhase>) -> ResponseItem {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_session_root_skips_threads_with_explicit_parent() {
|
||||
let control = AgentControl::default();
|
||||
|
||||
control.register_session_root(ThreadId::new(), Some(ThreadId::new()));
|
||||
|
||||
assert_eq!(control.state.agent_id_for_path(&AgentPath::root()), None);
|
||||
}
|
||||
|
||||
fn spawn_agent_call(call_id: &str) -> ResponseItem {
|
||||
ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
@@ -708,6 +717,53 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
|
||||
"full-history forked child should preserve the parent diff baseline"
|
||||
);
|
||||
|
||||
let mut disabled_hint_child_config = harness.config.clone();
|
||||
let _ = disabled_hint_child_config
|
||||
.features
|
||||
.enable(Feature::MultiAgentV2);
|
||||
disabled_hint_child_config.multi_agent_v2.usage_hint_enabled = false;
|
||||
disabled_hint_child_config
|
||||
.multi_agent_v2
|
||||
.subagent_usage_hint_text = Some("Disabled child subagent guidance.".to_string());
|
||||
let disabled_hint_child_thread_id = harness
|
||||
.control
|
||||
.spawn_agent_with_metadata(
|
||||
disabled_hint_child_config,
|
||||
text_input("child task without hints"),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})),
|
||||
SpawnAgentOptions {
|
||||
fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()),
|
||||
fork_mode: Some(SpawnAgentForkMode::FullHistory),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("forked spawn should honor disabled usage hints")
|
||||
.thread_id;
|
||||
let disabled_hint_child_thread = harness
|
||||
.manager
|
||||
.get_thread(disabled_hint_child_thread_id)
|
||||
.await
|
||||
.expect("disabled-hint child thread should be registered");
|
||||
let disabled_hint_history = disabled_hint_child_thread
|
||||
.codex
|
||||
.session
|
||||
.clone_history()
|
||||
.await;
|
||||
assert!(
|
||||
!history_contains_text(
|
||||
disabled_hint_history.raw_items(),
|
||||
"Disabled child subagent guidance.",
|
||||
),
|
||||
"full-history forked child should not add subagent guidance when usage hints are disabled"
|
||||
);
|
||||
|
||||
let expected = (
|
||||
child_thread_id,
|
||||
Op::UserInput {
|
||||
@@ -734,6 +790,11 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
|
||||
.shutdown_live_agent(child_thread_id)
|
||||
.await
|
||||
.expect("child shutdown should submit");
|
||||
let _ = harness
|
||||
.control
|
||||
.shutdown_live_agent(disabled_hint_child_thread_id)
|
||||
.await
|
||||
.expect("disabled-hint child shutdown should submit");
|
||||
let _ = parent_thread
|
||||
.submit(Op::Shutdown {})
|
||||
.await
|
||||
|
||||
@@ -173,6 +173,7 @@ struct ModelClientState {
|
||||
provider: SharedModelProvider,
|
||||
auth_env_telemetry: AuthEnvTelemetry,
|
||||
session_source: SessionSource,
|
||||
parent_thread_id: Option<ThreadId>,
|
||||
model_verbosity: Option<VerbosityConfig>,
|
||||
enable_request_compression: bool,
|
||||
include_timing_metrics: bool,
|
||||
@@ -321,6 +322,7 @@ impl ModelClient {
|
||||
installation_id: String,
|
||||
provider_info: ModelProviderInfo,
|
||||
session_source: SessionSource,
|
||||
parent_thread_id: Option<ThreadId>,
|
||||
model_verbosity: Option<VerbosityConfig>,
|
||||
enable_request_compression: bool,
|
||||
include_timing_metrics: bool,
|
||||
@@ -344,6 +346,7 @@ impl ModelClient {
|
||||
provider: model_provider,
|
||||
auth_env_telemetry,
|
||||
session_source,
|
||||
parent_thread_id,
|
||||
model_verbosity,
|
||||
enable_request_compression,
|
||||
include_timing_metrics,
|
||||
@@ -637,7 +640,7 @@ impl ModelClient {
|
||||
|
||||
fn build_responses_identity_headers(&self) -> ApiHeaderMap {
|
||||
let mut extra_headers = self.build_subagent_headers();
|
||||
if let Some(parent_thread_id) = parent_thread_id_header_value(&self.state.session_source)
|
||||
if let Some(parent_thread_id) = parent_thread_id_header_value(self.state.parent_thread_id)
|
||||
&& let Ok(val) = HeaderValue::from_str(&parent_thread_id)
|
||||
{
|
||||
extra_headers.insert(X_CODEX_PARENT_THREAD_ID_HEADER, val);
|
||||
@@ -664,7 +667,7 @@ impl ModelClient {
|
||||
if let Some(subagent) = subagent_header_value(&self.state.session_source) {
|
||||
client_metadata.insert(X_OPENAI_SUBAGENT_HEADER.to_string(), subagent);
|
||||
}
|
||||
if let Some(parent_thread_id) = parent_thread_id_header_value(&self.state.session_source) {
|
||||
if let Some(parent_thread_id) = parent_thread_id_header_value(self.state.parent_thread_id) {
|
||||
client_metadata.insert(
|
||||
X_CODEX_PARENT_THREAD_ID_HEADER.to_string(),
|
||||
parent_thread_id,
|
||||
@@ -1733,10 +1736,8 @@ fn subagent_header_value(session_source: &SessionSource) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn parent_thread_id_header_value(session_source: &SessionSource) -> Option<String> {
|
||||
session_source
|
||||
.parent_thread_id()
|
||||
.map(|parent_thread_id| parent_thread_id.to_string())
|
||||
fn parent_thread_id_header_value(parent_thread_id: Option<ThreadId>) -> Option<String> {
|
||||
parent_thread_id.map(|parent_thread_id| parent_thread_id.to_string())
|
||||
}
|
||||
|
||||
const RESPONSE_STREAM_CHANNEL_CAPACITY: usize = 1600;
|
||||
|
||||
@@ -61,6 +61,13 @@ use tracing_subscriber::registry::LookupSpan;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
fn test_model_client(session_source: SessionSource) -> ModelClient {
|
||||
test_model_client_with_parent(session_source, /*parent_thread_id*/ None)
|
||||
}
|
||||
|
||||
fn test_model_client_with_parent(
|
||||
session_source: SessionSource,
|
||||
parent_thread_id: Option<ThreadId>,
|
||||
) -> ModelClient {
|
||||
let provider = create_oss_provider_with_base_url("https://example.com/v1", WireApi::Responses);
|
||||
let thread_id = ThreadId::new();
|
||||
ModelClient::new(
|
||||
@@ -70,6 +77,7 @@ fn test_model_client(session_source: SessionSource) -> ModelClient {
|
||||
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
|
||||
provider,
|
||||
session_source,
|
||||
parent_thread_id,
|
||||
/*model_verbosity*/ None,
|
||||
/*enable_request_compression*/ false,
|
||||
/*include_timing_metrics*/ false,
|
||||
@@ -272,13 +280,16 @@ fn build_subagent_headers_sets_internal_memory_consolidation_label() {
|
||||
#[test]
|
||||
fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() {
|
||||
let parent_thread_id = ThreadId::new();
|
||||
let client = test_model_client(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 2,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
}));
|
||||
let client = test_model_client_with_parent(
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 2,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
}),
|
||||
Some(parent_thread_id),
|
||||
);
|
||||
|
||||
client.advance_window_generation();
|
||||
|
||||
@@ -520,6 +531,7 @@ fn model_client_with_counting_attestation(
|
||||
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
|
||||
provider,
|
||||
SessionSource::Exec,
|
||||
/*parent_thread_id*/ None,
|
||||
/*model_verbosity*/ None,
|
||||
/*enable_request_compression*/ false,
|
||||
/*include_timing_metrics*/ false,
|
||||
|
||||
@@ -74,6 +74,8 @@ pub(crate) async fn run_codex_thread_interactive(
|
||||
) -> Result<Codex, CodexErr> {
|
||||
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let (tx_ops, rx_ops) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let conversation_history = initial_history.unwrap_or(InitialHistory::New);
|
||||
let forked_from_thread_id = conversation_history.forked_from_id();
|
||||
let CodexSpawnOk { codex, .. } = Box::pin(Codex::spawn(CodexSpawnArgs {
|
||||
config,
|
||||
installation_id: parent_session.installation_id.clone(),
|
||||
@@ -84,9 +86,10 @@ pub(crate) async fn run_codex_thread_interactive(
|
||||
plugins_manager: Arc::clone(&parent_session.services.plugins_manager),
|
||||
mcp_manager: Arc::clone(&parent_session.services.mcp_manager),
|
||||
extensions: Arc::clone(&parent_session.services.extensions),
|
||||
conversation_history: initial_history.unwrap_or(InitialHistory::New),
|
||||
conversation_history,
|
||||
session_source: SessionSource::SubAgent(subagent_source.clone()),
|
||||
forked_from_thread_id: Some(parent_session.conversation_id),
|
||||
forked_from_thread_id,
|
||||
parent_thread_id: Some(parent_session.conversation_id),
|
||||
thread_source: Some(ThreadSource::Subagent),
|
||||
agent_control: parent_session.services.agent_control.clone(),
|
||||
dynamic_tools: Vec::new(),
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::session::SessionSettingsUpdate;
|
||||
use crate::session::SteerInputError;
|
||||
use codex_features::Feature;
|
||||
use codex_otel::SessionTelemetry;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::Personality;
|
||||
@@ -67,6 +68,7 @@ pub struct ThreadConfigSnapshot {
|
||||
pub personality: Option<Personality>,
|
||||
pub collaboration_mode: CollaborationMode,
|
||||
pub session_source: SessionSource,
|
||||
pub parent_thread_id: Option<ThreadId>,
|
||||
pub thread_source: Option<ThreadSource>,
|
||||
}
|
||||
|
||||
@@ -262,6 +264,14 @@ impl CodexThread {
|
||||
self.codex.session.inject_if_running(items).await
|
||||
}
|
||||
|
||||
/// Starts a regular turn with model-visible items only if the thread is idle.
|
||||
pub async fn try_start_turn_if_idle(
|
||||
&self,
|
||||
items: Vec<ResponseItem>,
|
||||
) -> Result<(), Vec<ResponseItem>> {
|
||||
self.codex.session.try_start_turn_if_idle(items).await
|
||||
}
|
||||
|
||||
pub async fn set_app_server_client_info(
|
||||
&self,
|
||||
app_server_client_name: Option<String>,
|
||||
|
||||
@@ -9856,7 +9856,55 @@ enabled = true
|
||||
assert_eq!(config.multi_agent_v2.max_wait_timeout_ms, 3_600_000);
|
||||
assert_eq!(config.multi_agent_v2.default_wait_timeout_ms, 30_000);
|
||||
assert_eq!(config.agent_max_threads, Some(3));
|
||||
assert!(!config.multi_agent_v2.non_code_mode_only);
|
||||
assert_eq!(
|
||||
config.multi_agent_v2.root_agent_usage_hint_text.as_deref(),
|
||||
Some(DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT)
|
||||
);
|
||||
assert!(
|
||||
!config
|
||||
.multi_agent_v2
|
||||
.root_agent_usage_hint_text
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("maximum concurrency"),
|
||||
);
|
||||
assert_eq!(
|
||||
config.multi_agent_v2.subagent_usage_hint_text.as_deref(),
|
||||
Some(DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT)
|
||||
);
|
||||
assert!(
|
||||
!config
|
||||
.multi_agent_v2
|
||||
.subagent_usage_hint_text
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("maximum concurrency"),
|
||||
);
|
||||
assert!(config.multi_agent_v2.non_code_mode_only);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_empty_usage_hint_overrides_clear_default_hints() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features.multi_agent_v2]
|
||||
enabled = true
|
||||
root_agent_usage_hint_text = ""
|
||||
subagent_usage_hint_text = ""
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert_eq!(config.multi_agent_v2.root_agent_usage_hint_text, None);
|
||||
assert_eq!(config.multi_agent_v2.subagent_usage_hint_text, None);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -186,6 +186,44 @@ pub(crate) const DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION: usiz
|
||||
pub(crate) const DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS: i64 = 10_000;
|
||||
pub(crate) const DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS: i64 = 3600 * 1000;
|
||||
pub(crate) const DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000;
|
||||
const DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT: &str = r#"You are `/root`, the primary agent in a team of agents collaborating to fulfill the user's goals.
|
||||
|
||||
At the start of your turn, you are the active agent.
|
||||
You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents.
|
||||
All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools.
|
||||
|
||||
You can use `spawn_agent` to create a new agent, `assign_task` to give an existing agent a new task and trigger a turn, and `send_message` to pass a message to a running agent without triggering a turn.
|
||||
Child agents can also spawn their own sub-agents.
|
||||
You can decide how much context you want to propagate to your sub-agents with the `fork_turns` parameter.
|
||||
|
||||
You will receive messages in the analysis channel in the form:
|
||||
```
|
||||
Message Type: MESSAGE | FINAL_ANSWER
|
||||
Sender: <author>
|
||||
Payload:
|
||||
<payload text>
|
||||
```
|
||||
They may be addressed as to=/root
|
||||
"#;
|
||||
const DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT: &str = r#"You are an agent in a team of agents collaborating to complete a task.
|
||||
|
||||
You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents. All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools.
|
||||
|
||||
You can use `spawn_agent` to create a new agent, `assign_task` to give an existing agent a new task and trigger a turn, and `send_message` to pass a message to a running agent.
|
||||
Child agents can also spawn their own sub-agents.
|
||||
|
||||
When you provide a response in the final channel, that content is immediately delivered back to your parent agent.
|
||||
|
||||
You will receive messages in the analysis channel in the form:
|
||||
```
|
||||
Message Type: NEW_TASK | MESSAGE | FINAL_ANSWER
|
||||
Task name: <recipient> # only for NEW_TASK -- this determines your identity
|
||||
Sender: <author>
|
||||
Payload:
|
||||
<payload text>
|
||||
```
|
||||
You may also see them addressed as to=/root/..., which indicates your identity is /root/...
|
||||
"#;
|
||||
pub(crate) const HARD_MIN_MULTI_AGENT_V2_TIMEOUT_MS: i64 = 0;
|
||||
pub(crate) const HARD_MAX_MULTI_AGENT_V2_TIMEOUT_MS: i64 =
|
||||
DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS;
|
||||
@@ -943,11 +981,15 @@ impl Default for MultiAgentV2Config {
|
||||
default_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS,
|
||||
usage_hint_enabled: true,
|
||||
usage_hint_text: None,
|
||||
root_agent_usage_hint_text: None,
|
||||
subagent_usage_hint_text: None,
|
||||
root_agent_usage_hint_text: Some(
|
||||
DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT.to_string(),
|
||||
),
|
||||
subagent_usage_hint_text: Some(
|
||||
DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT.to_string(),
|
||||
),
|
||||
tool_namespace: None,
|
||||
hide_spawn_agent_metadata: false,
|
||||
non_code_mode_only: false,
|
||||
non_code_mode_only: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2145,14 +2187,14 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config
|
||||
.and_then(|config| config.usage_hint_text.as_ref())
|
||||
.cloned()
|
||||
.or(default.usage_hint_text);
|
||||
let root_agent_usage_hint_text = base
|
||||
.and_then(|config| config.root_agent_usage_hint_text.as_ref())
|
||||
.cloned()
|
||||
.or(default.root_agent_usage_hint_text);
|
||||
let subagent_usage_hint_text = base
|
||||
.and_then(|config| config.subagent_usage_hint_text.as_ref())
|
||||
.cloned()
|
||||
.or(default.subagent_usage_hint_text);
|
||||
let root_agent_usage_hint_text = resolve_optional_prompt_text(
|
||||
base.map(|config| &config.root_agent_usage_hint_text),
|
||||
default.root_agent_usage_hint_text,
|
||||
);
|
||||
let subagent_usage_hint_text = resolve_optional_prompt_text(
|
||||
base.map(|config| &config.subagent_usage_hint_text),
|
||||
default.subagent_usage_hint_text,
|
||||
);
|
||||
let tool_namespace = base
|
||||
.and_then(|config| config.tool_namespace.as_ref())
|
||||
.cloned()
|
||||
@@ -2193,6 +2235,17 @@ fn resolve_terminal_resize_reflow_config(config_toml: &ConfigToml) -> TerminalRe
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_optional_prompt_text(
|
||||
configured: Option<&Option<String>>,
|
||||
default: Option<String>,
|
||||
) -> Option<String> {
|
||||
match configured {
|
||||
Some(Some(value)) if value.is_empty() => None,
|
||||
Some(Some(value)) => Some(value.clone()),
|
||||
Some(None) | None => default,
|
||||
}
|
||||
}
|
||||
|
||||
fn multi_agent_v2_toml_config(features: Option<&FeaturesToml>) -> Option<&MultiAgentV2ConfigToml> {
|
||||
match features?.multi_agent_v2.as_ref()? {
|
||||
FeatureToml::Enabled(_) => None,
|
||||
|
||||
@@ -156,8 +156,8 @@ pub(crate) fn is_guardian_reviewer_source(
|
||||
) -> bool {
|
||||
matches!(
|
||||
session_source,
|
||||
codex_protocol::protocol::SessionSource::SubAgent(SubAgentSource::Other(name))
|
||||
if name == GUARDIAN_REVIEWER_NAME
|
||||
codex_protocol::protocol::SessionSource::SubAgent(SubAgentSource::Other(label))
|
||||
if label == GUARDIAN_REVIEWER_NAME
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -704,6 +704,7 @@ fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str);
|
||||
HookSource::SessionFlags => "session_flags",
|
||||
HookSource::Plugin => "plugin",
|
||||
HookSource::CloudRequirements => "cloud_requirements",
|
||||
HookSource::CloudManagedConfig => "cloud_managed_config",
|
||||
HookSource::LegacyManagedConfigFile => "legacy_managed_config_file",
|
||||
HookSource::LegacyManagedConfigMdm => "legacy_managed_config_mdm",
|
||||
HookSource::Unknown => "unknown",
|
||||
|
||||
@@ -45,6 +45,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R
|
||||
meta: SessionMeta {
|
||||
id: thread_id,
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
timestamp: TEST_TIMESTAMP.to_string(),
|
||||
cwd: std::path::PathBuf::from("."),
|
||||
originator: "test_originator".to_string(),
|
||||
|
||||
@@ -32,6 +32,7 @@ fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThre
|
||||
thread_id: ThreadId::new(),
|
||||
rollout_path: Some(PathBuf::from("/tmp/rollout.jsonl")),
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
preview: first_user_message.to_string(),
|
||||
name: (!title.is_empty()).then(|| title.to_string()),
|
||||
model_provider: "test-provider".to_string(),
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
use super::input_queue::TurnInput;
|
||||
use super::session::Session;
|
||||
use super::turn_context::TurnContext;
|
||||
use crate::state::ActiveTurn;
|
||||
use crate::state::TurnState;
|
||||
use crate::tasks::RegularTask;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use std::sync::Arc;
|
||||
|
||||
impl Session {
|
||||
/// Returns the input if there is no active turn to inject into.
|
||||
@@ -28,6 +32,75 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a regular turn with the provided items only if the session is idle.
|
||||
pub(crate) async fn try_start_turn_if_idle(
|
||||
self: &Arc<Self>,
|
||||
input: Vec<ResponseItem>,
|
||||
) -> Result<(), Vec<ResponseItem>> {
|
||||
if input.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if self.input_queue.has_trigger_turn_mailbox_items().await {
|
||||
return Err(input);
|
||||
}
|
||||
|
||||
let turn_state = {
|
||||
let mut active_turn = self.active_turn.lock().await;
|
||||
if active_turn.is_some() {
|
||||
return Err(input);
|
||||
}
|
||||
let active_turn = active_turn.get_or_insert_with(ActiveTurn::default);
|
||||
Arc::clone(&active_turn.turn_state)
|
||||
};
|
||||
|
||||
if self.input_queue.has_trigger_turn_mailbox_items().await {
|
||||
self.clear_reserved_idle_turn(&turn_state).await;
|
||||
self.maybe_start_turn_for_pending_work().await;
|
||||
return Err(input);
|
||||
}
|
||||
|
||||
let turn_context = self
|
||||
.new_default_turn_with_sub_id(uuid::Uuid::new_v4().to_string())
|
||||
.await;
|
||||
self.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref())
|
||||
.await;
|
||||
if self.input_queue.has_trigger_turn_mailbox_items().await {
|
||||
self.clear_reserved_idle_turn(&turn_state).await;
|
||||
self.maybe_start_turn_for_pending_work().await;
|
||||
return Err(input);
|
||||
}
|
||||
let still_reserved = {
|
||||
let active_turn = self.active_turn.lock().await;
|
||||
active_turn.as_ref().is_some_and(|active_turn| {
|
||||
active_turn.task.is_none() && Arc::ptr_eq(&active_turn.turn_state, &turn_state)
|
||||
})
|
||||
};
|
||||
if !still_reserved {
|
||||
self.clear_reserved_idle_turn(&turn_state).await;
|
||||
return Err(input);
|
||||
}
|
||||
|
||||
self.input_queue
|
||||
.extend_pending_input_for_turn_state(
|
||||
turn_state.as_ref(),
|
||||
input.into_iter().map(TurnInput::ResponseItem).collect(),
|
||||
)
|
||||
.await;
|
||||
self.start_task(turn_context, Vec::new(), RegularTask::new())
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn clear_reserved_idle_turn(&self, turn_state: &Arc<tokio::sync::Mutex<TurnState>>) {
|
||||
let mut active_turn_guard = self.active_turn.lock().await;
|
||||
if let Some(active_turn) = active_turn_guard.as_ref()
|
||||
&& active_turn.task.is_none()
|
||||
&& Arc::ptr_eq(&active_turn.turn_state, turn_state)
|
||||
{
|
||||
*active_turn_guard = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Injects items into active work, or records them without starting a turn.
|
||||
pub(crate) async fn inject_no_new_turn(
|
||||
&self,
|
||||
|
||||
@@ -361,7 +361,6 @@ use codex_protocol::protocol::WarningEvent;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_tools::ToolEnvironmentMode;
|
||||
use codex_tools::UnifiedExecShellMode;
|
||||
use codex_tools::shell_command_backend_for_features;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
#[cfg(test)]
|
||||
use codex_utils_stream_parser::ProposedPlanSegment;
|
||||
@@ -401,6 +400,7 @@ pub(crate) struct CodexSpawnArgs {
|
||||
pub(crate) conversation_history: InitialHistory,
|
||||
pub(crate) session_source: SessionSource,
|
||||
pub(crate) forked_from_thread_id: Option<ThreadId>,
|
||||
pub(crate) parent_thread_id: Option<ThreadId>,
|
||||
pub(crate) thread_source: Option<ThreadSource>,
|
||||
pub(crate) agent_control: AgentControl,
|
||||
pub(crate) dynamic_tools: Vec<DynamicToolSpec>,
|
||||
@@ -466,6 +466,7 @@ impl Codex {
|
||||
conversation_history,
|
||||
session_source,
|
||||
forked_from_thread_id,
|
||||
parent_thread_id,
|
||||
thread_source,
|
||||
agent_control,
|
||||
dynamic_tools,
|
||||
@@ -594,6 +595,7 @@ impl Codex {
|
||||
app_server_client_version: None,
|
||||
session_source,
|
||||
forked_from_thread_id,
|
||||
parent_thread_id,
|
||||
thread_source,
|
||||
dynamic_tools,
|
||||
persist_extended_history,
|
||||
|
||||
@@ -12,6 +12,10 @@ pub(super) fn usage_hint_text<'a>(
|
||||
}
|
||||
|
||||
let multi_agent_v2 = &turn_context.config.multi_agent_v2;
|
||||
if !multi_agent_v2.usage_hint_enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
match session_source {
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. }) => {
|
||||
multi_agent_v2.subagent_usage_hint_text.as_deref()
|
||||
|
||||
@@ -31,9 +31,8 @@ pub(super) async fn spawn_review_thread(
|
||||
.models_manager
|
||||
.list_models(RefreshStrategy::OnlineIfUncached)
|
||||
.await;
|
||||
let shell_command_backend = shell_command_backend_for_features(review_features.get());
|
||||
let unified_exec_shell_mode = UnifiedExecShellMode::for_session(
|
||||
shell_command_backend,
|
||||
codex_tools::unified_exec_uses_zsh_fork_for_features(review_features.get()),
|
||||
crate::tools::tool_user_shell_type(sess.services.user_shell.as_ref()),
|
||||
sess.services.shell_zsh_path.as_ref(),
|
||||
sess.services.main_execve_wrapper_exe.as_ref(),
|
||||
@@ -90,6 +89,7 @@ pub(super) async fn spawn_review_thread(
|
||||
sess.session_id().to_string(),
|
||||
sess.thread_id().to_string(),
|
||||
forked_from_thread_id,
|
||||
parent_turn_context.parent_thread_id,
|
||||
&session_source,
|
||||
parent_turn_context.thread_source,
|
||||
review_turn_id.clone(),
|
||||
@@ -113,6 +113,7 @@ pub(super) async fn spawn_review_thread(
|
||||
reasoning_effort,
|
||||
reasoning_summary,
|
||||
session_source,
|
||||
parent_thread_id: parent_turn_context.parent_thread_id,
|
||||
thread_source: parent_turn_context.thread_source,
|
||||
environments: parent_turn_context.environments.clone(),
|
||||
available_models,
|
||||
|
||||
@@ -106,6 +106,8 @@ pub(crate) struct SessionConfiguration {
|
||||
pub(super) session_source: SessionSource,
|
||||
/// Immediate history source copied into this thread, when this thread was forked.
|
||||
pub(super) forked_from_thread_id: Option<ThreadId>,
|
||||
/// Immediate control/spawn parent for this thread, when it has one.
|
||||
pub(super) parent_thread_id: Option<ThreadId>,
|
||||
/// Optional analytics source classification for this thread.
|
||||
pub(super) thread_source: Option<ThreadSource>,
|
||||
pub(super) dynamic_tools: Vec<DynamicToolSpec>,
|
||||
@@ -182,6 +184,7 @@ impl SessionConfiguration {
|
||||
personality: self.personality,
|
||||
collaboration_mode: self.collaboration_mode.clone(),
|
||||
session_source: self.session_source.clone(),
|
||||
parent_thread_id: self.parent_thread_id,
|
||||
thread_source: self.thread_source,
|
||||
}
|
||||
}
|
||||
@@ -499,6 +502,10 @@ impl Session {
|
||||
.forked_from_thread_id
|
||||
.or_else(|| initial_history.forked_from_id());
|
||||
session_configuration.forked_from_thread_id = forked_from_id;
|
||||
let parent_thread_id = session_configuration
|
||||
.parent_thread_id
|
||||
.or_else(|| initial_history.get_resumed_parent_thread_id());
|
||||
session_configuration.parent_thread_id = parent_thread_id;
|
||||
|
||||
let event_persistence_mode = if session_configuration.persist_extended_history {
|
||||
ThreadEventPersistenceMode::Extended
|
||||
@@ -538,6 +545,7 @@ impl Session {
|
||||
CreateThreadParams {
|
||||
thread_id,
|
||||
forked_from_id,
|
||||
parent_thread_id,
|
||||
source: session_source,
|
||||
thread_source: session_configuration.thread_source,
|
||||
base_instructions: BaseInstructions {
|
||||
@@ -1018,6 +1026,7 @@ impl Session {
|
||||
installation_id.clone(),
|
||||
session_configuration.provider.clone(),
|
||||
session_configuration.session_source.clone(),
|
||||
session_configuration.parent_thread_id,
|
||||
config.model_verbosity,
|
||||
config.features.enabled(Feature::EnableRequestCompression),
|
||||
config.features.enabled(Feature::RuntimeMetrics),
|
||||
@@ -1027,7 +1036,7 @@ impl Session {
|
||||
.with_prompt_cache_key_override(
|
||||
crate::guardian::prompt_cache_key_override_for_review_session(
|
||||
&session_configuration.session_source,
|
||||
session_configuration.forked_from_thread_id,
|
||||
session_configuration.parent_thread_id,
|
||||
),
|
||||
),
|
||||
code_mode_service: crate::tools::code_mode::CodeModeService::new(),
|
||||
@@ -1070,6 +1079,7 @@ impl Session {
|
||||
session_id,
|
||||
thread_id,
|
||||
forked_from_id,
|
||||
parent_thread_id,
|
||||
thread_source: session_configuration.thread_source,
|
||||
thread_name: session_configuration.thread_name.clone(),
|
||||
model: session_configuration.collaboration_mode.model().to_string(),
|
||||
|
||||
@@ -459,6 +459,7 @@ fn test_model_client_session() -> crate::client::ModelClientSession {
|
||||
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
|
||||
ModelProviderInfo::create_openai_provider(/* base_url */ /*base_url*/ None),
|
||||
codex_protocol::protocol::SessionSource::Exec,
|
||||
/*parent_thread_id*/ None,
|
||||
/*model_verbosity*/ None,
|
||||
/*enable_request_compression*/ false,
|
||||
/*include_timing_metrics*/ false,
|
||||
@@ -3116,6 +3117,7 @@ async fn set_rate_limits_retains_previous_credits() {
|
||||
app_server_client_version: None,
|
||||
session_source: SessionSource::Exec,
|
||||
forked_from_thread_id: None,
|
||||
parent_thread_id: None,
|
||||
thread_source: None,
|
||||
dynamic_tools: Vec::new(),
|
||||
persist_extended_history: false,
|
||||
@@ -3221,6 +3223,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
|
||||
app_server_client_version: None,
|
||||
session_source: SessionSource::Exec,
|
||||
forked_from_thread_id: None,
|
||||
parent_thread_id: None,
|
||||
thread_source: None,
|
||||
dynamic_tools: Vec::new(),
|
||||
persist_extended_history: false,
|
||||
@@ -3469,6 +3472,7 @@ async fn attach_thread_persistence(session: &mut Session) -> PathBuf {
|
||||
CreateThreadParams {
|
||||
thread_id: session.conversation_id,
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
source: SessionSource::Exec,
|
||||
thread_source: None,
|
||||
base_instructions: BaseInstructions::default(),
|
||||
@@ -3749,6 +3753,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati
|
||||
app_server_client_version: None,
|
||||
session_source: SessionSource::Exec,
|
||||
forked_from_thread_id: None,
|
||||
parent_thread_id: None,
|
||||
thread_source: None,
|
||||
dynamic_tools: Vec::new(),
|
||||
persist_extended_history: false,
|
||||
@@ -4493,6 +4498,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() {
|
||||
app_server_client_version: None,
|
||||
session_source: SessionSource::Exec,
|
||||
forked_from_thread_id: None,
|
||||
parent_thread_id: None,
|
||||
thread_source: None,
|
||||
dynamic_tools: Vec::new(),
|
||||
persist_extended_history: false,
|
||||
@@ -4603,6 +4609,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
app_server_client_version: None,
|
||||
session_source: SessionSource::Exec,
|
||||
forked_from_thread_id: None,
|
||||
parent_thread_id: None,
|
||||
thread_source: None,
|
||||
dynamic_tools: Vec::new(),
|
||||
persist_extended_history: false,
|
||||
@@ -4697,6 +4704,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
|
||||
session_configuration.provider.clone(),
|
||||
session_configuration.session_source.clone(),
|
||||
session_configuration.parent_thread_id,
|
||||
config.model_verbosity,
|
||||
config.features.enabled(Feature::EnableRequestCompression),
|
||||
config.features.enabled(Feature::RuntimeMetrics),
|
||||
@@ -4838,6 +4846,7 @@ async fn make_session_with_config_and_rx(
|
||||
app_server_client_version: None,
|
||||
session_source: SessionSource::Exec,
|
||||
forked_from_thread_id: None,
|
||||
parent_thread_id: None,
|
||||
thread_source: None,
|
||||
dynamic_tools: Vec::new(),
|
||||
persist_extended_history: false,
|
||||
@@ -4942,6 +4951,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx(
|
||||
app_server_client_version: None,
|
||||
session_source: session_source.clone(),
|
||||
forked_from_thread_id: None,
|
||||
parent_thread_id: None,
|
||||
thread_source: None,
|
||||
dynamic_tools: Vec::new(),
|
||||
persist_extended_history: false,
|
||||
@@ -5964,6 +5974,7 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() {
|
||||
CreateThreadParams {
|
||||
thread_id: session.conversation_id,
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
source: SessionSource::Exec,
|
||||
thread_source: None,
|
||||
base_instructions: BaseInstructions::default(),
|
||||
@@ -6446,6 +6457,7 @@ where
|
||||
app_server_client_version: None,
|
||||
session_source: SessionSource::Exec,
|
||||
forked_from_thread_id: None,
|
||||
parent_thread_id: None,
|
||||
thread_source: None,
|
||||
dynamic_tools,
|
||||
persist_extended_history: false,
|
||||
@@ -6540,6 +6552,7 @@ where
|
||||
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
|
||||
session_configuration.provider.clone(),
|
||||
session_configuration.session_source.clone(),
|
||||
session_configuration.parent_thread_id,
|
||||
config.model_verbosity,
|
||||
config.features.enabled(Feature::EnableRequestCompression),
|
||||
config.features.enabled(Feature::RuntimeMetrics),
|
||||
@@ -7192,6 +7205,34 @@ async fn build_initial_context_omits_multi_agent_v2_usage_hints_when_feature_dis
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_initial_context_omits_multi_agent_v2_usage_hints_when_hint_disabled() {
|
||||
let (session, turn_context, _rx_event) = make_session_and_context_with_auth_and_config_and_rx(
|
||||
CodexAuth::from_api_key("Test API Key"),
|
||||
Vec::new(),
|
||||
|config| {
|
||||
let _ = config.features.enable(Feature::MultiAgentV2);
|
||||
config.multi_agent_v2.usage_hint_enabled = false;
|
||||
config.multi_agent_v2.root_agent_usage_hint_text = Some("Root guidance.".to_string());
|
||||
config.multi_agent_v2.subagent_usage_hint_text = Some("Subagent guidance.".to_string());
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let initial_context = session.build_initial_context(turn_context.as_ref()).await;
|
||||
|
||||
let developer_messages = developer_message_texts(&initial_context);
|
||||
assert!(
|
||||
!developer_messages.iter().any(|message| {
|
||||
matches!(
|
||||
message.as_slice(),
|
||||
["Root guidance."] | ["Subagent guidance."]
|
||||
)
|
||||
}),
|
||||
"did not expect multi-agent v2 usage hint developer messages, got {developer_messages:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_initial_context_omits_default_image_save_location_with_image_history() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
@@ -8399,6 +8440,34 @@ async fn thread_idle_lifecycle_waits_for_trigger_turn_mailbox_work() {
|
||||
assert_eq!(0, calls.load(std::sync::atomic::Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn try_start_turn_if_idle_rejects_active_turn_without_injecting() {
|
||||
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
|
||||
sess.spawn_task(
|
||||
Arc::clone(&tc),
|
||||
Vec::new(),
|
||||
NeverEndingTask {
|
||||
kind: TaskKind::Regular,
|
||||
listen_to_cancellation_token: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let item = user_message("synthetic idle input");
|
||||
let err = sess
|
||||
.try_start_turn_if_idle(vec![item.clone()])
|
||||
.await
|
||||
.expect_err("active turn should reject idle-only input");
|
||||
|
||||
assert_eq!(vec![item], err);
|
||||
assert_eq!(
|
||||
Vec::<TurnInput>::new(),
|
||||
sess.input_queue.get_pending_input(&sess.active_turn).await
|
||||
);
|
||||
|
||||
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn steer_input_requires_active_turn() {
|
||||
let (sess, _tc, _rx) = make_session_and_context_with_rx().await;
|
||||
|
||||
@@ -693,6 +693,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
|
||||
GUARDIAN_REVIEWER_NAME.to_string(),
|
||||
)),
|
||||
forked_from_thread_id: None,
|
||||
parent_thread_id: None,
|
||||
thread_source: None,
|
||||
agent_control: AgentControl::default(),
|
||||
dynamic_tools: Vec::new(),
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::environment_selection::ResolvedTurnEnvironments;
|
||||
use codex_model_provider::SharedModelProvider;
|
||||
use codex_model_provider::create_model_provider;
|
||||
use codex_protocol::SessionId;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::AdditionalPermissionProfile;
|
||||
use codex_protocol::openai_models::ToolMode;
|
||||
use codex_protocol::protocol::ThreadSource;
|
||||
@@ -61,6 +62,7 @@ pub struct TurnContext {
|
||||
pub(crate) reasoning_effort: Option<ReasoningEffortConfig>,
|
||||
pub(crate) reasoning_summary: ReasoningSummaryConfig,
|
||||
pub(crate) session_source: SessionSource,
|
||||
pub(crate) parent_thread_id: Option<ThreadId>,
|
||||
pub(crate) thread_source: Option<ThreadSource>,
|
||||
pub(crate) environments: ResolvedTurnEnvironments,
|
||||
/// The session's absolute working directory. All relative paths provided
|
||||
@@ -219,6 +221,7 @@ impl TurnContext {
|
||||
reasoning_effort,
|
||||
reasoning_summary: self.reasoning_summary,
|
||||
session_source: self.session_source.clone(),
|
||||
parent_thread_id: self.parent_thread_id,
|
||||
thread_source: self.thread_source,
|
||||
environments: self.environments.clone(),
|
||||
#[allow(deprecated)]
|
||||
@@ -479,10 +482,8 @@ impl Session {
|
||||
let provider_for_context = create_model_provider(provider, auth_manager);
|
||||
let session_telemetry_for_context = session_telemetry;
|
||||
let available_models = models_manager.try_list_models().unwrap_or_default();
|
||||
let shell_command_backend =
|
||||
shell_command_backend_for_features(per_turn_config.features.get());
|
||||
let unified_exec_shell_mode = UnifiedExecShellMode::for_session(
|
||||
shell_command_backend,
|
||||
codex_tools::unified_exec_uses_zsh_fork_for_features(per_turn_config.features.get()),
|
||||
crate::tools::tool_user_shell_type(user_shell),
|
||||
shell_zsh_path,
|
||||
main_execve_wrapper_exe,
|
||||
@@ -508,6 +509,7 @@ impl Session {
|
||||
session_id.to_string(),
|
||||
thread_id.to_string(),
|
||||
session_configuration.forked_from_thread_id,
|
||||
session_configuration.parent_thread_id,
|
||||
&session_configuration.session_source,
|
||||
session_configuration.thread_source,
|
||||
sub_id.clone(),
|
||||
@@ -531,6 +533,7 @@ impl Session {
|
||||
reasoning_effort,
|
||||
reasoning_summary,
|
||||
session_source,
|
||||
parent_thread_id: session_configuration.parent_thread_id,
|
||||
thread_source: session_configuration.thread_source,
|
||||
environments,
|
||||
#[allow(deprecated)]
|
||||
|
||||
@@ -187,6 +187,7 @@ pub(crate) struct ResumeThreadWithHistoryOptions {
|
||||
pub(crate) initial_history: InitialHistory,
|
||||
pub(crate) agent_control: AgentControl,
|
||||
pub(crate) session_source: SessionSource,
|
||||
pub(crate) parent_thread_id: Option<ThreadId>,
|
||||
pub(crate) inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
|
||||
pub(crate) inherited_exec_policy: Option<Arc<crate::exec_policy::ExecPolicyManager>>,
|
||||
}
|
||||
@@ -601,6 +602,7 @@ impl ThreadManager {
|
||||
Arc::clone(&self.state.auth_manager),
|
||||
self.agent_control(),
|
||||
session_source,
|
||||
/*parent_thread_id*/ None,
|
||||
forked_from_thread_id,
|
||||
thread_source,
|
||||
options.dynamic_tools,
|
||||
@@ -685,6 +687,7 @@ impl ThreadManager {
|
||||
auth_manager,
|
||||
self.agent_control(),
|
||||
session_source,
|
||||
/*parent_thread_id*/ None,
|
||||
/*forked_from_thread_id*/ None,
|
||||
thread_source,
|
||||
Vec::new(),
|
||||
@@ -713,6 +716,7 @@ impl ThreadManager {
|
||||
InitialHistory::New,
|
||||
Arc::clone(&self.state.auth_manager),
|
||||
self.agent_control(),
|
||||
/*parent_thread_id*/ None,
|
||||
/*forked_from_thread_id*/ None,
|
||||
/*thread_source*/ None,
|
||||
Vec::new(),
|
||||
@@ -746,6 +750,7 @@ impl ThreadManager {
|
||||
auth_manager,
|
||||
self.agent_control(),
|
||||
session_source,
|
||||
/*parent_thread_id*/ None,
|
||||
/*forked_from_thread_id*/ None,
|
||||
thread_source,
|
||||
Vec::new(),
|
||||
@@ -916,6 +921,7 @@ impl ThreadManager {
|
||||
history,
|
||||
Arc::clone(&self.state.auth_manager),
|
||||
self.agent_control(),
|
||||
/*parent_thread_id*/ None,
|
||||
forked_from_thread_id,
|
||||
thread_source,
|
||||
Vec::new(),
|
||||
@@ -1039,6 +1045,7 @@ impl ThreadManagerState {
|
||||
config,
|
||||
agent_control,
|
||||
self.session_source.clone(),
|
||||
/*parent_thread_id*/ None,
|
||||
/*forked_from_thread_id*/ None,
|
||||
/*thread_source*/ None,
|
||||
/*persist_extended_history*/ false,
|
||||
@@ -1056,6 +1063,7 @@ impl ThreadManagerState {
|
||||
config: Config,
|
||||
agent_control: AgentControl,
|
||||
session_source: SessionSource,
|
||||
parent_thread_id: Option<ThreadId>,
|
||||
forked_from_thread_id: Option<ThreadId>,
|
||||
thread_source: Option<ThreadSource>,
|
||||
persist_extended_history: bool,
|
||||
@@ -1073,6 +1081,7 @@ impl ThreadManagerState {
|
||||
Arc::clone(&self.auth_manager),
|
||||
agent_control,
|
||||
session_source,
|
||||
parent_thread_id,
|
||||
forked_from_thread_id,
|
||||
thread_source,
|
||||
Vec::new(),
|
||||
@@ -1096,6 +1105,7 @@ impl ThreadManagerState {
|
||||
initial_history,
|
||||
agent_control,
|
||||
session_source,
|
||||
parent_thread_id,
|
||||
inherited_shell_snapshot,
|
||||
inherited_exec_policy,
|
||||
} = options;
|
||||
@@ -1108,6 +1118,7 @@ impl ThreadManagerState {
|
||||
Arc::clone(&self.auth_manager),
|
||||
agent_control,
|
||||
session_source,
|
||||
parent_thread_id,
|
||||
/*forked_from_thread_id*/ None,
|
||||
thread_source,
|
||||
Vec::new(),
|
||||
@@ -1130,6 +1141,7 @@ impl ThreadManagerState {
|
||||
agent_control: AgentControl,
|
||||
session_source: SessionSource,
|
||||
thread_source: Option<ThreadSource>,
|
||||
parent_thread_id: Option<ThreadId>,
|
||||
forked_from_thread_id: Option<ThreadId>,
|
||||
persist_extended_history: bool,
|
||||
inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
|
||||
@@ -1145,6 +1157,7 @@ impl ThreadManagerState {
|
||||
Arc::clone(&self.auth_manager),
|
||||
agent_control,
|
||||
session_source,
|
||||
parent_thread_id,
|
||||
forked_from_thread_id,
|
||||
thread_source,
|
||||
Vec::new(),
|
||||
@@ -1167,6 +1180,7 @@ impl ThreadManagerState {
|
||||
initial_history: InitialHistory,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
agent_control: AgentControl,
|
||||
parent_thread_id: Option<ThreadId>,
|
||||
forked_from_thread_id: Option<ThreadId>,
|
||||
thread_source: Option<ThreadSource>,
|
||||
dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
|
||||
@@ -1182,6 +1196,7 @@ impl ThreadManagerState {
|
||||
auth_manager,
|
||||
agent_control,
|
||||
self.session_source.clone(),
|
||||
parent_thread_id,
|
||||
forked_from_thread_id,
|
||||
thread_source,
|
||||
dynamic_tools,
|
||||
@@ -1204,6 +1219,7 @@ impl ThreadManagerState {
|
||||
auth_manager: Arc<AuthManager>,
|
||||
agent_control: AgentControl,
|
||||
session_source: SessionSource,
|
||||
parent_thread_id: Option<ThreadId>,
|
||||
forked_from_thread_id: Option<ThreadId>,
|
||||
thread_source: Option<ThreadSource>,
|
||||
dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
|
||||
@@ -1258,6 +1274,7 @@ impl ThreadManagerState {
|
||||
conversation_history: initial_history,
|
||||
session_source,
|
||||
forked_from_thread_id,
|
||||
parent_thread_id,
|
||||
thread_source,
|
||||
agent_control,
|
||||
dynamic_tools,
|
||||
|
||||
@@ -211,6 +211,7 @@ async fn run_agent_job_loop(
|
||||
"agent_job:{job_id}"
|
||||
)))),
|
||||
SpawnAgentOptions {
|
||||
parent_thread_id: Some(session.conversation_id),
|
||||
environments: Some(turn.environments.to_selections()),
|
||||
..Default::default()
|
||||
},
|
||||
|
||||
@@ -124,6 +124,7 @@ async fn handle_spawn_agent(
|
||||
SpawnAgentOptions {
|
||||
fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()),
|
||||
fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory),
|
||||
parent_thread_id: Some(session.conversation_id),
|
||||
environments: Some(turn.environments.to_selections()),
|
||||
},
|
||||
))
|
||||
|
||||
@@ -11,9 +11,9 @@ use std::collections::BTreeMap;
|
||||
pub const MULTI_AGENT_V1_NAMESPACE: &str = "multi_agent_v1";
|
||||
const MULTI_AGENT_V1_NAMESPACE_DESCRIPTION: &str = "Tools for spawning and managing sub-agents.";
|
||||
|
||||
const SPAWN_AGENT_INHERITED_MODEL_GUIDANCE: &str = "Spawned agents inherit your current model by default. If provided, `model` specifies the model to use for the spawned agent.";
|
||||
const SPAWN_AGENT_INHERITED_MODEL_GUIDANCE: &str = "Spawned agents inherit your current model by default. Omit `model` to use that preferred default; set `model` only when an explicit override is needed.";
|
||||
const SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION: &str =
|
||||
"Model override for the new agent. Omit to inherit the parent model.";
|
||||
"Model override for the new agent. Omit unless an explicit override is needed.";
|
||||
const SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION: &str =
|
||||
"Service tier override for the new agent. Omit unless explicitly requested.";
|
||||
const MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT_DESCRIPTION: usize = 5;
|
||||
|
||||
@@ -30,7 +30,7 @@ impl ToolExecutor<ToolInvocation> for Handler {
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.register_session_root(session.conversation_id, &turn.session_source);
|
||||
.register_session_root(session.conversation_id, turn.parent_thread_id);
|
||||
let agents = session
|
||||
.services
|
||||
.agent_control
|
||||
|
||||
@@ -144,6 +144,7 @@ async fn handle_spawn_agent(
|
||||
SpawnAgentOptions {
|
||||
fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()),
|
||||
fork_mode,
|
||||
parent_thread_id: Some(session.conversation_id),
|
||||
environments: Some(turn.environments.to_selections()),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -13,12 +13,15 @@ pub struct CommandToolOptions {
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn create_exec_command_tool(options: CommandToolOptions) -> ToolSpec {
|
||||
create_exec_command_tool_with_environment_id(options, /*include_environment_id*/ false)
|
||||
create_exec_command_tool_with_environment_id(
|
||||
options, /*include_environment_id*/ false, /*include_shell_parameter*/ true,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn create_exec_command_tool_with_environment_id(
|
||||
options: CommandToolOptions,
|
||||
include_environment_id: bool,
|
||||
include_shell_parameter: bool,
|
||||
) -> ToolSpec {
|
||||
let mut properties = BTreeMap::from([
|
||||
(
|
||||
@@ -32,12 +35,6 @@ pub(crate) fn create_exec_command_tool_with_environment_id(
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"shell".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Shell binary to launch. Defaults to the user's default shell.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"tty".to_string(),
|
||||
JsonSchema::boolean(Some(
|
||||
@@ -58,6 +55,14 @@ pub(crate) fn create_exec_command_tool_with_environment_id(
|
||||
)),
|
||||
),
|
||||
]);
|
||||
if include_shell_parameter {
|
||||
properties.insert(
|
||||
"shell".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Shell binary to launch. Defaults to the user's default shell.".to_string(),
|
||||
)),
|
||||
);
|
||||
}
|
||||
if options.allow_login_shell {
|
||||
properties.insert(
|
||||
"login".to_string(),
|
||||
|
||||
@@ -6,6 +6,13 @@ fn windows_shell_guidance_description() -> String {
|
||||
format!("\n\n{}", windows_shell_guidance())
|
||||
}
|
||||
|
||||
fn has_parameter(tool: &ToolSpec, parameter_name: &str) -> bool {
|
||||
serde_json::to_value(tool)
|
||||
.expect("tool spec should serialize")
|
||||
.pointer(&format!("/parameters/properties/{parameter_name}"))
|
||||
.is_some()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_command_tool_matches_expected_spec() {
|
||||
let tool = create_exec_command_tool(CommandToolOptions {
|
||||
@@ -88,6 +95,21 @@ fn exec_command_tool_matches_expected_spec() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_command_tool_can_hide_shell_parameter() {
|
||||
let tool = create_exec_command_tool_with_environment_id(
|
||||
CommandToolOptions {
|
||||
allow_login_shell: true,
|
||||
exec_permission_approvals_enabled: false,
|
||||
},
|
||||
/*include_environment_id*/ false,
|
||||
/*include_shell_parameter*/ false,
|
||||
);
|
||||
|
||||
assert!(!has_parameter(&tool, "shell"));
|
||||
assert!(has_parameter(&tool, "cmd"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_stdin_tool_matches_expected_spec() {
|
||||
let tool = create_write_stdin_tool();
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::hook_names::HookToolName;
|
||||
use crate::tools::registry::PostToolUsePayload;
|
||||
use codex_exec_server::Environment;
|
||||
use codex_protocol::models::AdditionalPermissionProfile;
|
||||
use codex_tools::UnifiedExecShellMode;
|
||||
use serde::Deserialize;
|
||||
@@ -124,14 +125,33 @@ pub(crate) fn get_command(
|
||||
shell_type: shell.shell_type.clone(),
|
||||
})
|
||||
}
|
||||
UnifiedExecShellMode::ZshFork(zsh_fork_config) => Ok(ResolvedCommand {
|
||||
command: vec![
|
||||
zsh_fork_config.shell_zsh_path.to_string_lossy().to_string(),
|
||||
if use_login_shell { "-lc" } else { "-c" }.to_string(),
|
||||
args.cmd.clone(),
|
||||
],
|
||||
shell_type: ShellType::Zsh,
|
||||
}),
|
||||
UnifiedExecShellMode::ZshFork(zsh_fork_config) => {
|
||||
if args.shell.is_some() {
|
||||
return Err(
|
||||
"`shell` is not supported for local zsh-fork exec; omit `shell` to use zsh-fork, or target a remote environment where `shell` is supported.".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(ResolvedCommand {
|
||||
command: vec![
|
||||
zsh_fork_config.shell_zsh_path.to_string_lossy().to_string(),
|
||||
if use_login_shell { "-lc" } else { "-c" }.to_string(),
|
||||
args.cmd.clone(),
|
||||
],
|
||||
shell_type: ShellType::Zsh,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn shell_mode_for_environment(
|
||||
turn_shell_mode: &UnifiedExecShellMode,
|
||||
environment: &Environment,
|
||||
) -> UnifiedExecShellMode {
|
||||
if environment.is_remote() {
|
||||
UnifiedExecShellMode::Direct
|
||||
} else {
|
||||
turn_shell_mode.clone()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,12 +38,14 @@ use super::ExecCommandArgs;
|
||||
use super::ExecCommandEnvironmentArgs;
|
||||
use super::get_command;
|
||||
use super::post_unified_exec_tool_use_payload;
|
||||
use super::shell_mode_for_environment;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct ExecCommandHandlerOptions {
|
||||
pub(crate) allow_login_shell: bool,
|
||||
pub(crate) exec_permission_approvals_enabled: bool,
|
||||
pub(crate) include_environment_id: bool,
|
||||
pub(crate) include_shell_parameter: bool,
|
||||
}
|
||||
|
||||
pub struct ExecCommandHandler {
|
||||
@@ -57,6 +59,7 @@ impl Default for ExecCommandHandler {
|
||||
allow_login_shell: false,
|
||||
exec_permission_approvals_enabled: false,
|
||||
include_environment_id: false,
|
||||
include_shell_parameter: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -81,6 +84,7 @@ impl ToolExecutor<ToolInvocation> for ExecCommandHandler {
|
||||
exec_permission_approvals_enabled: self.options.exec_permission_approvals_enabled,
|
||||
},
|
||||
self.options.include_environment_id,
|
||||
self.options.include_shell_parameter,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -140,10 +144,12 @@ impl ToolExecutor<ToolInvocation> for ExecCommandHandler {
|
||||
)
|
||||
.await;
|
||||
let process_id = manager.allocate_process_id().await;
|
||||
let shell_mode =
|
||||
shell_mode_for_environment(&turn.unified_exec_shell_mode, environment.as_ref());
|
||||
let resolved_command = get_command(
|
||||
&args,
|
||||
session.user_shell(),
|
||||
&turn.unified_exec_shell_mode,
|
||||
&shell_mode,
|
||||
turn.config.permissions.allow_login_shell,
|
||||
)
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
@@ -260,6 +266,7 @@ impl ToolExecutor<ToolInvocation> for ExecCommandHandler {
|
||||
cwd,
|
||||
sandbox_cwd: turn_environment.cwd.clone(),
|
||||
environment,
|
||||
shell_mode,
|
||||
network: context.turn.network.clone(),
|
||||
tty,
|
||||
sandbox_permissions: effective_additional_permissions.sandbox_permissions,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::*;
|
||||
use crate::shell::ShellType;
|
||||
use crate::shell::default_user_shell;
|
||||
use codex_exec_server::Environment;
|
||||
use codex_tools::UnifiedExecShellMode;
|
||||
use codex_tools::ZshForkConfig;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -165,7 +166,7 @@ fn test_get_command_rejects_explicit_login_when_disallowed() -> anyhow::Result<(
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_command_ignores_explicit_shell_in_zsh_fork_mode() -> anyhow::Result<()> {
|
||||
fn test_get_command_rejects_explicit_shell_in_zsh_fork_mode() -> anyhow::Result<()> {
|
||||
let json = r#"{"cmd": "echo hello", "shell": "/bin/bash"}"#;
|
||||
let args: ExecCommandArgs = parse_arguments(json)?;
|
||||
let shell_zsh_path = AbsolutePathBuf::from_absolute_path(if cfg!(windows) {
|
||||
@@ -174,7 +175,7 @@ fn test_get_command_ignores_explicit_shell_in_zsh_fork_mode() -> anyhow::Result<
|
||||
"/opt/codex/zsh"
|
||||
})?;
|
||||
let shell_mode = UnifiedExecShellMode::ZshFork(ZshForkConfig {
|
||||
shell_zsh_path: shell_zsh_path.clone(),
|
||||
shell_zsh_path,
|
||||
main_execve_wrapper_exe: AbsolutePathBuf::from_absolute_path(if cfg!(windows) {
|
||||
r"C:\opt\codex\codex-execve-wrapper"
|
||||
} else {
|
||||
@@ -182,23 +183,50 @@ fn test_get_command_ignores_explicit_shell_in_zsh_fork_mode() -> anyhow::Result<
|
||||
})?,
|
||||
});
|
||||
|
||||
let resolved = get_command(
|
||||
let err = get_command(
|
||||
&args,
|
||||
Arc::new(default_user_shell()),
|
||||
&shell_mode,
|
||||
/*allow_login_shell*/ true,
|
||||
)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
.expect_err("explicit shell should be rejected");
|
||||
|
||||
assert!(
|
||||
err.contains("`shell` is not supported for local zsh-fork exec"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shell_mode_for_environment_uses_direct_mode_for_remote_environments() -> anyhow::Result<()>
|
||||
{
|
||||
let shell_zsh_path = AbsolutePathBuf::from_absolute_path(if cfg!(windows) {
|
||||
r"C:\opt\codex\zsh"
|
||||
} else {
|
||||
"/opt/codex/zsh"
|
||||
})?;
|
||||
let shell_mode = UnifiedExecShellMode::ZshFork(ZshForkConfig {
|
||||
shell_zsh_path,
|
||||
main_execve_wrapper_exe: AbsolutePathBuf::from_absolute_path(if cfg!(windows) {
|
||||
r"C:\opt\codex\codex-execve-wrapper"
|
||||
} else {
|
||||
"/opt/codex/codex-execve-wrapper"
|
||||
})?,
|
||||
});
|
||||
let local_environment = Environment::default_for_tests();
|
||||
let remote_environment =
|
||||
Environment::create_for_tests(Some("ws://127.0.0.1:1/remote-exec-server".to_string()))?;
|
||||
|
||||
assert_eq!(
|
||||
resolved.command,
|
||||
vec![
|
||||
shell_zsh_path.to_string_lossy().to_string(),
|
||||
"-lc".to_string(),
|
||||
"echo hello".to_string()
|
||||
]
|
||||
shell_mode_for_environment(&shell_mode, &local_environment),
|
||||
shell_mode
|
||||
);
|
||||
assert_eq!(resolved.shell_type, ShellType::Zsh);
|
||||
assert_eq!(
|
||||
shell_mode_for_environment(&shell_mode, &remote_environment),
|
||||
UnifiedExecShellMode::Direct
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::shell::ShellType;
|
||||
use crate::tools::runtimes::build_sandbox_command;
|
||||
use crate::tools::runtimes::exec_env_for_sandbox_permissions;
|
||||
use crate::tools::runtimes::prepend_zsh_fork_bin_to_path;
|
||||
use crate::tools::sandboxing::ExecApprovalRequirement;
|
||||
use crate::tools::sandboxing::PermissionRequestPayload;
|
||||
use crate::tools::sandboxing::SandboxAttempt;
|
||||
use crate::tools::sandboxing::ToolCtx;
|
||||
@@ -100,6 +101,19 @@ fn approval_sandbox_permissions(
|
||||
}
|
||||
}
|
||||
|
||||
fn parent_approved_sandbox_override(
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
additional_permissions_preapproved: bool,
|
||||
exec_approval_requirement: &ExecApprovalRequirement,
|
||||
) -> bool {
|
||||
sandbox_permissions.requests_sandbox_override()
|
||||
&& (additional_permissions_preapproved
|
||||
|| matches!(
|
||||
exec_approval_requirement,
|
||||
ExecApprovalRequirement::NeedsApproval { .. }
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) async fn try_run_zsh_fork(
|
||||
req: &ShellRequest,
|
||||
attempt: &SandboxAttempt<'_>,
|
||||
@@ -228,6 +242,11 @@ pub(super) async fn try_run_zsh_fork(
|
||||
sandbox_permissions: req.sandbox_permissions,
|
||||
approval_sandbox_permissions,
|
||||
prompt_permissions: req.additional_permissions.clone(),
|
||||
parent_sandbox_override_approved: parent_approved_sandbox_override(
|
||||
req.sandbox_permissions,
|
||||
req.additional_permissions_preapproved,
|
||||
&req.exec_approval_requirement,
|
||||
),
|
||||
stopwatch: stopwatch.clone(),
|
||||
};
|
||||
|
||||
@@ -304,6 +323,11 @@ pub(crate) async fn prepare_unified_exec_zsh_fork(
|
||||
req.additional_permissions_preapproved,
|
||||
),
|
||||
prompt_permissions: req.additional_permissions.clone(),
|
||||
parent_sandbox_override_approved: parent_approved_sandbox_override(
|
||||
req.sandbox_permissions,
|
||||
req.additional_permissions_preapproved,
|
||||
&req.exec_approval_requirement,
|
||||
),
|
||||
stopwatch: Stopwatch::unlimited(),
|
||||
};
|
||||
|
||||
@@ -336,6 +360,7 @@ struct CoreShellActionProvider {
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
approval_sandbox_permissions: SandboxPermissions,
|
||||
prompt_permissions: Option<AdditionalPermissionProfile>,
|
||||
parent_sandbox_override_approved: bool,
|
||||
stopwatch: Stopwatch,
|
||||
}
|
||||
|
||||
@@ -635,9 +660,22 @@ impl EscalationPolicy for CoreShellActionProvider {
|
||||
let decision_driven_by_policy =
|
||||
Self::decision_driven_by_policy(&evaluation.matched_rules, evaluation.decision);
|
||||
let unsandboxed_allowed = unsandboxed_execution_allowed(&self.file_system_sandbox_policy);
|
||||
let needs_escalation = unsandboxed_allowed
|
||||
let needs_unsandboxed_escalation = unsandboxed_allowed
|
||||
&& (self.sandbox_permissions.requires_escalated_permissions()
|
||||
|| decision_driven_by_policy);
|
||||
// The parent shell/unified-exec approval already covered unmatched
|
||||
// prompts caused by its sandbox override. Explicit exec-policy rules
|
||||
// still keep their own decisions.
|
||||
let decision = if self.parent_sandbox_override_approved
|
||||
&& evaluation.decision == Decision::Prompt
|
||||
&& !decision_driven_by_policy
|
||||
{
|
||||
Decision::Allow
|
||||
} else {
|
||||
evaluation.decision
|
||||
};
|
||||
let needs_escalation =
|
||||
self.sandbox_permissions.uses_additional_permissions() || needs_unsandboxed_escalation;
|
||||
|
||||
let decision_source = if decision_driven_by_policy {
|
||||
DecisionSource::PrefixRule
|
||||
@@ -655,7 +693,7 @@ impl EscalationPolicy for CoreShellActionProvider {
|
||||
),
|
||||
};
|
||||
self.process_decision(
|
||||
evaluation.decision,
|
||||
decision,
|
||||
needs_escalation,
|
||||
program,
|
||||
argv,
|
||||
@@ -866,7 +904,7 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
|
||||
EscalationExecution::Unsandboxed => PreparedExec {
|
||||
command,
|
||||
cwd: workdir.to_path_buf(),
|
||||
env,
|
||||
env: exec_env_for_sandbox_permissions(&env, SandboxPermissions::RequireEscalated),
|
||||
arg0: Some(first_arg.clone()),
|
||||
},
|
||||
EscalationExecution::TurnDefault => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::CoreShellActionProvider;
|
||||
use super::CoreShellCommandExecutor;
|
||||
use super::InterceptedExecPolicyContext;
|
||||
use super::ParsedShellCommand;
|
||||
use super::commands_for_intercepted_exec_policy;
|
||||
@@ -16,6 +17,9 @@ use codex_execpolicy::PolicyParser;
|
||||
use codex_execpolicy::RuleMatch;
|
||||
use codex_hooks::Hooks;
|
||||
use codex_hooks::HooksConfig;
|
||||
use codex_network_proxy::PROXY_ACTIVE_ENV_KEY;
|
||||
use codex_network_proxy::PROXY_ENV_KEYS;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::models::AdditionalPermissionProfile;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
@@ -29,13 +33,16 @@ use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::GranularApprovalConfig;
|
||||
use codex_protocol::protocol::GuardianCommandSource;
|
||||
use codex_sandboxing::SandboxType;
|
||||
use codex_sandboxing::policy_transforms::effective_permission_profile;
|
||||
use codex_shell_escalation::EscalationExecution;
|
||||
use codex_shell_escalation::EscalationPermissions;
|
||||
use codex_shell_escalation::ExecResult;
|
||||
use codex_shell_escalation::ResolvedPermissionProfile;
|
||||
use codex_shell_escalation::ShellCommandExecutor;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -146,6 +153,42 @@ fn approval_sandbox_permissions_only_downgrades_preapproved_additional_permissio
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_approved_sandbox_override_tracks_parent_approval_sources() {
|
||||
assert!(super::parent_approved_sandbox_override(
|
||||
SandboxPermissions::RequireEscalated,
|
||||
/*additional_permissions_preapproved*/ false,
|
||||
&crate::tools::sandboxing::ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
));
|
||||
assert!(super::parent_approved_sandbox_override(
|
||||
SandboxPermissions::WithAdditionalPermissions,
|
||||
/*additional_permissions_preapproved*/ true,
|
||||
&crate::tools::sandboxing::ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
));
|
||||
assert!(!super::parent_approved_sandbox_override(
|
||||
SandboxPermissions::UseDefault,
|
||||
/*additional_permissions_preapproved*/ false,
|
||||
&crate::tools::sandboxing::ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
));
|
||||
assert!(!super::parent_approved_sandbox_override(
|
||||
SandboxPermissions::RequireEscalated,
|
||||
/*additional_permissions_preapproved*/ false,
|
||||
&crate::tools::sandboxing::ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: true,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_shell_script_preserves_login_flag() {
|
||||
assert_eq!(
|
||||
@@ -348,6 +391,188 @@ fn shell_request_escalation_execution_is_explicit() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsandboxed_intercepted_exec_strips_managed_network_env() -> anyhow::Result<()> {
|
||||
let workdir = test_sandbox_cwd();
|
||||
let executor = CoreShellCommandExecutor {
|
||||
command: Vec::new(),
|
||||
cwd: workdir.clone(),
|
||||
permission_profile: PermissionProfile::workspace_write(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
|
||||
sandbox: SandboxType::None,
|
||||
env: HashMap::new(),
|
||||
network: None,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
arg0: None,
|
||||
sandbox_policy_cwd: workdir.clone(),
|
||||
windows_sandbox_workspace_roots: vec![workdir.clone()],
|
||||
codex_linux_sandbox_exe: None,
|
||||
use_legacy_landlock: false,
|
||||
};
|
||||
let mut env = HashMap::new();
|
||||
env.insert(PROXY_ACTIVE_ENV_KEY.to_string(), "1".to_string());
|
||||
for key in PROXY_ENV_KEYS {
|
||||
env.insert((*key).to_string(), format!("proxy-{key}"));
|
||||
}
|
||||
|
||||
let prepared = executor
|
||||
.prepare_escalated_exec(
|
||||
&AbsolutePathBuf::from_absolute_path("/usr/bin/curl")?,
|
||||
&["curl".to_string(), "example.com".to_string()],
|
||||
&workdir,
|
||||
env,
|
||||
EscalationExecution::Unsandboxed,
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(!prepared.env.contains_key(PROXY_ACTIVE_ENV_KEY));
|
||||
for key in PROXY_ENV_KEYS {
|
||||
assert!(!prepared.env.contains_key(*key));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preapproved_additional_permissions_escalate_intercepted_exec() -> anyhow::Result<()> {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let requested_permissions = AdditionalPermissionProfile {
|
||||
file_system: Some(FileSystemPermissions::from_read_write_roots(
|
||||
/*read*/ None,
|
||||
Some(vec![
|
||||
AbsolutePathBuf::from_absolute_path("/tmp/output").unwrap(),
|
||||
]),
|
||||
)),
|
||||
..Default::default()
|
||||
};
|
||||
let workdir = test_sandbox_cwd();
|
||||
let permission_profile = effective_permission_profile(
|
||||
&PermissionProfile::workspace_write(),
|
||||
Some(&requested_permissions),
|
||||
);
|
||||
let provider = CoreShellActionProvider {
|
||||
policy: Arc::new(RwLock::new(codex_execpolicy::Policy::empty())),
|
||||
session: Arc::new(session),
|
||||
turn: Arc::new(turn_context),
|
||||
call_id: "preapproved-additional-permissions".to_string(),
|
||||
tool_name: GuardianCommandSource::Shell,
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
permission_profile: permission_profile.clone(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_policy_cwd: workdir.clone(),
|
||||
sandbox_permissions: SandboxPermissions::WithAdditionalPermissions,
|
||||
approval_sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prompt_permissions: Some(requested_permissions),
|
||||
parent_sandbox_override_approved: true,
|
||||
stopwatch: codex_shell_escalation::Stopwatch::new(Duration::from_secs(1)),
|
||||
};
|
||||
|
||||
let action = codex_shell_escalation::EscalationPolicy::determine_action(
|
||||
&provider,
|
||||
&AbsolutePathBuf::from_absolute_path("/usr/bin/printf")?,
|
||||
&["printf".to_string(), "hello".to_string()],
|
||||
&workdir,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let expected = codex_shell_escalation::EscalationDecision::Escalate(
|
||||
EscalationExecution::Permissions(EscalationPermissions::ResolvedPermissionProfile(
|
||||
ResolvedPermissionProfile { permission_profile },
|
||||
)),
|
||||
);
|
||||
assert_eq!(action, expected);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parent_require_escalated_approval_escalates_intercepted_exec() -> anyhow::Result<()> {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let workdir = test_sandbox_cwd();
|
||||
let provider = CoreShellActionProvider {
|
||||
policy: Arc::new(RwLock::new(codex_execpolicy::Policy::empty())),
|
||||
session: Arc::new(session),
|
||||
turn: Arc::new(turn_context),
|
||||
call_id: "parent-require-escalated".to_string(),
|
||||
tool_name: GuardianCommandSource::Shell,
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
permission_profile: PermissionProfile::workspace_write(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_policy_cwd: workdir.clone(),
|
||||
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
approval_sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
prompt_permissions: None,
|
||||
parent_sandbox_override_approved: true,
|
||||
stopwatch: codex_shell_escalation::Stopwatch::new(Duration::from_secs(1)),
|
||||
};
|
||||
|
||||
let action = codex_shell_escalation::EscalationPolicy::determine_action(
|
||||
&provider,
|
||||
&AbsolutePathBuf::from_absolute_path("/usr/bin/curl")?,
|
||||
&["curl".to_string(), "example.com".to_string()],
|
||||
&workdir,
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
action,
|
||||
codex_shell_escalation::EscalationDecision::Escalate(EscalationExecution::Unsandboxed)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parent_approval_does_not_override_intercepted_exec_policy_prompt() -> anyhow::Result<()> {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser.parse(
|
||||
"test.rules",
|
||||
r#"prefix_rule(pattern = ["curl"], decision = "prompt")"#,
|
||||
)?;
|
||||
let workdir = test_sandbox_cwd();
|
||||
let provider = CoreShellActionProvider {
|
||||
policy: Arc::new(RwLock::new(parser.build())),
|
||||
session: Arc::new(session),
|
||||
turn: Arc::new(turn_context),
|
||||
call_id: "parent-policy-prompt".to_string(),
|
||||
tool_name: GuardianCommandSource::Shell,
|
||||
approval_policy: AskForApproval::Granular(GranularApprovalConfig {
|
||||
sandbox_approval: true,
|
||||
rules: false,
|
||||
skill_approval: true,
|
||||
request_permissions: true,
|
||||
mcp_elicitations: true,
|
||||
}),
|
||||
permission_profile: PermissionProfile::workspace_write(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_policy_cwd: workdir.clone(),
|
||||
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
approval_sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
prompt_permissions: None,
|
||||
parent_sandbox_override_approved: true,
|
||||
stopwatch: codex_shell_escalation::Stopwatch::new(Duration::from_secs(1)),
|
||||
};
|
||||
|
||||
let action = codex_shell_escalation::EscalationPolicy::determine_action(
|
||||
&provider,
|
||||
&AbsolutePathBuf::from_absolute_path("/usr/bin/curl")?,
|
||||
&["curl".to_string(), "example.com".to_string()],
|
||||
&workdir,
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
action,
|
||||
codex_shell_escalation::EscalationDecision::Deny {
|
||||
reason: Some("Execution forbidden by policy".to_string()),
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Result<()> {
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
@@ -460,6 +685,7 @@ async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Resul
|
||||
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
approval_sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
prompt_permissions: None,
|
||||
parent_sandbox_override_approved: false,
|
||||
stopwatch: codex_shell_escalation::Stopwatch::new(Duration::from_secs(1)),
|
||||
};
|
||||
|
||||
@@ -677,6 +903,7 @@ prefix_rule(pattern = ["{cat_path_literal}"], decision = "allow")
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
approval_sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prompt_permissions: None,
|
||||
parent_sandbox_override_approved: false,
|
||||
stopwatch: codex_shell_escalation::Stopwatch::new(Duration::from_secs(1)),
|
||||
};
|
||||
|
||||
@@ -720,6 +947,7 @@ async fn denied_reads_keep_granular_sandbox_rejection_for_escalation() -> anyhow
|
||||
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
approval_sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
prompt_permissions: None,
|
||||
parent_sandbox_override_approved: false,
|
||||
stopwatch: codex_shell_escalation::Stopwatch::new(Duration::from_secs(1)),
|
||||
};
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::SandboxErr;
|
||||
use codex_protocol::models::AdditionalPermissionProfile;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_sandboxing::SandboxType;
|
||||
use codex_sandboxing::SandboxablePreference;
|
||||
use codex_shell_command::powershell::prefix_powershell_script_with_utf8;
|
||||
use codex_tools::UnifiedExecShellMode;
|
||||
@@ -118,6 +119,65 @@ impl<'a> UnifiedExecRuntime<'a> {
|
||||
shell_mode,
|
||||
}
|
||||
}
|
||||
|
||||
fn first_attempt_sandbox_permissions(&self, req: &UnifiedExecRequest) -> SandboxPermissions {
|
||||
if matches!(&self.shell_mode, UnifiedExecShellMode::ZshFork(_))
|
||||
&& req.sandbox_permissions.requires_escalated_permissions()
|
||||
{
|
||||
SandboxPermissions::UseDefault
|
||||
} else {
|
||||
req.sandbox_permissions
|
||||
}
|
||||
}
|
||||
|
||||
fn first_attempt_exec_approval_requirement(
|
||||
&self,
|
||||
req: &UnifiedExecRequest,
|
||||
) -> ExecApprovalRequirement {
|
||||
if matches!(&self.shell_mode, UnifiedExecShellMode::ZshFork(_))
|
||||
&& let ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: true,
|
||||
proposed_execpolicy_amendment,
|
||||
} = &req.exec_approval_requirement
|
||||
{
|
||||
ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: proposed_execpolicy_amendment.clone(),
|
||||
}
|
||||
} else {
|
||||
req.exec_approval_requirement.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_sandbox_permissions(
|
||||
&self,
|
||||
req: &UnifiedExecRequest,
|
||||
attempt: &SandboxAttempt<'_>,
|
||||
) -> SandboxPermissions {
|
||||
if matches!(&self.shell_mode, UnifiedExecShellMode::ZshFork(_))
|
||||
&& attempt.sandbox != SandboxType::None
|
||||
&& req.sandbox_permissions.requires_escalated_permissions()
|
||||
{
|
||||
SandboxPermissions::UseDefault
|
||||
} else {
|
||||
req.sandbox_permissions
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_additional_permissions(
|
||||
&self,
|
||||
req: &UnifiedExecRequest,
|
||||
attempt: &SandboxAttempt<'_>,
|
||||
) -> Option<AdditionalPermissionProfile> {
|
||||
if matches!(&self.shell_mode, UnifiedExecShellMode::ZshFork(_))
|
||||
&& attempt.sandbox != SandboxType::None
|
||||
&& req.sandbox_permissions.requires_escalated_permissions()
|
||||
{
|
||||
None
|
||||
} else {
|
||||
req.additional_permissions.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Sandboxable for UnifiedExecRuntime<'_> {
|
||||
@@ -203,7 +263,7 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
|
||||
&self,
|
||||
req: &UnifiedExecRequest,
|
||||
) -> Option<ExecApprovalRequirement> {
|
||||
Some(req.exec_approval_requirement.clone())
|
||||
Some(self.first_attempt_exec_approval_requirement(req))
|
||||
}
|
||||
|
||||
fn permission_request_payload(
|
||||
@@ -217,7 +277,7 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
|
||||
}
|
||||
|
||||
fn sandbox_permissions(&self, req: &UnifiedExecRequest) -> SandboxPermissions {
|
||||
req.sandbox_permissions
|
||||
self.first_attempt_sandbox_permissions(req)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,17 +324,15 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
|
||||
let base_command = &req.command;
|
||||
let session_shell = ctx.session.user_shell();
|
||||
let (file_system_sandbox_policy, _) = attempt.permissions.to_runtime_permissions();
|
||||
let sandbox_permissions = sandbox_permissions_preserving_denied_reads(
|
||||
req.sandbox_permissions,
|
||||
let launch_sandbox_permissions = sandbox_permissions_preserving_denied_reads(
|
||||
self.launch_sandbox_permissions(req, attempt),
|
||||
&file_system_sandbox_policy,
|
||||
);
|
||||
let req = &UnifiedExecRequest {
|
||||
sandbox_permissions,
|
||||
..req.clone()
|
||||
};
|
||||
let managed_network =
|
||||
managed_network_for_sandbox_permissions(req.network.as_ref(), req.sandbox_permissions);
|
||||
let mut env = exec_env_for_sandbox_permissions(&req.env, req.sandbox_permissions);
|
||||
let managed_network = managed_network_for_sandbox_permissions(
|
||||
req.network.as_ref(),
|
||||
launch_sandbox_permissions,
|
||||
);
|
||||
let mut env = exec_env_for_sandbox_permissions(&req.env, launch_sandbox_permissions);
|
||||
if let Some(network) = managed_network {
|
||||
network.apply_to_env(&mut env);
|
||||
}
|
||||
@@ -316,9 +374,13 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
|
||||
};
|
||||
|
||||
if let UnifiedExecShellMode::ZshFork(zsh_fork_config) = &self.shell_mode {
|
||||
let command =
|
||||
build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone())
|
||||
.map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?;
|
||||
let command = build_sandbox_command(
|
||||
&command,
|
||||
&req.cwd,
|
||||
&env,
|
||||
self.launch_additional_permissions(req, attempt),
|
||||
)
|
||||
.map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?;
|
||||
let options = unified_exec_options(attempt.network_denial_cancellation_token.clone());
|
||||
let mut exec_env = attempt
|
||||
.env_for(command, options, managed_network)
|
||||
@@ -400,8 +462,15 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS;
|
||||
use crate::tools::sandboxing::SandboxAttempt;
|
||||
use crate::tools::sandboxing::ToolRuntime;
|
||||
use codex_exec_server::Environment;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::models::NetworkPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_sandboxing::SandboxManager;
|
||||
use codex_sandboxing::SandboxType;
|
||||
use codex_tools::ZshForkConfig;
|
||||
use std::time::Duration;
|
||||
use tempfile::tempdir;
|
||||
|
||||
@@ -463,4 +532,149 @@ mod tests {
|
||||
|
||||
assert_eq!(runtime.sandbox_cwd(&request), Some(&sandbox_cwd));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zsh_fork_first_attempt_uses_turn_default_sandbox_permissions() {
|
||||
let manager = UnifiedExecProcessManager::default();
|
||||
let request = test_request(
|
||||
SandboxPermissions::RequireEscalated,
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
);
|
||||
let direct_runtime = UnifiedExecRuntime::new(&manager, UnifiedExecShellMode::Direct);
|
||||
let zsh_fork_runtime = UnifiedExecRuntime::new(&manager, zsh_fork_mode());
|
||||
|
||||
assert_eq!(
|
||||
direct_runtime.sandbox_permissions(&request),
|
||||
SandboxPermissions::RequireEscalated
|
||||
);
|
||||
assert_eq!(
|
||||
zsh_fork_runtime.sandbox_permissions(&request),
|
||||
SandboxPermissions::UseDefault
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zsh_fork_first_attempt_preserves_additional_permissions_request() {
|
||||
let manager = UnifiedExecProcessManager::default();
|
||||
let request = test_request(
|
||||
SandboxPermissions::WithAdditionalPermissions,
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
);
|
||||
let zsh_fork_runtime = UnifiedExecRuntime::new(&manager, zsh_fork_mode());
|
||||
|
||||
assert_eq!(
|
||||
zsh_fork_runtime.sandbox_permissions(&request),
|
||||
SandboxPermissions::WithAdditionalPermissions
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zsh_fork_sandboxed_launch_preserves_additional_permissions() {
|
||||
let manager = UnifiedExecProcessManager::default();
|
||||
let runtime = UnifiedExecRuntime::new(&manager, zsh_fork_mode());
|
||||
let additional_permissions = AdditionalPermissionProfile {
|
||||
network: Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let mut request = test_request(
|
||||
SandboxPermissions::WithAdditionalPermissions,
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
);
|
||||
request.additional_permissions = Some(additional_permissions.clone());
|
||||
let cwd = request.sandbox_cwd.clone();
|
||||
let workspace_roots = vec![cwd.clone()];
|
||||
let sandbox_manager = SandboxManager::new();
|
||||
let permissions = PermissionProfile::workspace_write();
|
||||
let attempt = SandboxAttempt {
|
||||
sandbox: SandboxType::MacosSeatbelt,
|
||||
permissions: &permissions,
|
||||
enforce_managed_network: true,
|
||||
manager: &sandbox_manager,
|
||||
sandbox_cwd: &cwd,
|
||||
workspace_roots: workspace_roots.as_slice(),
|
||||
codex_linux_sandbox_exe: None,
|
||||
use_legacy_landlock: false,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
windows_sandbox_private_desktop: false,
|
||||
network_denial_cancellation_token: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
runtime.launch_sandbox_permissions(&request, &attempt),
|
||||
SandboxPermissions::WithAdditionalPermissions
|
||||
);
|
||||
assert_eq!(
|
||||
runtime.launch_additional_permissions(&request, &attempt),
|
||||
Some(additional_permissions)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zsh_fork_execpolicy_allow_does_not_bypass_trampoline_sandbox() {
|
||||
let manager = UnifiedExecProcessManager::default();
|
||||
let request = test_request(
|
||||
SandboxPermissions::UseDefault,
|
||||
ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: true,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
);
|
||||
let runtime = UnifiedExecRuntime::new(&manager, zsh_fork_mode());
|
||||
|
||||
assert_eq!(
|
||||
runtime.exec_approval_requirement(&request),
|
||||
Some(ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fn test_request(
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
exec_approval_requirement: ExecApprovalRequirement,
|
||||
) -> UnifiedExecRequest {
|
||||
let cwd = AbsolutePathBuf::try_from(std::env::current_dir().unwrap())
|
||||
.expect("current dir is absolute");
|
||||
UnifiedExecRequest {
|
||||
command: vec!["zsh".to_string(), "-c".to_string(), "echo hi".to_string()],
|
||||
shell_type: ShellType::Zsh,
|
||||
hook_command: "echo hi".to_string(),
|
||||
process_id: 1000,
|
||||
cwd: cwd.clone(),
|
||||
sandbox_cwd: cwd,
|
||||
environment: Arc::new(Environment::default_for_tests()),
|
||||
env: HashMap::new(),
|
||||
exec_server_env_config: None,
|
||||
explicit_env_overrides: HashMap::new(),
|
||||
network: None,
|
||||
tty: false,
|
||||
sandbox_permissions,
|
||||
additional_permissions: None,
|
||||
#[cfg(unix)]
|
||||
additional_permissions_preapproved: false,
|
||||
justification: None,
|
||||
exec_approval_requirement,
|
||||
}
|
||||
}
|
||||
|
||||
fn zsh_fork_mode() -> UnifiedExecShellMode {
|
||||
let cwd = std::env::current_dir().expect("read current dir");
|
||||
UnifiedExecShellMode::ZshFork(ZshForkConfig {
|
||||
shell_zsh_path: AbsolutePathBuf::try_from(cwd.join("zsh")).expect("absolute zsh path"),
|
||||
main_execve_wrapper_exe: AbsolutePathBuf::try_from(cwd.join("execve-wrapper"))
|
||||
.expect("absolute wrapper path"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ use codex_tools::ToolExecutor;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolOutput;
|
||||
use codex_tools::ToolSpec;
|
||||
use codex_tools::UnifiedExecShellMode;
|
||||
use codex_tools::can_request_original_image_detail;
|
||||
use codex_tools::collect_code_mode_exec_prompt_tool_definitions;
|
||||
use codex_tools::collect_request_plugin_install_entries;
|
||||
@@ -564,6 +565,7 @@ fn add_shell_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut Planne
|
||||
allow_login_shell,
|
||||
exec_permission_approvals_enabled,
|
||||
include_environment_id,
|
||||
include_shell_parameter: unified_exec_should_include_shell_parameter(turn_context),
|
||||
}));
|
||||
planned_tools.add(WriteStdinHandler);
|
||||
|
||||
@@ -580,6 +582,17 @@ fn add_shell_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut Planne
|
||||
}
|
||||
}
|
||||
|
||||
fn unified_exec_should_include_shell_parameter(turn_context: &TurnContext) -> bool {
|
||||
!matches!(
|
||||
&turn_context.unified_exec_shell_mode,
|
||||
UnifiedExecShellMode::ZshFork(_)
|
||||
) || turn_context
|
||||
.environments
|
||||
.turn_environments
|
||||
.iter()
|
||||
.any(|environment| environment.environment.is_remote())
|
||||
}
|
||||
|
||||
fn add_mcp_resource_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) {
|
||||
if context.mcp_tools.is_some() {
|
||||
planned_tools.add(ListMcpResourcesHandler);
|
||||
|
||||
@@ -233,6 +233,21 @@ fn set_features(turn: &mut TurnContext, features: &[Feature]) {
|
||||
}
|
||||
}
|
||||
|
||||
fn zsh_fork_config_for_spec_plan_tests() -> codex_tools::ZshForkConfig {
|
||||
let placeholder_exe = codex_utils_absolute_path::AbsolutePathBuf::try_from(
|
||||
std::env::current_exe().expect("current exe path"),
|
||||
)
|
||||
.expect("current exe should be absolute");
|
||||
|
||||
// Spec planning only checks whether the shell mode is ZshFork. These paths
|
||||
// are never executed, so use a stable absolute placeholder instead of
|
||||
// depending on packaged zsh-fork artifacts in schema tests.
|
||||
codex_tools::ZshForkConfig {
|
||||
shell_zsh_path: placeholder_exe.clone(),
|
||||
main_execve_wrapper_exe: placeholder_exe,
|
||||
}
|
||||
}
|
||||
|
||||
fn update_config(turn: &mut TurnContext, update: impl FnOnce(&mut crate::config::Config)) {
|
||||
let mut config = (*turn.config).clone();
|
||||
update(&mut config);
|
||||
@@ -406,6 +421,120 @@ async fn shell_family_registers_visible_unified_exec_and_hidden_legacy_shell() {
|
||||
plan.assert_visible_lacks(&["shell_command"]);
|
||||
plan.assert_registered_contains(&["exec_command", "write_stdin", "shell_command"]);
|
||||
assert_eq!(plan.exposure("shell_command"), ToolExposure::Hidden);
|
||||
assert!(has_parameter(plan.visible_spec("exec_command"), "shell"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shell_zsh_fork_stays_standalone_until_unified_exec_composition_is_enabled() {
|
||||
let standalone = probe(|turn| {
|
||||
set_features(turn, &[Feature::ShellTool, Feature::UnifiedExec]);
|
||||
set_feature(turn, Feature::ShellZshFork, /*enabled*/ true);
|
||||
set_feature(turn, Feature::UnifiedExecZshFork, /*enabled*/ false);
|
||||
turn.model_info.shell_type = ConfigShellToolType::ShellCommand;
|
||||
})
|
||||
.await;
|
||||
|
||||
standalone.assert_visible_contains(&["shell_command"]);
|
||||
standalone.assert_visible_lacks(&["exec_command", "write_stdin"]);
|
||||
standalone.assert_registered_contains(&["shell_command"]);
|
||||
standalone.assert_registered_lacks(&["exec_command", "write_stdin"]);
|
||||
|
||||
let composed = probe(|turn| {
|
||||
set_features(
|
||||
turn,
|
||||
&[
|
||||
Feature::ShellTool,
|
||||
Feature::UnifiedExec,
|
||||
Feature::ShellZshFork,
|
||||
Feature::UnifiedExecZshFork,
|
||||
],
|
||||
);
|
||||
turn.model_info.shell_type = ConfigShellToolType::ShellCommand;
|
||||
})
|
||||
.await;
|
||||
|
||||
if codex_utils_pty::conpty_supported() {
|
||||
composed.assert_visible_contains(&["exec_command", "write_stdin"]);
|
||||
composed.assert_visible_lacks(&["shell_command"]);
|
||||
composed.assert_registered_contains(&["exec_command", "write_stdin", "shell_command"]);
|
||||
assert_eq!(composed.exposure("shell_command"), ToolExposure::Hidden);
|
||||
} else {
|
||||
composed.assert_visible_contains(&["shell_command"]);
|
||||
composed.assert_visible_lacks(&["exec_command", "write_stdin"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zsh_fork_unified_exec_hides_shell_parameter() {
|
||||
if !codex_utils_pty::conpty_supported() {
|
||||
return;
|
||||
}
|
||||
|
||||
let plan = probe(|turn| {
|
||||
set_features(
|
||||
turn,
|
||||
&[
|
||||
Feature::ShellTool,
|
||||
Feature::UnifiedExec,
|
||||
Feature::ShellZshFork,
|
||||
Feature::UnifiedExecZshFork,
|
||||
],
|
||||
);
|
||||
turn.unified_exec_shell_mode =
|
||||
codex_tools::UnifiedExecShellMode::ZshFork(zsh_fork_config_for_spec_plan_tests());
|
||||
})
|
||||
.await;
|
||||
|
||||
plan.assert_visible_contains(&["exec_command", "write_stdin"]);
|
||||
assert!(!has_parameter(plan.visible_spec("exec_command"), "shell"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zsh_fork_unified_exec_keeps_shell_parameter_when_remote_environment_available() {
|
||||
if !codex_utils_pty::conpty_supported() {
|
||||
return;
|
||||
}
|
||||
|
||||
let plan = probe(|turn| {
|
||||
set_features(
|
||||
turn,
|
||||
&[
|
||||
Feature::ShellTool,
|
||||
Feature::UnifiedExec,
|
||||
Feature::ShellZshFork,
|
||||
Feature::UnifiedExecZshFork,
|
||||
],
|
||||
);
|
||||
turn.unified_exec_shell_mode =
|
||||
codex_tools::UnifiedExecShellMode::ZshFork(zsh_fork_config_for_spec_plan_tests());
|
||||
let remote_cwd = turn
|
||||
.environments
|
||||
.primary()
|
||||
.expect("primary environment")
|
||||
.cwd
|
||||
.clone();
|
||||
turn.environments
|
||||
.turn_environments
|
||||
.push(crate::session::turn_context::TurnEnvironment {
|
||||
environment_id: "remote".to_string(),
|
||||
environment: Arc::new(
|
||||
codex_exec_server::Environment::create_for_tests(Some(
|
||||
"ws://127.0.0.1:1/remote-exec-server".to_string(),
|
||||
))
|
||||
.expect("remote test environment"),
|
||||
),
|
||||
cwd: remote_cwd,
|
||||
shell: None,
|
||||
});
|
||||
})
|
||||
.await;
|
||||
|
||||
plan.assert_visible_contains(&["exec_command", "write_stdin"]);
|
||||
assert!(has_parameter(plan.visible_spec("exec_command"), "shell"));
|
||||
assert!(has_parameter(
|
||||
plan.visible_spec("exec_command"),
|
||||
"environment_id"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user