fix: preserve workdir operation error classifications

This commit is contained in:
2026-08-31 23:14:23 +09:00
parent 23f671fa48
commit 8ffb716817
4 changed files with 201 additions and 21 deletions
+1
View File
@@ -42,6 +42,7 @@ impl From<ToolsError> for ToolError {
workdir::WorkdirError::NotFound(_) workdir::WorkdirError::NotFound(_)
| workdir::WorkdirError::Io { .. } | workdir::WorkdirError::Io { .. }
| workdir::WorkdirError::Unavailable(_) | workdir::WorkdirError::Unavailable(_)
| workdir::WorkdirError::OperationFailed
| workdir::WorkdirError::Transport(_), | workdir::WorkdirError::Transport(_),
) => ToolError::ExecutionFailed(err.to_string()), ) => ToolError::ExecutionFailed(err.to_string()),
ToolsError::FileSystem(_) ToolsError::FileSystem(_)
+149 -17
View File
@@ -102,8 +102,18 @@ pub enum WorkdirTransportErrorCode {
Conflict, Conflict,
Unsupported, Unsupported,
InvalidRequest, InvalidRequest,
Denied,
OutOfScope,
SymlinkOutOfScope,
BrokenSymlink,
SymlinkTargetIsDirectory,
ReadOnly,
IsDirectory,
SymlinkDirectoryNotTraversed,
UnknownCommand, UnknownCommand,
Unavailable, Unavailable,
Io,
Transport,
Internal, Internal,
} }
@@ -114,8 +124,18 @@ impl WorkdirTransportErrorCode {
Self::Conflict => "conflict", Self::Conflict => "conflict",
Self::Unsupported => "unsupported", Self::Unsupported => "unsupported",
Self::InvalidRequest => "invalid_request", Self::InvalidRequest => "invalid_request",
Self::Denied => "denied",
Self::OutOfScope => "out_of_scope",
Self::SymlinkOutOfScope => "symlink_out_of_scope",
Self::BrokenSymlink => "broken_symlink",
Self::SymlinkTargetIsDirectory => "symlink_target_is_directory",
Self::ReadOnly => "read_only",
Self::IsDirectory => "is_directory",
Self::SymlinkDirectoryNotTraversed => "symlink_directory_not_traversed",
Self::UnknownCommand => "unknown_command", Self::UnknownCommand => "unknown_command",
Self::Unavailable => "unavailable", Self::Unavailable => "unavailable",
Self::Io => "io",
Self::Transport => "transport",
Self::Internal => "internal", Self::Internal => "internal",
} }
} }
@@ -125,9 +145,16 @@ impl WorkdirTransportErrorCode {
match self { match self {
Self::NotFound | Self::UnknownCommand => 404, Self::NotFound | Self::UnknownCommand => 404,
Self::Conflict => 409, Self::Conflict => 409,
Self::Unsupported | Self::InvalidRequest => 400, Self::Denied | Self::OutOfScope | Self::SymlinkOutOfScope | Self::ReadOnly => 403,
Self::Unsupported
| Self::InvalidRequest
| Self::BrokenSymlink
| Self::SymlinkTargetIsDirectory
| Self::IsDirectory
| Self::SymlinkDirectoryNotTraversed => 400,
Self::Unavailable => 503, Self::Unavailable => 503,
Self::Internal => 500, Self::Io | Self::Internal => 500,
Self::Transport => 502,
} }
} }
} }
@@ -160,8 +187,41 @@ impl WorkdirTransportError {
WorkdirError::Unavailable(_) | WorkdirError::SessionClosed => { WorkdirError::Unavailable(_) | WorkdirError::SessionClosed => {
(Code::Unavailable, "Workdir session is unavailable") (Code::Unavailable, "Workdir session is unavailable")
} }
WorkdirError::Denied(_) => (Code::InvalidRequest, "Workdir operation was denied"), WorkdirError::Denied(_) => (Code::Denied, "Workdir operation was denied"),
WorkdirError::Transport(_) => (Code::Internal, "Workdir transport failed"), WorkdirError::OutOfScope(_) => (Code::OutOfScope, "Workdir path is out of scope"),
WorkdirError::SymlinkOutOfScope { .. } => (
Code::SymlinkOutOfScope,
"Workdir symlink target is out of scope",
),
WorkdirError::BrokenSymlink { .. } => {
(Code::BrokenSymlink, "Workdir symlink target does not exist")
}
WorkdirError::SymlinkTargetIsDirectory { .. } => (
Code::SymlinkTargetIsDirectory,
"Workdir symlink target is a directory",
),
WorkdirError::ReadOnly(_) => (Code::ReadOnly, "Workdir path is read-only"),
WorkdirError::IsDirectory(_) => (Code::IsDirectory, "Workdir path is a directory"),
WorkdirError::SymlinkDirectoryNotTraversed { .. } => (
Code::SymlinkDirectoryNotTraversed,
"Workdir symlink directory was not traversed",
),
WorkdirError::Io { source, .. } => match source.kind() {
std::io::ErrorKind::NotFound => (Code::NotFound, "Workdir path was not found"),
std::io::ErrorKind::PermissionDenied => {
(Code::Denied, "Workdir operation was denied")
}
std::io::ErrorKind::AlreadyExists => {
(Code::Conflict, "Workdir resource already exists")
}
std::io::ErrorKind::InvalidInput | std::io::ErrorKind::InvalidData => {
(Code::InvalidRequest, "Workdir operation request is invalid")
}
std::io::ErrorKind::TimedOut => (Code::Unavailable, "Workdir operation timed out"),
_ => (Code::Io, "Workdir I/O operation failed"),
},
WorkdirError::OperationFailed => (Code::Internal, "Workdir operation failed"),
WorkdirError::Transport(_) => (Code::Transport, "Workdir transport failed"),
WorkdirError::InvalidPath(_) WorkdirError::InvalidPath(_)
| WorkdirError::RelativePath(_) | WorkdirError::RelativePath(_)
| WorkdirError::InvalidGlob(_) | WorkdirError::InvalidGlob(_)
@@ -169,14 +229,6 @@ impl WorkdirTransportError {
| WorkdirError::InvalidArgument(_) => { | WorkdirError::InvalidArgument(_) => {
(Code::InvalidRequest, "Workdir operation request is invalid") (Code::InvalidRequest, "Workdir operation request is invalid")
} }
WorkdirError::OutOfScope(_)
| WorkdirError::SymlinkOutOfScope { .. }
| WorkdirError::BrokenSymlink { .. }
| WorkdirError::SymlinkTargetIsDirectory { .. }
| WorkdirError::ReadOnly(_)
| WorkdirError::IsDirectory(_)
| WorkdirError::SymlinkDirectoryNotTraversed { .. }
| WorkdirError::Io { .. } => (Code::Internal, "Workdir operation failed"),
}; };
Self { Self {
code, code,
@@ -190,10 +242,38 @@ impl WorkdirTransportError {
Code::NotFound => WorkdirError::NotFound("<remote>".into()), Code::NotFound => WorkdirError::NotFound("<remote>".into()),
Code::Conflict => WorkdirError::Conflict(self.message), Code::Conflict => WorkdirError::Conflict(self.message),
Code::Unsupported => WorkdirError::UnsupportedOperation(self.message), Code::Unsupported => WorkdirError::UnsupportedOperation(self.message),
Code::UnknownCommand => WorkdirError::UnknownCommand("<remote>".to_string()),
Code::InvalidRequest => WorkdirError::InvalidArgument(self.message), Code::InvalidRequest => WorkdirError::InvalidArgument(self.message),
Code::Denied => WorkdirError::Denied(self.message),
Code::OutOfScope => WorkdirError::OutOfScope("<remote>".into()),
Code::SymlinkOutOfScope => WorkdirError::SymlinkOutOfScope {
path: "<remote>".into(),
target: "<remote-target>".into(),
required_permission: "requested",
},
Code::BrokenSymlink => WorkdirError::BrokenSymlink {
path: "<remote>".into(),
link: "<remote-link>".into(),
target: "<remote-target>".into(),
},
Code::SymlinkTargetIsDirectory => WorkdirError::SymlinkTargetIsDirectory {
path: "<remote>".into(),
target: "<remote-target>".into(),
},
Code::ReadOnly => WorkdirError::ReadOnly("<remote>".into()),
Code::IsDirectory => WorkdirError::IsDirectory("<remote>".into()),
Code::SymlinkDirectoryNotTraversed => WorkdirError::SymlinkDirectoryNotTraversed {
tool: "remote operation",
path: "<remote>".into(),
target: "<remote-target>".into(),
},
Code::UnknownCommand => WorkdirError::UnknownCommand("<remote>".to_string()),
Code::Unavailable => WorkdirError::Unavailable(self.message), Code::Unavailable => WorkdirError::Unavailable(self.message),
Code::Internal => WorkdirError::Transport(self.message), Code::Io => WorkdirError::Io {
path: "<remote>".into(),
source: std::io::Error::other(self.message),
},
Code::Transport => WorkdirError::Transport(self.message),
Code::Internal => WorkdirError::OperationFailed,
} }
} }
} }
@@ -584,8 +664,42 @@ mod tests {
"modified externally", "modified externally",
), ),
(WorkdirTransportErrorCode::Unsupported, 400, "unsupported"), (WorkdirTransportErrorCode::Unsupported, 400, "unsupported"),
(WorkdirTransportErrorCode::Denied, 403, "denied"),
(
WorkdirTransportErrorCode::OutOfScope,
403,
"outside allowed scope",
),
(
WorkdirTransportErrorCode::SymlinkOutOfScope,
403,
"outside allowed requested scope",
),
(
WorkdirTransportErrorCode::BrokenSymlink,
400,
"broken symlink",
),
(
WorkdirTransportErrorCode::SymlinkTargetIsDirectory,
400,
"symlink to a directory",
),
(WorkdirTransportErrorCode::ReadOnly, 403, "read-only"),
(WorkdirTransportErrorCode::IsDirectory, 400, "expected file"),
(
WorkdirTransportErrorCode::SymlinkDirectoryNotTraversed,
400,
"does not follow symlink directories",
),
(WorkdirTransportErrorCode::Unavailable, 503, "unavailable"), (WorkdirTransportErrorCode::Unavailable, 503, "unavailable"),
(WorkdirTransportErrorCode::Internal, 500, "transport failed"), (WorkdirTransportErrorCode::Io, 500, "I/O error"),
(
WorkdirTransportErrorCode::Transport,
502,
"transport failed",
),
(WorkdirTransportErrorCode::Internal, 500, "operation failed"),
] { ] {
let transport = WorkdirTransportError { let transport = WorkdirTransportError {
code, code,
@@ -620,7 +734,8 @@ mod tests {
let transport = WorkdirTransportError::from_workdir_error(&WorkdirError::Transport( let transport = WorkdirTransportError::from_workdir_error(&WorkdirError::Transport(
"Workspace API request timed out".to_string(), "Workspace API request timed out".to_string(),
)); ));
assert_eq!(transport.code, WorkdirTransportErrorCode::Internal); assert_eq!(transport.code, WorkdirTransportErrorCode::Transport);
assert_eq!(transport.code.http_status(), 502);
assert_eq!(transport.message, "Workdir transport failed"); assert_eq!(transport.message, "Workdir transport failed");
assert!(matches!( assert!(matches!(
transport.into_workdir_error(), transport.into_workdir_error(),
@@ -635,8 +750,25 @@ mod tests {
source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "host detail"), source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "host detail"),
}; };
let transport = WorkdirTransportError::from_workdir_error(&error); let transport = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(transport.code, WorkdirTransportErrorCode::Internal); assert_eq!(transport.code, WorkdirTransportErrorCode::Denied);
assert!(!transport.message.contains("/secret")); assert!(!transport.message.contains("/secret"));
assert!(!transport.message.contains("host detail")); assert!(!transport.message.contains("host detail"));
assert!(matches!(
transport.into_workdir_error(),
WorkdirError::Denied(_)
));
let error = WorkdirError::Io {
path: "/secret/runtime/root/file".into(),
source: std::io::Error::other("host detail"),
};
let transport = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(transport.code, WorkdirTransportErrorCode::Io);
assert!(!transport.message.contains("/secret"));
assert!(!transport.message.contains("host detail"));
assert!(matches!(
transport.into_workdir_error(),
WorkdirError::Io { .. }
));
} }
} }
+3
View File
@@ -234,6 +234,9 @@ pub enum WorkdirError {
#[error("Workdir session is unavailable: {0}")] #[error("Workdir session is unavailable: {0}")]
Unavailable(String), Unavailable(String),
#[error("Workdir operation failed")]
OperationFailed,
#[error("Workdir transport failed: {0}")] #[error("Workdir transport failed: {0}")]
Transport(String), Transport(String),
+48 -4
View File
@@ -15931,10 +15931,50 @@ mod tests {
workdir::http::WorkdirTransportErrorCode::Unsupported, workdir::http::WorkdirTransportErrorCode::Unsupported,
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
), ),
(
workdir::http::WorkdirTransportErrorCode::Denied,
StatusCode::FORBIDDEN,
),
(
workdir::http::WorkdirTransportErrorCode::OutOfScope,
StatusCode::FORBIDDEN,
),
(
workdir::http::WorkdirTransportErrorCode::SymlinkOutOfScope,
StatusCode::FORBIDDEN,
),
(
workdir::http::WorkdirTransportErrorCode::BrokenSymlink,
StatusCode::BAD_REQUEST,
),
(
workdir::http::WorkdirTransportErrorCode::SymlinkTargetIsDirectory,
StatusCode::BAD_REQUEST,
),
(
workdir::http::WorkdirTransportErrorCode::ReadOnly,
StatusCode::FORBIDDEN,
),
(
workdir::http::WorkdirTransportErrorCode::IsDirectory,
StatusCode::BAD_REQUEST,
),
(
workdir::http::WorkdirTransportErrorCode::SymlinkDirectoryNotTraversed,
StatusCode::BAD_REQUEST,
),
( (
workdir::http::WorkdirTransportErrorCode::Unavailable, workdir::http::WorkdirTransportErrorCode::Unavailable,
StatusCode::SERVICE_UNAVAILABLE, StatusCode::SERVICE_UNAVAILABLE,
), ),
(
workdir::http::WorkdirTransportErrorCode::Io,
StatusCode::INTERNAL_SERVER_ERROR,
),
(
workdir::http::WorkdirTransportErrorCode::Transport,
StatusCode::BAD_GATEWAY,
),
( (
workdir::http::WorkdirTransportErrorCode::Internal, workdir::http::WorkdirTransportErrorCode::Internal,
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
@@ -15991,7 +16031,7 @@ mod tests {
async fn workdir_operation_error_response_redacts_provider_internal_details() { async fn workdir_operation_error_response_redacts_provider_internal_details() {
let error = workdir::WorkdirError::Io { let error = workdir::WorkdirError::Io {
path: PathBuf::from("/host/private/worktree/secret.txt"), path: PathBuf::from("/host/private/worktree/secret.txt"),
source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "token=secret"), source: std::io::Error::other("token=secret"),
}; };
let public = current_worker_workdir_operation_error( let public = current_worker_workdir_operation_error(
&RuntimeWorkerRef::new("runtime", "worker"), &RuntimeWorkerRef::new("runtime", "worker"),
@@ -16003,10 +16043,14 @@ mod tests {
assert!(!text.contains("/host/private")); assert!(!text.contains("/host/private"));
assert!(!text.contains("token=secret")); assert!(!text.contains("token=secret"));
let decoded: WorkdirTransportError = serde_json::from_str(&text).unwrap(); let decoded: WorkdirTransportError = serde_json::from_str(&text).unwrap();
assert_eq!( assert_eq!(decoded.code, workdir::http::WorkdirTransportErrorCode::Io);
decoded.code,
workdir::http::WorkdirTransportErrorCode::Internal let remote = current_worker_workdir_operation_error(
&RuntimeWorkerRef::new("runtime", "worker"),
workdir::WorkdirError::OperationFailed,
); );
let response = WorkdirOperationApiError::Provider(remote).into_response();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
} }
#[test] #[test]