app-server: preserve command item cwd

This commit is contained in:
Adam Perry
2026-06-16 17:39:01 +00:00
parent bd47c95c4c
commit 4a4c8fad12
36 changed files with 154 additions and 65 deletions
@@ -3964,7 +3964,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/ApiPathString"
}
],
"description": "The command's working directory."
@@ -17392,7 +17392,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
"$ref": "#/definitions/v2/ApiPathString"
}
],
"description": "The command's working directory."
@@ -15200,7 +15200,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/ApiPathString"
}
],
"description": "The command's working directory."
@@ -5,6 +5,9 @@
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"ApiPathString": {
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -687,7 +690,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/ApiPathString"
}
],
"description": "The command's working directory."
@@ -5,6 +5,9 @@
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"ApiPathString": {
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -687,7 +690,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/ApiPathString"
}
],
"description": "The command's working directory."
@@ -5,6 +5,9 @@
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"ApiPathString": {
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -831,7 +834,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/ApiPathString"
}
],
"description": "The command's working directory."
@@ -28,6 +28,9 @@
"AgentPath": {
"type": "string"
},
"ApiPathString": {
"type": "string"
},
"ApprovalsReviewer": {
"description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.",
"enum": [
@@ -1315,7 +1318,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/ApiPathString"
}
],
"description": "The command's working directory."
@@ -8,6 +8,9 @@
"AgentPath": {
"type": "string"
},
"ApiPathString": {
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -1130,7 +1133,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/ApiPathString"
}
],
"description": "The command's working directory."
@@ -8,6 +8,9 @@
"AgentPath": {
"type": "string"
},
"ApiPathString": {
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -1130,7 +1133,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/ApiPathString"
}
],
"description": "The command's working directory."
@@ -8,6 +8,9 @@
"AgentPath": {
"type": "string"
},
"ApiPathString": {
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -1130,7 +1133,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/ApiPathString"
}
],
"description": "The command's working directory."
@@ -28,6 +28,9 @@
"AgentPath": {
"type": "string"
},
"ApiPathString": {
"type": "string"
},
"ApprovalsReviewer": {
"description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.",
"enum": [
@@ -1315,7 +1318,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/ApiPathString"
}
],
"description": "The command's working directory."
@@ -8,6 +8,9 @@
"AgentPath": {
"type": "string"
},
"ApiPathString": {
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -1130,7 +1133,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/ApiPathString"
}
],
"description": "The command's working directory."
@@ -1318,7 +1318,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/ApiPathString"
}
],
"description": "The command's working directory."
@@ -8,6 +8,9 @@
"AgentPath": {
"type": "string"
},
"ApiPathString": {
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -1130,7 +1133,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/ApiPathString"
}
],
"description": "The command's working directory."
@@ -8,6 +8,9 @@
"AgentPath": {
"type": "string"
},
"ApiPathString": {
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -1130,7 +1133,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/ApiPathString"
}
],
"description": "The command's working directory."
@@ -5,6 +5,9 @@
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"ApiPathString": {
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -831,7 +834,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/ApiPathString"
}
],
"description": "The command's working directory."
@@ -5,6 +5,9 @@
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"ApiPathString": {
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -831,7 +834,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/ApiPathString"
}
],
"description": "The command's working directory."
@@ -5,6 +5,9 @@
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"ApiPathString": {
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -831,7 +834,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/ApiPathString"
}
],
"description": "The command's working directory."
@@ -2,6 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { ApiPathString } from "../ApiPathString";
import type { MessagePhase } from "../MessagePhase";
import type { ReasoningEffort } from "../ReasoningEffort";
import type { JsonValue } from "../serde_json/JsonValue";
@@ -32,7 +33,7 @@ command: string,
/**
* The command's working directory.
*/
cwd: AbsolutePathBuf,
cwd: ApiPathString,
/**
* Identifier for the underlying PTY process (when available).
*/
@@ -43,13 +43,28 @@ use std::path::PathBuf;
#[expect(
clippy::expect_used,
reason = "legacy app-server command items still require a host-native cwd"
reason = "command event paths are absolute and have an inferred convention"
)]
fn project_command_cwd_to_host(cwd: &PathUri) -> AbsolutePathBuf {
let cwd = ApiPathString::from_path_uri(cwd, PathConvention::native())
fn command_cwd_for_api_and_actions(cwd: &PathUri) -> (ApiPathString, AbsolutePathBuf) {
let convention = cwd
.infer_path_convention()
.expect("command cwd should have a native path convention");
let api_cwd = ApiPathString::from_path_uri(cwd, convention)
.expect("command cwd should render using its native path convention");
let host_cwd = ApiPathString::from_path_uri(cwd, PathConvention::native())
.expect("command cwd should render using the app-server host path convention");
AbsolutePathBuf::try_from(cwd.into_string())
.expect("projected command cwd should be absolute on the app-server host")
let host_cwd = AbsolutePathBuf::try_from(host_cwd.into_string())
.expect("projected command cwd should be absolute on the app-server host");
(api_cwd, host_cwd)
}
#[expect(
clippy::expect_used,
reason = "guardian command paths are native to the app-server host"
)]
fn api_path_from_host(cwd: &AbsolutePathBuf) -> ApiPathString {
ApiPathString::from_abs_path(cwd, PathConvention::native())
.expect("guardian command cwd should render using the host path convention")
}
pub fn build_file_change_approval_request_item(
@@ -81,11 +96,11 @@ pub fn build_file_change_end_item(payload: &PatchApplyEndEvent) -> ThreadItem {
pub fn build_command_execution_approval_request_item(
payload: &ExecApprovalRequestEvent,
) -> ThreadItem {
let cwd = project_command_cwd_to_host(&payload.cwd);
let (cwd, host_cwd) = command_cwd_for_api_and_actions(&payload.cwd);
ThreadItem::CommandExecution {
id: payload.call_id.clone(),
command: shlex_join(&payload.command),
cwd: cwd.clone(),
cwd,
process_id: None,
source: CommandExecutionSource::Agent,
status: CommandExecutionStatus::InProgress,
@@ -93,7 +108,7 @@ pub fn build_command_execution_approval_request_item(
.parsed_cmd
.iter()
.cloned()
.map(|parsed| CommandAction::from_core_with_cwd(parsed, &cwd))
.map(|parsed| CommandAction::from_core_with_cwd(parsed, &host_cwd))
.collect(),
aggregated_output: None,
exit_code: None,
@@ -102,11 +117,11 @@ pub fn build_command_execution_approval_request_item(
}
pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> ThreadItem {
let cwd = project_command_cwd_to_host(&payload.cwd);
let (cwd, host_cwd) = command_cwd_for_api_and_actions(&payload.cwd);
ThreadItem::CommandExecution {
id: payload.call_id.clone(),
command: shlex_join(&payload.command),
cwd: cwd.clone(),
cwd,
process_id: payload.process_id.clone(),
source: payload.source.into(),
status: CommandExecutionStatus::InProgress,
@@ -114,7 +129,7 @@ pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> Th
.parsed_cmd
.iter()
.cloned()
.map(|parsed| CommandAction::from_core_with_cwd(parsed, &cwd))
.map(|parsed| CommandAction::from_core_with_cwd(parsed, &host_cwd))
.collect(),
aggregated_output: None,
exit_code: None,
@@ -129,12 +144,12 @@ pub fn build_command_execution_end_item(payload: &ExecCommandEndEvent) -> Thread
Some(payload.aggregated_output.clone())
};
let duration_ms = i64::try_from(payload.duration.as_millis()).unwrap_or(i64::MAX);
let cwd = project_command_cwd_to_host(&payload.cwd);
let (cwd, host_cwd) = command_cwd_for_api_and_actions(&payload.cwd);
ThreadItem::CommandExecution {
id: payload.call_id.clone(),
command: shlex_join(&payload.command),
cwd: cwd.clone(),
cwd,
process_id: payload.process_id.clone(),
source: payload.source.into(),
status: (&payload.status).into(),
@@ -142,7 +157,7 @@ pub fn build_command_execution_end_item(payload: &ExecCommandEndEvent) -> Thread
.parsed_cmd
.iter()
.cloned()
.map(|parsed| CommandAction::from_core_with_cwd(parsed, &cwd))
.map(|parsed| CommandAction::from_core_with_cwd(parsed, &host_cwd))
.collect(),
aggregated_output,
exit_code: Some(payload.exit_code),
@@ -168,7 +183,7 @@ pub fn build_item_from_guardian_event(
Some(ThreadItem::CommandExecution {
id: id.clone(),
command,
cwd: cwd.clone(),
cwd: api_path_from_host(cwd),
process_id: None,
source: CommandExecutionSource::Agent,
status,
@@ -204,7 +219,7 @@ pub fn build_item_from_guardian_event(
Some(ThreadItem::CommandExecution {
id: id.clone(),
command,
cwd: cwd.clone(),
cwd: api_path_from_host(cwd),
process_id: None,
source: CommandExecutionSource::Agent,
status,
@@ -1278,12 +1278,19 @@ mod tests {
use codex_protocol::protocol::WebSearchEndEvent;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use codex_utils_path_uri::ApiPathString;
use codex_utils_path_uri::PathConvention;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
use std::time::Duration;
use uuid::Uuid;
fn test_api_path(path: &str) -> ApiPathString {
ApiPathString::from_abs_path(&test_path_buf(path).abs(), PathConvention::native())
.expect("API path")
}
#[test]
fn builds_multiple_turns_with_reasoning_items() {
let events = vec![
@@ -2124,7 +2131,7 @@ mod tests {
ThreadItem::CommandExecution {
id: "exec-1".into(),
command: "echo 'hello world'".into(),
cwd: test_path_buf("/tmp").abs(),
cwd: test_api_path("/tmp"),
process_id: Some("pid-1".into()),
source: CommandExecutionSource::Agent,
status: CommandExecutionStatus::Completed,
@@ -2355,7 +2362,7 @@ mod tests {
ThreadItem::CommandExecution {
id: "exec-declined".into(),
command: "ls".into(),
cwd: test_path_buf("/tmp").abs(),
cwd: test_api_path("/tmp"),
process_id: Some("pid-2".into()),
source: CommandExecutionSource::Agent,
status: CommandExecutionStatus::Declined,
@@ -2453,7 +2460,7 @@ mod tests {
ThreadItem::CommandExecution {
id: "guardian-exec".into(),
command: "rm -rf /tmp/guardian".into(),
cwd: test_path_buf("/tmp").abs(),
cwd: test_api_path("/tmp"),
process_id: None,
source: CommandExecutionSource::Agent,
status: CommandExecutionStatus::Declined,
@@ -2519,7 +2526,7 @@ mod tests {
ThreadItem::CommandExecution {
id: "guardian-execve".into(),
command: "/bin/rm -f /tmp/file.sqlite".into(),
cwd: test_path_buf("/tmp").abs(),
cwd: test_api_path("/tmp"),
process_id: None,
source: CommandExecutionSource::Agent,
status: CommandExecutionStatus::InProgress,
@@ -2617,7 +2624,7 @@ mod tests {
ThreadItem::CommandExecution {
id: "exec-late".into(),
command: "echo done".into(),
cwd: test_path_buf("/tmp").abs(),
cwd: test_api_path("/tmp"),
process_id: Some("pid-42".into()),
source: CommandExecutionSource::Agent,
status: CommandExecutionStatus::Completed,
@@ -257,7 +257,7 @@ pub enum ThreadItem {
/// The command to be executed.
command: String,
/// The command's working directory.
cwd: AbsolutePathBuf,
cwd: ApiPathString,
/// Identifier for the underlying PTY process (when available).
process_id: Option<String>,
#[serde(default)]