mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Attribute command executions to trusted plugin scripts (#35020)
## What changed - Resolve shell and unified-exec commands against the trusted plugin roots loaded for each turn. - Add optional `pluginId` and safe plugin-relative `scriptPath` fields to command execution items and legacy execution events, and propagate them through app-server notifications. - Include the attribution in command execution analytics while rejecting absolute, unsafe, and unattributed script paths. ## Testing - Cover attribution for cached curated and remote plugin scripts from command execution through core and app-server events. - Verify analytics serialization and unsafe-path filtering. GitOrigin-RevId: 02fac3a233284ccfc6642fa502a95f1881dba83d
This commit is contained in:
@@ -866,6 +866,8 @@ fn sample_command_execution_item_with_id(
|
||||
) -> ThreadItem {
|
||||
ThreadItem::CommandExecution {
|
||||
id: id.to_string(),
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
command: "echo hi".to_string(),
|
||||
cwd: test_path_buf("/tmp").abs().into(),
|
||||
process_id: Some("pid-1".to_string()),
|
||||
@@ -883,16 +885,22 @@ fn sample_command_execution_item_with_actions(
|
||||
exit_code: Option<i32>,
|
||||
duration_ms: Option<i64>,
|
||||
command_actions: Vec<CommandAction>,
|
||||
plugin_id: Option<&str>,
|
||||
script_path: Option<&str>,
|
||||
) -> ThreadItem {
|
||||
let mut item = sample_command_execution_item(status, exit_code, duration_ms);
|
||||
let ThreadItem::CommandExecution {
|
||||
command_actions: item_command_actions,
|
||||
plugin_id: item_plugin_id,
|
||||
script_path: item_script_path,
|
||||
..
|
||||
} = &mut item
|
||||
else {
|
||||
unreachable!("sample command execution item should be CommandExecution");
|
||||
};
|
||||
*item_command_actions = command_actions;
|
||||
*item_plugin_id = plugin_id.map(str::to_string);
|
||||
*item_script_path = script_path.map(str::to_string);
|
||||
item
|
||||
}
|
||||
|
||||
@@ -1625,6 +1633,8 @@ fn command_execution_event_serializes_expected_shape() {
|
||||
requested_additional_permissions: false,
|
||||
requested_network_access: false,
|
||||
},
|
||||
plugin_id: Some("sample@openai-curated".to_string()),
|
||||
script_path: Some("scripts/run.py".to_string()),
|
||||
command_execution_source: CommandExecutionSource::Agent,
|
||||
exit_code: Some(0),
|
||||
command_total_action_count: 4,
|
||||
@@ -1674,6 +1684,8 @@ fn command_execution_event_serializes_expected_shape() {
|
||||
"failure_kind": null,
|
||||
"requested_additional_permissions": false,
|
||||
"requested_network_access": false,
|
||||
"plugin_id": "sample@openai-curated",
|
||||
"script_path": "scripts/run.py",
|
||||
"command_execution_source": "agent",
|
||||
"exit_code": 0,
|
||||
"command_total_action_count": 4,
|
||||
@@ -2446,6 +2458,8 @@ async fn item_lifecycle_notifications_publish_command_execution_event() {
|
||||
command: "cargo test".to_string(),
|
||||
},
|
||||
],
|
||||
Some("sample@openai-curated"),
|
||||
Some("scripts/run.py"),
|
||||
),
|
||||
},
|
||||
))),
|
||||
@@ -2461,6 +2475,11 @@ async fn item_lifecycle_notifications_publish_command_execution_event() {
|
||||
assert_eq!(payload[0]["event_params"]["turn_id"], "turn-1");
|
||||
assert_eq!(payload[0]["event_params"]["item_id"], "item-1");
|
||||
assert_eq!(payload[0]["event_params"]["tool_name"], "shell");
|
||||
assert_eq!(
|
||||
payload[0]["event_params"]["plugin_id"],
|
||||
"sample@openai-curated"
|
||||
);
|
||||
assert_eq!(payload[0]["event_params"]["script_path"], "scripts/run.py");
|
||||
assert_eq!(
|
||||
payload[0]["event_params"]["command_execution_source"],
|
||||
"agent"
|
||||
|
||||
@@ -649,6 +649,8 @@ pub(crate) enum WebSearchActionKind {
|
||||
pub(crate) struct CodexCommandExecutionEventParams {
|
||||
#[serde(flatten)]
|
||||
pub(crate) base: CodexToolItemEventBase,
|
||||
pub(crate) plugin_id: Option<String>,
|
||||
pub(crate) script_path: Option<String>,
|
||||
pub(crate) command_execution_source: CommandExecutionSource,
|
||||
pub(crate) exit_code: Option<i32>,
|
||||
pub(crate) command_total_action_count: u64,
|
||||
|
||||
@@ -133,6 +133,7 @@ use codex_login::default_client::originator;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::items::is_safe_plugin_relative_path;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
@@ -1799,6 +1800,8 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
|
||||
match item {
|
||||
ThreadItem::CommandExecution {
|
||||
id,
|
||||
plugin_id,
|
||||
script_path,
|
||||
source,
|
||||
status,
|
||||
command_actions,
|
||||
@@ -1832,6 +1835,11 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
|
||||
event_type: "codex_command_execution_event",
|
||||
event_params: CodexCommandExecutionEventParams {
|
||||
base,
|
||||
plugin_id: plugin_id.clone(),
|
||||
script_path: safe_plugin_relative_script_path(
|
||||
plugin_id.as_deref(),
|
||||
script_path.as_deref(),
|
||||
),
|
||||
command_execution_source: *source,
|
||||
exit_code: *exit_code,
|
||||
command_total_action_count: action_counts.total,
|
||||
@@ -2108,6 +2116,14 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
|
||||
}
|
||||
}
|
||||
|
||||
fn safe_plugin_relative_script_path(
|
||||
plugin_id: Option<&str>,
|
||||
script_path: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let script_path = script_path.filter(|path| is_safe_plugin_relative_path(path))?;
|
||||
plugin_id.map(|_| script_path.to_string())
|
||||
}
|
||||
|
||||
struct ToolItemOutcome {
|
||||
terminal_status: ToolItemTerminalStatus,
|
||||
failure_kind: Option<ToolItemFailureKind>,
|
||||
@@ -2888,4 +2904,23 @@ mod tests {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_execution_script_paths_reject_unsafe_values() {
|
||||
assert_eq!(
|
||||
safe_plugin_relative_script_path(
|
||||
Some("sample@openai-curated"),
|
||||
Some("/home/user/.codex/plugins/cache/openai-curated/sample/scripts/run.py"),
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
safe_plugin_relative_script_path(Some("sample@openai-curated"), Some("scripts/run.py"),),
|
||||
Some("scripts/run.py".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
safe_plugin_relative_script_path(/*plugin_id*/ None, Some("scripts/run.py"),),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4216,6 +4216,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginId": {
|
||||
"default": null,
|
||||
"description": "Trusted first-party plugin id when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"processId": {
|
||||
"description": "Identifier for the underlying PTY process (when available).",
|
||||
"type": [
|
||||
@@ -4223,6 +4231,14 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"scriptPath": {
|
||||
"default": null,
|
||||
"description": "Safe plugin-relative path when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
+16
@@ -18908,6 +18908,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginId": {
|
||||
"default": null,
|
||||
"description": "Trusted first-party plugin id when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"processId": {
|
||||
"description": "Identifier for the underlying PTY process (when available).",
|
||||
"type": [
|
||||
@@ -18915,6 +18923,14 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"scriptPath": {
|
||||
"default": null,
|
||||
"description": "Safe plugin-relative path when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
+16
@@ -16672,6 +16672,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginId": {
|
||||
"default": null,
|
||||
"description": "Trusted first-party plugin id when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"processId": {
|
||||
"description": "Identifier for the underlying PTY process (when available).",
|
||||
"type": [
|
||||
@@ -16679,6 +16687,14 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"scriptPath": {
|
||||
"default": null,
|
||||
"description": "Safe plugin-relative path when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -769,6 +769,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginId": {
|
||||
"default": null,
|
||||
"description": "Trusted first-party plugin id when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"processId": {
|
||||
"description": "Identifier for the underlying PTY process (when available).",
|
||||
"type": [
|
||||
@@ -776,6 +784,14 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"scriptPath": {
|
||||
"default": null,
|
||||
"description": "Safe plugin-relative path when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -769,6 +769,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginId": {
|
||||
"default": null,
|
||||
"description": "Trusted first-party plugin id when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"processId": {
|
||||
"description": "Identifier for the underlying PTY process (when available).",
|
||||
"type": [
|
||||
@@ -776,6 +784,14 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"scriptPath": {
|
||||
"default": null,
|
||||
"description": "Safe plugin-relative path when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -914,6 +914,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginId": {
|
||||
"default": null,
|
||||
"description": "Trusted first-party plugin id when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"processId": {
|
||||
"description": "Identifier for the underlying PTY process (when available).",
|
||||
"type": [
|
||||
@@ -921,6 +929,14 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"scriptPath": {
|
||||
"default": null,
|
||||
"description": "Safe plugin-relative path when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -1447,6 +1447,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginId": {
|
||||
"default": null,
|
||||
"description": "Trusted first-party plugin id when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"processId": {
|
||||
"description": "Identifier for the underlying PTY process (when available).",
|
||||
"type": [
|
||||
@@ -1454,6 +1462,14 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"scriptPath": {
|
||||
"default": null,
|
||||
"description": "Safe plugin-relative path when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -1238,6 +1238,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginId": {
|
||||
"default": null,
|
||||
"description": "Trusted first-party plugin id when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"processId": {
|
||||
"description": "Identifier for the underlying PTY process (when available).",
|
||||
"type": [
|
||||
@@ -1245,6 +1253,14 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"scriptPath": {
|
||||
"default": null,
|
||||
"description": "Safe plugin-relative path when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
+16
@@ -1238,6 +1238,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginId": {
|
||||
"default": null,
|
||||
"description": "Trusted first-party plugin id when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"processId": {
|
||||
"description": "Identifier for the underlying PTY process (when available).",
|
||||
"type": [
|
||||
@@ -1245,6 +1253,14 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"scriptPath": {
|
||||
"default": null,
|
||||
"description": "Safe plugin-relative path when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -1238,6 +1238,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginId": {
|
||||
"default": null,
|
||||
"description": "Trusted first-party plugin id when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"processId": {
|
||||
"description": "Identifier for the underlying PTY process (when available).",
|
||||
"type": [
|
||||
@@ -1245,6 +1253,14 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"scriptPath": {
|
||||
"default": null,
|
||||
"description": "Safe plugin-relative path when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -1447,6 +1447,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginId": {
|
||||
"default": null,
|
||||
"description": "Trusted first-party plugin id when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"processId": {
|
||||
"description": "Identifier for the underlying PTY process (when available).",
|
||||
"type": [
|
||||
@@ -1454,6 +1462,14 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"scriptPath": {
|
||||
"default": null,
|
||||
"description": "Safe plugin-relative path when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -1238,6 +1238,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginId": {
|
||||
"default": null,
|
||||
"description": "Trusted first-party plugin id when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"processId": {
|
||||
"description": "Identifier for the underlying PTY process (when available).",
|
||||
"type": [
|
||||
@@ -1245,6 +1253,14 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"scriptPath": {
|
||||
"default": null,
|
||||
"description": "Safe plugin-relative path when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -1447,6 +1447,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginId": {
|
||||
"default": null,
|
||||
"description": "Trusted first-party plugin id when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"processId": {
|
||||
"description": "Identifier for the underlying PTY process (when available).",
|
||||
"type": [
|
||||
@@ -1454,6 +1462,14 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"scriptPath": {
|
||||
"default": null,
|
||||
"description": "Safe plugin-relative path when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -1238,6 +1238,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginId": {
|
||||
"default": null,
|
||||
"description": "Trusted first-party plugin id when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"processId": {
|
||||
"description": "Identifier for the underlying PTY process (when available).",
|
||||
"type": [
|
||||
@@ -1245,6 +1253,14 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"scriptPath": {
|
||||
"default": null,
|
||||
"description": "Safe plugin-relative path when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -1238,6 +1238,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginId": {
|
||||
"default": null,
|
||||
"description": "Trusted first-party plugin id when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"processId": {
|
||||
"description": "Identifier for the underlying PTY process (when available).",
|
||||
"type": [
|
||||
@@ -1245,6 +1253,14 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"scriptPath": {
|
||||
"default": null,
|
||||
"description": "Safe plugin-relative path when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -914,6 +914,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginId": {
|
||||
"default": null,
|
||||
"description": "Trusted first-party plugin id when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"processId": {
|
||||
"description": "Identifier for the underlying PTY process (when available).",
|
||||
"type": [
|
||||
@@ -921,6 +929,14 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"scriptPath": {
|
||||
"default": null,
|
||||
"description": "Safe plugin-relative path when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -914,6 +914,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginId": {
|
||||
"default": null,
|
||||
"description": "Trusted first-party plugin id when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"processId": {
|
||||
"description": "Identifier for the underlying PTY process (when available).",
|
||||
"type": [
|
||||
@@ -921,6 +929,14 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"scriptPath": {
|
||||
"default": null,
|
||||
"description": "Safe plugin-relative path when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -914,6 +914,14 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginId": {
|
||||
"default": null,
|
||||
"description": "Trusted first-party plugin id when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"processId": {
|
||||
"description": "Identifier for the underlying PTY process (when available).",
|
||||
"type": [
|
||||
@@ -921,6 +929,14 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"scriptPath": {
|
||||
"default": null,
|
||||
"description": "Safe plugin-relative path when this command resolves to one plugin script.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -28,6 +28,14 @@ import type { SubAgentActivityKind } from "./SubAgentActivityKind";
|
||||
import type { UserInput } from "./UserInput";
|
||||
|
||||
export type ThreadItem = { "type": "userMessage", id: string, clientId: string | null, content: Array<UserInput>, } | { "type": "hookPrompt", id: string, fragments: Array<HookPromptFragment>, } | { "type": "agentMessage", id: string, text: string, phase: MessagePhase | null, memoryCitation: MemoryCitation | null, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array<string>, content: Array<string>, } | { "type": "commandExecution", id: string,
|
||||
/**
|
||||
* Trusted first-party plugin id when this command resolves to one plugin script.
|
||||
*/
|
||||
pluginId: string | null,
|
||||
/**
|
||||
* Safe plugin-relative path when this command resolves to one plugin script.
|
||||
*/
|
||||
scriptPath: string | null,
|
||||
/**
|
||||
* The command to be executed.
|
||||
*/
|
||||
|
||||
@@ -79,6 +79,8 @@ pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> Th
|
||||
let command_actions = command_actions_for_path_uri(&payload.parsed_cmd, &payload.cwd);
|
||||
ThreadItem::CommandExecution {
|
||||
id: payload.call_id.clone(),
|
||||
plugin_id: payload.plugin_id.clone(),
|
||||
script_path: payload.script_path.clone(),
|
||||
command: shlex_join(&payload.command),
|
||||
cwd: payload.cwd.clone().into(),
|
||||
process_id: payload.process_id.clone(),
|
||||
@@ -102,6 +104,8 @@ pub fn build_command_execution_end_item(payload: &ExecCommandEndEvent) -> Thread
|
||||
|
||||
ThreadItem::CommandExecution {
|
||||
id: payload.call_id.clone(),
|
||||
plugin_id: payload.plugin_id.clone(),
|
||||
script_path: payload.script_path.clone(),
|
||||
command: shlex_join(&payload.command),
|
||||
cwd: payload.cwd.clone().into(),
|
||||
process_id: payload.process_id.clone(),
|
||||
@@ -175,6 +179,8 @@ pub fn build_item_from_guardian_event(
|
||||
}];
|
||||
Some(ThreadItem::CommandExecution {
|
||||
id: id.clone(),
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
command,
|
||||
cwd: cwd.clone().into(),
|
||||
process_id: None,
|
||||
@@ -211,6 +217,8 @@ pub fn build_item_from_guardian_event(
|
||||
};
|
||||
Some(ThreadItem::CommandExecution {
|
||||
id: id.clone(),
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
command,
|
||||
cwd: cwd.clone().into(),
|
||||
process_id: None,
|
||||
|
||||
@@ -2093,6 +2093,8 @@ mod tests {
|
||||
let thread_id = ThreadId::new();
|
||||
let command_item = CoreTurnItem::CommandExecution(CoreCommandExecutionItem {
|
||||
id: "exec-1".to_string(),
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
process_id: Some("pid-1".to_string()),
|
||||
command: vec!["echo".to_string(), "hello world".to_string()],
|
||||
cwd: test_path_buf("/tmp").abs().into(),
|
||||
@@ -2145,6 +2147,8 @@ mod tests {
|
||||
turns[0].items,
|
||||
vec![ThreadItem::CommandExecution {
|
||||
id: "exec-1".to_string(),
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
command: "echo 'hello world'".to_string(),
|
||||
cwd: test_path_buf("/tmp").abs().into(),
|
||||
process_id: Some("pid-1".to_string()),
|
||||
@@ -2701,6 +2705,8 @@ mod tests {
|
||||
}),
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: "exec-1".into(),
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
process_id: Some("pid-1".into()),
|
||||
turn_id: "turn-1".into(),
|
||||
completed_at_ms: 0,
|
||||
@@ -2764,6 +2770,8 @@ mod tests {
|
||||
turns[0].items[2],
|
||||
ThreadItem::CommandExecution {
|
||||
id: "exec-1".into(),
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
command: "echo 'hello world'".into(),
|
||||
cwd: test_path_buf("/tmp").abs().into(),
|
||||
process_id: Some("pid-1".into()),
|
||||
@@ -2978,6 +2986,8 @@ mod tests {
|
||||
}),
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: "exec-declined".into(),
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
process_id: Some("pid-2".into()),
|
||||
turn_id: "turn-1".into(),
|
||||
completed_at_ms: 0,
|
||||
@@ -3023,6 +3033,8 @@ mod tests {
|
||||
turns[0].items[1],
|
||||
ThreadItem::CommandExecution {
|
||||
id: "exec-declined".into(),
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
command: "ls".into(),
|
||||
cwd: test_path_buf("/tmp").abs().into(),
|
||||
process_id: Some("pid-2".into()),
|
||||
@@ -3121,6 +3133,8 @@ mod tests {
|
||||
turns[0].items[1],
|
||||
ThreadItem::CommandExecution {
|
||||
id: "guardian-exec".into(),
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
command: "rm -rf /tmp/guardian".into(),
|
||||
cwd: test_path_buf("/tmp").abs().into(),
|
||||
process_id: None,
|
||||
@@ -3187,6 +3201,8 @@ mod tests {
|
||||
turns[0].items[1],
|
||||
ThreadItem::CommandExecution {
|
||||
id: "guardian-execve".into(),
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
command: "/bin/rm -f /tmp/file.sqlite".into(),
|
||||
cwd: test_path_buf("/tmp").abs().into(),
|
||||
process_id: None,
|
||||
@@ -3246,6 +3262,8 @@ mod tests {
|
||||
}),
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: "exec-late".into(),
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
process_id: Some("pid-42".into()),
|
||||
turn_id: "turn-a".into(),
|
||||
completed_at_ms: 0,
|
||||
@@ -3289,6 +3307,8 @@ mod tests {
|
||||
turns[0].items[1],
|
||||
ThreadItem::CommandExecution {
|
||||
id: "exec-late".into(),
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
command: "echo done".into(),
|
||||
cwd: test_path_buf("/tmp").abs().into(),
|
||||
process_id: Some("pid-42".into()),
|
||||
@@ -3348,6 +3368,8 @@ mod tests {
|
||||
}),
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: "exec-unknown-turn".into(),
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
process_id: Some("pid-42".into()),
|
||||
turn_id: "turn-missing".into(),
|
||||
completed_at_ms: 0,
|
||||
|
||||
@@ -269,6 +269,12 @@ pub enum ThreadItem {
|
||||
#[ts(rename_all = "camelCase")]
|
||||
CommandExecution {
|
||||
id: String,
|
||||
/// Trusted first-party plugin id when this command resolves to one plugin script.
|
||||
#[serde(default)]
|
||||
plugin_id: Option<String>,
|
||||
/// Safe plugin-relative path when this command resolves to one plugin script.
|
||||
#[serde(default)]
|
||||
script_path: Option<String>,
|
||||
/// The command to be executed.
|
||||
command: String,
|
||||
/// The command's working directory.
|
||||
@@ -836,6 +842,8 @@ impl From<CoreTurnItem> for ThreadItem {
|
||||
},
|
||||
CoreTurnItem::CommandExecution(command) => ThreadItem::CommandExecution {
|
||||
id: command.id,
|
||||
plugin_id: command.plugin_id,
|
||||
script_path: command.script_path,
|
||||
command: shlex_join(&command.command),
|
||||
cwd: command.cwd.clone().into(),
|
||||
process_id: command.process_id,
|
||||
|
||||
@@ -2760,6 +2760,8 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
|
||||
|
||||
let command_item = TurnItem::CommandExecution(CommandExecutionItem {
|
||||
id: "exec-1".to_string(),
|
||||
plugin_id: Some("sample@openai-curated".to_string()),
|
||||
script_path: Some("scripts/run.py".to_string()),
|
||||
process_id: Some("pid-1".to_string()),
|
||||
command: vec!["echo".to_string(), "done".to_string()],
|
||||
cwd: PathUri::from_abs_path(&test_path_buf("/tmp").abs()),
|
||||
@@ -2781,6 +2783,8 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
|
||||
ThreadItem::from(command_item),
|
||||
ThreadItem::CommandExecution {
|
||||
id: "exec-1".to_string(),
|
||||
plugin_id: Some("sample@openai-curated".to_string()),
|
||||
script_path: Some("scripts/run.py".to_string()),
|
||||
command: "echo done".to_string(),
|
||||
cwd: LegacyAppPathString::from_abs_path(&test_path_buf("/tmp").abs()),
|
||||
process_id: Some("pid-1".to_string()),
|
||||
|
||||
@@ -1461,7 +1461,7 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn
|
||||
- `agentMessage` — `{id, text}` containing the accumulated agent reply.
|
||||
- `plan` — `{id, text}` emitted for plan-mode turns; plan text can stream via `item/plan/delta` (experimental).
|
||||
- `reasoning` — `{id, summary, content}` where `summary` holds streamed reasoning summaries (applicable for most OpenAI models) and `content` holds raw reasoning blocks (applicable for e.g. open source models).
|
||||
- `commandExecution` — `{id, command, cwd, status, commandActions, aggregatedOutput?, exitCode?, durationMs?}` for sandboxed commands; `status` is `inProgress`, `completed`, `failed`, or `declined`.
|
||||
- `commandExecution` — `{id, pluginId?, scriptPath?, command, cwd, status, commandActions, aggregatedOutput?, exitCode?, durationMs?}` for sandboxed commands; `pluginId` is present only for commands attributed to a trusted first-party plugin, newly attributed items also include `scriptPath` as a safe `/`-separated path relative to the trusted plugin root, older history may omit `scriptPath`, and `status` is `inProgress`, `completed`, `failed`, or `declined`.
|
||||
- `fileChange` — `{id, changes, status}` describing proposed edits; `changes` list `{path, kind, diff}` and `status` is `inProgress`, `completed`, `failed`, or `declined`.
|
||||
- `mcpToolCall` — `{id, server, tool, status, arguments, appContext, mcpAppResourceUri?, pluginId, result?, error?}` describing MCP calls; `appContext` is `{connectorId, linkId, resourceUri, appName, actionName}` for calls through a trusted MCP app, where `connectorId` identifies the connector that owns the tool, `linkId` identifies the app link, `resourceUri` points to the widget template, `appName` is the connector's display name, and `actionName` is the stable connector `Action.name`. `appName` and `actionName` may be null for older rollout entries. The top-level `mcpAppResourceUri` is deprecated and temporarily duplicated for client migration. `tool` identifies the raw MCP tool. `status` is `inProgress`, `completed`, or `failed`.
|
||||
- `collabToolCall` — `{id, tool, status, senderThreadId, receiverThreadId?, newThreadId?, prompt?, agentStatus?}` describing collab tool calls (`spawn_agent`, `send_input`, `resume_agent`, `wait`, `close_agent`); `status` is `inProgress`, `completed`, or `failed`.
|
||||
|
||||
@@ -1355,6 +1355,8 @@ async fn start_command_execution_item(
|
||||
started_at_ms: now_unix_timestamp_ms(),
|
||||
item: ThreadItem::CommandExecution {
|
||||
id: item_id,
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
command,
|
||||
cwd,
|
||||
process_id: None,
|
||||
@@ -1399,6 +1401,8 @@ async fn complete_command_execution_item(
|
||||
|
||||
let item = ThreadItem::CommandExecution {
|
||||
id: item_id,
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
command,
|
||||
cwd,
|
||||
process_id,
|
||||
@@ -2537,6 +2541,8 @@ mod tests {
|
||||
payload.item,
|
||||
ThreadItem::CommandExecution {
|
||||
id: "cmd-1".to_string(),
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
command: completion_item.command.clone(),
|
||||
cwd: completion_item.cwd.clone(),
|
||||
process_id: None,
|
||||
|
||||
@@ -4081,6 +4081,133 @@ async fn command_execution_notifications_include_process_id() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(windows, ignore = "plugin attribution fixture is Unix-only")]
|
||||
#[tokio::test]
|
||||
async fn command_execution_notifications_include_trusted_plugin_id() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
skip_if_wine_exec!(Ok(()), "plugin attribution fixture is Unix-only");
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
let curated_sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
let plugin_root = codex_home
|
||||
.path()
|
||||
.join("plugins/cache/openai-curated/google-calendar/01234567");
|
||||
let script_path = plugin_root.join("scripts/run.sh");
|
||||
let synced_root = codex_home.path().join(".tmp/plugins");
|
||||
for path in [
|
||||
plugin_root.join(".codex-plugin"),
|
||||
script_path
|
||||
.parent()
|
||||
.expect("script path should have parent")
|
||||
.to_path_buf(),
|
||||
synced_root.join(".agents/plugins"),
|
||||
] {
|
||||
std::fs::create_dir_all(path)?;
|
||||
}
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"google-calendar","version":"0.1.0"}"#,
|
||||
)?;
|
||||
std::fs::write(&script_path, "echo hi\n")?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(".tmp/plugins.sha"),
|
||||
format!("{curated_sha}\n"),
|
||||
)?;
|
||||
std::fs::write(
|
||||
synced_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "openai-curated",
|
||||
"plugins": [{
|
||||
"name": "google-calendar",
|
||||
"source": {"source": "local", "path": "./plugins/google-calendar"}
|
||||
}]
|
||||
}"#,
|
||||
)?;
|
||||
let responses = vec![
|
||||
create_shell_command_sse_response(
|
||||
vec![
|
||||
"/bin/sh".to_string(),
|
||||
script_path.to_string_lossy().into_owned(),
|
||||
],
|
||||
/*workdir*/ None,
|
||||
/*timeout_ms*/ None,
|
||||
"plugin-command",
|
||||
)?,
|
||||
create_final_assistant_message_sse_response("done")?,
|
||||
];
|
||||
let server = create_mock_responses_server_sequence(responses).await;
|
||||
MockResponsesConfig::new(&server.uri())
|
||||
.with_sandbox_mode("danger-full-access")
|
||||
.enable_feature(Feature::Plugins)
|
||||
.disable_feature(Feature::RemotePlugin)
|
||||
.with_extra_config("[plugins.\"google-calendar@openai-curated\"]\nenabled = true")
|
||||
.write(codex_home.path())?;
|
||||
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.build_initialized()
|
||||
.await?;
|
||||
let ThreadStartResponse { thread, .. } = mcp
|
||||
.start_thread(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let _: TurnStartResponse = mcp
|
||||
.request(|request_id| ClientRequest::TurnStart {
|
||||
request_id,
|
||||
params: TurnStartParams {
|
||||
thread_id: thread.id,
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "run a plugin command".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess),
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
|
||||
for method in ["item/started", "item/completed"] {
|
||||
timeout(DEFAULT_READ_TIMEOUT, async {
|
||||
loop {
|
||||
let notification = mcp.read_stream_until_notification_message(method).await?;
|
||||
let params = notification.params.expect("item notification params");
|
||||
let item_json = params.get("item").expect("item notification item").clone();
|
||||
let item = serde_json::from_value::<ThreadItem>(item_json.clone())?;
|
||||
if matches!(item, ThreadItem::CommandExecution { .. }) {
|
||||
let emitted_script_path = item_json
|
||||
.get("scriptPath")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.expect("command execution item should include scriptPath");
|
||||
assert_eq!(
|
||||
(item_json["pluginId"].as_str(), emitted_script_path),
|
||||
(Some("google-calendar@openai-curated"), "scripts/run.sh")
|
||||
);
|
||||
assert!(
|
||||
!emitted_script_path.contains(script_path.to_string_lossy().as_ref()),
|
||||
"scriptPath must not serialize the absolute fixture path"
|
||||
);
|
||||
assert!(
|
||||
!emitted_script_path.contains("plugins/cache"),
|
||||
"scriptPath must not serialize a plugin cache path"
|
||||
);
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
}
|
||||
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_start_with_elevated_override_does_not_persist_project_trust() -> Result<()> {
|
||||
let responses = vec![create_final_assistant_message_sse_response("Done")?];
|
||||
|
||||
@@ -32,6 +32,16 @@ pub struct PluginCommandAttribution {
|
||||
pub normalized_relative_path: String,
|
||||
}
|
||||
|
||||
impl PluginCommandAttribution {
|
||||
/// Returns the paired fields used at command protocol boundaries.
|
||||
pub fn serialized_fields(&self) -> (String, String) {
|
||||
(
|
||||
self.plugin_id.as_key(),
|
||||
self.normalized_relative_path.clone(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Active first-party roots eligible for command attribution.
|
||||
/// Trusted means OpenAI-shipped synced code or a server-installed global
|
||||
/// remote plugin cache entry, not a local override.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use super::*;
|
||||
use crate::environment_selection::TurnEnvironmentSnapshot;
|
||||
use crate::shell_snapshot::ShellSnapshotFile;
|
||||
use codex_core_plugins::PluginCommandAttribution;
|
||||
use codex_core_plugins::TrustedPluginRoots;
|
||||
use codex_core_skills::HostSkillsSnapshot;
|
||||
use codex_file_system::FileSystemSandboxContext;
|
||||
use codex_model_provider::SharedModelProvider;
|
||||
@@ -173,6 +175,16 @@ impl TurnContext {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn plugin_attribution_for_command(
|
||||
&self,
|
||||
command: &[String],
|
||||
cwd: &AbsolutePathBuf,
|
||||
) -> Option<PluginCommandAttribution> {
|
||||
self.extension_data
|
||||
.get::<TrustedPluginRoots>()?
|
||||
.resolve_attribution(command, cwd)
|
||||
}
|
||||
|
||||
pub(crate) fn permission_profile(&self) -> PermissionProfile {
|
||||
self.permission_profile.clone()
|
||||
}
|
||||
@@ -742,6 +754,10 @@ impl Session {
|
||||
.plugins_manager
|
||||
.plugins_for_config(&plugins_input)
|
||||
.await;
|
||||
let trusted_plugin_roots = TrustedPluginRoots::from_plugin_load_outcome(
|
||||
&plugin_outcome,
|
||||
per_turn_config.codex_home.as_path(),
|
||||
);
|
||||
let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots();
|
||||
let plugin_skill_snapshots = self
|
||||
.services
|
||||
@@ -785,6 +801,7 @@ impl Session {
|
||||
sub_id,
|
||||
skills_snapshot,
|
||||
);
|
||||
turn_context.extension_data.insert(trusted_plugin_roots);
|
||||
turn_context.realtime_active = self.conversation.running_state().await.is_some();
|
||||
|
||||
if let Some(final_schema) = final_output_json_schema {
|
||||
|
||||
@@ -184,6 +184,8 @@ pub(crate) async fn execute_user_shell_command(
|
||||
turn_context.as_ref(),
|
||||
&TurnItem::CommandExecution(CommandExecutionItem {
|
||||
id: call_id.clone(),
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
process_id: None,
|
||||
command: display_command.clone(),
|
||||
cwd: cwd.clone().into(),
|
||||
@@ -268,6 +270,8 @@ pub(crate) async fn execute_user_shell_command(
|
||||
turn_context.as_ref(),
|
||||
TurnItem::CommandExecution(CommandExecutionItem {
|
||||
id: call_id,
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
process_id: None,
|
||||
command: display_command.clone(),
|
||||
cwd: cwd.clone().into(),
|
||||
@@ -291,6 +295,8 @@ pub(crate) async fn execute_user_shell_command(
|
||||
turn_context.as_ref(),
|
||||
TurnItem::CommandExecution(CommandExecutionItem {
|
||||
id: call_id.clone(),
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
process_id: None,
|
||||
command: display_command.clone(),
|
||||
cwd: cwd.clone().into(),
|
||||
@@ -334,6 +340,8 @@ pub(crate) async fn execute_user_shell_command(
|
||||
turn_context.as_ref(),
|
||||
TurnItem::CommandExecution(CommandExecutionItem {
|
||||
id: call_id,
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
process_id: None,
|
||||
command: display_command,
|
||||
cwd: cwd.into(),
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::session::turn_context::TurnContext;
|
||||
use crate::tools::context::SharedTurnDiffTracker;
|
||||
use crate::tools::sandboxing::ToolError;
|
||||
use codex_apply_patch::AppliedPatchDelta;
|
||||
use codex_core_plugins::PluginCommandAttribution;
|
||||
use codex_protocol::error::CodexErrorDetails;
|
||||
use codex_protocol::error::SandboxErr;
|
||||
use codex_protocol::exec_output::ExecToolCallOutput;
|
||||
@@ -99,26 +100,21 @@ fn tracker_update_for_known_delta<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn emit_exec_command_begin(
|
||||
ctx: ToolEventCtx<'_>,
|
||||
command: &[String],
|
||||
cwd: &PathUri,
|
||||
parsed_cmd: &[ParsedCommand],
|
||||
source: ExecCommandSource,
|
||||
interaction_input: Option<String>,
|
||||
process_id: Option<&str>,
|
||||
) {
|
||||
async fn emit_exec_command_begin(ctx: ToolEventCtx<'_>, exec_input: &ExecCommandInput<'_>) {
|
||||
let (plugin_id, script_path) = plugin_attribution_fields(exec_input.plugin_attribution);
|
||||
ctx.session
|
||||
.emit_turn_item_started(
|
||||
ctx.turn,
|
||||
&TurnItem::CommandExecution(CommandExecutionItem {
|
||||
id: ctx.call_id.to_string(),
|
||||
process_id: process_id.map(str::to_owned),
|
||||
command: command.to_vec(),
|
||||
cwd: cwd.clone(),
|
||||
parsed_cmd: parsed_cmd.to_vec(),
|
||||
source,
|
||||
interaction_input,
|
||||
plugin_id,
|
||||
script_path,
|
||||
process_id: exec_input.process_id.map(str::to_owned),
|
||||
command: exec_input.command.to_vec(),
|
||||
cwd: exec_input.cwd.clone(),
|
||||
parsed_cmd: exec_input.parsed_cmd.to_vec(),
|
||||
source: exec_input.source,
|
||||
interaction_input: exec_input.interaction_input.map(str::to_owned),
|
||||
status: CommandExecutionStatus::InProgress,
|
||||
stdout: None,
|
||||
stderr: None,
|
||||
@@ -137,6 +133,7 @@ pub(crate) enum ToolEmitter {
|
||||
cwd: PathUri,
|
||||
source: ExecCommandSource,
|
||||
parsed_cmd: Vec<ParsedCommand>,
|
||||
plugin_attribution: Option<PluginCommandAttribution>,
|
||||
},
|
||||
ApplyPatch {
|
||||
changes: HashMap<PathBuf, FileChange>,
|
||||
@@ -149,17 +146,24 @@ pub(crate) enum ToolEmitter {
|
||||
source: ExecCommandSource,
|
||||
parsed_cmd: Vec<ParsedCommand>,
|
||||
process_id: Option<String>,
|
||||
plugin_attribution: Option<PluginCommandAttribution>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ToolEmitter {
|
||||
pub fn shell(command: Vec<String>, cwd: AbsolutePathBuf, source: ExecCommandSource) -> Self {
|
||||
pub fn shell(
|
||||
command: Vec<String>,
|
||||
cwd: AbsolutePathBuf,
|
||||
source: ExecCommandSource,
|
||||
plugin_attribution: Option<PluginCommandAttribution>,
|
||||
) -> Self {
|
||||
let parsed_cmd = parse_command(&command);
|
||||
Self::Shell {
|
||||
command,
|
||||
cwd: PathUri::from_abs_path(&cwd),
|
||||
source,
|
||||
parsed_cmd,
|
||||
plugin_attribution,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +184,7 @@ impl ToolEmitter {
|
||||
cwd: PathUri,
|
||||
source: ExecCommandSource,
|
||||
process_id: Option<String>,
|
||||
plugin_attribution: Option<PluginCommandAttribution>,
|
||||
) -> Self {
|
||||
let parsed_cmd = parse_command(command);
|
||||
Self::UnifiedExec {
|
||||
@@ -188,6 +193,7 @@ impl ToolEmitter {
|
||||
source,
|
||||
parsed_cmd,
|
||||
process_id,
|
||||
plugin_attribution,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +205,7 @@ impl ToolEmitter {
|
||||
cwd,
|
||||
source,
|
||||
parsed_cmd,
|
||||
plugin_attribution,
|
||||
..
|
||||
},
|
||||
stage,
|
||||
@@ -206,8 +213,13 @@ impl ToolEmitter {
|
||||
emit_exec_stage(
|
||||
ctx,
|
||||
ExecCommandInput::new(
|
||||
command, cwd, parsed_cmd, *source, /*interaction_input*/ None,
|
||||
command,
|
||||
cwd,
|
||||
parsed_cmd,
|
||||
*source,
|
||||
/*interaction_input*/ None,
|
||||
/*process_id*/ None,
|
||||
plugin_attribution.as_ref(),
|
||||
),
|
||||
stage,
|
||||
)
|
||||
@@ -329,6 +341,7 @@ impl ToolEmitter {
|
||||
source,
|
||||
parsed_cmd,
|
||||
process_id,
|
||||
plugin_attribution,
|
||||
},
|
||||
stage,
|
||||
) => {
|
||||
@@ -341,6 +354,7 @@ impl ToolEmitter {
|
||||
*source,
|
||||
/*interaction_input*/ None,
|
||||
process_id.as_deref(),
|
||||
plugin_attribution.as_ref(),
|
||||
),
|
||||
stage,
|
||||
)
|
||||
@@ -454,6 +468,7 @@ struct ExecCommandInput<'a> {
|
||||
source: ExecCommandSource,
|
||||
interaction_input: Option<&'a str>,
|
||||
process_id: Option<&'a str>,
|
||||
plugin_attribution: Option<&'a PluginCommandAttribution>,
|
||||
}
|
||||
|
||||
impl<'a> ExecCommandInput<'a> {
|
||||
@@ -464,6 +479,7 @@ impl<'a> ExecCommandInput<'a> {
|
||||
source: ExecCommandSource,
|
||||
interaction_input: Option<&'a str>,
|
||||
process_id: Option<&'a str>,
|
||||
plugin_attribution: Option<&'a PluginCommandAttribution>,
|
||||
) -> Self {
|
||||
Self {
|
||||
command,
|
||||
@@ -472,6 +488,7 @@ impl<'a> ExecCommandInput<'a> {
|
||||
source,
|
||||
interaction_input,
|
||||
process_id,
|
||||
plugin_attribution,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -493,16 +510,7 @@ async fn emit_exec_stage(
|
||||
) {
|
||||
match stage {
|
||||
ToolEventStage::Begin => {
|
||||
emit_exec_command_begin(
|
||||
ctx,
|
||||
exec_input.command,
|
||||
exec_input.cwd,
|
||||
exec_input.parsed_cmd,
|
||||
exec_input.source,
|
||||
exec_input.interaction_input.map(str::to_owned),
|
||||
exec_input.process_id,
|
||||
)
|
||||
.await;
|
||||
emit_exec_command_begin(ctx, &exec_input).await;
|
||||
}
|
||||
ToolEventStage::Success { output, .. }
|
||||
| ToolEventStage::Failure(ToolEventFailure::Output(output)) => {
|
||||
@@ -558,11 +566,14 @@ async fn emit_exec_end(
|
||||
exec_input: ExecCommandInput<'_>,
|
||||
exec_result: ExecCommandResult,
|
||||
) {
|
||||
let (plugin_id, script_path) = plugin_attribution_fields(exec_input.plugin_attribution);
|
||||
ctx.session
|
||||
.emit_turn_item_completed(
|
||||
ctx.turn,
|
||||
TurnItem::CommandExecution(CommandExecutionItem {
|
||||
id: ctx.call_id.to_string(),
|
||||
plugin_id,
|
||||
script_path,
|
||||
process_id: exec_input.process_id.map(str::to_owned),
|
||||
command: exec_input.command.to_vec(),
|
||||
cwd: exec_input.cwd.clone(),
|
||||
@@ -581,6 +592,14 @@ async fn emit_exec_end(
|
||||
.await;
|
||||
}
|
||||
|
||||
fn plugin_attribution_fields(
|
||||
attribution: Option<&PluginCommandAttribution>,
|
||||
) -> (Option<String>, Option<String>) {
|
||||
attribution
|
||||
.map(PluginCommandAttribution::serialized_fields)
|
||||
.unzip()
|
||||
}
|
||||
|
||||
async fn emit_patch_end(
|
||||
ctx: ToolEventCtx<'_>,
|
||||
changes: HashMap<PathBuf, FileChange>,
|
||||
|
||||
@@ -156,7 +156,14 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result<FunctionToolOutput, Func
|
||||
}
|
||||
|
||||
let source = ExecCommandSource::Agent;
|
||||
let emitter = ToolEmitter::shell(exec_params.command.clone(), exec_params.cwd.clone(), source);
|
||||
let plugin_attribution =
|
||||
turn.plugin_attribution_for_command(&exec_params.command, &exec_params.cwd);
|
||||
let emitter = ToolEmitter::shell(
|
||||
exec_params.command.clone(),
|
||||
exec_params.cwd.clone(),
|
||||
source,
|
||||
plugin_attribution,
|
||||
);
|
||||
let event_ctx = ToolEventCtx::new(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::tools::events::ToolEventCtx;
|
||||
use crate::tools::events::ToolEventFailure;
|
||||
use crate::tools::events::ToolEventStage;
|
||||
use crate::unified_exec::head_tail_buffer::HeadTailBuffer;
|
||||
use codex_core_plugins::PluginCommandAttribution;
|
||||
use codex_protocol::exec_output::ExecToolCallOutput;
|
||||
use codex_protocol::exec_output::StreamOutput;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
@@ -162,6 +163,7 @@ pub(crate) fn spawn_exit_watcher(
|
||||
command: Vec<String>,
|
||||
cwd: PathUri,
|
||||
process_id: i32,
|
||||
plugin_attribution: Option<PluginCommandAttribution>,
|
||||
transcript: Arc<Mutex<HeadTailBuffer>>,
|
||||
started_at: Instant,
|
||||
network_denial_monitor: Option<tokio::task::JoinHandle<()>>,
|
||||
@@ -190,6 +192,7 @@ pub(crate) fn spawn_exit_watcher(
|
||||
command,
|
||||
cwd,
|
||||
Some(process_id.to_string()),
|
||||
plugin_attribution,
|
||||
transcript,
|
||||
String::new(),
|
||||
message,
|
||||
@@ -205,6 +208,7 @@ pub(crate) fn spawn_exit_watcher(
|
||||
command,
|
||||
cwd,
|
||||
Some(process_id.to_string()),
|
||||
plugin_attribution,
|
||||
transcript,
|
||||
String::new(),
|
||||
exit_code,
|
||||
@@ -258,6 +262,7 @@ pub(crate) async fn emit_exec_end_for_unified_exec(
|
||||
command: Vec<String>,
|
||||
cwd: PathUri,
|
||||
process_id: Option<String>,
|
||||
plugin_attribution: Option<PluginCommandAttribution>,
|
||||
transcript: Arc<Mutex<HeadTailBuffer>>,
|
||||
fallback_output: String,
|
||||
exit_code: i32,
|
||||
@@ -283,6 +288,7 @@ pub(crate) async fn emit_exec_end_for_unified_exec(
|
||||
cwd,
|
||||
ExecCommandSource::UnifiedExecStartup,
|
||||
process_id,
|
||||
plugin_attribution,
|
||||
);
|
||||
emitter
|
||||
.emit(
|
||||
@@ -303,6 +309,7 @@ pub(crate) async fn emit_failed_exec_end_for_unified_exec(
|
||||
command: Vec<String>,
|
||||
cwd: PathUri,
|
||||
process_id: Option<String>,
|
||||
plugin_attribution: Option<PluginCommandAttribution>,
|
||||
transcript: Arc<Mutex<HeadTailBuffer>>,
|
||||
fallback_output: String,
|
||||
message: String,
|
||||
@@ -337,6 +344,7 @@ pub(crate) async fn emit_failed_exec_end_for_unified_exec(
|
||||
cwd,
|
||||
ExecCommandSource::UnifiedExecStartup,
|
||||
process_id,
|
||||
plugin_attribution,
|
||||
);
|
||||
emitter
|
||||
.emit(
|
||||
|
||||
@@ -160,6 +160,7 @@ async fn exit_watcher_waits_for_late_network_denial_before_classifying_end() ->
|
||||
vec!["proof".to_string()],
|
||||
cwd,
|
||||
/*process_id*/ 123,
|
||||
/*plugin_attribution*/ None,
|
||||
transcript,
|
||||
Instant::now(),
|
||||
Some(network_denial_monitor),
|
||||
|
||||
@@ -58,6 +58,7 @@ use crate::unified_exec::process::OutputBuffer;
|
||||
use crate::unified_exec::process::OutputHandles;
|
||||
use crate::unified_exec::process::SpawnLifecycleHandle;
|
||||
use crate::unified_exec::process::UnifiedExecProcess;
|
||||
use codex_core_plugins::PluginCommandAttribution;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::config_types::ShellEnvironmentPolicy;
|
||||
use codex_protocol::error::CodexErr;
|
||||
@@ -326,6 +327,7 @@ async fn emit_failed_initial_exec_end_if_unstored(
|
||||
context: &UnifiedExecContext,
|
||||
request: &ExecCommandRequest,
|
||||
cwd: PathUri,
|
||||
plugin_attribution: Option<PluginCommandAttribution>,
|
||||
transcript: Arc<tokio::sync::Mutex<HeadTailBuffer>>,
|
||||
fallback_output: String,
|
||||
message: String,
|
||||
@@ -342,6 +344,7 @@ async fn emit_failed_initial_exec_end_if_unstored(
|
||||
request.command.clone(),
|
||||
cwd,
|
||||
Some(request.process_id.to_string()),
|
||||
plugin_attribution,
|
||||
transcript,
|
||||
fallback_output,
|
||||
message,
|
||||
@@ -445,11 +448,17 @@ impl UnifiedExecProcessManager {
|
||||
&context.call_id,
|
||||
/*turn_diff_tracker*/ None,
|
||||
);
|
||||
let plugin_attribution = cwd.to_abs_path().ok().and_then(|cwd| {
|
||||
context
|
||||
.turn
|
||||
.plugin_attribution_for_command(&request.command, &cwd)
|
||||
});
|
||||
let emitter = ToolEmitter::unified_exec(
|
||||
&request.command,
|
||||
cwd.clone(),
|
||||
ExecCommandSource::UnifiedExecStartup,
|
||||
Some(request.process_id.to_string()),
|
||||
plugin_attribution.clone(),
|
||||
);
|
||||
emitter.emit(event_ctx, ToolEventStage::Begin).await;
|
||||
|
||||
@@ -466,6 +475,7 @@ impl UnifiedExecProcessManager {
|
||||
&request.command,
|
||||
request.hook_command.clone(),
|
||||
cwd.clone(),
|
||||
plugin_attribution.clone(),
|
||||
start,
|
||||
request.process_id,
|
||||
request.tty,
|
||||
@@ -528,6 +538,7 @@ impl UnifiedExecProcessManager {
|
||||
context,
|
||||
&request,
|
||||
cwd.clone(),
|
||||
plugin_attribution.clone(),
|
||||
Arc::clone(&transcript),
|
||||
text.clone(),
|
||||
message.clone(),
|
||||
@@ -548,6 +559,7 @@ impl UnifiedExecProcessManager {
|
||||
context,
|
||||
&request,
|
||||
cwd.clone(),
|
||||
plugin_attribution.clone(),
|
||||
Arc::clone(&transcript),
|
||||
text.clone(),
|
||||
message.clone(),
|
||||
@@ -607,6 +619,7 @@ impl UnifiedExecProcessManager {
|
||||
context,
|
||||
&request,
|
||||
cwd.clone(),
|
||||
plugin_attribution.clone(),
|
||||
Arc::clone(&transcript),
|
||||
text.clone(),
|
||||
message.clone(),
|
||||
@@ -625,6 +638,7 @@ impl UnifiedExecProcessManager {
|
||||
request.command.clone(),
|
||||
cwd.clone(),
|
||||
Some(process_id.to_string()),
|
||||
plugin_attribution.clone(),
|
||||
Arc::clone(&transcript),
|
||||
text.clone(),
|
||||
exit,
|
||||
@@ -929,6 +943,7 @@ impl UnifiedExecProcessManager {
|
||||
command: &[String],
|
||||
hook_command: String,
|
||||
cwd: PathUri,
|
||||
plugin_attribution: Option<PluginCommandAttribution>,
|
||||
started_at: Instant,
|
||||
process_id: i32,
|
||||
tty: bool,
|
||||
@@ -970,6 +985,7 @@ impl UnifiedExecProcessManager {
|
||||
command.to_vec(),
|
||||
cwd,
|
||||
process_id,
|
||||
plugin_attribution,
|
||||
transcript,
|
||||
started_at,
|
||||
network_denial_monitor,
|
||||
|
||||
@@ -394,6 +394,7 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() {
|
||||
&request,
|
||||
#[allow(deprecated)]
|
||||
turn.cwd.clone().into(),
|
||||
/*plugin_attribution*/ None,
|
||||
transcript,
|
||||
"PRE_DENIAL_MARKER".to_string(),
|
||||
"Network access denied".to_string(),
|
||||
|
||||
@@ -6,16 +6,22 @@ use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_core_plugins::store::PluginStore;
|
||||
use codex_features::Feature;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use core_test_support::apps_test_server::AppsTestServer;
|
||||
use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL;
|
||||
use core_test_support::responses::ResponseMock;
|
||||
use core_test_support::responses::ResponsesRequest;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_function_call;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::ev_tool_search_call;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
@@ -24,10 +30,14 @@ use core_test_support::responses::namespace_child_tool;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::skip_if_remote;
|
||||
use core_test_support::stdio_server_bin;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::local_selections;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::test_codex::turn_permission_fields;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use core_test_support::wait_for_mcp_server;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::MockServer;
|
||||
@@ -39,6 +49,7 @@ const SAMPLE_PLUGIN_APP_NAMESPACE: &str = "mcp__codex_apps__google_calendar";
|
||||
const SAMPLE_PLUGIN_MCP_NAMESPACE: &str = "mcp__sample";
|
||||
const PLUGIN_APP_SEARCH_CALL_ID: &str = "plugin-app-search";
|
||||
const PLUGIN_MCP_SEARCH_CALL_ID: &str = "plugin-mcp-search";
|
||||
const REMOTE_PLUGIN_CONFIG_NAME: &str = "sample@openai-curated-remote";
|
||||
|
||||
fn sample_plugin_root(home: &TempDir) -> std::path::PathBuf {
|
||||
home.path().join("plugins/cache/test/sample/local")
|
||||
@@ -64,6 +75,34 @@ fn write_sample_plugin_manifest_and_config(home: &TempDir) -> std::path::PathBuf
|
||||
plugin_root
|
||||
}
|
||||
|
||||
fn write_remote_plugin_script_and_config(home: &TempDir) -> std::path::PathBuf {
|
||||
let plugin_id = PluginId::parse(REMOTE_PLUGIN_CONFIG_NAME).expect("plugin id");
|
||||
let store = PluginStore::new(home.path().to_path_buf());
|
||||
let plugin_root = store.plugin_root(&plugin_id, "1.2.3");
|
||||
let script_path = plugin_root.join("scripts/run.sh");
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))
|
||||
.expect("create remote plugin manifest dir");
|
||||
std::fs::create_dir_all(script_path.parent().expect("script parent"))
|
||||
.expect("create remote plugin scripts dir");
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample","version":"1.2.3"}"#,
|
||||
)
|
||||
.expect("write remote plugin manifest");
|
||||
std::fs::write(&script_path, "echo remote attribution\n").expect("write remote plugin script");
|
||||
store
|
||||
.write_remote_plugin_id(&plugin_id, "plugins~Plugin_sample")
|
||||
.expect("persist remote plugin id");
|
||||
std::fs::write(
|
||||
home.path().join("config.toml"),
|
||||
format!(
|
||||
"[features]\nplugins = true\n\n[plugins.\"{REMOTE_PLUGIN_CONFIG_NAME}\"]\nenabled = true\n"
|
||||
),
|
||||
)
|
||||
.expect("write remote plugin config");
|
||||
script_path.into_path_buf()
|
||||
}
|
||||
|
||||
fn write_plugin_skill_plugin(home: &TempDir) -> std::path::PathBuf {
|
||||
let plugin_root = write_sample_plugin_manifest_and_config(home);
|
||||
let skill_dir = plugin_root.join("skills/sample-search");
|
||||
@@ -204,6 +243,100 @@ fn searched_plugin_tools(
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn persisted_remote_plugin_command_attribution_flows_through_turn_context() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
skip_if_remote!(
|
||||
Ok(()),
|
||||
"remote plugin attribution fixture uses a local Codex home cache"
|
||||
);
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let codex_home = Arc::new(TempDir::new()?);
|
||||
let script_path = write_remote_plugin_script_and_config(codex_home.as_ref());
|
||||
let script_path = script_path.to_string_lossy();
|
||||
let command = shlex::try_join(["/bin/sh", script_path.as_ref()])?;
|
||||
let call_id = "remote-plugin-command";
|
||||
let arguments = serde_json::to_string(&serde_json::json!({
|
||||
"command": command,
|
||||
"login": false,
|
||||
}))?;
|
||||
mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(call_id, "shell_command", &arguments),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_home(Arc::clone(&codex_home))
|
||||
.with_model("gpt-5.2");
|
||||
let test_codex = builder.build_with_auto_env(&server).await?;
|
||||
let codex = Arc::clone(&test_codex.codex);
|
||||
let cwd = test_codex.config.cwd.clone();
|
||||
let session_model = test_codex.session_configured.model.clone();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, cwd.as_path());
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![codex_protocol::user_input::UserInput::Text {
|
||||
text: "run the remote plugin script".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
additional_context: Default::default(),
|
||||
thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {
|
||||
environments: Some(local_selections(cwd)),
|
||||
approval_policy: Some(AskForApproval::Never),
|
||||
sandbox_policy: Some(sandbox_policy),
|
||||
permission_profile,
|
||||
collaboration_mode: Some(codex_protocol::config_types::CollaborationMode {
|
||||
mode: codex_protocol::config_types::ModeKind::Default,
|
||||
settings: codex_protocol::config_types::Settings {
|
||||
model: session_model,
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
},
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
|
||||
let begin = wait_for_event_match(&codex, |event| match event {
|
||||
EventMsg::ExecCommandBegin(event) if event.call_id == call_id => Some(event.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
let end = wait_for_event_match(&codex, |event| match event {
|
||||
EventMsg::ExecCommandEnd(event) if event.call_id == call_id => Some(event.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
for (plugin_id, script_path) in [
|
||||
(begin.plugin_id.as_deref(), begin.script_path.as_deref()),
|
||||
(end.plugin_id.as_deref(), end.script_path.as_deref()),
|
||||
] {
|
||||
assert_eq!(plugin_id, Some(REMOTE_PLUGIN_CONFIG_NAME));
|
||||
assert_eq!(script_path, Some("scripts/run.sh"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn capability_sections_render_in_developer_message_in_order() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -173,6 +173,8 @@ fn command_execution_started_and_completed_translate_to_thread_events() {
|
||||
command: "ls".to_string(),
|
||||
cwd: test_path_buf("/tmp/project").abs().into(),
|
||||
process_id: Some("123".to_string()),
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
source: CommandExecutionSource::UserShell,
|
||||
status: ApiCommandExecutionStatus::InProgress,
|
||||
command_actions: Vec::<CommandAction>::new(),
|
||||
@@ -213,6 +215,8 @@ fn command_execution_started_and_completed_translate_to_thread_events() {
|
||||
command: "ls".to_string(),
|
||||
cwd: test_path_buf("/tmp/project").abs().into(),
|
||||
process_id: Some("123".to_string()),
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
source: CommandExecutionSource::UserShell,
|
||||
status: ApiCommandExecutionStatus::Completed,
|
||||
command_actions: Vec::<CommandAction>::new(),
|
||||
@@ -1335,6 +1339,8 @@ fn turn_completion_reconciles_started_items_from_turn_items() {
|
||||
command: "ls".to_string(),
|
||||
cwd: test_path_buf("/tmp/project").abs().into(),
|
||||
process_id: Some("123".to_string()),
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
source: CommandExecutionSource::UserShell,
|
||||
status: ApiCommandExecutionStatus::InProgress,
|
||||
command_actions: Vec::<CommandAction>::new(),
|
||||
@@ -1375,6 +1381,8 @@ fn turn_completion_reconciles_started_items_from_turn_items() {
|
||||
command: "ls".to_string(),
|
||||
cwd: test_path_buf("/tmp/project").abs().into(),
|
||||
process_id: Some("123".to_string()),
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
source: CommandExecutionSource::UserShell,
|
||||
status: ApiCommandExecutionStatus::Completed,
|
||||
command_actions: Vec::<CommandAction>::new(),
|
||||
|
||||
@@ -203,6 +203,12 @@ pub struct CommandExecutionItem {
|
||||
pub id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub plugin_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub script_path: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub process_id: Option<String>,
|
||||
pub command: Vec<String>,
|
||||
pub cwd: PathUri,
|
||||
|
||||
@@ -159,6 +159,8 @@ impl CommandExecutionItem {
|
||||
pub(crate) fn as_legacy_begin_event(&self, turn_id: String, started_at_ms: i64) -> EventMsg {
|
||||
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: self.id.clone(),
|
||||
plugin_id: self.plugin_id.clone(),
|
||||
script_path: self.script_path.clone(),
|
||||
process_id: self.process_id.clone(),
|
||||
turn_id,
|
||||
started_at_ms,
|
||||
@@ -183,6 +185,8 @@ impl CommandExecutionItem {
|
||||
};
|
||||
Some(EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: self.id.clone(),
|
||||
plugin_id: self.plugin_id.clone(),
|
||||
script_path: self.script_path.clone(),
|
||||
process_id: self.process_id.clone(),
|
||||
turn_id,
|
||||
completed_at_ms,
|
||||
|
||||
@@ -3516,6 +3516,14 @@ pub enum ExecCommandStatus {
|
||||
pub struct ExecCommandBeginEvent {
|
||||
/// Identifier so this can be paired with the ExecCommandEnd event.
|
||||
pub call_id: String,
|
||||
/// Trusted first-party plugin attributed to this command, when known.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub plugin_id: Option<String>,
|
||||
/// Safe plugin-relative path attributed to this command, when known.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub script_path: Option<String>,
|
||||
/// Identifier for the underlying PTY process (when available).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
@@ -3542,6 +3550,14 @@ pub struct ExecCommandBeginEvent {
|
||||
pub struct ExecCommandEndEvent {
|
||||
/// Identifier for the ExecCommandBegin that finished.
|
||||
pub call_id: String,
|
||||
/// Trusted first-party plugin attributed to this command, when known.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub plugin_id: Option<String>,
|
||||
/// Safe plugin-relative path attributed to this command, when known.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub script_path: Option<String>,
|
||||
/// Identifier for the underlying PTY process (when available).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
@@ -5454,6 +5470,8 @@ mod tests {
|
||||
started_at_ms: 10,
|
||||
item: TurnItem::CommandExecution(CommandExecutionItem {
|
||||
id: "exec-1".into(),
|
||||
plugin_id: Some("sample@openai-curated".into()),
|
||||
script_path: Some("scripts/run.py".into()),
|
||||
process_id: Some("pid-1".into()),
|
||||
command: vec!["echo".into(), "done".into()],
|
||||
cwd: cwd.clone(),
|
||||
@@ -5477,6 +5495,8 @@ mod tests {
|
||||
completed_at_ms: 20,
|
||||
item: TurnItem::CommandExecution(CommandExecutionItem {
|
||||
id: "exec-1".into(),
|
||||
plugin_id: Some("sample@openai-curated".into()),
|
||||
script_path: Some("scripts/run.py".into()),
|
||||
process_id: Some("pid-1".into()),
|
||||
command: vec!["echo".into(), "done".into()],
|
||||
cwd,
|
||||
@@ -5499,10 +5519,15 @@ mod tests {
|
||||
started.as_legacy_events(/*show_raw_agent_reasoning*/ false).as_slice(),
|
||||
[EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id,
|
||||
plugin_id,
|
||||
script_path,
|
||||
turn_id,
|
||||
started_at_ms: 10,
|
||||
..
|
||||
})] if call_id == "exec-1" && turn_id == "turn-1"
|
||||
})] if call_id == "exec-1"
|
||||
&& plugin_id.as_deref() == Some("sample@openai-curated")
|
||||
&& script_path.as_deref() == Some("scripts/run.py")
|
||||
&& turn_id == "turn-1"
|
||||
));
|
||||
assert!(matches!(
|
||||
completed
|
||||
@@ -5510,11 +5535,17 @@ mod tests {
|
||||
.as_slice(),
|
||||
[EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id,
|
||||
plugin_id,
|
||||
script_path,
|
||||
turn_id,
|
||||
completed_at_ms: 20,
|
||||
aggregated_output,
|
||||
..
|
||||
})] if call_id == "exec-1" && turn_id == "turn-1" && aggregated_output == "done\n"
|
||||
})] if call_id == "exec-1"
|
||||
&& plugin_id.as_deref() == Some("sample@openai-curated")
|
||||
&& script_path.as_deref() == Some("scripts/run.py")
|
||||
&& turn_id == "turn-1"
|
||||
&& aggregated_output == "done\n"
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +167,8 @@ impl<'a> From<&'a ExecCommandBeginEvent> for ExecCommandBeginTracePayload<'a> {
|
||||
fn from(event: &'a ExecCommandBeginEvent) -> Self {
|
||||
let ExecCommandBeginEvent {
|
||||
call_id,
|
||||
plugin_id: _,
|
||||
script_path: _,
|
||||
process_id,
|
||||
turn_id,
|
||||
started_at_ms,
|
||||
@@ -220,6 +222,8 @@ impl<'a> From<&'a ExecCommandEndEvent> for ExecCommandEndTracePayload<'a> {
|
||||
fn from(event: &'a ExecCommandEndEvent) -> Self {
|
||||
let ExecCommandEndEvent {
|
||||
call_id,
|
||||
plugin_id: _,
|
||||
script_path: _,
|
||||
process_id,
|
||||
turn_id,
|
||||
completed_at_ms,
|
||||
|
||||
@@ -56,6 +56,8 @@ fn exec_command_trace_payloads_use_inferred_native_cwd() -> anyhow::Result<()> {
|
||||
// Windows and POSIX paths on every platform.
|
||||
let begin = EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: "call-begin".to_string(),
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
process_id: Some("process-1".to_string()),
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at_ms: 1234,
|
||||
@@ -67,6 +69,8 @@ fn exec_command_trace_payloads_use_inferred_native_cwd() -> anyhow::Result<()> {
|
||||
});
|
||||
let end = EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: "call-end".to_string(),
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
process_id: None,
|
||||
turn_id: "turn-1".to_string(),
|
||||
completed_at_ms: 2345,
|
||||
|
||||
@@ -16,6 +16,8 @@ fn agent_status_uses_bounded_buffered_activity() {
|
||||
.expect("absolute path")
|
||||
.into(),
|
||||
process_id: None,
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
source: CommandExecutionSource::Agent,
|
||||
status: CommandExecutionStatus::Completed,
|
||||
command_actions: Vec::new(),
|
||||
|
||||
@@ -174,7 +174,7 @@ stream_max_retries = 0
|
||||
),
|
||||
)?;
|
||||
app.config.codex_home = codex_home.path().to_path_buf().abs();
|
||||
app.config.sqlite_home = codex_home.path().to_path_buf();
|
||||
app.config.sqlite = codex_state::SqliteConfig::new_for_testing(codex_home.path().abs());
|
||||
app.config.model = Some(CURRENT_MODEL.to_string());
|
||||
app.config.model_provider_id = MODEL_PROVIDER_ID.to_string();
|
||||
app.config.model_provider = ModelProviderInfo {
|
||||
|
||||
@@ -179,6 +179,8 @@ mod tests {
|
||||
command: "true".to_string(),
|
||||
cwd: AbsolutePathBuf::current_dir().expect("current dir").into(),
|
||||
process_id: None,
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
source: CommandExecutionSource::Agent,
|
||||
status: CommandExecutionStatus::InProgress,
|
||||
command_actions: Vec::new(),
|
||||
|
||||
@@ -808,6 +808,8 @@ async fn live_app_server_command_execution_strips_shell_wrapper() {
|
||||
command: command.clone(),
|
||||
cwd: test_path_buf("/tmp").abs().into(),
|
||||
process_id: None,
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
source: AppServerCommandExecutionSource::UserShell,
|
||||
status: AppServerCommandExecutionStatus::InProgress,
|
||||
command_actions: vec![AppServerCommandAction::Unknown {
|
||||
@@ -830,6 +832,8 @@ async fn live_app_server_command_execution_strips_shell_wrapper() {
|
||||
command,
|
||||
cwd: test_path_buf("/tmp").abs().into(),
|
||||
process_id: None,
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
source: AppServerCommandExecutionSource::UserShell,
|
||||
status: AppServerCommandExecutionStatus::Completed,
|
||||
command_actions: vec![AppServerCommandAction::Unknown {
|
||||
|
||||
@@ -365,6 +365,8 @@ async fn exec_end_without_begin_uses_event_command() {
|
||||
command: codex_shell_command::parse_command::shlex_join(&command),
|
||||
cwd: cwd.into(),
|
||||
process_id: None,
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
source: ExecCommandSource::Agent,
|
||||
status: AppServerCommandExecutionStatus::Completed,
|
||||
command_actions,
|
||||
|
||||
@@ -830,6 +830,8 @@ pub(super) fn begin_exec_with_source(
|
||||
command: codex_shell_command::parse_command::shlex_join(&command),
|
||||
cwd: chat.config.cwd.clone().into(),
|
||||
process_id: None,
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
source,
|
||||
status: AppServerCommandExecutionStatus::InProgress,
|
||||
command_actions,
|
||||
@@ -853,6 +855,8 @@ pub(super) fn begin_unified_exec_startup(
|
||||
command: codex_shell_command::parse_command::shlex_join(&command),
|
||||
cwd: chat.config.cwd.clone().into(),
|
||||
process_id: Some(process_id.to_string()),
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
source: ExecCommandSource::UnifiedExecStartup,
|
||||
status: AppServerCommandExecutionStatus::InProgress,
|
||||
command_actions: Vec::new(),
|
||||
@@ -1065,6 +1069,8 @@ pub(super) fn end_exec(
|
||||
command,
|
||||
cwd,
|
||||
process_id,
|
||||
plugin_id,
|
||||
script_path,
|
||||
source,
|
||||
command_actions,
|
||||
..
|
||||
@@ -1079,6 +1085,8 @@ pub(super) fn end_exec(
|
||||
command,
|
||||
cwd,
|
||||
process_id,
|
||||
plugin_id,
|
||||
script_path,
|
||||
source,
|
||||
status: if exit_code == 0 {
|
||||
AppServerCommandExecutionStatus::Completed
|
||||
|
||||
@@ -4683,6 +4683,8 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() {
|
||||
command: codex_shell_command::parse_command::shlex_join(&command),
|
||||
cwd: cwd.clone().into(),
|
||||
process_id: None,
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
source: ExecCommandSource::Agent,
|
||||
status: AppServerCommandExecutionStatus::InProgress,
|
||||
command_actions: command_actions.clone(),
|
||||
@@ -4698,6 +4700,8 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() {
|
||||
command: codex_shell_command::parse_command::shlex_join(&command),
|
||||
cwd: cwd.into(),
|
||||
process_id: None,
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
source: ExecCommandSource::Agent,
|
||||
status: AppServerCommandExecutionStatus::Completed,
|
||||
command_actions,
|
||||
|
||||
Reference in New Issue
Block a user