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:
Kyle Brown
2026-07-23 22:31:53 +00:00
committed by copyberry
parent 5bdbd3ee90
commit 84fa68b429
52 changed files with 858 additions and 32 deletions
@@ -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"
+2
View File
@@ -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,
+35
View File
@@ -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": [
{
@@ -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": [
{
@@ -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": [
{
@@ -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()),
+1 -1
View File
@@ -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.
+17
View File
@@ -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 {
+8
View File
@@ -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(),
+46 -27
View File
@@ -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>,
+8 -1
View File
@@ -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(),
+133
View File
@@ -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(),
+6
View File
@@ -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,
+4
View File
@@ -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,
+33 -2
View File
@@ -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,