fix: retain structured Workdir refusal diagnostics across provider boundaries
This commit is contained in:
@@ -66,10 +66,7 @@ impl<'a> CheckedTarget<'a> {
|
|||||||
} else {
|
} else {
|
||||||
policy.is_readable_paths(logical, logical)
|
policy.is_readable_paths(logical, logical)
|
||||||
}) {
|
}) {
|
||||||
return Err(std::io::Error::new(
|
return Err(crate::FsDenialReason::CheckoutTargetDenied.into_io_error());
|
||||||
std::io::ErrorKind::PermissionDenied,
|
|
||||||
"checkout target denied",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
let parent = if relative.as_os_str().is_empty() {
|
let parent = if relative.as_os_str().is_empty() {
|
||||||
None
|
None
|
||||||
@@ -294,10 +291,7 @@ impl FsAccessPolicy for CheckedCreate<'_, '_> {
|
|||||||
if components.peek().is_some()
|
if components.peek().is_some()
|
||||||
&& !self.target.policy.is_writable_paths(&parent, &parent)
|
&& !self.target.policy.is_writable_paths(&parent, &parent)
|
||||||
{
|
{
|
||||||
return Err(std::io::Error::new(
|
return Err(crate::FsDenialReason::CheckoutCreateParentDenied.into_io_error());
|
||||||
std::io::ErrorKind::PermissionDenied,
|
|
||||||
"Create parent denied",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Nested mkdir may have effects before failure; never auto-retry.
|
// Nested mkdir may have effects before failure; never auto-retry.
|
||||||
|
|||||||
@@ -37,6 +37,26 @@ pub const MAX_RESULT_PATH_BYTES: usize = 1024 * 1024;
|
|||||||
/// Provider-side ceiling for aggregate grep source bytes per operation.
|
/// Provider-side ceiling for aggregate grep source bytes per operation.
|
||||||
pub const MAX_GREP_SOURCE_BYTES: u64 = 64 * 1024 * 1024;
|
pub const MAX_GREP_SOURCE_BYTES: u64 = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Safe origin for a synthetic `PermissionDenied` from this crate.
|
||||||
|
/// Contains no paths or provider-authored text; real OS errors are not wrapped.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||||
|
pub enum FsDenialReason {
|
||||||
|
#[error("path is outside provider root")]
|
||||||
|
ProviderRootExceeded,
|
||||||
|
#[error("symbolic links are not permitted by this provider")]
|
||||||
|
ProviderSymlinkDenied,
|
||||||
|
#[error("checkout target denied")]
|
||||||
|
CheckoutTargetDenied,
|
||||||
|
#[error("Create parent denied")]
|
||||||
|
CheckoutCreateParentDenied,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FsDenialReason {
|
||||||
|
pub fn into_io_error(self) -> std::io::Error {
|
||||||
|
std::io::Error::new(std::io::ErrorKind::PermissionDenied, self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Open `path` beneath `root` without following any symbolic link. Linux uses
|
/// Open `path` beneath `root` without following any symbolic link. Linux uses
|
||||||
/// `openat2` so resolution and open are one kernel-enforced operation. Other
|
/// `openat2` so resolution and open are one kernel-enforced operation. Other
|
||||||
/// platforms fail closed rather than silently weakening an External grant.
|
/// platforms fail closed rather than silently weakening an External grant.
|
||||||
@@ -91,10 +111,7 @@ pub fn open_beneath_no_symlinks_at(
|
|||||||
const RESOLVE_BENEATH: u64 = 0x08;
|
const RESOLVE_BENEATH: u64 = 0x08;
|
||||||
|
|
||||||
if relative.is_absolute() {
|
if relative.is_absolute() {
|
||||||
return Err(std::io::Error::new(
|
return Err(crate::FsDenialReason::ProviderRootExceeded.into_io_error());
|
||||||
std::io::ErrorKind::PermissionDenied,
|
|
||||||
"path is outside provider root",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
let relative = if relative.as_os_str().is_empty() {
|
let relative = if relative.as_os_str().is_empty() {
|
||||||
CString::new(".").expect("static path")
|
CString::new(".").expect("static path")
|
||||||
@@ -122,10 +139,7 @@ pub fn open_beneath_no_symlinks_at(
|
|||||||
if fd < 0 {
|
if fd < 0 {
|
||||||
let error = std::io::Error::last_os_error();
|
let error = std::io::Error::last_os_error();
|
||||||
if error.raw_os_error() == Some(libc::ELOOP) {
|
if error.raw_os_error() == Some(libc::ELOOP) {
|
||||||
return Err(std::io::Error::new(
|
return Err(crate::FsDenialReason::ProviderSymlinkDenied.into_io_error());
|
||||||
std::io::ErrorKind::PermissionDenied,
|
|
||||||
"symbolic links are not permitted by this provider",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
@@ -146,12 +160,9 @@ pub fn open_beneath_no_symlinks_at(
|
|||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub fn open_beneath_no_symlinks(root: &Path, path: &Path) -> std::io::Result<std::fs::File> {
|
pub fn open_beneath_no_symlinks(root: &Path, path: &Path) -> std::io::Result<std::fs::File> {
|
||||||
let relative = path.strip_prefix(root).map_err(|_| {
|
let relative = path
|
||||||
std::io::Error::new(
|
.strip_prefix(root)
|
||||||
std::io::ErrorKind::PermissionDenied,
|
.map_err(|_| crate::FsDenialReason::ProviderRootExceeded.into_io_error())?;
|
||||||
"path is outside provider root",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let root = open_root_no_symlinks(root)?;
|
let root = open_root_no_symlinks(root)?;
|
||||||
open_beneath_no_symlinks_at(&root, relative)
|
open_beneath_no_symlinks_at(&root, relative)
|
||||||
}
|
}
|
||||||
@@ -184,20 +195,14 @@ pub fn atomic_write_beneath_no_symlinks_at(
|
|||||||
use std::os::unix::ffi::OsStrExt;
|
use std::os::unix::ffi::OsStrExt;
|
||||||
|
|
||||||
if relative.is_absolute() {
|
if relative.is_absolute() {
|
||||||
return Err(std::io::Error::new(
|
return Err(crate::FsDenialReason::ProviderRootExceeded.into_io_error());
|
||||||
std::io::ErrorKind::PermissionDenied,
|
|
||||||
"path is outside provider root",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
let mut components = relative.components().peekable();
|
let mut components = relative.components().peekable();
|
||||||
let mut parent = root.try_clone()?;
|
let mut parent = root.try_clone()?;
|
||||||
let mut file_name = None::<OsString>;
|
let mut file_name = None::<OsString>;
|
||||||
while let Some(component) = components.next() {
|
while let Some(component) = components.next() {
|
||||||
let std::path::Component::Normal(name) = component else {
|
let std::path::Component::Normal(name) = component else {
|
||||||
return Err(std::io::Error::new(
|
return Err(crate::FsDenialReason::ProviderRootExceeded.into_io_error());
|
||||||
std::io::ErrorKind::PermissionDenied,
|
|
||||||
"path is outside provider root",
|
|
||||||
));
|
|
||||||
};
|
};
|
||||||
if components.peek().is_none() {
|
if components.peek().is_none() {
|
||||||
file_name = Some(name.to_os_string());
|
file_name = Some(name.to_os_string());
|
||||||
|
|||||||
@@ -98,6 +98,24 @@ pub struct WorkdirOperationApiError {
|
|||||||
pub diagnostics: Option<Vec<Diagnostic>>,
|
pub diagnostics: Option<Vec<Diagnostic>>,
|
||||||
#[serde(skip, default = "default_repository_error_status")]
|
#[serde(skip, default = "default_repository_error_status")]
|
||||||
status: u16,
|
status: u16,
|
||||||
|
/// Server-local metadata retained by generated error response extensions, never the wire body.
|
||||||
|
#[serde(skip)]
|
||||||
|
pub workdir_log: Option<WorkdirOperationLogContext>,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub denial_reason: Option<workdir::WorkdirDenialReason>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal operational correlation. Producers supply fixed-length ID fingerprints and
|
||||||
|
/// an allowlisted operation label, not request text, paths, commands or credentials.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct WorkdirOperationLogContext {
|
||||||
|
pub operation: &'static str,
|
||||||
|
pub stage: &'static str,
|
||||||
|
pub workspace_id_hash: String,
|
||||||
|
pub runtime_id_hash: Option<String>,
|
||||||
|
pub worker_id_hash: Option<String>,
|
||||||
|
pub workdir_id_hash: Option<String>,
|
||||||
|
pub attachment_alias_hash: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkdirOperationApiError {
|
impl WorkdirOperationApiError {
|
||||||
@@ -108,6 +126,8 @@ impl WorkdirOperationApiError {
|
|||||||
message: error.message,
|
message: error.message,
|
||||||
diagnostics: Some(error.diagnostics),
|
diagnostics: Some(error.diagnostics),
|
||||||
status: error.status,
|
status: error.status,
|
||||||
|
workdir_log: None,
|
||||||
|
denial_reason: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +139,8 @@ impl WorkdirOperationApiError {
|
|||||||
message: error.message,
|
message: error.message,
|
||||||
diagnostics: None,
|
diagnostics: None,
|
||||||
status,
|
status,
|
||||||
|
workdir_log: None,
|
||||||
|
denial_reason: error.denial_reason,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13984,3 +14006,34 @@ mod typescript_tests {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod workdir_error_diagnostic_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workdir_internal_diagnostics_never_enter_public_error_body_or_schema() {
|
||||||
|
let mut error = WorkdirOperationApiError::provider(workdir::http::WorkdirTransportError {
|
||||||
|
code: workdir::http::WorkdirTransportErrorCode::Denied,
|
||||||
|
message: "Workdir operation was denied".to_string(),
|
||||||
|
denial_reason: Some(workdir::WorkdirDenialReason::OsPermissionDenied),
|
||||||
|
});
|
||||||
|
error.workdir_log = Some(WorkdirOperationLogContext {
|
||||||
|
operation: "write",
|
||||||
|
workspace_id_hash: "internal-workspace-fingerprint".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let wire = serde_json::to_value(&error).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
wire,
|
||||||
|
serde_json::json!({"code": "denied", "message": "Workdir operation was denied"})
|
||||||
|
);
|
||||||
|
assert_eq!(api_macros::HttpError::status_code(&error), 403);
|
||||||
|
let decoded: WorkdirOperationApiError = serde_json::from_value(wire).unwrap();
|
||||||
|
assert!(decoded.denial_reason.is_none());
|
||||||
|
assert!(decoded.workdir_log.is_none());
|
||||||
|
let schema = serde_json::to_value(schemars::schema_for!(WorkdirOperationApiError)).unwrap();
|
||||||
|
assert!(schema["properties"].get("workdir_log").is_none());
|
||||||
|
assert!(schema["properties"].get("denial_reason").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -421,6 +421,13 @@ fn checkout_known_refusals_have_typed_codes_without_changing_normal_tools() {
|
|||||||
use workdir::{WorkdirError, WorkdirSessionCapability};
|
use workdir::{WorkdirError, WorkdirSessionCapability};
|
||||||
let cases = [
|
let cases = [
|
||||||
(WorkdirError::Denied("denied".into()), "checkout_denied"),
|
(WorkdirError::Denied("denied".into()), "checkout_denied"),
|
||||||
|
(
|
||||||
|
WorkdirError::DenialContext {
|
||||||
|
reason: workdir::WorkdirDenialReason::OsPermissionDenied,
|
||||||
|
source: Box::new(WorkdirError::OutOfScope("outside".into())),
|
||||||
|
},
|
||||||
|
"checkout_denied",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
WorkdirError::OutOfScope("outside".into()),
|
WorkdirError::OutOfScope("outside".into()),
|
||||||
"checkout_denied",
|
"checkout_denied",
|
||||||
|
|||||||
@@ -139,7 +139,12 @@ impl FileTarget {
|
|||||||
|
|
||||||
pub(crate) fn checked_error(error: workdir::WorkdirError) -> ToolError {
|
pub(crate) fn checked_error(error: workdir::WorkdirError) -> ToolError {
|
||||||
use workdir::WorkdirError;
|
use workdir::WorkdirError;
|
||||||
|
if let WorkdirError::DenialContext { source, .. } = error {
|
||||||
|
// Diagnostic enrichment must not change the established tool classification.
|
||||||
|
return checked_error(*source);
|
||||||
|
}
|
||||||
let code = match &error {
|
let code = match &error {
|
||||||
|
WorkdirError::DenialContext { .. } => unreachable!("unwrapped above"),
|
||||||
WorkdirError::Conflict(_) | WorkdirError::NotFound(_) => "checkout_stale",
|
WorkdirError::Conflict(_) | WorkdirError::NotFound(_) => "checkout_stale",
|
||||||
WorkdirError::Denied { .. }
|
WorkdirError::Denied { .. }
|
||||||
| WorkdirError::OutOfScope { .. }
|
| WorkdirError::OutOfScope { .. }
|
||||||
|
|||||||
@@ -148,8 +148,9 @@ impl CheckoutSearchRequest {
|
|||||||
if !std::path::Path::new(self.operation.path().as_str())
|
if !std::path::Path::new(self.operation.path().as_str())
|
||||||
.starts_with(self.output_root.as_str())
|
.starts_with(self.output_root.as_str())
|
||||||
{
|
{
|
||||||
return Err(WorkdirError::Denied(
|
return Err(WorkdirError::denied(
|
||||||
"checkout search must stay beneath its output root".into(),
|
crate::WorkdirDenialReason::CheckoutOutputRootExceeded,
|
||||||
|
"checkout search must stay beneath its output root",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if self.scope_layers.len() > 16
|
if self.scope_layers.len() > 16
|
||||||
|
|||||||
@@ -751,7 +751,15 @@ async fn checkout_search_native_skips_symlinked_ignore_sources_without_outside_e
|
|||||||
)))
|
)))
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(error, WorkdirError::OutOfScope(_)));
|
let classification = match &error {
|
||||||
|
WorkdirError::DenialContext { source, .. } => source.as_ref(),
|
||||||
|
error => error,
|
||||||
|
};
|
||||||
|
assert!(matches!(classification, WorkdirError::OutOfScope(_)));
|
||||||
|
assert_ne!(
|
||||||
|
error.denial_reason(),
|
||||||
|
Some(crate::WorkdirDenialReason::OsPermissionDenied)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for (relative, hidden) in [
|
for (relative, hidden) in [
|
||||||
(".ignore", "root-ignore-visible.txt"),
|
(".ignore", "root-ignore-visible.txt"),
|
||||||
|
|||||||
@@ -497,18 +497,24 @@ impl<'de> Deserialize<'de> for ExternalWorkdirOperationResult {
|
|||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct ExternalWorkdirOperationError {
|
pub struct ExternalWorkdirOperationError {
|
||||||
code: crate::http::WorkdirTransportErrorCode,
|
code: crate::http::WorkdirTransportErrorCode,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
denial_reason: Option<crate::WorkdirDenialReason>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExternalWorkdirOperationError {
|
impl ExternalWorkdirOperationError {
|
||||||
/// Collapse provider failures to a closed error code. Provider-authored
|
/// Keep only closed error codes and typed refusal reasons. Provider-authored
|
||||||
/// diagnostics never cross the External Workdir trust boundary.
|
/// text never crosses the External Workdir trust boundary.
|
||||||
pub fn from_transport_error(error: WorkdirTransportError) -> Self {
|
pub fn from_transport_error(error: WorkdirTransportError) -> Self {
|
||||||
Self { code: error.code }
|
Self {
|
||||||
|
code: error.code,
|
||||||
|
denial_reason: error.denial_reason,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn into_transport_error(self) -> WorkdirTransportError {
|
pub fn into_transport_error(self) -> WorkdirTransportError {
|
||||||
WorkdirTransportError {
|
WorkdirTransportError {
|
||||||
code: self.code,
|
code: self.code,
|
||||||
|
denial_reason: self.denial_reason,
|
||||||
message: format!("External Workdir provider reported {}", self.code.as_str()),
|
message: format!("External Workdir provider reported {}", self.code.as_str()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -719,10 +725,45 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn external_error_wire_forwards_allowlisted_reasons_without_provider_text() {
|
||||||
|
use crate::WorkdirDenialReason as Reason;
|
||||||
|
use crate::http::WorkdirTransportErrorCode as Code;
|
||||||
|
for reason in [
|
||||||
|
Some(Reason::ExternalScopeSymlink),
|
||||||
|
Some(Reason::OsPermissionDenied),
|
||||||
|
None,
|
||||||
|
] {
|
||||||
|
let external =
|
||||||
|
ExternalWorkdirOperationError::from_transport_error(WorkdirTransportError {
|
||||||
|
code: Code::Denied,
|
||||||
|
message: "/home/operator/private provider-secret".repeat(4096),
|
||||||
|
denial_reason: reason,
|
||||||
|
});
|
||||||
|
let wire = serde_json::to_string(&external).unwrap();
|
||||||
|
assert!(wire.len() < 128);
|
||||||
|
assert_no_provider_authority(&serde_json::from_str(&wire).unwrap());
|
||||||
|
let decoded: ExternalWorkdirOperationError = serde_json::from_str(&wire).unwrap();
|
||||||
|
let error = decoded.into_transport_error().into_workdir_error();
|
||||||
|
let forwarded = WorkdirTransportError::from_workdir_error(&error);
|
||||||
|
assert_eq!(forwarded.code, Code::Denied);
|
||||||
|
assert_eq!(forwarded.denial_reason, reason);
|
||||||
|
assert_eq!(forwarded.message, "Workdir operation was denied");
|
||||||
|
}
|
||||||
|
let old: ExternalWorkdirOperationError =
|
||||||
|
serde_json::from_str(r#"{"code":"denied"}"#).unwrap();
|
||||||
|
assert_eq!(old.into_transport_error().denial_reason, None);
|
||||||
|
for reason in ["/home/operator/private", &"provider-secret".repeat(4096)] {
|
||||||
|
let input = serde_json::json!({"code":"denied", "denial_reason":reason});
|
||||||
|
assert!(serde_json::from_value::<ExternalWorkdirOperationError>(input).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn provider_failures_are_closed_and_drop_provider_authored_diagnostics() {
|
fn provider_failures_are_closed_and_drop_provider_authored_diagnostics() {
|
||||||
let error = ExternalWorkdirOperationError::from_transport_error(WorkdirTransportError {
|
let error = ExternalWorkdirOperationError::from_transport_error(WorkdirTransportError {
|
||||||
code: crate::http::WorkdirTransportErrorCode::Internal,
|
code: crate::http::WorkdirTransportErrorCode::Internal,
|
||||||
|
denial_reason: None,
|
||||||
message: "/home/operator/private provider-secret\ncontrol".to_string(),
|
message: "/home/operator/private provider-secret\ncontrol".to_string(),
|
||||||
});
|
});
|
||||||
let frame = ExternalWorkdirProviderFrame::current(
|
let frame = ExternalWorkdirProviderFrame::current(
|
||||||
|
|||||||
@@ -357,9 +357,17 @@ async fn external_local_provider_rejects_symlink_roots_and_traversal() {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
let classification = match &error {
|
||||||
|
WorkdirError::DenialContext { source, .. } => source.as_ref(),
|
||||||
|
error => error,
|
||||||
|
};
|
||||||
|
assert_ne!(
|
||||||
|
error.denial_reason(),
|
||||||
|
Some(crate::WorkdirDenialReason::OsPermissionDenied)
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
matches!(
|
matches!(
|
||||||
error,
|
classification,
|
||||||
WorkdirError::SymlinkOutOfScope { .. } | WorkdirError::OutOfScope(_)
|
WorkdirError::SymlinkOutOfScope { .. } | WorkdirError::OutOfScope(_)
|
||||||
),
|
),
|
||||||
"unexpected error for {path}: {error:?}"
|
"unexpected error for {path}: {error:?}"
|
||||||
@@ -375,6 +383,10 @@ async fn external_local_provider_rejects_symlink_roots_and_traversal() {
|
|||||||
BoundedReadLimits::new(4096, 1024).unwrap(),
|
BoundedReadLimits::new(4096, 1024).unwrap(),
|
||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
error.denial_reason(),
|
||||||
|
Some(crate::WorkdirDenialReason::ExternalRootSymlink)
|
||||||
|
);
|
||||||
assert!(matches!(error, WorkdirError::Denied(_)));
|
assert!(matches!(error, WorkdirError::Denied(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,9 +420,17 @@ async fn external_read_write_provider_rejects_symlink_mutation_escape() {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
let classification = match &error {
|
||||||
|
WorkdirError::DenialContext { source, .. } => source.as_ref(),
|
||||||
|
error => error,
|
||||||
|
};
|
||||||
|
assert_ne!(
|
||||||
|
error.denial_reason(),
|
||||||
|
Some(crate::WorkdirDenialReason::OsPermissionDenied)
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
matches!(
|
matches!(
|
||||||
error,
|
classification,
|
||||||
WorkdirError::SymlinkOutOfScope { .. }
|
WorkdirError::SymlinkOutOfScope { .. }
|
||||||
| WorkdirError::OutOfScope(_)
|
| WorkdirError::OutOfScope(_)
|
||||||
| WorkdirError::Denied(_)
|
| WorkdirError::Denied(_)
|
||||||
@@ -559,7 +579,15 @@ async fn external_grep_honors_gitignore_above_a_nested_search_root() {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(symlink_error, WorkdirError::OutOfScope(_)));
|
let classification = match &symlink_error {
|
||||||
|
WorkdirError::DenialContext { source, .. } => source.as_ref(),
|
||||||
|
error => error,
|
||||||
|
};
|
||||||
|
assert!(matches!(classification, WorkdirError::OutOfScope(_)));
|
||||||
|
assert_ne!(
|
||||||
|
symlink_error.denial_reason(),
|
||||||
|
Some(crate::WorkdirDenialReason::OsPermissionDenied)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
|||||||
@@ -269,6 +269,9 @@ impl WorkdirTransportErrorCode {
|
|||||||
pub struct WorkdirTransportError {
|
pub struct WorkdirTransportError {
|
||||||
pub code: WorkdirTransportErrorCode,
|
pub code: WorkdirTransportErrorCode,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
|
/// Internal provider diagnostics. Public response boundaries may omit this.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub denial_reason: Option<crate::WorkdirDenialReason>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkdirTransportError {
|
impl WorkdirTransportError {
|
||||||
@@ -276,6 +279,11 @@ impl WorkdirTransportError {
|
|||||||
pub fn from_workdir_error(error: &WorkdirError) -> Self {
|
pub fn from_workdir_error(error: &WorkdirError) -> Self {
|
||||||
use WorkdirTransportErrorCode as Code;
|
use WorkdirTransportErrorCode as Code;
|
||||||
let (code, message) = match error {
|
let (code, message) = match error {
|
||||||
|
WorkdirError::DenialContext { reason, source } => {
|
||||||
|
let mut transport = Self::from_workdir_error(source);
|
||||||
|
transport.denial_reason = Some(*reason);
|
||||||
|
return transport;
|
||||||
|
}
|
||||||
WorkdirError::OutcomeUnknown(_) => (
|
WorkdirError::OutcomeUnknown(_) => (
|
||||||
Code::OutcomeUnknown,
|
Code::OutcomeUnknown,
|
||||||
"Workdir operation may have effects; inspect before retrying",
|
"Workdir operation may have effects; inspect before retrying",
|
||||||
@@ -289,6 +297,7 @@ impl WorkdirTransportError {
|
|||||||
return Self {
|
return Self {
|
||||||
code: Code::Unsupported,
|
code: Code::Unsupported,
|
||||||
message: format!("Workdir capability {capability:?} is not available"),
|
message: format!("Workdir capability {capability:?} is not available"),
|
||||||
|
denial_reason: None,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
WorkdirError::UnsupportedOperation(_) => {
|
WorkdirError::UnsupportedOperation(_) => {
|
||||||
@@ -346,12 +355,13 @@ impl WorkdirTransportError {
|
|||||||
Self {
|
Self {
|
||||||
code,
|
code,
|
||||||
message: message.to_string(),
|
message: message.to_string(),
|
||||||
|
denial_reason: error.denial_reason(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn into_workdir_error(self) -> WorkdirError {
|
pub fn into_workdir_error(self) -> WorkdirError {
|
||||||
use WorkdirTransportErrorCode as Code;
|
use WorkdirTransportErrorCode as Code;
|
||||||
match self.code {
|
let error = match self.code {
|
||||||
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::OutcomeUnknown => WorkdirError::OutcomeUnknown(
|
Code::OutcomeUnknown => WorkdirError::OutcomeUnknown(
|
||||||
@@ -359,7 +369,11 @@ impl WorkdirTransportError {
|
|||||||
),
|
),
|
||||||
Code::Unsupported => WorkdirError::UnsupportedOperation(self.message),
|
Code::Unsupported => WorkdirError::UnsupportedOperation(self.message),
|
||||||
Code::InvalidRequest => WorkdirError::InvalidArgument(self.message),
|
Code::InvalidRequest => WorkdirError::InvalidArgument(self.message),
|
||||||
Code::Denied => WorkdirError::Denied(self.message),
|
Code::Denied => WorkdirError::Denied(crate::WorkdirDenial {
|
||||||
|
reason: self.denial_reason,
|
||||||
|
// Provider-authored text is not trusted diagnostic data.
|
||||||
|
message: "Workdir operation was denied".into(),
|
||||||
|
}),
|
||||||
Code::OutOfScope => WorkdirError::OutOfScope("<remote>".into()),
|
Code::OutOfScope => WorkdirError::OutOfScope("<remote>".into()),
|
||||||
Code::SymlinkOutOfScope => WorkdirError::SymlinkOutOfScope {
|
Code::SymlinkOutOfScope => WorkdirError::SymlinkOutOfScope {
|
||||||
path: "<remote>".into(),
|
path: "<remote>".into(),
|
||||||
@@ -390,7 +404,18 @@ impl WorkdirTransportError {
|
|||||||
},
|
},
|
||||||
Code::Transport => WorkdirError::Transport(self.message),
|
Code::Transport => WorkdirError::Transport(self.message),
|
||||||
Code::Internal => WorkdirError::OperationFailed,
|
Code::Internal => WorkdirError::OperationFailed,
|
||||||
|
};
|
||||||
|
// Preserve any supplied reason independently of the public classification.
|
||||||
|
// Without a reason, retain the existing classification's deliberate fallback.
|
||||||
|
if let Some(reason) = self.denial_reason {
|
||||||
|
if error.denial_reason() != Some(reason) {
|
||||||
|
return WorkdirError::DenialContext {
|
||||||
|
reason,
|
||||||
|
source: Box::new(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -949,6 +974,7 @@ mod tests {
|
|||||||
] {
|
] {
|
||||||
let transport = WorkdirTransportError {
|
let transport = WorkdirTransportError {
|
||||||
code,
|
code,
|
||||||
|
denial_reason: None,
|
||||||
message: if code == WorkdirTransportErrorCode::Conflict {
|
message: if code == WorkdirTransportErrorCode::Conflict {
|
||||||
"The target file's content or existence changed since it was last observed; read the file again before retrying"
|
"The target file's content or existence changed since it was last observed; read the file again before retrying"
|
||||||
.to_string()
|
.to_string()
|
||||||
|
|||||||
+143
-3
@@ -211,7 +211,8 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
|
|||||||
}) {
|
}) {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(WorkdirError::Denied(
|
Err(WorkdirError::denied(
|
||||||
|
WorkdirDenialReason::ScopeResolutionUnavailable,
|
||||||
"Workdir provider cannot establish resolved scope authority".to_string(),
|
"Workdir provider cannot establish resolved scope authority".to_string(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -221,7 +222,8 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
|
|||||||
&self,
|
&self,
|
||||||
_request: WorkdirScopeOverlapRequest,
|
_request: WorkdirScopeOverlapRequest,
|
||||||
) -> Result<bool, WorkdirError> {
|
) -> Result<bool, WorkdirError> {
|
||||||
Err(WorkdirError::Denied(
|
Err(WorkdirError::denied(
|
||||||
|
WorkdirDenialReason::ScopeComparisonUnavailable,
|
||||||
"Workdir provider cannot compare resolved scope authority".to_string(),
|
"Workdir provider cannot compare resolved scope authority".to_string(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -292,10 +294,125 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
|
|||||||
|
|
||||||
pub type WorkdirSessionHandle = Arc<dyn WorkdirSession>;
|
pub type WorkdirSessionHandle = Arc<dyn WorkdirSession>;
|
||||||
|
|
||||||
|
/// Closed diagnostic vocabulary: no request text, paths or secrets.
|
||||||
|
/// A reason describes a refusing branch, not an authorization grant.
|
||||||
|
#[derive(
|
||||||
|
Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
|
||||||
|
)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum WorkdirDenialReason {
|
||||||
|
ScopeResolutionUnavailable,
|
||||||
|
ScopeComparisonUnavailable,
|
||||||
|
ScopedCapabilityDenied,
|
||||||
|
LogicalScopeExceeded,
|
||||||
|
InvalidScopedPath,
|
||||||
|
ChildWriteLeaseConflict,
|
||||||
|
EmptyScope,
|
||||||
|
ParentCapabilityDenied,
|
||||||
|
CommandRequiresWritableScope,
|
||||||
|
ParentScopeExceeded,
|
||||||
|
CwdOutsideReadableScope,
|
||||||
|
ReadOnlySession,
|
||||||
|
ExternalRootSymlink,
|
||||||
|
ExternalRootChanged,
|
||||||
|
ExternalRootMoved,
|
||||||
|
ExternalScopeSymlink,
|
||||||
|
AttachmentScopeExceeded,
|
||||||
|
DelegatedScopeExceeded,
|
||||||
|
CheckoutOutputRootExceeded,
|
||||||
|
ProviderRootExceeded,
|
||||||
|
ProviderSymlinkDenied,
|
||||||
|
CheckoutTargetDenied,
|
||||||
|
CheckoutCreateParentDenied,
|
||||||
|
CheckoutSearchPathDenied,
|
||||||
|
CheckoutEnumerationDenied,
|
||||||
|
PathOutOfScope,
|
||||||
|
SymlinkTargetOutOfScope,
|
||||||
|
PathReadOnly,
|
||||||
|
OsPermissionDenied,
|
||||||
|
/// PermissionDenied without a typed provider origin or an OS error code.
|
||||||
|
/// In particular, do not mislabel lower-layer synthetic refusals as OS errors.
|
||||||
|
UnclassifiedPermissionDenied,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkdirDenialReason {
|
||||||
|
/// Stable allowlisted label for internal diagnostics, matching the wire value.
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::ScopeResolutionUnavailable => "scope_resolution_unavailable",
|
||||||
|
Self::ScopeComparisonUnavailable => "scope_comparison_unavailable",
|
||||||
|
Self::ScopedCapabilityDenied => "scoped_capability_denied",
|
||||||
|
Self::LogicalScopeExceeded => "logical_scope_exceeded",
|
||||||
|
Self::InvalidScopedPath => "invalid_scoped_path",
|
||||||
|
Self::ChildWriteLeaseConflict => "child_write_lease_conflict",
|
||||||
|
Self::EmptyScope => "empty_scope",
|
||||||
|
Self::ParentCapabilityDenied => "parent_capability_denied",
|
||||||
|
Self::CommandRequiresWritableScope => "command_requires_writable_scope",
|
||||||
|
Self::ParentScopeExceeded => "parent_scope_exceeded",
|
||||||
|
Self::CwdOutsideReadableScope => "cwd_outside_readable_scope",
|
||||||
|
Self::ReadOnlySession => "read_only_session",
|
||||||
|
Self::ExternalRootSymlink => "external_root_symlink",
|
||||||
|
Self::ExternalRootChanged => "external_root_changed",
|
||||||
|
Self::ExternalRootMoved => "external_root_moved",
|
||||||
|
Self::ExternalScopeSymlink => "external_scope_symlink",
|
||||||
|
Self::AttachmentScopeExceeded => "attachment_scope_exceeded",
|
||||||
|
Self::DelegatedScopeExceeded => "delegated_scope_exceeded",
|
||||||
|
Self::CheckoutOutputRootExceeded => "checkout_output_root_exceeded",
|
||||||
|
Self::ProviderRootExceeded => "provider_root_exceeded",
|
||||||
|
Self::ProviderSymlinkDenied => "provider_symlink_denied",
|
||||||
|
Self::CheckoutTargetDenied => "checkout_target_denied",
|
||||||
|
Self::CheckoutCreateParentDenied => "checkout_create_parent_denied",
|
||||||
|
Self::CheckoutSearchPathDenied => "checkout_search_path_denied",
|
||||||
|
Self::CheckoutEnumerationDenied => "checkout_enumeration_denied",
|
||||||
|
Self::PathOutOfScope => "path_out_of_scope",
|
||||||
|
Self::SymlinkTargetOutOfScope => "symlink_target_out_of_scope",
|
||||||
|
Self::PathReadOnly => "path_read_only",
|
||||||
|
Self::OsPermissionDenied => "os_permission_denied",
|
||||||
|
Self::UnclassifiedPermissionDenied => "unclassified_permission_denied",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Original local messages remain available, but are not diagnostic wire data.
|
||||||
|
/// Missing reasons from older providers remain explicitly unknown.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct WorkdirDenial {
|
||||||
|
pub reason: Option<WorkdirDenialReason>,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for WorkdirDenial {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(&self.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl From<String> for WorkdirDenial {
|
||||||
|
fn from(message: String) -> Self {
|
||||||
|
Self {
|
||||||
|
reason: None,
|
||||||
|
message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl From<&str> for WorkdirDenial {
|
||||||
|
fn from(message: &str) -> Self {
|
||||||
|
message.to_string().into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum WorkdirError {
|
pub enum WorkdirError {
|
||||||
#[error("Workdir operation denied: {0}")]
|
#[error("Workdir operation denied: {0}")]
|
||||||
Denied(String),
|
Denied(WorkdirDenial),
|
||||||
|
|
||||||
|
/// Retain the source's existing classification and display while attaching
|
||||||
|
/// a reason (including checkout's historical PermissionDenied mapping).
|
||||||
|
#[error("{source}")]
|
||||||
|
DenialContext {
|
||||||
|
reason: WorkdirDenialReason,
|
||||||
|
#[source]
|
||||||
|
source: Box<WorkdirError>,
|
||||||
|
},
|
||||||
|
|
||||||
#[error("Workdir session is closed")]
|
#[error("Workdir session is closed")]
|
||||||
SessionClosed,
|
SessionClosed,
|
||||||
@@ -398,6 +515,29 @@ pub enum WorkdirError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl WorkdirError {
|
impl WorkdirError {
|
||||||
|
pub fn denied(reason: WorkdirDenialReason, message: impl Into<String>) -> Self {
|
||||||
|
Self::Denied(WorkdirDenial {
|
||||||
|
reason: Some(reason),
|
||||||
|
message: message.into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Safe diagnostic data only; never infer a reason from arbitrary messages.
|
||||||
|
pub fn denial_reason(&self) -> Option<WorkdirDenialReason> {
|
||||||
|
use WorkdirDenialReason as Reason;
|
||||||
|
match self {
|
||||||
|
Self::Denied(denial) => denial.reason,
|
||||||
|
Self::DenialContext { reason, .. } => Some(*reason),
|
||||||
|
Self::OutOfScope(_) => Some(Reason::PathOutOfScope),
|
||||||
|
Self::SymlinkOutOfScope { .. } => Some(Reason::SymlinkTargetOutOfScope),
|
||||||
|
Self::ReadOnly(_) => Some(Reason::PathReadOnly),
|
||||||
|
Self::Io { source, .. } if source.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||||
|
Some(local::io_denial_reason(source))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn io(path: &Path, source: std::io::Error) -> Self {
|
pub(crate) fn io(path: &Path, source: std::io::Error) -> Self {
|
||||||
Self::Io {
|
Self::Io {
|
||||||
path: path.to_path_buf(),
|
path: path.to_path_buf(),
|
||||||
|
|||||||
+139
-24
@@ -213,6 +213,51 @@ struct ScopeAccess {
|
|||||||
operation_guard: Option<OperationGuard>,
|
operation_guard: Option<OperationGuard>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Policy refusals carried through FsAccessPolicy's I/O interface are not OS
|
||||||
|
/// PermissionDenied. This marker contains only fixed text and a closed reason.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
#[error("{message}")]
|
||||||
|
struct PolicyPermissionDenied {
|
||||||
|
reason: crate::WorkdirDenialReason,
|
||||||
|
message: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn policy_permission_denied(
|
||||||
|
reason: crate::WorkdirDenialReason,
|
||||||
|
message: &'static str,
|
||||||
|
) -> std::io::Error {
|
||||||
|
std::io::Error::new(
|
||||||
|
std::io::ErrorKind::PermissionDenied,
|
||||||
|
PolicyPermissionDenied { reason, message },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn io_denial_reason(error: &std::io::Error) -> crate::WorkdirDenialReason {
|
||||||
|
use crate::WorkdirDenialReason as Reason;
|
||||||
|
if let Some(source) = error.get_ref() {
|
||||||
|
if let Some(denial) = source.downcast_ref::<PolicyPermissionDenied>() {
|
||||||
|
return denial.reason;
|
||||||
|
}
|
||||||
|
if let Some(denial) = source.downcast_ref::<fs_operation::FsDenialReason>() {
|
||||||
|
return match denial {
|
||||||
|
fs_operation::FsDenialReason::ProviderRootExceeded => Reason::ProviderRootExceeded,
|
||||||
|
fs_operation::FsDenialReason::ProviderSymlinkDenied => {
|
||||||
|
Reason::ProviderSymlinkDenied
|
||||||
|
}
|
||||||
|
fs_operation::FsDenialReason::CheckoutTargetDenied => Reason::CheckoutTargetDenied,
|
||||||
|
fs_operation::FsDenialReason::CheckoutCreateParentDenied => {
|
||||||
|
Reason::CheckoutCreateParentDenied
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if error.raw_os_error().is_some() {
|
||||||
|
Reason::OsPermissionDenied
|
||||||
|
} else {
|
||||||
|
Reason::UnclassifiedPermissionDenied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ScopeAccess {
|
impl ScopeAccess {
|
||||||
fn new(
|
fn new(
|
||||||
scope: Arc<Scope>,
|
scope: Arc<Scope>,
|
||||||
@@ -238,8 +283,8 @@ impl ScopeAccess {
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let relative = path.strip_prefix(&self.root).map_err(|_| {
|
let relative = path.strip_prefix(&self.root).map_err(|_| {
|
||||||
std::io::Error::new(
|
policy_permission_denied(
|
||||||
std::io::ErrorKind::PermissionDenied,
|
crate::WorkdirDenialReason::ProviderRootExceeded,
|
||||||
"path is outside provider root",
|
"path is outside provider root",
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
@@ -288,8 +333,8 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
|
|||||||
if logical.starts_with(&self.root) {
|
if logical.starts_with(&self.root) {
|
||||||
Ok(logical.to_path_buf())
|
Ok(logical.to_path_buf())
|
||||||
} else {
|
} else {
|
||||||
Err(std::io::Error::new(
|
Err(policy_permission_denied(
|
||||||
std::io::ErrorKind::PermissionDenied,
|
crate::WorkdirDenialReason::ProviderRootExceeded,
|
||||||
"path is outside provider root",
|
"path is outside provider root",
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -394,8 +439,8 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let relative = resolved.strip_prefix(&self.root).map_err(|_| {
|
let relative = resolved.strip_prefix(&self.root).map_err(|_| {
|
||||||
std::io::Error::new(
|
policy_permission_denied(
|
||||||
std::io::ErrorKind::PermissionDenied,
|
crate::WorkdirDenialReason::ProviderRootExceeded,
|
||||||
"path is outside provider root",
|
"path is outside provider root",
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
@@ -491,8 +536,8 @@ impl SearchAccess {
|
|||||||
if self.read_allowed(logical, resolved) {
|
if self.read_allowed(logical, resolved) {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(std::io::Error::new(
|
Err(policy_permission_denied(
|
||||||
std::io::ErrorKind::PermissionDenied,
|
crate::WorkdirDenialReason::CheckoutSearchPathDenied,
|
||||||
"checkout search path denied",
|
"checkout search path denied",
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -545,8 +590,8 @@ impl fs_operation::FsAccessPolicy for SearchAccess {
|
|||||||
}
|
}
|
||||||
fn open_read_dir(&self, logical: &Path, resolved: &Path) -> std::io::Result<std::fs::ReadDir> {
|
fn open_read_dir(&self, logical: &Path, resolved: &Path) -> std::io::Result<std::fs::ReadDir> {
|
||||||
if !self.enumeration_allowed(logical, resolved) {
|
if !self.enumeration_allowed(logical, resolved) {
|
||||||
return Err(std::io::Error::new(
|
return Err(policy_permission_denied(
|
||||||
std::io::ErrorKind::PermissionDenied,
|
crate::WorkdirDenialReason::CheckoutEnumerationDenied,
|
||||||
"checkout directory enumeration denied",
|
"checkout directory enumeration denied",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -648,7 +693,8 @@ impl ExternalWorkdirRoot {
|
|||||||
let selected_metadata = std::fs::symlink_metadata(directory)
|
let selected_metadata = std::fs::symlink_metadata(directory)
|
||||||
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?;
|
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?;
|
||||||
if selected_metadata.file_type().is_symlink() {
|
if selected_metadata.file_type().is_symlink() {
|
||||||
return Err(WorkdirError::Denied(
|
return Err(WorkdirError::denied(
|
||||||
|
crate::WorkdirDenialReason::ExternalRootSymlink,
|
||||||
"External Workdir root must not be a symbolic link".to_string(),
|
"External Workdir root must not be a symbolic link".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -678,7 +724,8 @@ impl ExternalWorkdirRoot {
|
|||||||
if (selected_metadata.dev(), selected_metadata.ino())
|
if (selected_metadata.dev(), selected_metadata.ino())
|
||||||
!= (opened_metadata.dev(), opened_metadata.ino())
|
!= (opened_metadata.dev(), opened_metadata.ino())
|
||||||
{
|
{
|
||||||
return Err(WorkdirError::Denied(
|
return Err(WorkdirError::denied(
|
||||||
|
crate::WorkdirDenialReason::ExternalRootChanged,
|
||||||
"External Workdir root changed while it was being pinned".to_string(),
|
"External Workdir root changed while it was being pinned".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -691,7 +738,8 @@ impl ExternalWorkdirRoot {
|
|||||||
let path = std::fs::read_link(format!("/proc/self/fd/{}", handle.as_raw_fd()))
|
let path = std::fs::read_link(format!("/proc/self/fd/{}", handle.as_raw_fd()))
|
||||||
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?;
|
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?;
|
||||||
if !path.is_absolute() || path.to_string_lossy().ends_with(" (deleted)") {
|
if !path.is_absolute() || path.to_string_lossy().ends_with(" (deleted)") {
|
||||||
return Err(WorkdirError::Denied(
|
return Err(WorkdirError::denied(
|
||||||
|
crate::WorkdirDenialReason::ExternalRootMoved,
|
||||||
"External Workdir root moved while it was being pinned".to_string(),
|
"External Workdir root moved while it was being pinned".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -1334,7 +1382,8 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
self.validate_operation_path(&request.path)?;
|
self.validate_operation_path(&request.path)?;
|
||||||
let logical = self.inner.root.join(request.path.as_str());
|
let logical = self.inner.root.join(request.path.as_str());
|
||||||
if self.inner.reject_symlinks && fs_operation::first_symlink(&logical).is_some() {
|
if self.inner.reject_symlinks && fs_operation::first_symlink(&logical).is_some() {
|
||||||
return Err(WorkdirError::Denied(
|
return Err(WorkdirError::denied(
|
||||||
|
crate::WorkdirDenialReason::ExternalScopeSymlink,
|
||||||
"External Workdir scope cannot traverse symbolic links".to_string(),
|
"External Workdir scope cannot traverse symbolic links".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -1353,10 +1402,13 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
WorkdirToolScopePermission::Write => parent_permission == Some(Permission::Write),
|
WorkdirToolScopePermission::Write => parent_permission == Some(Permission::Write),
|
||||||
};
|
};
|
||||||
if !parent_allows {
|
if !parent_allows {
|
||||||
return Err(WorkdirError::Denied(format!(
|
return Err(WorkdirError::denied(
|
||||||
"Workdir path `{}` exceeds the provider attachment scope",
|
crate::WorkdirDenialReason::AttachmentScopeExceeded,
|
||||||
request.path
|
format!(
|
||||||
)));
|
"Workdir path `{}` exceeds the provider attachment scope",
|
||||||
|
request.path
|
||||||
|
),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let allowed = request.rules.iter().any(|rule| {
|
let allowed = request.rules.iter().any(|rule| {
|
||||||
if request.permission == WorkdirToolScopePermission::Write
|
if request.permission == WorkdirToolScopePermission::Write
|
||||||
@@ -1383,10 +1435,13 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
if allowed {
|
if allowed {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(WorkdirError::Denied(format!(
|
Err(WorkdirError::denied(
|
||||||
"Workdir path `{}` is outside the provider-resolved delegated scope",
|
crate::WorkdirDenialReason::DelegatedScopeExceeded,
|
||||||
request.path
|
format!(
|
||||||
)))
|
"Workdir path `{}` is outside the provider-resolved delegated scope",
|
||||||
|
request.path
|
||||||
|
),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1432,7 +1487,12 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
let logical = request.operation.path().clone();
|
let logical = request.operation.path().clone();
|
||||||
let relative = Path::new(logical.as_str())
|
let relative = Path::new(logical.as_str())
|
||||||
.strip_prefix(request.output_root.as_str())
|
.strip_prefix(request.output_root.as_str())
|
||||||
.map_err(|_| WorkdirError::Denied("checkout search escaped output root".into()))?;
|
.map_err(|_| {
|
||||||
|
WorkdirError::denied(
|
||||||
|
crate::WorkdirDenialReason::CheckoutOutputRootExceeded,
|
||||||
|
"checkout search escaped output root",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
*request.operation.path_mut() = WorkdirPath::new(relative.to_str().ok_or_else(|| {
|
*request.operation.path_mut() = WorkdirPath::new(relative.to_str().ok_or_else(|| {
|
||||||
WorkdirError::InvalidPath("checkout search path is not UTF8".into())
|
WorkdirError::InvalidPath("checkout search path is not UTF8".into())
|
||||||
})?)?;
|
})?)?;
|
||||||
@@ -2075,7 +2135,10 @@ fn sanitize_error(error: WorkdirError, logical: &WorkdirPath) -> WorkdirError {
|
|||||||
WorkdirError::Io { source, .. }
|
WorkdirError::Io { source, .. }
|
||||||
if source.kind() == std::io::ErrorKind::PermissionDenied =>
|
if source.kind() == std::io::ErrorKind::PermissionDenied =>
|
||||||
{
|
{
|
||||||
WorkdirError::OutOfScope(path)
|
WorkdirError::DenialContext {
|
||||||
|
reason: io_denial_reason(&source),
|
||||||
|
source: Box::new(WorkdirError::OutOfScope(path)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
WorkdirError::Io { source, .. } => WorkdirError::Unavailable(format!(
|
WorkdirError::Io { source, .. } => WorkdirError::Unavailable(format!(
|
||||||
"I/O operation failed for {logical}: {}",
|
"I/O operation failed for {logical}: {}",
|
||||||
@@ -2561,6 +2624,58 @@ mod tests {
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checkout_permission_mapping_distinguishes_provider_policy_from_os_without_reclassifying() {
|
||||||
|
use crate::WorkdirDenialReason as Reason;
|
||||||
|
use crate::http::{WorkdirTransportError, WorkdirTransportErrorCode as Code};
|
||||||
|
use fs_operation::FsAccessPolicy;
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let access = ScopeAccess::new(
|
||||||
|
Arc::new(Scope::writable(root.path()).unwrap()),
|
||||||
|
root.path(),
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
// Exercise the actual policy boundary, not a string-based stand-in.
|
||||||
|
let policy = access
|
||||||
|
.resolve_access_path(Path::new("/outside/private-secret"))
|
||||||
|
.unwrap_err();
|
||||||
|
let os = std::io::Error::from_raw_os_error(libc::EACCES);
|
||||||
|
for (source, reason) in [
|
||||||
|
(policy, Reason::ProviderRootExceeded),
|
||||||
|
(os, Reason::OsPermissionDenied),
|
||||||
|
] {
|
||||||
|
let error = sanitize_error(
|
||||||
|
WorkdirError::io(Path::new("/host/private-secret"), source),
|
||||||
|
&WorkdirPath::new("file").unwrap(),
|
||||||
|
);
|
||||||
|
assert_eq!(error.denial_reason(), Some(reason));
|
||||||
|
assert_eq!(error.to_string(), "path is outside allowed scope: file");
|
||||||
|
let transport = WorkdirTransportError::from_workdir_error(&error);
|
||||||
|
assert_eq!(transport.code, Code::OutOfScope);
|
||||||
|
assert_eq!(transport.message, "Workdir path is out of scope");
|
||||||
|
assert_eq!(transport.denial_reason, Some(reason));
|
||||||
|
assert_eq!(
|
||||||
|
WorkdirTransportError::from_workdir_error(&transport.clone().into_workdir_error()),
|
||||||
|
transport
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!serde_json::to_string(&transport)
|
||||||
|
.unwrap()
|
||||||
|
.contains("private-secret")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Ordinary OS I/O remains the existing generic denied/403 classification.
|
||||||
|
let error = WorkdirError::io(
|
||||||
|
Path::new("/host/private-secret"),
|
||||||
|
std::io::Error::from_raw_os_error(libc::EACCES),
|
||||||
|
);
|
||||||
|
let transport = WorkdirTransportError::from_workdir_error(&error);
|
||||||
|
assert_eq!(transport.code, Code::Denied);
|
||||||
|
assert_eq!(transport.denial_reason, Some(Reason::OsPermissionDenied));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn checkout_error_mapping_distinguishes_absence_from_stale_execution_at_all_boundaries() {
|
fn checkout_error_mapping_distinguishes_absence_from_stale_execution_at_all_boundaries() {
|
||||||
let path = WorkdirPath::new("missing").unwrap();
|
let path = WorkdirPath::new("missing").unwrap();
|
||||||
|
|||||||
+91
-44
@@ -595,9 +595,10 @@ impl ScopedWorkdirSession {
|
|||||||
if self.capabilities.supports(required) {
|
if self.capabilities.supports(required) {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(WorkdirError::Denied(format!(
|
Err(WorkdirError::denied(
|
||||||
"scoped Workdir tools do not permit {operation}"
|
crate::WorkdirDenialReason::ScopedCapabilityDenied,
|
||||||
)))
|
format!("scoped Workdir tools do not permit {operation}"),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,9 +613,12 @@ impl ScopedWorkdirSession {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|rule| rule_allows_path(rule, path, permission))
|
.any(|rule| rule_allows_path(rule, path, permission))
|
||||||
{
|
{
|
||||||
return Err(WorkdirError::Denied(format!(
|
return Err(WorkdirError::denied(
|
||||||
"logical workdir path `{path}` is outside the scoped {permission:?} scope"
|
crate::WorkdirDenialReason::LogicalScopeExceeded,
|
||||||
)));
|
format!(
|
||||||
|
"logical workdir path `{path}` is outside the scoped {permission:?} scope"
|
||||||
|
),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -626,9 +630,17 @@ impl ScopedWorkdirSession {
|
|||||||
}
|
}
|
||||||
let joined = Path::new(self.cwd.as_str()).join(path.as_str());
|
let joined = Path::new(self.cwd.as_str()).join(path.as_str());
|
||||||
let joined = joined.to_str().ok_or_else(|| {
|
let joined = joined.to_str().ok_or_else(|| {
|
||||||
WorkdirError::Denied("logical Workdir path is not valid UTF-8".into())
|
WorkdirError::denied(
|
||||||
|
crate::WorkdirDenialReason::InvalidScopedPath,
|
||||||
|
"logical Workdir path is not valid UTF-8",
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
FsPath::new(joined).map_err(|error| WorkdirError::Denied(error.to_string()))
|
FsPath::new(joined).map_err(|error| {
|
||||||
|
WorkdirError::denied(
|
||||||
|
crate::WorkdirDenialReason::InvalidScopedPath,
|
||||||
|
error.to_string(),
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_read(
|
fn ensure_read(
|
||||||
@@ -739,9 +751,10 @@ impl ScopedWorkdirSession {
|
|||||||
})
|
})
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
return Err(WorkdirError::Denied(format!(
|
return Err(WorkdirError::denied(
|
||||||
"path `{path}` is leased to child Workdir tools"
|
crate::WorkdirDenialReason::ChildWriteLeaseConflict,
|
||||||
)));
|
format!("path `{path}` is leased to child Workdir tools"),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -792,8 +805,9 @@ impl ScopedWorkdirSession {
|
|||||||
) -> Result<WorkdirSessionCapabilities, WorkdirError> {
|
) -> Result<WorkdirSessionCapabilities, WorkdirError> {
|
||||||
self.ensure_active()?;
|
self.ensure_active()?;
|
||||||
if rules.is_empty() {
|
if rules.is_empty() {
|
||||||
return Err(WorkdirError::Denied(
|
return Err(WorkdirError::denied(
|
||||||
"workdir tool scope requires at least one logical scope rule".into(),
|
crate::WorkdirDenialReason::EmptyScope,
|
||||||
|
"workdir tool scope requires at least one logical scope rule",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let writable = rules
|
let writable = rules
|
||||||
@@ -804,22 +818,25 @@ impl ScopedWorkdirSession {
|
|||||||
&& (!self.capabilities.supports(WorkdirSessionCapability::Write)
|
&& (!self.capabilities.supports(WorkdirSessionCapability::Write)
|
||||||
|| !self.capabilities.supports(WorkdirSessionCapability::Edit)))
|
|| !self.capabilities.supports(WorkdirSessionCapability::Edit)))
|
||||||
{
|
{
|
||||||
return Err(WorkdirError::Denied(
|
return Err(WorkdirError::denied(
|
||||||
"parent Workdir session cannot scope the requested capabilities".into(),
|
crate::WorkdirDenialReason::ParentCapabilityDenied,
|
||||||
|
"parent Workdir session cannot scope the requested capabilities",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if command {
|
if command {
|
||||||
if !writable {
|
if !writable {
|
||||||
return Err(WorkdirError::Denied(
|
return Err(WorkdirError::denied(
|
||||||
"command execution requires a writable scoped path".into(),
|
crate::WorkdirDenialReason::CommandRequiresWritableScope,
|
||||||
|
"command execution requires a writable scoped path",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if !self
|
if !self
|
||||||
.capabilities
|
.capabilities
|
||||||
.supports(WorkdirSessionCapability::Command)
|
.supports(WorkdirSessionCapability::Command)
|
||||||
{
|
{
|
||||||
return Err(WorkdirError::Denied(
|
return Err(WorkdirError::denied(
|
||||||
"parent Workdir session does not support Command".into(),
|
crate::WorkdirDenialReason::ParentCapabilityDenied,
|
||||||
|
"parent Workdir session does not support Command",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -829,10 +846,13 @@ impl ScopedWorkdirSession {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|parent| rule_contains_rule(parent, requested))
|
.any(|parent| rule_contains_rule(parent, requested))
|
||||||
{
|
{
|
||||||
return Err(WorkdirError::Denied(format!(
|
return Err(WorkdirError::denied(
|
||||||
"logical workdir scope `{}` exceeds the parent tool scope",
|
crate::WorkdirDenialReason::ParentScopeExceeded,
|
||||||
requested.target
|
format!(
|
||||||
)));
|
"logical workdir scope `{}` exceeds the parent tool scope",
|
||||||
|
requested.target
|
||||||
|
),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -866,10 +886,13 @@ impl ScopedWorkdirSession {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|rule| rule_allows_path(rule, &request.cwd, WorkdirToolScopePermission::Read))
|
.any(|rule| rule_allows_path(rule, &request.cwd, WorkdirToolScopePermission::Read))
|
||||||
{
|
{
|
||||||
return Err(WorkdirError::Denied(format!(
|
return Err(WorkdirError::denied(
|
||||||
"scoped tool cwd `{}` is outside the readable scope",
|
crate::WorkdirDenialReason::CwdOutsideReadableScope,
|
||||||
request.cwd
|
format!(
|
||||||
)));
|
"scoped tool cwd `{}` is outside the readable scope",
|
||||||
|
request.cwd
|
||||||
|
),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
self.ensure_scope_targets_are_authorized(&request.rules)
|
self.ensure_scope_targets_are_authorized(&request.rules)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -913,10 +936,13 @@ impl ScopedWorkdirSession {
|
|||||||
})
|
})
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
return Err(WorkdirError::Denied(format!(
|
return Err(WorkdirError::denied(
|
||||||
"scoped write path `{}` overlaps an active child scope after provider resolution",
|
crate::WorkdirDenialReason::ChildWriteLeaseConflict,
|
||||||
requested.target
|
format!(
|
||||||
)));
|
"scoped write path `{}` overlaps an active child scope after provider resolution",
|
||||||
|
requested.target
|
||||||
|
),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1446,7 +1472,10 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
|
|||||||
request: WorkdirScopeAuthorizationRequest,
|
request: WorkdirScopeAuthorizationRequest,
|
||||||
) -> Result<(), WorkdirError> {
|
) -> Result<(), WorkdirError> {
|
||||||
if request.permission == WorkdirToolScopePermission::Write {
|
if request.permission == WorkdirToolScopePermission::Write {
|
||||||
return Err(WorkdirError::Denied("read-only workdir session".into()));
|
return Err(WorkdirError::denied(
|
||||||
|
crate::WorkdirDenialReason::ReadOnlySession,
|
||||||
|
"read-only workdir session",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
self.inner.authorize_scope_path(request).await
|
self.inner.authorize_scope_path(request).await
|
||||||
}
|
}
|
||||||
@@ -1509,11 +1538,17 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn write(&self, _request: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
async fn write(&self, _request: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
||||||
Err(WorkdirError::Denied("read-only workdir session".into()))
|
Err(WorkdirError::denied(
|
||||||
|
crate::WorkdirDenialReason::ReadOnlySession,
|
||||||
|
"read-only workdir session",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn edit(&self, _request: EditRequest) -> Result<EditResult, WorkdirError> {
|
async fn edit(&self, _request: EditRequest) -> Result<EditResult, WorkdirError> {
|
||||||
Err(WorkdirError::Denied("read-only workdir session".into()))
|
Err(WorkdirError::denied(
|
||||||
|
crate::WorkdirDenialReason::ReadOnlySession,
|
||||||
|
"read-only workdir session",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError> {
|
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError> {
|
||||||
@@ -1529,22 +1564,34 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn start_command(&self, _request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
|
async fn start_command(&self, _request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
|
||||||
Err(WorkdirError::Denied("read-only workdir session".into()))
|
Err(WorkdirError::denied(
|
||||||
|
crate::WorkdirDenialReason::ReadOnlySession,
|
||||||
|
"read-only workdir session",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn command_status(&self, _handle: CommandHandle) -> Result<CommandStatus, WorkdirError> {
|
async fn command_status(&self, _handle: CommandHandle) -> Result<CommandStatus, WorkdirError> {
|
||||||
Err(WorkdirError::Denied("read-only workdir session".into()))
|
Err(WorkdirError::denied(
|
||||||
|
crate::WorkdirDenialReason::ReadOnlySession,
|
||||||
|
"read-only workdir session",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn command_output(
|
async fn command_output(
|
||||||
&self,
|
&self,
|
||||||
_request: CommandOutputRequest,
|
_request: CommandOutputRequest,
|
||||||
) -> Result<CommandOutput, WorkdirError> {
|
) -> Result<CommandOutput, WorkdirError> {
|
||||||
Err(WorkdirError::Denied("read-only workdir session".into()))
|
Err(WorkdirError::denied(
|
||||||
|
crate::WorkdirDenialReason::ReadOnlySession,
|
||||||
|
"read-only workdir session",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cancel_command(&self, _handle: CommandHandle) -> Result<(), WorkdirError> {
|
async fn cancel_command(&self, _handle: CommandHandle) -> Result<(), WorkdirError> {
|
||||||
Err(WorkdirError::Denied("read-only workdir session".into()))
|
Err(WorkdirError::denied(
|
||||||
|
crate::WorkdirDenialReason::ReadOnlySession,
|
||||||
|
"read-only workdir session",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn close(&self) -> Result<(), WorkdirError> {
|
async fn close(&self) -> Result<(), WorkdirError> {
|
||||||
@@ -2226,7 +2273,7 @@ mod tests {
|
|||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
child.read(read("link")).await,
|
child.read(read("link")).await,
|
||||||
Err(WorkdirError::Denied(message))
|
Err(WorkdirError::Denied(message))
|
||||||
if message.contains("provider-resolved delegated scope")
|
if message.message.contains("provider-resolved delegated scope")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2252,7 +2299,7 @@ mod tests {
|
|||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
child.read(read("escape")).await,
|
child.read(read("escape")).await,
|
||||||
Err(WorkdirError::Denied(message))
|
Err(WorkdirError::Denied(message))
|
||||||
if message.contains("provider-resolved delegated scope")
|
if message.message.contains("provider-resolved delegated scope")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2353,7 +2400,7 @@ mod tests {
|
|||||||
.scope(request("alias-b", WorkdirToolScopePermission::Write))
|
.scope(request("alias-b", WorkdirToolScopePermission::Write))
|
||||||
.await,
|
.await,
|
||||||
Err(WorkdirError::Denied(message))
|
Err(WorkdirError::Denied(message))
|
||||||
if message.contains("overlaps an active child scope after provider resolution")
|
if message.message.contains("overlaps an active child scope after provider resolution")
|
||||||
));
|
));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
parent
|
parent
|
||||||
@@ -2364,7 +2411,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.await,
|
.await,
|
||||||
Err(WorkdirError::Denied(message))
|
Err(WorkdirError::Denied(message))
|
||||||
if message.contains("leased to child Workdir tools")
|
if message.message.contains("leased to child Workdir tools")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2383,7 +2430,7 @@ mod tests {
|
|||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
child.scope(expanded).await,
|
child.scope(expanded).await,
|
||||||
Err(WorkdirError::Denied(message))
|
Err(WorkdirError::Denied(message))
|
||||||
if message.contains("exceeds the parent tool scope")
|
if message.message.contains("exceeds the parent tool scope")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2452,7 +2499,7 @@ mod tests {
|
|||||||
.write(write("secret/parent", "must-be-blocked"))
|
.write(write("secret/parent", "must-be-blocked"))
|
||||||
.await,
|
.await,
|
||||||
Err(WorkdirError::Denied(message))
|
Err(WorkdirError::Denied(message))
|
||||||
if message.contains("leased to child Workdir tools")
|
if message.message.contains("leased to child Workdir tools")
|
||||||
));
|
));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fs::read_to_string(root.path().join("secret/from-child")).unwrap(),
|
fs::read_to_string(root.path().join("secret/from-child")).unwrap(),
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
//! Refusal reasons are selected by providers, not parsed from their messages.
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use manifest::{Permission, Scope, ScopeConfig, ScopeRule, SharedScope, SymlinkPolicy};
|
||||||
|
use workdir::{
|
||||||
|
LocalWorkdirSession, ReadOnlyWorkdirSession, StatRequest, Workdir,
|
||||||
|
WorkdirDenialReason as Reason, WorkdirPath, WorkdirScopeAuthorizationRequest, WorkdirSession,
|
||||||
|
WorkdirSessionCapabilities, WorkdirToolBroker, WorkdirToolScope,
|
||||||
|
WorkdirToolScopePermission as ToolPermission, WorkdirToolScopeRule, WriteRequest,
|
||||||
|
http::{WorkdirTransportError, WorkdirTransportErrorCode as Code},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn path(value: &str) -> WorkdirPath {
|
||||||
|
WorkdirPath::new(value).unwrap()
|
||||||
|
}
|
||||||
|
fn rule(target: &str, permission: ToolPermission) -> WorkdirToolScopeRule {
|
||||||
|
WorkdirToolScopeRule {
|
||||||
|
target: path(target),
|
||||||
|
permission,
|
||||||
|
recursive: true,
|
||||||
|
symlink_policy: SymlinkPolicy::Resolved,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn scoped(target: &str, permission: ToolPermission) -> WorkdirToolScope {
|
||||||
|
WorkdirToolScope {
|
||||||
|
rules: vec![rule(target, permission)],
|
||||||
|
cwd: path(target),
|
||||||
|
command: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn assert_reason(error: workdir::WorkdirError, reason: Reason) {
|
||||||
|
assert_eq!(error.denial_reason(), Some(reason), "{error}");
|
||||||
|
let transport = WorkdirTransportError::from_workdir_error(&error);
|
||||||
|
assert_eq!(transport.code.http_status(), 403);
|
||||||
|
assert_eq!(transport.denial_reason, Some(reason));
|
||||||
|
let wire = serde_json::to_string(&transport).unwrap();
|
||||||
|
assert!(!wire.contains("private-input"));
|
||||||
|
let decoded: WorkdirTransportError = serde_json::from_str(&wire).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
WorkdirTransportError::from_workdir_error(&decoded.into_workdir_error()),
|
||||||
|
transport
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn local_authorize_scope_refusals_distinguish_attachment_and_delegation() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let session = LocalWorkdirSession::materialized_bound(
|
||||||
|
Workdir::new("denials"),
|
||||||
|
root.path().to_owned(),
|
||||||
|
root.path().to_owned(),
|
||||||
|
SharedScope::new(
|
||||||
|
Scope::from_config(&ScopeConfig {
|
||||||
|
allow: vec![ScopeRule {
|
||||||
|
target: root.path().join("allowed"),
|
||||||
|
permission: Permission::Write,
|
||||||
|
recursive: true,
|
||||||
|
symlink_policy: SymlinkPolicy::Resolved,
|
||||||
|
}],
|
||||||
|
deny: vec![],
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
),
|
||||||
|
WorkdirSessionCapabilities::ALL,
|
||||||
|
);
|
||||||
|
for (rules, requested, reason) in [
|
||||||
|
(
|
||||||
|
vec![rule("", ToolPermission::Read)],
|
||||||
|
"private-input",
|
||||||
|
Reason::AttachmentScopeExceeded,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
vec![rule("allowed/child", ToolPermission::Read)],
|
||||||
|
"allowed/private-input",
|
||||||
|
Reason::DelegatedScopeExceeded,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let error = session
|
||||||
|
.authorize_scope_path(WorkdirScopeAuthorizationRequest {
|
||||||
|
rules,
|
||||||
|
path: path(requested),
|
||||||
|
permission: ToolPermission::Read,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_reason(error, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn broker_scope_refusal_matrix_preserves_distinct_reasons() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let local = Arc::new(LocalWorkdirSession::new(
|
||||||
|
Scope::writable(root.path()).unwrap(),
|
||||||
|
root.path().to_owned(),
|
||||||
|
));
|
||||||
|
let parent = WorkdirToolBroker::new(local);
|
||||||
|
let mut empty = scoped("", ToolPermission::Read);
|
||||||
|
empty.rules.clear();
|
||||||
|
let mut command = scoped("", ToolPermission::Read);
|
||||||
|
command.command = true;
|
||||||
|
let mut cwd = scoped("allowed", ToolPermission::Read);
|
||||||
|
cwd.cwd = path("private-input");
|
||||||
|
for (request, reason) in [
|
||||||
|
(empty, Reason::EmptyScope),
|
||||||
|
(command, Reason::CommandRequiresWritableScope),
|
||||||
|
(cwd, Reason::CwdOutsideReadableScope),
|
||||||
|
] {
|
||||||
|
assert_reason(parent.scope(request).await.unwrap_err(), reason);
|
||||||
|
}
|
||||||
|
let mut child_request = scoped("allowed", ToolPermission::Write);
|
||||||
|
child_request.rules[0].recursive = false;
|
||||||
|
let child = parent.scope(child_request).await.unwrap();
|
||||||
|
assert_reason(
|
||||||
|
child
|
||||||
|
.scope(scoped("private-input", ToolPermission::Read))
|
||||||
|
.await
|
||||||
|
.unwrap_err(),
|
||||||
|
Reason::ParentScopeExceeded,
|
||||||
|
);
|
||||||
|
assert_reason(
|
||||||
|
parent
|
||||||
|
.scope(scoped("allowed", ToolPermission::Write))
|
||||||
|
.await
|
||||||
|
.unwrap_err(),
|
||||||
|
Reason::ChildWriteLeaseConflict,
|
||||||
|
);
|
||||||
|
assert_reason(
|
||||||
|
parent
|
||||||
|
.write(WriteRequest {
|
||||||
|
path: path("allowed/private-input"),
|
||||||
|
content: "unused".into(),
|
||||||
|
expected_hash: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err(),
|
||||||
|
Reason::ChildWriteLeaseConflict,
|
||||||
|
);
|
||||||
|
assert_reason(
|
||||||
|
child
|
||||||
|
.stat(StatRequest {
|
||||||
|
path: path("private-input/nested"),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err(),
|
||||||
|
Reason::DelegatedScopeExceeded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_only_and_scoped_capability_refusals_do_not_collapse() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let local = Arc::new(LocalWorkdirSession::new(
|
||||||
|
Scope::writable(root.path()).unwrap(),
|
||||||
|
root.path().to_owned(),
|
||||||
|
));
|
||||||
|
let parent = WorkdirToolBroker::new(local.clone());
|
||||||
|
let child = parent
|
||||||
|
.scope(scoped("", ToolPermission::Read))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let request = WriteRequest {
|
||||||
|
path: path("private-input"),
|
||||||
|
content: "unused".into(),
|
||||||
|
expected_hash: None,
|
||||||
|
};
|
||||||
|
assert_reason(
|
||||||
|
child
|
||||||
|
.start_command(workdir::CommandRequest {
|
||||||
|
command: "must-not-run".into(),
|
||||||
|
timeout_secs: 1,
|
||||||
|
output_limit: 1,
|
||||||
|
cwd: WorkdirPath::root(),
|
||||||
|
spill_dir: None,
|
||||||
|
tool_call_id: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err(),
|
||||||
|
Reason::ScopedCapabilityDenied,
|
||||||
|
);
|
||||||
|
let readonly = ReadOnlyWorkdirSession::new(local);
|
||||||
|
assert_reason(
|
||||||
|
readonly.write(request).await.unwrap_err(),
|
||||||
|
Reason::ReadOnlySession,
|
||||||
|
);
|
||||||
|
let error = parent
|
||||||
|
.checkout_search(workdir::CheckoutSearchRequest {
|
||||||
|
operation: workdir::CheckoutSearchOperation::List(workdir::ListRequest {
|
||||||
|
path: path("private-input"),
|
||||||
|
limit: 10,
|
||||||
|
after: None,
|
||||||
|
}),
|
||||||
|
output_root: path("allowed"),
|
||||||
|
scope_layers: vec![],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_reason(error, Reason::CheckoutOutputRootExceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn denial_wire_is_allowlisted_and_old_missing_reason_remains_unknown() {
|
||||||
|
let old: WorkdirTransportError =
|
||||||
|
serde_json::from_str(r#"{"code":"denied","message":"private-input secret"}"#).unwrap();
|
||||||
|
let error = old.into_workdir_error();
|
||||||
|
assert_eq!(error.denial_reason(), None);
|
||||||
|
let forwarded = WorkdirTransportError::from_workdir_error(&error);
|
||||||
|
assert_eq!(forwarded.code, Code::Denied);
|
||||||
|
assert_eq!(forwarded.message, "Workdir operation was denied");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(forwarded).unwrap(),
|
||||||
|
serde_json::json!({"code":"denied", "message":"Workdir operation was denied"})
|
||||||
|
);
|
||||||
|
for reason in ["/host/private-input", "secret", &"x".repeat(8192)] {
|
||||||
|
let input =
|
||||||
|
serde_json::json!({"code":"denied", "message":"ignored", "denial_reason":reason});
|
||||||
|
assert!(serde_json::from_value::<WorkdirTransportError>(input).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A provider with filesystem methods, but no provider-side scope resolver.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct UnresolvedProvider(LocalWorkdirSession);
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl WorkdirSession for UnresolvedProvider {
|
||||||
|
fn workdir(&self) -> &Workdir {
|
||||||
|
self.0.workdir()
|
||||||
|
}
|
||||||
|
fn capabilities(&self) -> WorkdirSessionCapabilities {
|
||||||
|
self.0.capabilities()
|
||||||
|
}
|
||||||
|
async fn stat(&self, r: StatRequest) -> Result<workdir::StatResult, workdir::WorkdirError> {
|
||||||
|
self.0.stat(r).await
|
||||||
|
}
|
||||||
|
async fn read(
|
||||||
|
&self,
|
||||||
|
r: workdir::ReadRequest,
|
||||||
|
) -> Result<workdir::ReadResult, workdir::WorkdirError> {
|
||||||
|
self.0.read(r).await
|
||||||
|
}
|
||||||
|
async fn write(&self, r: WriteRequest) -> Result<workdir::WriteResult, workdir::WorkdirError> {
|
||||||
|
self.0.write(r).await
|
||||||
|
}
|
||||||
|
async fn edit(
|
||||||
|
&self,
|
||||||
|
r: workdir::EditRequest,
|
||||||
|
) -> Result<workdir::EditResult, workdir::WorkdirError> {
|
||||||
|
self.0.edit(r).await
|
||||||
|
}
|
||||||
|
async fn list(
|
||||||
|
&self,
|
||||||
|
r: workdir::ListRequest,
|
||||||
|
) -> Result<workdir::ListResult, workdir::WorkdirError> {
|
||||||
|
self.0.list(r).await
|
||||||
|
}
|
||||||
|
async fn glob(
|
||||||
|
&self,
|
||||||
|
r: workdir::GlobRequest,
|
||||||
|
) -> Result<workdir::GlobResult, workdir::WorkdirError> {
|
||||||
|
self.0.glob(r).await
|
||||||
|
}
|
||||||
|
async fn grep(
|
||||||
|
&self,
|
||||||
|
r: workdir::GrepRequest,
|
||||||
|
) -> Result<workdir::GrepResult, workdir::WorkdirError> {
|
||||||
|
self.0.grep(r).await
|
||||||
|
}
|
||||||
|
async fn start_command(
|
||||||
|
&self,
|
||||||
|
r: workdir::CommandRequest,
|
||||||
|
) -> Result<workdir::CommandHandle, workdir::WorkdirError> {
|
||||||
|
self.0.start_command(r).await
|
||||||
|
}
|
||||||
|
async fn command_status(
|
||||||
|
&self,
|
||||||
|
h: workdir::CommandHandle,
|
||||||
|
) -> Result<workdir::CommandStatus, workdir::WorkdirError> {
|
||||||
|
self.0.command_status(h).await
|
||||||
|
}
|
||||||
|
async fn command_output(
|
||||||
|
&self,
|
||||||
|
r: workdir::CommandOutputRequest,
|
||||||
|
) -> Result<workdir::CommandOutput, workdir::WorkdirError> {
|
||||||
|
self.0.command_output(r).await
|
||||||
|
}
|
||||||
|
async fn cancel_command(&self, h: workdir::CommandHandle) -> Result<(), workdir::WorkdirError> {
|
||||||
|
self.0.cancel_command(h).await
|
||||||
|
}
|
||||||
|
async fn close(&self) -> Result<(), workdir::WorkdirError> {
|
||||||
|
self.0.close().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn providers_without_scope_resolution_fail_closed_with_specific_reasons() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let provider = UnresolvedProvider(LocalWorkdirSession::new(
|
||||||
|
Scope::writable(root.path()).unwrap(),
|
||||||
|
root.path().to_owned(),
|
||||||
|
));
|
||||||
|
let rules = vec![rule("", ToolPermission::Read)];
|
||||||
|
assert_reason(
|
||||||
|
provider
|
||||||
|
.authorize_scope_path(WorkdirScopeAuthorizationRequest {
|
||||||
|
rules: rules.clone(),
|
||||||
|
path: path("private-input"),
|
||||||
|
permission: ToolPermission::Read,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err(),
|
||||||
|
Reason::ScopeResolutionUnavailable,
|
||||||
|
);
|
||||||
|
assert_reason(
|
||||||
|
provider
|
||||||
|
.scope_rules_overlap(workdir::WorkdirScopeOverlapRequest {
|
||||||
|
left: rules[0].clone(),
|
||||||
|
right: rules[0].clone(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err(),
|
||||||
|
Reason::ScopeComparisonUnavailable,
|
||||||
|
);
|
||||||
|
let mut logical = rules;
|
||||||
|
logical[0].symlink_policy = SymlinkPolicy::Logical;
|
||||||
|
provider
|
||||||
|
.authorize_scope_path(WorkdirScopeAuthorizationRequest {
|
||||||
|
rules: logical,
|
||||||
|
path: path("private-input"),
|
||||||
|
permission: ToolPermission::Read,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use fs_operation::{AtomicWriteMode, CheckedTarget, FsAccessPolicy, FsDenialReason, FsError};
|
||||||
|
use workdir::{
|
||||||
|
WorkdirDenialReason as Reason, WorkdirError,
|
||||||
|
http::{WorkdirTransportError, WorkdirTransportErrorCode as Code},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn assert_origin(source: std::io::Error, reason: Reason, message: &str) {
|
||||||
|
assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied);
|
||||||
|
assert_eq!(source.raw_os_error(), None);
|
||||||
|
assert_eq!(source.to_string(), message);
|
||||||
|
assert_transport_reason(source, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_transport_reason(source: std::io::Error, reason: Reason) {
|
||||||
|
let error = WorkdirError::from(FsError::io("/private-input/secret", source));
|
||||||
|
assert!(matches!(&error, WorkdirError::Io { .. }));
|
||||||
|
assert_eq!(error.denial_reason(), Some(reason));
|
||||||
|
let transport = WorkdirTransportError::from_workdir_error(&error);
|
||||||
|
assert_eq!(transport.code, Code::Denied);
|
||||||
|
assert_eq!(transport.code.http_status(), 403);
|
||||||
|
assert_eq!(transport.message, "Workdir operation was denied");
|
||||||
|
let wire = serde_json::to_value(&transport).unwrap();
|
||||||
|
assert_eq!(wire["denial_reason"], reason.as_str());
|
||||||
|
assert!(!wire.to_string().contains("private-input"));
|
||||||
|
assert!(!wire.to_string().contains("secret"));
|
||||||
|
let decoded: WorkdirTransportError = serde_json::from_value(wire).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
WorkdirTransportError::from_workdir_error(&decoded.into_workdir_error()),
|
||||||
|
transport
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn synthetic_fs_origins_survive_fs_workdir_and_transport_conversion() {
|
||||||
|
for (origin, reason, message) in [
|
||||||
|
(
|
||||||
|
FsDenialReason::ProviderRootExceeded,
|
||||||
|
Reason::ProviderRootExceeded,
|
||||||
|
"path is outside provider root",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
FsDenialReason::ProviderSymlinkDenied,
|
||||||
|
Reason::ProviderSymlinkDenied,
|
||||||
|
"symbolic links are not permitted by this provider",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
FsDenialReason::CheckoutTargetDenied,
|
||||||
|
Reason::CheckoutTargetDenied,
|
||||||
|
"checkout target denied",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
FsDenialReason::CheckoutCreateParentDenied,
|
||||||
|
Reason::CheckoutCreateParentDenied,
|
||||||
|
"Create parent denied",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert_origin(origin.into_io_error(), reason, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_permission_denied_is_not_guessed_from_kind_or_message() {
|
||||||
|
for message in [
|
||||||
|
"Permission denied (os error 13)",
|
||||||
|
"checkout target denied",
|
||||||
|
"/private-input/secret",
|
||||||
|
] {
|
||||||
|
assert_transport_reason(
|
||||||
|
std::io::Error::new(std::io::ErrorKind::PermissionDenied, message),
|
||||||
|
Reason::UnclassifiedPermissionDenied,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn raw_os_permission_denied_retains_errno_and_os_origin() {
|
||||||
|
for errno in [libc::EACCES, libc::EPERM] {
|
||||||
|
let source = std::io::Error::from_raw_os_error(errno);
|
||||||
|
let error = WorkdirError::from(FsError::io("/private-input/secret", source));
|
||||||
|
let WorkdirError::Io { source, .. } = &error else {
|
||||||
|
panic!("classification changed")
|
||||||
|
};
|
||||||
|
assert_eq!(source.raw_os_error(), Some(errno));
|
||||||
|
assert_transport_reason(
|
||||||
|
std::io::Error::from_raw_os_error(errno),
|
||||||
|
Reason::OsPermissionDenied,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn actual_confined_open_and_write_refusals_keep_distinct_origins() {
|
||||||
|
use fs_operation::{
|
||||||
|
atomic_write_beneath_no_symlinks_at, open_beneath_no_symlinks, open_beneath_no_symlinks_at,
|
||||||
|
open_root_no_symlinks,
|
||||||
|
};
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let directory = open_root_no_symlinks(root.path()).unwrap();
|
||||||
|
let outside = root.path().parent().unwrap().join("private-input");
|
||||||
|
for source in [
|
||||||
|
open_beneath_no_symlinks_at(&directory, &outside).unwrap_err(),
|
||||||
|
open_beneath_no_symlinks(root.path(), &outside).unwrap_err(),
|
||||||
|
atomic_write_beneath_no_symlinks_at(
|
||||||
|
&directory,
|
||||||
|
&outside,
|
||||||
|
b"unused",
|
||||||
|
AtomicWriteMode::CreateNew,
|
||||||
|
|| Ok(()),
|
||||||
|
)
|
||||||
|
.unwrap_err(),
|
||||||
|
atomic_write_beneath_no_symlinks_at(
|
||||||
|
&directory,
|
||||||
|
Path::new("../private-input"),
|
||||||
|
b"unused",
|
||||||
|
AtomicWriteMode::CreateNew,
|
||||||
|
|| Ok(()),
|
||||||
|
)
|
||||||
|
.unwrap_err(),
|
||||||
|
] {
|
||||||
|
assert_origin(
|
||||||
|
source,
|
||||||
|
Reason::ProviderRootExceeded,
|
||||||
|
"path is outside provider root",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
std::fs::write(root.path().join("file"), "secret").unwrap();
|
||||||
|
std::os::unix::fs::symlink("file", root.path().join("link")).unwrap();
|
||||||
|
assert_origin(
|
||||||
|
open_beneath_no_symlinks_at(&directory, Path::new("link")).unwrap_err(),
|
||||||
|
Reason::ProviderSymlinkDenied,
|
||||||
|
"symbolic links are not permitted by this provider",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExactWrite<'a>(&'a Path);
|
||||||
|
impl FsAccessPolicy for ExactWrite<'_> {
|
||||||
|
fn is_readable(&self, _: &Path) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn is_writable(&self, path: &Path) -> bool {
|
||||||
|
path == self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn actual_checked_target_and_create_parent_refusals_keep_distinct_origins() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let directory = fs_operation::open_root_no_symlinks(root.path()).unwrap();
|
||||||
|
let destination = root.path().join("parent/file");
|
||||||
|
let policy = ExactWrite(&destination);
|
||||||
|
let source = CheckedTarget::pin(&directory, Path::new(""), root.path(), &policy, true)
|
||||||
|
.err()
|
||||||
|
.expect("target policy must reject");
|
||||||
|
assert_origin(
|
||||||
|
source,
|
||||||
|
Reason::CheckoutTargetDenied,
|
||||||
|
"checkout target denied",
|
||||||
|
);
|
||||||
|
let target =
|
||||||
|
CheckedTarget::pin(&directory, Path::new(""), root.path(), &policy, false).unwrap();
|
||||||
|
let create = target.create_access("parent/file".into(), destination.clone());
|
||||||
|
let error = fs_operation::run_write(
|
||||||
|
root.path(),
|
||||||
|
fs_operation::WriteRequest {
|
||||||
|
path: fs_operation::FsPath::new("parent/file").unwrap(),
|
||||||
|
content: "unused".into(),
|
||||||
|
expected_hash: None,
|
||||||
|
},
|
||||||
|
&create,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
let FsError::Io { source, .. } = error else {
|
||||||
|
panic!("expected parent policy denial")
|
||||||
|
};
|
||||||
|
assert_origin(
|
||||||
|
source,
|
||||||
|
Reason::CheckoutCreateParentDenied,
|
||||||
|
"Create parent denied",
|
||||||
|
);
|
||||||
|
assert!(!root.path().join("parent").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_transport_code_forwards_supplied_reason_without_reclassification() {
|
||||||
|
// JSON is the provider boundary. An optional reason is independent of code/status.
|
||||||
|
for code in [
|
||||||
|
"not_found",
|
||||||
|
"conflict",
|
||||||
|
"outcome_unknown",
|
||||||
|
"unsupported",
|
||||||
|
"invalid_request",
|
||||||
|
"denied",
|
||||||
|
"out_of_scope",
|
||||||
|
"symlink_out_of_scope",
|
||||||
|
"broken_symlink",
|
||||||
|
"symlink_target_is_directory",
|
||||||
|
"read_only",
|
||||||
|
"is_directory",
|
||||||
|
"symlink_directory_not_traversed",
|
||||||
|
"unknown_command",
|
||||||
|
"unavailable",
|
||||||
|
"io",
|
||||||
|
"transport",
|
||||||
|
"internal",
|
||||||
|
] {
|
||||||
|
let decode = |reason| {
|
||||||
|
serde_json::from_value::<WorkdirTransportError>(serde_json::json!({
|
||||||
|
"code": code, "message": "private-input secret", "denial_reason": reason,
|
||||||
|
}))
|
||||||
|
.unwrap()
|
||||||
|
.into_workdir_error()
|
||||||
|
};
|
||||||
|
let baseline_error = decode(None::<Reason>);
|
||||||
|
let baseline = WorkdirTransportError::from_workdir_error(&baseline_error);
|
||||||
|
let error = decode(Some(Reason::CheckoutTargetDenied));
|
||||||
|
let forwarded = WorkdirTransportError::from_workdir_error(&error);
|
||||||
|
assert_eq!(
|
||||||
|
error.denial_reason(),
|
||||||
|
Some(Reason::CheckoutTargetDenied),
|
||||||
|
"{code}"
|
||||||
|
);
|
||||||
|
assert_eq!(forwarded.code, baseline.code, "{code}");
|
||||||
|
assert_eq!(
|
||||||
|
forwarded.code.http_status(),
|
||||||
|
baseline.code.http_status(),
|
||||||
|
"{code}"
|
||||||
|
);
|
||||||
|
assert_eq!(forwarded.message, baseline.message, "{code}");
|
||||||
|
assert_eq!(error.to_string(), baseline_error.to_string(), "{code}");
|
||||||
|
// Missing provider reasons retain the deliberate existing fallback: canonical
|
||||||
|
// path classifications supply their known reason; all other codes stay unknown.
|
||||||
|
assert_eq!(
|
||||||
|
baseline_error.denial_reason(),
|
||||||
|
match code {
|
||||||
|
"out_of_scope" => Some(Reason::PathOutOfScope),
|
||||||
|
"symlink_out_of_scope" => Some(Reason::SymlinkTargetOutOfScope),
|
||||||
|
"read_only" => Some(Reason::PathReadOnly),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
"{code}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diagnostic_labels_match_allowlisted_wire_values() {
|
||||||
|
for label in [
|
||||||
|
"scope_resolution_unavailable",
|
||||||
|
"scope_comparison_unavailable",
|
||||||
|
"scoped_capability_denied",
|
||||||
|
"logical_scope_exceeded",
|
||||||
|
"invalid_scoped_path",
|
||||||
|
"child_write_lease_conflict",
|
||||||
|
"empty_scope",
|
||||||
|
"parent_capability_denied",
|
||||||
|
"command_requires_writable_scope",
|
||||||
|
"parent_scope_exceeded",
|
||||||
|
"cwd_outside_readable_scope",
|
||||||
|
"read_only_session",
|
||||||
|
"external_root_symlink",
|
||||||
|
"external_root_changed",
|
||||||
|
"external_root_moved",
|
||||||
|
"external_scope_symlink",
|
||||||
|
"attachment_scope_exceeded",
|
||||||
|
"delegated_scope_exceeded",
|
||||||
|
"checkout_output_root_exceeded",
|
||||||
|
"provider_root_exceeded",
|
||||||
|
"provider_symlink_denied",
|
||||||
|
"checkout_target_denied",
|
||||||
|
"checkout_create_parent_denied",
|
||||||
|
"checkout_search_path_denied",
|
||||||
|
"checkout_enumeration_denied",
|
||||||
|
"path_out_of_scope",
|
||||||
|
"symlink_target_out_of_scope",
|
||||||
|
"path_read_only",
|
||||||
|
"os_permission_denied",
|
||||||
|
"unclassified_permission_denied",
|
||||||
|
] {
|
||||||
|
let reason: Reason = serde_json::from_value(serde_json::json!(label)).unwrap();
|
||||||
|
assert_eq!(reason.as_str(), label);
|
||||||
|
assert_eq!(serde_json::to_value(reason).unwrap(), label);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
#![cfg(feature = "http-client")]
|
||||||
|
//! Loopback fake at the HTTP provider boundary; no product process or live service.
|
||||||
|
use std::{
|
||||||
|
io::{Read, Write},
|
||||||
|
net::{TcpListener, TcpStream},
|
||||||
|
thread,
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
use workdir::{
|
||||||
|
StatRequest, Workdir, WorkdirDenialReason as Reason, WorkdirPath, WorkdirSession,
|
||||||
|
http::{
|
||||||
|
OpenWorkdirSessionRequest, RemoteWorkdirSession, WorkdirTransportError,
|
||||||
|
WorkdirTransportErrorCode as Code,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn reply(listener: &TcpListener, status: &str, body: &str) {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||||
|
.unwrap();
|
||||||
|
stream
|
||||||
|
.set_write_timeout(Some(Duration::from_secs(5)))
|
||||||
|
.unwrap();
|
||||||
|
read_request(&mut stream);
|
||||||
|
write!(stream, "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap();
|
||||||
|
}
|
||||||
|
fn read_request(stream: &mut TcpStream) {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
let mut byte = [0];
|
||||||
|
while !bytes.ends_with(b"\r\n\r\n") {
|
||||||
|
stream.read_exact(&mut byte).unwrap();
|
||||||
|
bytes.push(byte[0]);
|
||||||
|
assert!(bytes.len() < 16384);
|
||||||
|
}
|
||||||
|
let headers = String::from_utf8(bytes).unwrap();
|
||||||
|
let length: usize = headers
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| {
|
||||||
|
let (name, value) = line.split_once(':')?;
|
||||||
|
name.eq_ignore_ascii_case("content-length")
|
||||||
|
.then(|| value.trim().parse().unwrap())
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
assert!(length < 16384);
|
||||||
|
stream.read_exact(&mut vec![0; length]).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn remote_http_denials_forward_only_typed_reasons_and_old_missing_reason() {
|
||||||
|
for (code, reason) in [
|
||||||
|
(Code::Denied, Some(Reason::ReadOnlySession)),
|
||||||
|
(Code::Denied, Some(Reason::OsPermissionDenied)),
|
||||||
|
(Code::OutOfScope, Some(Reason::OsPermissionDenied)),
|
||||||
|
(Code::Denied, None),
|
||||||
|
] {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
// Host paths and secrets in provider-authored text must not become diagnostics.
|
||||||
|
let mut error = serde_json::json!({"code":code, "message":"/secret/host/root bearer-secret command-secret"});
|
||||||
|
if let Some(reason) = reason {
|
||||||
|
error["denial_reason"] = serde_json::to_value(reason).unwrap();
|
||||||
|
}
|
||||||
|
let server = thread::spawn(move || {
|
||||||
|
reply(
|
||||||
|
&listener,
|
||||||
|
"200 OK",
|
||||||
|
r#"{"session_id":"s","workdir_id":"w","capabilities":{"bits":63}}"#,
|
||||||
|
);
|
||||||
|
reply(&listener, "403 Forbidden", &error.to_string());
|
||||||
|
});
|
||||||
|
let remote = RemoteWorkdirSession::open(
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.timeout(Duration::from_secs(5))
|
||||||
|
.build()
|
||||||
|
.unwrap(),
|
||||||
|
format!("http://{address}").parse().unwrap(),
|
||||||
|
"request-secret",
|
||||||
|
Workdir::new("w").id().clone(),
|
||||||
|
OpenWorkdirSessionRequest::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let denied = remote
|
||||||
|
.stat(StatRequest {
|
||||||
|
path: WorkdirPath::new("file").unwrap(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(denied.denial_reason(), reason);
|
||||||
|
assert!(!denied.to_string().contains("secret"));
|
||||||
|
let forwarded = WorkdirTransportError::from_workdir_error(&denied);
|
||||||
|
assert_eq!(forwarded.denial_reason, reason);
|
||||||
|
assert_eq!(forwarded.code, code);
|
||||||
|
assert_eq!(forwarded.code.http_status(), 403);
|
||||||
|
assert_eq!(
|
||||||
|
forwarded.message,
|
||||||
|
if code == Code::Denied {
|
||||||
|
"Workdir operation was denied"
|
||||||
|
} else {
|
||||||
|
"Workdir path is out of scope"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let wire = serde_json::to_string(&forwarded).unwrap();
|
||||||
|
assert!(!wire.contains("secret"));
|
||||||
|
assert!(wire.len() < 256);
|
||||||
|
server.join().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2350,6 +2350,7 @@ impl RuntimeHttpWorkdirError {
|
|||||||
Self {
|
Self {
|
||||||
status,
|
status,
|
||||||
payload: WorkdirTransportError {
|
payload: WorkdirTransportError {
|
||||||
|
denial_reason: None,
|
||||||
code,
|
code,
|
||||||
message: message.into(),
|
message: message.into(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -191,15 +191,24 @@ impl Provider {
|
|||||||
};
|
};
|
||||||
let observation = match selected.session.checkout_observe(target.clone()).await {
|
let observation = match selected.session.checkout_observe(target.clone()).await {
|
||||||
Ok(o) => o,
|
Ok(o) => o,
|
||||||
Err(
|
Err(failure) => {
|
||||||
workdir::WorkdirError::Denied(_)
|
// Internal diagnostic enrichment is transparent to WIP's existing
|
||||||
| workdir::WorkdirError::OutOfScope(_)
|
// unavailable/refusal classification, including remote providers.
|
||||||
| workdir::WorkdirError::NotFound(_)
|
let mut classification = &failure;
|
||||||
| workdir::WorkdirError::SessionClosed
|
while let workdir::WorkdirError::DenialContext { source, .. } = classification {
|
||||||
| workdir::WorkdirError::UnsupportedOperation(_)
|
classification = source.as_ref();
|
||||||
| workdir::WorkdirError::Unsupported(_),
|
}
|
||||||
) => return Ok(None),
|
if matches!(
|
||||||
Err(_) => {
|
classification,
|
||||||
|
workdir::WorkdirError::Denied(_)
|
||||||
|
| workdir::WorkdirError::OutOfScope(_)
|
||||||
|
| workdir::WorkdirError::NotFound(_)
|
||||||
|
| workdir::WorkdirError::SessionClosed
|
||||||
|
| workdir::WorkdirError::UnsupportedOperation(_)
|
||||||
|
| workdir::WorkdirError::Unsupported(_)
|
||||||
|
) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
return Err(error(
|
return Err(error(
|
||||||
ProtocolErrorCode::Internal,
|
ProtocolErrorCode::Internal,
|
||||||
"checkout observation unavailable",
|
"checkout observation unavailable",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ struct ProviderState {
|
|||||||
requests: Mutex<Vec<(String, String)>>,
|
requests: Mutex<Vec<(String, String)>>,
|
||||||
operations: Mutex<Vec<WorkdirSessionOperation>>,
|
operations: Mutex<Vec<WorkdirSessionOperation>>,
|
||||||
write_fault: Mutex<Option<WriteResponseFault>>,
|
write_fault: Mutex<Option<WriteResponseFault>>,
|
||||||
|
observe_refusal: Mutex<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderState {
|
impl ProviderState {
|
||||||
@@ -49,6 +50,7 @@ impl ProviderState {
|
|||||||
return Err((
|
return Err((
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
AxumJson(WorkdirTransportError {
|
AxumJson(WorkdirTransportError {
|
||||||
|
denial_reason: None,
|
||||||
code: WorkdirTransportErrorCode::Denied,
|
code: WorkdirTransportErrorCode::Denied,
|
||||||
message: "provider authorization denied".into(),
|
message: "provider authorization denied".into(),
|
||||||
}),
|
}),
|
||||||
@@ -140,6 +142,16 @@ async fn operate(
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.push(request.operation.clone());
|
.push(request.operation.clone());
|
||||||
|
if matches!(
|
||||||
|
&request.operation,
|
||||||
|
WorkdirSessionOperation::CheckoutObserve(_)
|
||||||
|
) && *state.observe_refusal.lock().unwrap()
|
||||||
|
{
|
||||||
|
return provider_error(WorkdirError::DenialContext {
|
||||||
|
reason: workdir::WorkdirDenialReason::OsPermissionDenied,
|
||||||
|
source: Box::new(WorkdirError::OutOfScope("/private/provider/path".into())),
|
||||||
|
});
|
||||||
|
}
|
||||||
let result =
|
let result =
|
||||||
dispatch_workdir_session_operation(state.session.as_ref(), request.operation).await;
|
dispatch_workdir_session_operation(state.session.as_ref(), request.operation).await;
|
||||||
match result {
|
match result {
|
||||||
@@ -225,6 +237,7 @@ impl HttpFixture {
|
|||||||
requests: Mutex::new(Vec::new()),
|
requests: Mutex::new(Vec::new()),
|
||||||
operations: Mutex::new(Vec::new()),
|
operations: Mutex::new(Vec::new()),
|
||||||
write_fault: Mutex::new(None),
|
write_fault: Mutex::new(None),
|
||||||
|
observe_refusal: Mutex::new(false),
|
||||||
});
|
});
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route(
|
.route(
|
||||||
@@ -755,3 +768,30 @@ async fn remote_checkout_list_pages_and_direct_inspect_read_use_provider_without
|
|||||||
.any(|op| matches!(op, WorkdirSessionOperation::List(_)))
|
.any(|op| matches!(op, WorkdirSessionOperation::List(_)))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn remote_checkout_diagnostic_context_keeps_wip_refusal_classification_without_retry() {
|
||||||
|
let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_ONLY).await;
|
||||||
|
*fixture.state.observe_refusal.lock().unwrap() = true;
|
||||||
|
let remote = fixture.open(TOKEN).await.unwrap();
|
||||||
|
let (runtime, _) = native_runtime(Arc::new(remote));
|
||||||
|
let failure = runtime
|
||||||
|
.inspect("/checkouts/main/src/deep/a.txt".into(), true)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
let text = failure.to_string();
|
||||||
|
assert!(
|
||||||
|
!text.contains("checkout observation unavailable"),
|
||||||
|
"refusal became an internal error: {text}"
|
||||||
|
);
|
||||||
|
assert!(!text.contains("/private/provider/path"));
|
||||||
|
assert!(
|
||||||
|
text.contains("protocol failure (NotFound)"),
|
||||||
|
"expected unavailable Object: {text}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fixture.state.operations.lock().unwrap().len(),
|
||||||
|
1,
|
||||||
|
"refusal was retried"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1236,6 +1236,7 @@ mod tests {
|
|||||||
WorkspaceResponse {
|
WorkspaceResponse {
|
||||||
status,
|
status,
|
||||||
body: serde_json::to_string(&workdir::http::WorkdirTransportError {
|
body: serde_json::to_string(&workdir::http::WorkdirTransportError {
|
||||||
|
denial_reason: None,
|
||||||
code,
|
code,
|
||||||
message: message.to_string(),
|
message: message.to_string(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -794,6 +794,10 @@ pub struct RuntimeSessionAttachment {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum RuntimeRegistryError {
|
pub enum RuntimeRegistryError {
|
||||||
|
WorkdirSessionOpenFailed {
|
||||||
|
runtime_id: String,
|
||||||
|
transport: workdir::http::WorkdirTransportError,
|
||||||
|
},
|
||||||
InvalidIdentifier {
|
InvalidIdentifier {
|
||||||
kind: &'static str,
|
kind: &'static str,
|
||||||
value: String,
|
value: String,
|
||||||
@@ -823,11 +827,19 @@ impl RuntimeRegistryError {
|
|||||||
worker.worker_id, worker.runtime_id
|
worker.worker_id, worker.runtime_id
|
||||||
),
|
),
|
||||||
Self::RuntimeOperationFailed { message, .. } => message.clone(),
|
Self::RuntimeOperationFailed { message, .. } => message.clone(),
|
||||||
|
Self::WorkdirSessionOpenFailed { transport, .. } => transport.message.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn into_error(self) -> Error {
|
pub fn into_error(self) -> Error {
|
||||||
match self {
|
match self {
|
||||||
|
Self::WorkdirSessionOpenFailed {
|
||||||
|
runtime_id,
|
||||||
|
transport,
|
||||||
|
} => Error::WorkdirSessionOpenFailed {
|
||||||
|
runtime_id,
|
||||||
|
transport,
|
||||||
|
},
|
||||||
Self::InvalidIdentifier { kind, value } => Error::InvalidRuntimeIdentifier {
|
Self::InvalidIdentifier { kind, value } => Error::InvalidRuntimeIdentifier {
|
||||||
kind: kind.to_string(),
|
kind: kind.to_string(),
|
||||||
value,
|
value,
|
||||||
@@ -1885,10 +1897,9 @@ impl RuntimeRegistry {
|
|||||||
runtime
|
runtime
|
||||||
.open_workdir_session(working_directory_id, owner_worker_id)
|
.open_workdir_session(working_directory_id, owner_worker_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| RuntimeRegistryError::RuntimeOperationFailed {
|
.map_err(|error| RuntimeRegistryError::WorkdirSessionOpenFailed {
|
||||||
runtime_id: runtime_id.to_string(),
|
runtime_id: runtime_id.to_string(),
|
||||||
code: "workdir_session_open_failed".to_string(),
|
transport: workdir::http::WorkdirTransportError::from_workdir_error(&error),
|
||||||
message: error.to_string(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ pub enum Error {
|
|||||||
code: String,
|
code: String,
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
|
#[error("runtime `{runtime_id}` operation failed (workdir_session_open_failed): {}", transport.message)]
|
||||||
|
WorkdirSessionOpenFailed {
|
||||||
|
runtime_id: String,
|
||||||
|
transport: workdir::http::WorkdirTransportError,
|
||||||
|
},
|
||||||
#[error("runtime `{runtime_id}` does not support `{capability}`")]
|
#[error("runtime `{runtime_id}` does not support `{capability}`")]
|
||||||
RuntimeCapabilityUnsupported {
|
RuntimeCapabilityUnsupported {
|
||||||
runtime_id: String,
|
runtime_id: String,
|
||||||
|
|||||||
@@ -11458,6 +11458,15 @@ impl server_api::ServerApi for ServerApiContractService {
|
|||||||
server_api::CurrentWorkerWorkdirOperationResponse,
|
server_api::CurrentWorkerWorkdirOperationResponse,
|
||||||
server_api::WorkdirOperationApiError,
|
server_api::WorkdirOperationApiError,
|
||||||
> {
|
> {
|
||||||
|
let fallback_log = server_api::WorkdirOperationLogContext {
|
||||||
|
operation: workdir_operation_label(&request.operation),
|
||||||
|
stage: "worker_identity",
|
||||||
|
workspace_id_hash: crate::worker_source::diagnostic_id_hash(&workspace_id),
|
||||||
|
attachment_alias_hash: crate::worker_source::diagnostic_id_hash(
|
||||||
|
&request.target_workdir,
|
||||||
|
),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
let Json(response) = scoped_execute_current_worker_workdir_operation(
|
let Json(response) = scoped_execute_current_worker_workdir_operation(
|
||||||
State(
|
State(
|
||||||
self.workspace_api()
|
self.workspace_api()
|
||||||
@@ -11465,22 +11474,18 @@ impl server_api::ServerApi for ServerApiContractService {
|
|||||||
.clone(),
|
.clone(),
|
||||||
),
|
),
|
||||||
AxumPath(ScopedWorkspacePath { workspace_id }),
|
AxumPath(ScopedWorkspacePath { workspace_id }),
|
||||||
current_worker_contract_headers(&context)
|
current_worker_contract_headers(&context).map_err(|error| {
|
||||||
.map_err(server_api::WorkdirOperationApiError::api)?,
|
let mut error = server_api::WorkdirOperationApiError::api(error);
|
||||||
|
error.workdir_log = Some(fallback_log);
|
||||||
|
error
|
||||||
|
})?,
|
||||||
Json(WorkspaceWorkdirSessionOperationRequest {
|
Json(WorkspaceWorkdirSessionOperationRequest {
|
||||||
target_workdir: request.target_workdir,
|
target_workdir: request.target_workdir,
|
||||||
operation: request.operation,
|
operation: request.operation,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| match error {
|
.map_err(InternalWorkdirOperationError::into_contract_error)?;
|
||||||
InternalWorkdirOperationError::Api(error) => {
|
|
||||||
server_api::WorkdirOperationApiError::api(error.into_repository_api_error())
|
|
||||||
}
|
|
||||||
InternalWorkdirOperationError::Provider(error) => {
|
|
||||||
server_api::WorkdirOperationApiError::provider(error)
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
Ok(server_api::CurrentWorkerWorkdirOperationResponse(response))
|
Ok(server_api::CurrentWorkerWorkdirOperationResponse(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11722,6 +11727,10 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
|
|||||||
async fn log_failed_api_response(request: Request, next: Next) -> Response {
|
async fn log_failed_api_response(request: Request, next: Next) -> Response {
|
||||||
let method = request.method().clone();
|
let method = request.method().clone();
|
||||||
let uri = request.uri().clone();
|
let uri = request.uri().clone();
|
||||||
|
let request_token_id_hash = request
|
||||||
|
.extensions()
|
||||||
|
.get::<crate::worker_source::VerifiedRuntimeRequestSource>()
|
||||||
|
.and_then(|source| source.token_id_hash.clone());
|
||||||
let response = next.run(request).await;
|
let response = next.run(request).await;
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
|
|
||||||
@@ -11742,11 +11751,23 @@ async fn log_failed_api_response(request: Request, next: Next) -> Response {
|
|||||||
.get::<ApiErrorLog>()
|
.get::<ApiErrorLog>()
|
||||||
.or(generated_error.as_ref());
|
.or(generated_error.as_ref());
|
||||||
let event = api_failure_log_event(&method, &uri, status, error);
|
let event = api_failure_log_event(&method, &uri, status, error);
|
||||||
|
let context = error.and_then(|error| error.workdir_log.as_ref());
|
||||||
|
let workdir_path = context.map(|_| bounded_auth_log_value(uri.path(), 512));
|
||||||
|
let workdir_method = context.map(|_| bounded_auth_log_value(method.as_str(), 32));
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
target: "yoi::api",
|
target: "yoi::api",
|
||||||
event = event.event,
|
event = event.event,
|
||||||
method = event.method,
|
method = workdir_method.as_deref().unwrap_or(event.method),
|
||||||
path = event.path,
|
path = workdir_path.as_deref().unwrap_or(event.path),
|
||||||
|
operation = context.map(|ctx| ctx.operation),
|
||||||
|
workdir_stage = context.map(|ctx| ctx.stage),
|
||||||
|
workspace_id_hash = context.map(|ctx| ctx.workspace_id_hash.as_str()),
|
||||||
|
runtime_id_hash = context.and_then(|ctx| ctx.runtime_id_hash.as_deref()),
|
||||||
|
worker_id_hash = context.and_then(|ctx| ctx.worker_id_hash.as_deref()),
|
||||||
|
workdir_id_hash = context.and_then(|ctx| ctx.workdir_id_hash.as_deref()),
|
||||||
|
attachment_alias_hash = context.map(|ctx| ctx.attachment_alias_hash.as_str()),
|
||||||
|
request_token_id_hash = context.and(request_token_id_hash.as_deref()),
|
||||||
|
denial_reason = error.and_then(|error| error.denial_reason).map(|reason| reason.as_str()),
|
||||||
status = event.status,
|
status = event.status,
|
||||||
kind = event.kind.unwrap_or("unknown"),
|
kind = event.kind.unwrap_or("unknown"),
|
||||||
message = event.message.unwrap_or(""),
|
message = event.message.unwrap_or(""),
|
||||||
@@ -17224,9 +17245,10 @@ fn ticket_notification_delivery_error_category(
|
|||||||
Err(RuntimeRegistryError::UnknownRuntime(_)) => Some("unknown_runtime"),
|
Err(RuntimeRegistryError::UnknownRuntime(_)) => Some("unknown_runtime"),
|
||||||
Err(RuntimeRegistryError::UnknownHost(_)) => Some("unknown_host"),
|
Err(RuntimeRegistryError::UnknownHost(_)) => Some("unknown_host"),
|
||||||
Err(RuntimeRegistryError::UnknownWorker { .. }) => Some("unknown_worker"),
|
Err(RuntimeRegistryError::UnknownWorker { .. }) => Some("unknown_worker"),
|
||||||
Err(RuntimeRegistryError::RuntimeOperationFailed { .. }) => {
|
Err(
|
||||||
Some("runtime_operation_failed")
|
RuntimeRegistryError::WorkdirSessionOpenFailed { .. }
|
||||||
}
|
| RuntimeRegistryError::RuntimeOperationFailed { .. },
|
||||||
|
) => Some("runtime_operation_failed"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18217,6 +18239,33 @@ fn validated_current_worker_attachment(
|
|||||||
enum InternalWorkdirOperationError {
|
enum InternalWorkdirOperationError {
|
||||||
Api(ApiError),
|
Api(ApiError),
|
||||||
Provider(WorkdirTransportError),
|
Provider(WorkdirTransportError),
|
||||||
|
Contextual {
|
||||||
|
error: Box<InternalWorkdirOperationError>,
|
||||||
|
context: server_api::WorkdirOperationLogContext,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InternalWorkdirOperationError {
|
||||||
|
fn into_contract_error(self) -> server_api::WorkdirOperationApiError {
|
||||||
|
match self {
|
||||||
|
Self::Api(error) => {
|
||||||
|
let reason = match &error.error {
|
||||||
|
Error::WorkdirSessionOpenFailed { transport, .. } => transport.denial_reason,
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let mut error =
|
||||||
|
server_api::WorkdirOperationApiError::api(error.into_repository_api_error());
|
||||||
|
error.denial_reason = reason;
|
||||||
|
error
|
||||||
|
}
|
||||||
|
Self::Provider(error) => server_api::WorkdirOperationApiError::provider(error),
|
||||||
|
Self::Contextual { error, context } => {
|
||||||
|
let mut error = error.into_contract_error();
|
||||||
|
error.workdir_log = Some(context);
|
||||||
|
error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<ApiError> for InternalWorkdirOperationError {
|
impl From<ApiError> for InternalWorkdirOperationError {
|
||||||
@@ -18239,21 +18288,35 @@ impl From<WorkdirTransportError> for InternalWorkdirOperationError {
|
|||||||
|
|
||||||
impl IntoResponse for InternalWorkdirOperationError {
|
impl IntoResponse for InternalWorkdirOperationError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
match self {
|
let error = self.into_contract_error();
|
||||||
Self::Api(error) => error.into_response(),
|
let status = StatusCode::from_u16(api_macros::HttpError::status_code(&error))
|
||||||
Self::Provider(error) => {
|
.expect("Workdir error status is valid");
|
||||||
let status = StatusCode::from_u16(error.code.http_status())
|
let log = ApiErrorLog::from_workdir_operation_error(&error, status);
|
||||||
.expect("Workdir transport error status is valid");
|
let mut response = (status, Json(error)).into_response();
|
||||||
let log = ApiErrorLog {
|
response.extensions_mut().insert(log);
|
||||||
kind: format!("workdir_session_operation_{}", error.code.as_str()),
|
response
|
||||||
message: error.message.clone(),
|
}
|
||||||
diagnostics: Vec::new(),
|
}
|
||||||
};
|
|
||||||
let mut response = (status, Json(error)).into_response();
|
fn workdir_operation_label(operation: &WorkdirSessionOperation) -> &'static str {
|
||||||
response.extensions_mut().insert(log);
|
match operation {
|
||||||
response
|
WorkdirSessionOperation::AuthorizeScope(_) => "authorize_scope",
|
||||||
}
|
WorkdirSessionOperation::ScopeRulesOverlap(_) => "scope_rules_overlap",
|
||||||
}
|
WorkdirSessionOperation::CheckoutSearch(_) => "checkout_search",
|
||||||
|
WorkdirSessionOperation::CheckoutObserve(_) => "checkout_observe",
|
||||||
|
WorkdirSessionOperation::CheckoutExecute(_) => "checkout_execute",
|
||||||
|
WorkdirSessionOperation::Stat(_) => "stat",
|
||||||
|
WorkdirSessionOperation::Read(_) => "read",
|
||||||
|
WorkdirSessionOperation::ReadBytes(_) => "read_bytes",
|
||||||
|
WorkdirSessionOperation::Write(_) => "write",
|
||||||
|
WorkdirSessionOperation::Edit(_) => "edit",
|
||||||
|
WorkdirSessionOperation::List(_) => "list",
|
||||||
|
WorkdirSessionOperation::Glob(_) => "glob",
|
||||||
|
WorkdirSessionOperation::Grep(_) => "grep",
|
||||||
|
WorkdirSessionOperation::CommandStart(_) => "command_start",
|
||||||
|
WorkdirSessionOperation::CommandStatus(_) => "command_status",
|
||||||
|
WorkdirSessionOperation::CommandOutput(_) => "command_output",
|
||||||
|
WorkdirSessionOperation::CommandCancel(_) => "command_cancel",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18263,19 +18326,34 @@ async fn scoped_execute_current_worker_workdir_operation(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(request): Json<WorkspaceWorkdirSessionOperationRequest>,
|
Json(request): Json<WorkspaceWorkdirSessionOperationRequest>,
|
||||||
) -> std::result::Result<Json<WorkdirSessionOperationResult>, InternalWorkdirOperationError> {
|
) -> std::result::Result<Json<WorkdirSessionOperationResult>, InternalWorkdirOperationError> {
|
||||||
|
let mut context = server_api::WorkdirOperationLogContext {
|
||||||
|
operation: workdir_operation_label(&request.operation),
|
||||||
|
workspace_id_hash: crate::worker_source::diagnostic_id_hash(&path.workspace_id),
|
||||||
|
attachment_alias_hash: crate::worker_source::diagnostic_id_hash(&request.target_workdir),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let result = async {
|
||||||
|
context.stage = "workspace_scope";
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
context.stage = "worker_identity";
|
||||||
let worker = current_worker_identity(&api, &path.workspace_id, &headers)?;
|
let worker = current_worker_identity(&api, &path.workspace_id, &headers)?;
|
||||||
|
context.runtime_id_hash = Some(crate::worker_source::diagnostic_id_hash(&worker.runtime_id));
|
||||||
|
context.worker_id_hash = Some(crate::worker_source::diagnostic_id_hash(&worker.worker_id));
|
||||||
let target_workdir = request.target_workdir;
|
let target_workdir = request.target_workdir;
|
||||||
let result = match request.operation {
|
let result = match request.operation {
|
||||||
WorkdirSessionOperation::CommandStart(command) => {
|
WorkdirSessionOperation::CommandStart(command) => {
|
||||||
let session_lock = current_worker_session_lock(&api, &worker);
|
let session_lock = current_worker_session_lock(&api, &worker);
|
||||||
let _session_guard = session_lock.lock().await;
|
let _session_guard = session_lock.lock().await;
|
||||||
|
context.stage = "attachment_validation";
|
||||||
let link = validated_current_worker_attachment(&api, &worker, &target_workdir)?;
|
let link = validated_current_worker_attachment(&api, &worker, &target_workdir)?;
|
||||||
|
context.workdir_id_hash = Some(crate::worker_source::diagnostic_id_hash(&link.workdir_id));
|
||||||
|
context.stage = "session_open";
|
||||||
let source = open_current_worker_workdir_session_locked(&api, &worker, &link).await?;
|
let source = open_current_worker_workdir_session_locked(&api, &worker, &link).await?;
|
||||||
|
context.stage = "provider_dispatch";
|
||||||
let provider_handle = source
|
let provider_handle = source
|
||||||
.start_command(command)
|
.start_command(command)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| current_worker_workdir_operation_error(&worker, error))?;
|
.map_err(workdir_operation_transport_error)?;
|
||||||
let registered_source = api
|
let registered_source = api
|
||||||
.workdir_sessions
|
.workdir_sessions
|
||||||
.lock()
|
.lock()
|
||||||
@@ -18301,31 +18379,31 @@ async fn scoped_execute_current_worker_workdir_operation(
|
|||||||
}
|
}
|
||||||
WorkdirSessionOperation::CommandStatus(external_handle) => {
|
WorkdirSessionOperation::CommandStatus(external_handle) => {
|
||||||
let (session, provider_handle) =
|
let (session, provider_handle) =
|
||||||
current_worker_command_session(&api, &worker, &target_workdir, &external_handle)?;
|
current_worker_command_session(&api, &worker, &target_workdir, &external_handle, &mut context)?;
|
||||||
session
|
session
|
||||||
.command_status(provider_handle)
|
.command_status(provider_handle)
|
||||||
.await
|
.await
|
||||||
.map(WorkdirSessionOperationResult::CommandStatus)
|
.map(WorkdirSessionOperationResult::CommandStatus)
|
||||||
.map_err(|error| current_worker_workdir_operation_error(&worker, error))?
|
.map_err(workdir_operation_transport_error)?
|
||||||
}
|
}
|
||||||
WorkdirSessionOperation::CommandOutput(mut output) => {
|
WorkdirSessionOperation::CommandOutput(mut output) => {
|
||||||
let (session, provider_handle) =
|
let (session, provider_handle) =
|
||||||
current_worker_command_session(&api, &worker, &target_workdir, &output.handle)?;
|
current_worker_command_session(&api, &worker, &target_workdir, &output.handle, &mut context)?;
|
||||||
output.handle = provider_handle;
|
output.handle = provider_handle;
|
||||||
session
|
session
|
||||||
.command_output(output)
|
.command_output(output)
|
||||||
.await
|
.await
|
||||||
.map(WorkdirSessionOperationResult::CommandOutput)
|
.map(WorkdirSessionOperationResult::CommandOutput)
|
||||||
.map_err(|error| current_worker_workdir_operation_error(&worker, error))?
|
.map_err(workdir_operation_transport_error)?
|
||||||
}
|
}
|
||||||
WorkdirSessionOperation::CommandCancel(external_handle) => {
|
WorkdirSessionOperation::CommandCancel(external_handle) => {
|
||||||
let (session, provider_handle) =
|
let (session, provider_handle) =
|
||||||
current_worker_command_session(&api, &worker, &target_workdir, &external_handle)?;
|
current_worker_command_session(&api, &worker, &target_workdir, &external_handle, &mut context)?;
|
||||||
session
|
session
|
||||||
.cancel_command(provider_handle)
|
.cancel_command(provider_handle)
|
||||||
.await
|
.await
|
||||||
.map(|()| WorkdirSessionOperationResult::CommandCancel)
|
.map(|()| WorkdirSessionOperationResult::CommandCancel)
|
||||||
.map_err(|error| current_worker_workdir_operation_error(&worker, error))?
|
.map_err(workdir_operation_transport_error)?
|
||||||
}
|
}
|
||||||
operation @ (WorkdirSessionOperation::AuthorizeScope(_)
|
operation @ (WorkdirSessionOperation::AuthorizeScope(_)
|
||||||
| WorkdirSessionOperation::ScopeRulesOverlap(_)
|
| WorkdirSessionOperation::ScopeRulesOverlap(_)
|
||||||
@@ -18342,14 +18420,23 @@ async fn scoped_execute_current_worker_workdir_operation(
|
|||||||
| WorkdirSessionOperation::Grep(_)) => {
|
| WorkdirSessionOperation::Grep(_)) => {
|
||||||
let session_lock = current_worker_session_lock(&api, &worker);
|
let session_lock = current_worker_session_lock(&api, &worker);
|
||||||
let _session_guard = session_lock.lock().await;
|
let _session_guard = session_lock.lock().await;
|
||||||
|
context.stage = "attachment_validation";
|
||||||
let link = validated_current_worker_attachment(&api, &worker, &target_workdir)?;
|
let link = validated_current_worker_attachment(&api, &worker, &target_workdir)?;
|
||||||
|
context.workdir_id_hash = Some(crate::worker_source::diagnostic_id_hash(&link.workdir_id));
|
||||||
|
context.stage = "session_open";
|
||||||
let source = open_current_worker_workdir_session_locked(&api, &worker, &link).await?;
|
let source = open_current_worker_workdir_session_locked(&api, &worker, &link).await?;
|
||||||
|
context.stage = "provider_dispatch";
|
||||||
execute_workdir_session_operation(&source, operation)
|
execute_workdir_session_operation(&source, operation)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| current_worker_workdir_operation_error(&worker, error))?
|
.map_err(workdir_operation_transport_error)?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
|
}.await;
|
||||||
|
result.map_err(|error| InternalWorkdirOperationError::Contextual {
|
||||||
|
error: Box::new(error),
|
||||||
|
context,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn current_worker_command_session(
|
fn current_worker_command_session(
|
||||||
@@ -18357,26 +18444,27 @@ fn current_worker_command_session(
|
|||||||
worker: &RuntimeWorkerRef,
|
worker: &RuntimeWorkerRef,
|
||||||
target_workdir: &str,
|
target_workdir: &str,
|
||||||
external_handle: &CommandHandle,
|
external_handle: &CommandHandle,
|
||||||
|
context: &mut server_api::WorkdirOperationLogContext,
|
||||||
) -> std::result::Result<(WorkdirSessionHandle, CommandHandle), InternalWorkdirOperationError> {
|
) -> std::result::Result<(WorkdirSessionHandle, CommandHandle), InternalWorkdirOperationError> {
|
||||||
let _link = validated_current_worker_attachment(api, worker, target_workdir)?;
|
context.stage = "attachment_validation";
|
||||||
|
let link = validated_current_worker_attachment(api, worker, target_workdir)?;
|
||||||
|
context.workdir_id_hash = Some(crate::worker_source::diagnostic_id_hash(&link.workdir_id));
|
||||||
|
context.stage = "command_lookup";
|
||||||
let command = api
|
let command = api
|
||||||
.workdir_sessions
|
.workdir_sessions
|
||||||
.lock()
|
.lock()
|
||||||
.expect("Workdir session registry lock poisoned")
|
.expect("Workdir session registry lock poisoned")
|
||||||
.command(worker, target_workdir, external_handle)
|
.command(worker, target_workdir, external_handle)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
InternalWorkdirOperationError::Provider(current_worker_workdir_operation_error(
|
InternalWorkdirOperationError::Provider(workdir_operation_transport_error(
|
||||||
worker,
|
|
||||||
workdir::WorkdirError::UnknownCommand(external_handle.0.clone()),
|
workdir::WorkdirError::UnknownCommand(external_handle.0.clone()),
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
context.stage = "provider_dispatch";
|
||||||
Ok((command.source, command.provider_handle))
|
Ok((command.source, command.provider_handle))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn current_worker_workdir_operation_error(
|
fn workdir_operation_transport_error(error: workdir::WorkdirError) -> WorkdirTransportError {
|
||||||
_worker: &RuntimeWorkerRef,
|
|
||||||
error: workdir::WorkdirError,
|
|
||||||
) -> WorkdirTransportError {
|
|
||||||
WorkdirTransportError::from_workdir_error(&error)
|
WorkdirTransportError::from_workdir_error(&error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23469,6 +23557,7 @@ async fn serve_external_workdir_provider(
|
|||||||
Some(ExternalProviderCommand::Operation { operation_id, operation, response }) => {
|
Some(ExternalProviderCommand::Operation { operation_id, operation, response }) => {
|
||||||
if revoking {
|
if revoking {
|
||||||
let _ = response.send(Err(WorkdirTransportError {
|
let _ = response.send(Err(WorkdirTransportError {
|
||||||
|
denial_reason: None,
|
||||||
code: workdir::http::WorkdirTransportErrorCode::Unavailable,
|
code: workdir::http::WorkdirTransportErrorCode::Unavailable,
|
||||||
message: "External Workdir provider shutdown is in progress".to_string(),
|
message: "External Workdir provider shutdown is in progress".to_string(),
|
||||||
}));
|
}));
|
||||||
@@ -23476,6 +23565,7 @@ async fn serve_external_workdir_provider(
|
|||||||
}
|
}
|
||||||
if pending.len() >= 16 {
|
if pending.len() >= 16 {
|
||||||
let _ = response.send(Err(WorkdirTransportError {
|
let _ = response.send(Err(WorkdirTransportError {
|
||||||
|
denial_reason: None,
|
||||||
code: workdir::http::WorkdirTransportErrorCode::Unavailable,
|
code: workdir::http::WorkdirTransportErrorCode::Unavailable,
|
||||||
message: "External Workdir provider is at its in-flight operation limit".to_string(),
|
message: "External Workdir provider is at its in-flight operation limit".to_string(),
|
||||||
}));
|
}));
|
||||||
@@ -23486,6 +23576,7 @@ async fn serve_external_workdir_provider(
|
|||||||
Ok(operation) => operation,
|
Ok(operation) => operation,
|
||||||
Err(message) => {
|
Err(message) => {
|
||||||
let _ = response.send(Err(WorkdirTransportError {
|
let _ = response.send(Err(WorkdirTransportError {
|
||||||
|
denial_reason: None,
|
||||||
code: workdir::http::WorkdirTransportErrorCode::InvalidRequest,
|
code: workdir::http::WorkdirTransportErrorCode::InvalidRequest,
|
||||||
message,
|
message,
|
||||||
}));
|
}));
|
||||||
@@ -23542,6 +23633,7 @@ async fn serve_external_workdir_provider(
|
|||||||
Err(error.into_transport_error())
|
Err(error.into_transport_error())
|
||||||
}
|
}
|
||||||
ExternalWorkdirOperationOutcome::Cancelled => Err(WorkdirTransportError {
|
ExternalWorkdirOperationOutcome::Cancelled => Err(WorkdirTransportError {
|
||||||
|
denial_reason: None,
|
||||||
code: workdir::http::WorkdirTransportErrorCode::Unavailable,
|
code: workdir::http::WorkdirTransportErrorCode::Unavailable,
|
||||||
message: "External Workdir operation was cancelled".to_string(),
|
message: "External Workdir operation was cancelled".to_string(),
|
||||||
}),
|
}),
|
||||||
@@ -24023,6 +24115,9 @@ fn workdir_runtime_failure_code(error: &RuntimeRegistryError) -> String {
|
|||||||
RuntimeRegistryError::UnknownRuntime(_) | RuntimeRegistryError::UnknownHost(_) => {
|
RuntimeRegistryError::UnknownRuntime(_) | RuntimeRegistryError::UnknownHost(_) => {
|
||||||
"runtime_unavailable".to_string()
|
"runtime_unavailable".to_string()
|
||||||
}
|
}
|
||||||
|
RuntimeRegistryError::WorkdirSessionOpenFailed { .. } => {
|
||||||
|
"workdir_session_open_failed".to_string()
|
||||||
|
}
|
||||||
RuntimeRegistryError::RuntimeOperationFailed { code, .. } => code.clone(),
|
RuntimeRegistryError::RuntimeOperationFailed { code, .. } => code.clone(),
|
||||||
RuntimeRegistryError::InvalidIdentifier { .. } => "invalid_runtime".to_string(),
|
RuntimeRegistryError::InvalidIdentifier { .. } => "invalid_runtime".to_string(),
|
||||||
RuntimeRegistryError::UnknownWorker { .. } => "runtime_workdir_lookup_failed".to_string(),
|
RuntimeRegistryError::UnknownWorker { .. } => "runtime_workdir_lookup_failed".to_string(),
|
||||||
@@ -24534,6 +24629,7 @@ fn runtime_reports_workdir_not_found(result: &crate::hosts::RuntimeWorkingDirect
|
|||||||
|
|
||||||
fn classify_workdir_provider_error(error: &RuntimeRegistryError) -> (&'static str, bool) {
|
fn classify_workdir_provider_error(error: &RuntimeRegistryError) -> (&'static str, bool) {
|
||||||
match error {
|
match error {
|
||||||
|
RuntimeRegistryError::WorkdirSessionOpenFailed { .. } => ("provider_unavailable", true),
|
||||||
RuntimeRegistryError::UnknownRuntime(_) => ("runtime_unavailable", true),
|
RuntimeRegistryError::UnknownRuntime(_) => ("runtime_unavailable", true),
|
||||||
RuntimeRegistryError::RuntimeOperationFailed { code, .. } => {
|
RuntimeRegistryError::RuntimeOperationFailed { code, .. } => {
|
||||||
let category =
|
let category =
|
||||||
@@ -32572,6 +32668,8 @@ struct ApiErrorLog {
|
|||||||
kind: String,
|
kind: String,
|
||||||
message: String,
|
message: String,
|
||||||
diagnostics: Vec<RuntimeDiagnostic>,
|
diagnostics: Vec<RuntimeDiagnostic>,
|
||||||
|
workdir_log: Option<server_api::WorkdirOperationLogContext>,
|
||||||
|
denial_reason: Option<workdir::WorkdirDenialReason>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApiErrorLog {
|
impl ApiErrorLog {
|
||||||
@@ -32584,9 +32682,11 @@ impl ApiErrorLog {
|
|||||||
kind: format!("workdir_session_operation_{}", code.as_str()),
|
kind: format!("workdir_session_operation_{}", code.as_str()),
|
||||||
message: sanitize_backend_error(&error.message),
|
message: sanitize_backend_error(&error.message),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
|
workdir_log: error.workdir_log.clone(),
|
||||||
|
denial_reason: error.denial_reason,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Self::from_repository_error(&server_api::RepositoryApiError::new(
|
let mut log = Self::from_repository_error(&server_api::RepositoryApiError::new(
|
||||||
status.as_u16(),
|
status.as_u16(),
|
||||||
error.error.as_deref().unwrap_or_else(|| {
|
error.error.as_deref().unwrap_or_else(|| {
|
||||||
status
|
status
|
||||||
@@ -32595,11 +32695,23 @@ impl ApiErrorLog {
|
|||||||
}),
|
}),
|
||||||
&error.message,
|
&error.message,
|
||||||
error.diagnostics.clone().unwrap_or_default(),
|
error.diagnostics.clone().unwrap_or_default(),
|
||||||
))
|
));
|
||||||
|
log.workdir_log = error.workdir_log.clone();
|
||||||
|
log.denial_reason = error.denial_reason;
|
||||||
|
if log.workdir_log.is_some() {
|
||||||
|
// Existing public API details are unchanged; do not copy request-derived
|
||||||
|
// alias/handle text or session-open errors into operational diagnostics.
|
||||||
|
log.kind = format!("workdir_session_operation_api_{}", status.as_u16());
|
||||||
|
log.message = "Workdir operation was rejected before provider dispatch".to_string();
|
||||||
|
log.diagnostics.clear();
|
||||||
|
}
|
||||||
|
log
|
||||||
}
|
}
|
||||||
|
|
||||||
fn from_repository_error(error: &server_api::RepositoryApiError) -> Self {
|
fn from_repository_error(error: &server_api::RepositoryApiError) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
workdir_log: None,
|
||||||
|
denial_reason: None,
|
||||||
kind: error
|
kind: error
|
||||||
.diagnostics
|
.diagnostics
|
||||||
.first()
|
.first()
|
||||||
@@ -32632,6 +32744,11 @@ impl From<merge_request::MergeRequestError> for ApiError {
|
|||||||
impl From<Error> for ApiError {
|
impl From<Error> for ApiError {
|
||||||
fn from(error: Error) -> Self {
|
fn from(error: Error) -> Self {
|
||||||
let diagnostics = match &error {
|
let diagnostics = match &error {
|
||||||
|
Error::WorkdirSessionOpenFailed { transport, .. } => vec![RuntimeDiagnostic {
|
||||||
|
code: "workdir_session_open_failed".to_string(),
|
||||||
|
severity: HostDiagnosticSeverity::Error,
|
||||||
|
message: transport.message.clone(),
|
||||||
|
}],
|
||||||
Error::RuntimeOperationFailed { code, message, .. } => vec![RuntimeDiagnostic {
|
Error::RuntimeOperationFailed { code, message, .. } => vec![RuntimeDiagnostic {
|
||||||
code: code.clone(),
|
code: code.clone(),
|
||||||
severity: HostDiagnosticSeverity::Error,
|
severity: HostDiagnosticSeverity::Error,
|
||||||
@@ -32850,13 +32967,18 @@ fn api_error_status(error: &Error) -> StatusCode {
|
|||||||
StatusCode::SERVICE_UNAVAILABLE
|
StatusCode::SERVICE_UNAVAILABLE
|
||||||
}
|
}
|
||||||
Error::WorkspaceSigningIdentity { .. } => StatusCode::SERVICE_UNAVAILABLE,
|
Error::WorkspaceSigningIdentity { .. } => StatusCode::SERVICE_UNAVAILABLE,
|
||||||
Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY,
|
Error::WorkdirSessionOpenFailed { .. } | Error::RuntimeOperationFailed { .. } => {
|
||||||
|
StatusCode::BAD_GATEWAY
|
||||||
|
}
|
||||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn api_error_response_message(error: &Error) -> String {
|
fn api_error_response_message(error: &Error) -> String {
|
||||||
match error {
|
match error {
|
||||||
|
Error::WorkdirSessionOpenFailed { transport, .. } => {
|
||||||
|
format!("workdir_session_open_failed: {}", transport.message)
|
||||||
|
}
|
||||||
Error::RuntimeOperationFailed { code, message, .. } => {
|
Error::RuntimeOperationFailed { code, message, .. } => {
|
||||||
format!("{code}: {}", sanitize_backend_error(message))
|
format!("{code}: {}", sanitize_backend_error(message))
|
||||||
}
|
}
|
||||||
@@ -32869,6 +32991,8 @@ impl IntoResponse for ApiError {
|
|||||||
let status = api_error_status(&self.error);
|
let status = api_error_status(&self.error);
|
||||||
let response_message = api_error_response_message(&self.error);
|
let response_message = api_error_response_message(&self.error);
|
||||||
let log = ApiErrorLog {
|
let log = ApiErrorLog {
|
||||||
|
workdir_log: None,
|
||||||
|
denial_reason: None,
|
||||||
kind: self
|
kind: self
|
||||||
.diagnostics
|
.diagnostics
|
||||||
.first()
|
.first()
|
||||||
@@ -33169,6 +33293,7 @@ mod tests {
|
|||||||
assert!(matches!(operation, WorkdirSessionOperation::Read(_)));
|
assert!(matches!(operation, WorkdirSessionOperation::Read(_)));
|
||||||
response
|
response
|
||||||
.send(Err(WorkdirTransportError {
|
.send(Err(WorkdirTransportError {
|
||||||
|
denial_reason: None,
|
||||||
code: workdir::http::WorkdirTransportErrorCode::NotFound,
|
code: workdir::http::WorkdirTransportErrorCode::NotFound,
|
||||||
message: "logical file was not found".to_string(),
|
message: "logical file was not found".to_string(),
|
||||||
}))
|
}))
|
||||||
@@ -35167,6 +35292,13 @@ mod tests {
|
|||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
struct WorkdirlessFixtureRuntime {
|
struct WorkdirlessFixtureRuntime {
|
||||||
|
workdir_session_factory: Option<
|
||||||
|
Arc<
|
||||||
|
dyn Fn(&str) -> std::result::Result<WorkdirSessionHandle, workdir::WorkdirError>
|
||||||
|
+ Send
|
||||||
|
+ Sync,
|
||||||
|
>,
|
||||||
|
>,
|
||||||
input_requests: Arc<Mutex<Vec<WorkerInputRequest>>>,
|
input_requests: Arc<Mutex<Vec<WorkerInputRequest>>>,
|
||||||
workers: Arc<Mutex<Vec<InternalWorkerSummary>>>,
|
workers: Arc<Mutex<Vec<InternalWorkerSummary>>>,
|
||||||
spawn_requests: Arc<Mutex<Vec<WorkerSpawnRequest>>>,
|
spawn_requests: Arc<Mutex<Vec<WorkerSpawnRequest>>>,
|
||||||
@@ -35304,6 +35436,27 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl crate::hosts::WorkspaceWorkerRuntime for WorkdirlessFixtureRuntime {
|
impl crate::hosts::WorkspaceWorkerRuntime for WorkdirlessFixtureRuntime {
|
||||||
|
fn open_workdir_session<'a>(
|
||||||
|
&'a self,
|
||||||
|
working_directory_id: &'a str,
|
||||||
|
_owner_worker_id: Option<&'a str>,
|
||||||
|
) -> std::pin::Pin<
|
||||||
|
Box<
|
||||||
|
dyn std::future::Future<
|
||||||
|
Output = std::result::Result<WorkdirSessionHandle, workdir::WorkdirError>,
|
||||||
|
> + Send
|
||||||
|
+ 'a,
|
||||||
|
>,
|
||||||
|
> {
|
||||||
|
Box::pin(async move {
|
||||||
|
self.workdir_session_factory.as_ref().ok_or_else(|| {
|
||||||
|
workdir::WorkdirError::Unavailable(
|
||||||
|
"Runtime does not expose Workdir operation sessions".to_string(),
|
||||||
|
)
|
||||||
|
})?(working_directory_id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn send_input(
|
fn send_input(
|
||||||
&self,
|
&self,
|
||||||
worker_id: &str,
|
worker_id: &str,
|
||||||
@@ -36453,6 +36606,8 @@ mod tests {
|
|||||||
.parse::<Uri>()
|
.parse::<Uri>()
|
||||||
.expect("valid URI");
|
.expect("valid URI");
|
||||||
let error = ApiErrorLog {
|
let error = ApiErrorLog {
|
||||||
|
workdir_log: None,
|
||||||
|
denial_reason: None,
|
||||||
kind: "ticket_backend_error".to_string(),
|
kind: "ticket_backend_error".to_string(),
|
||||||
message: "sqlite error: FOREIGN KEY constraint failed".to_string(),
|
message: "sqlite error: FOREIGN KEY constraint failed".to_string(),
|
||||||
diagnostics: vec![RuntimeDiagnostic {
|
diagnostics: vec![RuntimeDiagnostic {
|
||||||
@@ -36594,6 +36749,7 @@ mod tests {
|
|||||||
),
|
),
|
||||||
] {
|
] {
|
||||||
let response = InternalWorkdirOperationError::Provider(WorkdirTransportError {
|
let response = InternalWorkdirOperationError::Provider(WorkdirTransportError {
|
||||||
|
denial_reason: None,
|
||||||
code,
|
code,
|
||||||
message: "safe provider message".to_string(),
|
message: "safe provider message".to_string(),
|
||||||
})
|
})
|
||||||
@@ -36613,14 +36769,11 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn local_and_remote_validation_errors_share_workspace_classification() {
|
async fn local_and_remote_validation_errors_share_workspace_classification() {
|
||||||
let worker = RuntimeWorkerRef::new("runtime", "worker");
|
let local =
|
||||||
let local = current_worker_workdir_operation_error(
|
workdir_operation_transport_error(workdir::WorkdirError::InvalidGlob("[".to_string()));
|
||||||
&worker,
|
let remote = workdir_operation_transport_error(
|
||||||
workdir::WorkdirError::InvalidGlob("[".to_string()),
|
|
||||||
);
|
|
||||||
let remote = current_worker_workdir_operation_error(
|
|
||||||
&worker,
|
|
||||||
WorkdirTransportError {
|
WorkdirTransportError {
|
||||||
|
denial_reason: None,
|
||||||
code: workdir::http::WorkdirTransportErrorCode::InvalidRequest,
|
code: workdir::http::WorkdirTransportErrorCode::InvalidRequest,
|
||||||
message: "Workdir operation request is invalid".to_string(),
|
message: "Workdir operation request is invalid".to_string(),
|
||||||
}
|
}
|
||||||
@@ -36646,10 +36799,7 @@ mod tests {
|
|||||||
path: PathBuf::from("/host/private/worktree/secret.txt"),
|
path: PathBuf::from("/host/private/worktree/secret.txt"),
|
||||||
source: std::io::Error::other("token=secret"),
|
source: std::io::Error::other("token=secret"),
|
||||||
};
|
};
|
||||||
let public = current_worker_workdir_operation_error(
|
let public = workdir_operation_transport_error(error);
|
||||||
&RuntimeWorkerRef::new("runtime", "worker"),
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
let response = InternalWorkdirOperationError::Provider(public).into_response();
|
let response = InternalWorkdirOperationError::Provider(public).into_response();
|
||||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||||
let text = String::from_utf8(body.to_vec()).unwrap();
|
let text = String::from_utf8(body.to_vec()).unwrap();
|
||||||
@@ -36658,10 +36808,7 @@ mod tests {
|
|||||||
let decoded: WorkdirTransportError = serde_json::from_str(&text).unwrap();
|
let decoded: WorkdirTransportError = serde_json::from_str(&text).unwrap();
|
||||||
assert_eq!(decoded.code, workdir::http::WorkdirTransportErrorCode::Io);
|
assert_eq!(decoded.code, workdir::http::WorkdirTransportErrorCode::Io);
|
||||||
|
|
||||||
let remote = current_worker_workdir_operation_error(
|
let remote = workdir_operation_transport_error(workdir::WorkdirError::OperationFailed);
|
||||||
&RuntimeWorkerRef::new("runtime", "worker"),
|
|
||||||
workdir::WorkdirError::OperationFailed,
|
|
||||||
);
|
|
||||||
let response = InternalWorkdirOperationError::Provider(remote).into_response();
|
let response = InternalWorkdirOperationError::Provider(remote).into_response();
|
||||||
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
@@ -38261,6 +38408,7 @@ mod tests {
|
|||||||
.uri(format!("/api/w/{TEST_WORKSPACE_ID}/worker-control/workers"))
|
.uri(format!("/api/w/{TEST_WORKSPACE_ID}/worker-control/workers"))
|
||||||
// Unit fixture stands below proof middleware, so inject its verified subject.
|
// Unit fixture stands below proof middleware, so inject its verified subject.
|
||||||
.extension(crate::worker_source::VerifiedRuntimeRequestSource {
|
.extension(crate::worker_source::VerifiedRuntimeRequestSource {
|
||||||
|
token_id_hash: None,
|
||||||
runtime_id: fixture.controller.runtime_id.clone(),
|
runtime_id: fixture.controller.runtime_id.clone(),
|
||||||
worker_id: Some(fixture.controller.worker_id.clone()),
|
worker_id: Some(fixture.controller.worker_id.clone()),
|
||||||
})
|
})
|
||||||
@@ -38584,6 +38732,7 @@ mod tests {
|
|||||||
.method("POST")
|
.method("POST")
|
||||||
.uri(format!("/api/w/{TEST_WORKSPACE_ID}/worker-control/workers"))
|
.uri(format!("/api/w/{TEST_WORKSPACE_ID}/worker-control/workers"))
|
||||||
.extension(crate::worker_source::VerifiedRuntimeRequestSource {
|
.extension(crate::worker_source::VerifiedRuntimeRequestSource {
|
||||||
|
token_id_hash: None,
|
||||||
runtime_id: controller.runtime_id.clone(),
|
runtime_id: controller.runtime_id.clone(),
|
||||||
worker_id: Some(controller.worker_id.clone()),
|
worker_id: Some(controller.worker_id.clone()),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ impl Write for CapturedApiLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn request_with_logs(app: Router, request: Request<Body>) -> (StatusCode, Value, Vec<Value>) {
|
async fn request_with_logs(app: Router, request: Request<Body>) -> (StatusCode, Value, Vec<Value>) {
|
||||||
|
let token_hash = request
|
||||||
|
.headers()
|
||||||
|
.get(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(|proof| worker_runtime::auth::decode_runtime_request_source_claims(proof).ok())
|
||||||
|
.map(|claims| crate::worker_source::diagnostic_id_hash(&claims.jti));
|
||||||
let logs = CapturedApiLog::default();
|
let logs = CapturedApiLog::default();
|
||||||
let writer = logs.clone();
|
let writer = logs.clone();
|
||||||
let subscriber = tracing_subscriber::fmt()
|
let subscriber = tracing_subscriber::fmt()
|
||||||
@@ -32,18 +38,41 @@ async fn request_with_logs(app: Router, request: Request<Body>) -> (StatusCode,
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
assert_eq!(response.headers()[CONTENT_TYPE], "application/json");
|
let content_type = response.headers()[CONTENT_TYPE].clone();
|
||||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
content_type,
|
||||||
|
"application/json",
|
||||||
|
"status={status}, body={}",
|
||||||
|
String::from_utf8_lossy(&body)
|
||||||
|
);
|
||||||
let body: Value = serde_json::from_slice(&body).unwrap();
|
let body: Value = serde_json::from_slice(&body).unwrap();
|
||||||
let text = String::from_utf8(logs.0.lock().unwrap().clone()).unwrap();
|
let text = String::from_utf8(logs.0.lock().unwrap().clone()).unwrap();
|
||||||
for secret in ["query-secret", "bearer-secret", "body-secret"] {
|
for secret in [
|
||||||
|
"query-secret",
|
||||||
|
"bearer-secret",
|
||||||
|
"body-secret",
|
||||||
|
"/private/provider/path",
|
||||||
|
"/host/private",
|
||||||
|
"proof-secret",
|
||||||
|
"credential-secret",
|
||||||
|
"key-secret",
|
||||||
|
] {
|
||||||
assert!(!text.contains(secret), "log leaked request data: {text}");
|
assert!(!text.contains(secret), "log leaked request data: {text}");
|
||||||
}
|
}
|
||||||
let events = text
|
let events: Vec<Value> = text
|
||||||
.lines()
|
.lines()
|
||||||
.map(|line| serde_json::from_str::<Value>(line).unwrap())
|
.map(|line| serde_json::from_str::<Value>(line).unwrap())
|
||||||
.filter(|event| event["event"] == "api_error")
|
.filter(|event| event["event"] == "api_error")
|
||||||
.collect();
|
.collect();
|
||||||
|
for event in &events {
|
||||||
|
if event.get("operation").is_some() && token_hash.is_some() {
|
||||||
|
assert_eq!(
|
||||||
|
event["request_token_id_hash"],
|
||||||
|
serde_json::to_value(&token_hash).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
(status, body, events)
|
(status, body, events)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,17 +307,50 @@ async fn generated_workdir_provider_errors_preserve_response_and_log_details() {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
let app = build_router(fixture.api.clone());
|
let app = build_router(fixture.api.clone());
|
||||||
for (code, status, message) in [
|
for (code, status, message, reason) in [
|
||||||
(Code::Denied, 403, "Workdir operation was denied"),
|
(Code::Denied, 403, "Workdir operation was denied", None),
|
||||||
(Code::OutOfScope, 403, "Workdir path is out of scope"),
|
(
|
||||||
|
Code::Denied,
|
||||||
|
403,
|
||||||
|
"Workdir operation was denied",
|
||||||
|
Some(workdir::WorkdirDenialReason::OsPermissionDenied),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Code::Denied,
|
||||||
|
403,
|
||||||
|
"Workdir operation was denied",
|
||||||
|
Some(workdir::WorkdirDenialReason::ReadOnlySession),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Code::OutOfScope,
|
||||||
|
403,
|
||||||
|
"Workdir path is out of scope",
|
||||||
|
Some(workdir::WorkdirDenialReason::PathOutOfScope),
|
||||||
|
),
|
||||||
(
|
(
|
||||||
Code::SymlinkOutOfScope,
|
Code::SymlinkOutOfScope,
|
||||||
403,
|
403,
|
||||||
"Workdir symlink target is out of scope",
|
"Workdir symlink target is out of scope",
|
||||||
|
Some(workdir::WorkdirDenialReason::SymlinkTargetOutOfScope),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Code::ReadOnly,
|
||||||
|
403,
|
||||||
|
"Workdir path is read-only",
|
||||||
|
Some(workdir::WorkdirDenialReason::PathReadOnly),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Code::UnknownCommand,
|
||||||
|
404,
|
||||||
|
"Workdir command was not found",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Code::Unavailable,
|
||||||
|
503,
|
||||||
|
"Workdir session is unavailable",
|
||||||
|
None,
|
||||||
),
|
),
|
||||||
(Code::ReadOnly, 403, "Workdir path is read-only"),
|
|
||||||
(Code::UnknownCommand, 404, "Workdir command was not found"),
|
|
||||||
(Code::Unavailable, 503, "Workdir session is unavailable"),
|
|
||||||
] {
|
] {
|
||||||
let provider = async {
|
let provider = async {
|
||||||
let Some(ExternalProviderCommand::Operation {
|
let Some(ExternalProviderCommand::Operation {
|
||||||
@@ -305,6 +367,7 @@ async fn generated_workdir_provider_errors_preserve_response_and_log_details() {
|
|||||||
));
|
));
|
||||||
response
|
response
|
||||||
.send(Err(WorkdirTransportError {
|
.send(Err(WorkdirTransportError {
|
||||||
|
denial_reason: reason,
|
||||||
code,
|
code,
|
||||||
message: "body-secret /private/provider/path".to_string(),
|
message: "body-secret /private/provider/path".to_string(),
|
||||||
}))
|
}))
|
||||||
@@ -324,6 +387,22 @@ async fn generated_workdir_provider_errors_preserve_response_and_log_details() {
|
|||||||
assert_eq!(events[0]["status"], status);
|
assert_eq!(events[0]["status"], status);
|
||||||
assert_eq!(events[0]["method"], "POST");
|
assert_eq!(events[0]["method"], "POST");
|
||||||
assert_eq!(events[0]["path"], path);
|
assert_eq!(events[0]["path"], path);
|
||||||
|
assert_eq!(events[0]["operation"], "command_status");
|
||||||
|
assert_eq!(events[0]["workdir_stage"], "provider_dispatch");
|
||||||
|
assert_workdir_log_identity(
|
||||||
|
&events[0],
|
||||||
|
&fixture,
|
||||||
|
"checkout",
|
||||||
|
Some(&fixture.main_workdir_id),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
events[0]["denial_reason"],
|
||||||
|
serde_json::to_value(reason).unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
events[0]["request_token_id_hash"].as_str().unwrap().len(),
|
||||||
|
64
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let provider = async {
|
let provider = async {
|
||||||
let Some(ExternalProviderCommand::Operation { response, .. }) = commands.recv().await
|
let Some(ExternalProviderCommand::Operation { response, .. }) = commands.recv().await
|
||||||
@@ -374,8 +453,230 @@ async fn generated_workdir_api_errors_preserve_response_and_log_details() {
|
|||||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||||
assert_eq!(body, serde_json::to_value(expected).unwrap());
|
assert_eq!(body, serde_json::to_value(expected).unwrap());
|
||||||
assert_eq!(events.len(), 1, "{events:?}");
|
assert_eq!(events.len(), 1, "{events:?}");
|
||||||
assert_eq!(events[0]["kind"], "forbidden");
|
assert_eq!(events[0]["kind"], "workdir_session_operation_api_403");
|
||||||
assert_eq!(events[0]["message"], body["message"]);
|
assert_eq!(
|
||||||
|
events[0]["message"],
|
||||||
|
"Workdir operation was rejected before provider dispatch"
|
||||||
|
);
|
||||||
|
assert_eq!(events[0]["operation"], "command_status");
|
||||||
|
assert_eq!(events[0]["workdir_stage"], "worker_identity");
|
||||||
|
assert_eq!(events[0]["runtime_id_hash"], Value::Null);
|
||||||
|
assert_eq!(events[0]["worker_id_hash"], Value::Null);
|
||||||
|
assert_eq!(events[0]["workdir_id_hash"], Value::Null);
|
||||||
assert_eq!(events[0]["diagnostics"], "[]");
|
assert_eq!(events[0]["diagnostics"], "[]");
|
||||||
assert_eq!(events[0]["path"], path);
|
assert_eq!(events[0]["path"], path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn assert_workdir_log_identity(
|
||||||
|
event: &Value,
|
||||||
|
fixture: &ManualCoderAssignmentFixture,
|
||||||
|
alias: &str,
|
||||||
|
workdir_id: Option<&str>,
|
||||||
|
) {
|
||||||
|
let hash = crate::worker_source::diagnostic_id_hash;
|
||||||
|
assert_eq!(event["workspace_id_hash"], hash(TEST_WORKSPACE_ID));
|
||||||
|
assert_eq!(event["runtime_id_hash"], hash(&fixture.worker.runtime_id));
|
||||||
|
assert_eq!(event["worker_id_hash"], hash(&fixture.worker.worker_id));
|
||||||
|
assert_eq!(event["attachment_alias_hash"], hash(alias));
|
||||||
|
assert_eq!(
|
||||||
|
event["workdir_id_hash"],
|
||||||
|
serde_json::to_value(workdir_id.map(hash)).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn self_workdir_provider_refusals_log_operation_identity_without_request_text() {
|
||||||
|
use workdir::{WorkdirDenialReason as Reason, WorkdirSessionCapabilities as Caps};
|
||||||
|
let mut fixture = manual_worker_assignment_fixture().await;
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let provider_root = root.path().to_path_buf();
|
||||||
|
// Fresh sessions exercise the real opening/broker/dispatch path on each request.
|
||||||
|
fixture.runtime.workdir_session_factory = Some(Arc::new(move |id| {
|
||||||
|
Ok(Arc::new(workdir::LocalWorkdirSession::materialized_bound(
|
||||||
|
workdir::Workdir::new(id),
|
||||||
|
provider_root.clone(),
|
||||||
|
provider_root.clone(),
|
||||||
|
manifest::SharedScope::new(manifest::Scope::writable(&provider_root).unwrap()),
|
||||||
|
Caps::READ_ONLY,
|
||||||
|
)) as WorkdirSessionHandle)
|
||||||
|
}));
|
||||||
|
fixture
|
||||||
|
.api
|
||||||
|
.runtime
|
||||||
|
.register_or_replace(fixture.runtime.clone());
|
||||||
|
let identity =
|
||||||
|
worker_runtime::auth::RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id)
|
||||||
|
.unwrap();
|
||||||
|
configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id);
|
||||||
|
let path = format!("/api/w/{TEST_WORKSPACE_ID}/workers/self/workdir-session/operations");
|
||||||
|
let app = build_router(fixture.api.clone());
|
||||||
|
let secret = format!(
|
||||||
|
"body-secret credential-secret key-secret proof-secret{}",
|
||||||
|
"z".repeat(20_000)
|
||||||
|
);
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
json!({"operation": "read", "request": {"path": "/host/private/secret.txt", "offset": 0, "limit": 10, "max_bytes": 1024}}),
|
||||||
|
"read",
|
||||||
|
"out_of_scope",
|
||||||
|
Reason::PathOutOfScope,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!({"operation": "write", "request": {"path": "secret.txt", "content": secret.as_bytes()}}),
|
||||||
|
"write",
|
||||||
|
"denied",
|
||||||
|
Reason::ScopedCapabilityDenied,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!({"operation": "command_start", "request": {"command": secret, "timeout_secs": 1, "output_limit": 1024, "cwd": "", "tool_call_id": secret}}),
|
||||||
|
"command_start",
|
||||||
|
"denied",
|
||||||
|
Reason::ScopedCapabilityDenied,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!({"operation": "authorize_scope", "request": {"path": "secret.txt", "permission": "write", "rules": [{"target": "", "permission": "write", "recursive": true}]}}),
|
||||||
|
"authorize_scope",
|
||||||
|
"denied",
|
||||||
|
Reason::ScopeResolutionUnavailable,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (operation, label, code, reason) in cases {
|
||||||
|
let _: WorkdirSessionOperation =
|
||||||
|
serde_json::from_value(operation.clone()).expect("valid operation fixture");
|
||||||
|
let request = runtime_source_request(
|
||||||
|
&identity,
|
||||||
|
Some(&fixture.worker.worker_id),
|
||||||
|
"POST",
|
||||||
|
&format!("{path}?token=query-secret"),
|
||||||
|
serde_json::to_vec(&json!({"target_workdir": "checkout", "operation": operation}))
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let (status, body, events) = request_with_logs(app.clone(), request).await;
|
||||||
|
assert_eq!(status, StatusCode::FORBIDDEN, "{body}");
|
||||||
|
assert_eq!(body["code"], code, "{body}");
|
||||||
|
assert!(
|
||||||
|
body.get("denial_reason").is_none(),
|
||||||
|
"internal diagnostics leaked: {body}"
|
||||||
|
);
|
||||||
|
assert_eq!(events.len(), 1, "{events:?}");
|
||||||
|
assert_eq!(events[0]["operation"], label);
|
||||||
|
assert_eq!(events[0]["workdir_stage"], "provider_dispatch");
|
||||||
|
assert_eq!(
|
||||||
|
events[0]["denial_reason"],
|
||||||
|
serde_json::to_value(reason).unwrap()
|
||||||
|
);
|
||||||
|
assert_workdir_log_identity(
|
||||||
|
&events[0],
|
||||||
|
&fixture,
|
||||||
|
"checkout",
|
||||||
|
Some(&fixture.main_workdir_id),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
events[0]["request_token_id_hash"].as_str().unwrap().len(),
|
||||||
|
64
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
events[0].to_string().len() < 2000,
|
||||||
|
"unbounded log: {events:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!root.path().join("secret.txt").exists(),
|
||||||
|
"refusal changed filesystem"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn self_workdir_attachment_rejection_hashes_oversized_alias_and_omits_unknown_workdir() {
|
||||||
|
let mut fixture = manual_worker_assignment_fixture().await;
|
||||||
|
let identity =
|
||||||
|
worker_runtime::auth::RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id)
|
||||||
|
.unwrap();
|
||||||
|
configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id);
|
||||||
|
let path = format!("/api/w/{TEST_WORKSPACE_ID}/workers/self/workdir-session/operations");
|
||||||
|
let alias = format!(
|
||||||
|
"body-secret /host/private credential-secret key-secret proof-secret{}",
|
||||||
|
"あ".repeat(10_000)
|
||||||
|
);
|
||||||
|
let request = runtime_source_request(
|
||||||
|
&identity,
|
||||||
|
Some(&fixture.worker.worker_id),
|
||||||
|
"POST",
|
||||||
|
&path,
|
||||||
|
serde_json::to_vec(&json!({
|
||||||
|
"target_workdir": alias,
|
||||||
|
"operation": {"operation": "command_status", "request": "body-secret"}
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let (status, body, events) =
|
||||||
|
request_with_logs(build_router(fixture.api.clone()), request).await;
|
||||||
|
assert_eq!(status, StatusCode::CONFLICT, "{body}");
|
||||||
|
assert_eq!(events.len(), 1, "{events:?}");
|
||||||
|
assert_eq!(events[0]["workdir_stage"], "attachment_validation");
|
||||||
|
assert_eq!(events[0]["operation"], "command_status");
|
||||||
|
assert_workdir_log_identity(&events[0], &fixture, &alias, None);
|
||||||
|
assert_eq!(events[0]["denial_reason"], Value::Null);
|
||||||
|
assert!(
|
||||||
|
events[0].to_string().len() < 2000,
|
||||||
|
"unbounded log: {events:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn self_workdir_session_open_refusals_keep_reason_before_registry_conversion() {
|
||||||
|
use workdir::WorkdirDenialReason as Reason;
|
||||||
|
for (reason, os_error) in [
|
||||||
|
(Reason::ReadOnlySession, false),
|
||||||
|
(Reason::OsPermissionDenied, true),
|
||||||
|
] {
|
||||||
|
let mut fixture = manual_worker_assignment_fixture().await;
|
||||||
|
fixture.runtime.workdir_session_factory = Some(Arc::new(move |_| {
|
||||||
|
Err(if os_error {
|
||||||
|
workdir::WorkdirError::Io {
|
||||||
|
path: PathBuf::from("/host/private/key-secret"),
|
||||||
|
source: std::io::Error::from_raw_os_error(13),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
workdir::WorkdirError::denied(reason, "body-secret credential-secret /host/private")
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
fixture
|
||||||
|
.api
|
||||||
|
.runtime
|
||||||
|
.register_or_replace(fixture.runtime.clone());
|
||||||
|
let identity =
|
||||||
|
worker_runtime::auth::RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id)
|
||||||
|
.unwrap();
|
||||||
|
configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id);
|
||||||
|
let path = format!("/api/w/{TEST_WORKSPACE_ID}/workers/self/workdir-session/operations");
|
||||||
|
let request = runtime_source_request(&identity, Some(&fixture.worker.worker_id), "POST", &path, serde_json::to_vec(&json!({
|
||||||
|
"target_workdir": "checkout",
|
||||||
|
"operation": {"operation": "read", "request": {"path": "README.md", "offset": 0, "limit": 10, "max_bytes": 1024}}
|
||||||
|
})).unwrap());
|
||||||
|
let (status, body, events) =
|
||||||
|
request_with_logs(build_router(fixture.api.clone()), request).await;
|
||||||
|
// Opening retained its existing Registry HTTP meaning, not an operation's 403.
|
||||||
|
assert_eq!(status, StatusCode::BAD_GATEWAY, "{body}");
|
||||||
|
assert_eq!(
|
||||||
|
body["message"],
|
||||||
|
"workdir_session_open_failed: Workdir operation was denied"
|
||||||
|
);
|
||||||
|
assert!(body.get("denial_reason").is_none(), "{body}");
|
||||||
|
assert_eq!(events.len(), 1, "{events:?}");
|
||||||
|
assert_eq!(events[0]["workdir_stage"], "session_open");
|
||||||
|
assert_eq!(events[0]["operation"], "read");
|
||||||
|
assert_eq!(
|
||||||
|
events[0]["denial_reason"],
|
||||||
|
serde_json::to_value(reason).unwrap()
|
||||||
|
);
|
||||||
|
assert_workdir_log_identity(
|
||||||
|
&events[0],
|
||||||
|
&fixture,
|
||||||
|
"checkout",
|
||||||
|
Some(&fixture.main_workdir_id),
|
||||||
|
);
|
||||||
|
assert!(!body.to_string().contains("body-secret"));
|
||||||
|
assert!(!body.to_string().contains("/host/private"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -718,6 +718,7 @@ async fn drive_verified_upload_revoked_during_reception_cannot_commit_or_replay(
|
|||||||
let app = generated_workspace_contract_router(ServerApiContractService::Workspace(api.clone()))
|
let app = generated_workspace_contract_router(ServerApiContractService::Workspace(api.clone()))
|
||||||
.layer(Extension(
|
.layer(Extension(
|
||||||
crate::worker_source::VerifiedRuntimeRequestSource {
|
crate::worker_source::VerifiedRuntimeRequestSource {
|
||||||
|
token_id_hash: None,
|
||||||
runtime_id: workers[0].runtime_id.clone(),
|
runtime_id: workers[0].runtime_id.clone(),
|
||||||
worker_id: Some(workers[0].worker_id.clone()),
|
worker_id: Some(workers[0].worker_id.clone()),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ use crate::store::ControlPlaneStore;
|
|||||||
pub struct VerifiedRuntimeRequestSource {
|
pub struct VerifiedRuntimeRequestSource {
|
||||||
pub runtime_id: String,
|
pub runtime_id: String,
|
||||||
pub worker_id: Option<String>,
|
pub worker_id: Option<String>,
|
||||||
|
/// Existing proof JTI correlation, retained only as a fixed-length diagnostic fingerprint.
|
||||||
|
pub token_id_hash: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Safe diagnostics only: never stores proof, raw claims, keys or store errors.
|
/// Safe diagnostics only: never stores proof, raw claims, keys or store errors.
|
||||||
@@ -242,6 +244,7 @@ pub async fn verify_runtime_request_source_proof_with_store(
|
|||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
Ok(VerifiedRuntimeRequestSource {
|
Ok(VerifiedRuntimeRequestSource {
|
||||||
|
token_id_hash: Some(diagnostic_id_hash(&claims.jti)),
|
||||||
runtime_id: claims.iss,
|
runtime_id: claims.iss,
|
||||||
worker_id: claims.worker_id,
|
worker_id: claims.worker_id,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -836,6 +836,7 @@ where
|
|||||||
.unwrap_or_else(|_| ExternalWorkdirOperationOutcome::Failed {
|
.unwrap_or_else(|_| ExternalWorkdirOperationOutcome::Failed {
|
||||||
error: ExternalWorkdirOperationError::from_transport_error(
|
error: ExternalWorkdirOperationError::from_transport_error(
|
||||||
WorkdirTransportError {
|
WorkdirTransportError {
|
||||||
|
denial_reason: None,
|
||||||
code: workdir::http::WorkdirTransportErrorCode::Internal,
|
code: workdir::http::WorkdirTransportErrorCode::Internal,
|
||||||
message: "External Workdir operation executor failed".to_string(),
|
message: "External Workdir operation executor failed".to_string(),
|
||||||
},
|
},
|
||||||
@@ -953,6 +954,7 @@ async fn execute_operation(
|
|||||||
Ok(result) => ExternalWorkdirOperationOutcome::Completed { result },
|
Ok(result) => ExternalWorkdirOperationOutcome::Completed { result },
|
||||||
Err(message) => ExternalWorkdirOperationOutcome::Failed {
|
Err(message) => ExternalWorkdirOperationOutcome::Failed {
|
||||||
error: ExternalWorkdirOperationError::from_transport_error(WorkdirTransportError {
|
error: ExternalWorkdirOperationError::from_transport_error(WorkdirTransportError {
|
||||||
|
denial_reason: None,
|
||||||
code: workdir::http::WorkdirTransportErrorCode::InvalidRequest,
|
code: workdir::http::WorkdirTransportErrorCode::InvalidRequest,
|
||||||
message,
|
message,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# Workdir refusal diagnostics
|
||||||
|
|
||||||
|
This change improves the internal `api_error` record for
|
||||||
|
`POST /api/w/{workspace_id}/workers/self/workdir-session/operations`.
|
||||||
|
It does **not** identify or resolve the reported 2026-10-09 403: the running
|
||||||
|
binary revision and actual request are still unverified. Fixture success is
|
||||||
|
not evidence about that incident. No deployment, live asset cleanup, privilege
|
||||||
|
change, retry policy, or WIP dependency/version change is included (WIP remains
|
||||||
|
0.2.0).
|
||||||
|
|
||||||
|
## Diagnostic path and public contract
|
||||||
|
|
||||||
|
Original refusing branches attach a closed `WorkdirDenialReason`, rather than
|
||||||
|
recovering meaning from an error string. `WorkdirError::Denied` carries local
|
||||||
|
message plus optional typed reason. `DenialContext` retains an existing error
|
||||||
|
classification while enriching its diagnostics (notably checkout's historical
|
||||||
|
permission-to-out-of-scope mapping). Typed provider markers survive the
|
||||||
|
`FsAccessPolicy`/`io::Error` → `FsError` → `WorkdirError` boundaries.
|
||||||
|
|
||||||
|
`WorkdirTransportError.denial_reason` and
|
||||||
|
`ExternalWorkdirOperationError.denial_reason` carry only an optional snake-case
|
||||||
|
enum. Remote HTTP and External errors preserve that reason when converted back
|
||||||
|
to `WorkdirError` and then forwarded again. Provider text is not the reason.
|
||||||
|
The External codec continues dropping provider-authored message text. A missing
|
||||||
|
reason stays unknown for generic `denied`; existing code-level path/symlink/
|
||||||
|
read-only classifications can still identify their original category. Unknown
|
||||||
|
or oversized reason strings fail deserialization rather than becoming log data.
|
||||||
|
|
||||||
|
Runtime registry session-open failures now retain a safe transport error **before**
|
||||||
|
the former raw error-to-string conversion. Their existing Workspace HTTP meaning
|
||||||
|
is still 502 (`workdir_session_open_failed`); operation-level permission failures
|
||||||
|
remain 403. Session-open messages are normalized to the same safe provider
|
||||||
|
message vocabulary instead of forwarding raw local errors. No authorization,
|
||||||
|
scope, capability, session ownership, command ownership, or retry conditions
|
||||||
|
are relaxed.
|
||||||
|
|
||||||
|
The self handler attaches internal context to its error. Both the handwritten
|
||||||
|
response adapter and the generated route preserve it through the typed error
|
||||||
|
response extension to `ApiErrorLog` and the logging middleware. Server API
|
||||||
|
`workdir_log` and `denial_reason` are `serde(skip)` and omitted from schemas:
|
||||||
|
the external error envelope remains `{code, message}` for provider failures or
|
||||||
|
the existing Repository-style envelope for API/domain failures. Workspace
|
||||||
|
responses do not expose the new internal diagnostics. Authorized Runtime/provider
|
||||||
|
transport carries the enum to its consumer; it contains no free text or paths.
|
||||||
|
|
||||||
|
## Fields in `yoi::api` / `api_error`
|
||||||
|
|
||||||
|
- `operation`: fixed label from the typed operation (read, read_bytes, write,
|
||||||
|
edit, command_start/status/output/cancel, authorize_scope, scope_rules_overlap,
|
||||||
|
checkout_search/observe/execute, stat/list/glob/grep). No operation Debug dump.
|
||||||
|
- `workdir_stage`: workspace_scope, worker_identity, attachment_validation,
|
||||||
|
session_open, command_lookup, or provider_dispatch. API failures identify the
|
||||||
|
refusing stage without copying raw alias/handle/error text into the log.
|
||||||
|
- `denial_reason`: optional closed reason reported by the provider/error chain.
|
||||||
|
It describes the encountered refusing branch, not a permission grant and not
|
||||||
|
an independent attestation of an external provider's explanation.
|
||||||
|
- `workspace_id_hash`, `runtime_id_hash`, `worker_id_hash`, `workdir_id_hash`,
|
||||||
|
`attachment_alias_hash`: 64-character lowercase SHA-256 fingerprints via
|
||||||
|
`worker_source::diagnostic_id_hash`, using the existing
|
||||||
|
`yoi-runtime-proof-diagnostic-id-v1\0` domain prefix. Hash a known catalog ID
|
||||||
|
or attachment alias to locate its logs. Workspace and alias represent route/
|
||||||
|
request selection; Runtime/Worker are added only after current identity checks,
|
||||||
|
and Workdir only after a validated attachment is found. Missing information is
|
||||||
|
omitted, never inferred from a failed lookup or command handle.
|
||||||
|
- `request_token_id_hash`: fingerprint of the **existing verified Runtime proof
|
||||||
|
JTI**, reusing the T-729 hash algorithm. It is not the proof, raw token ID,
|
||||||
|
caller-supplied trace header, or a newly invented cross-service trace ID.
|
||||||
|
In-process/test callers without that proof correlation omit it. Authentication
|
||||||
|
still consumes the JTI using the existing policy; logging does not retry it.
|
||||||
|
- Existing `method`, query-free `path`, `status`, `kind`, `message`, `diagnostics`
|
||||||
|
remain. Workdir-context method/path are bounded to 32/512 UTF-8 bytes with
|
||||||
|
controls removed. Provider operation messages remain normalized safe text.
|
||||||
|
Contextual API failures use a fixed internal message and status-based kind,
|
||||||
|
with no raw domain diagnostics copied into this log. Empty `diagnostics` does
|
||||||
|
**not** mean the optional typed reason/context is absent.
|
||||||
|
|
||||||
|
Fingerprints are bounded correlation, not encryption: low-entropy identifiers
|
||||||
|
can be guessed. Do not put credentials in IDs. Neither hashes nor stage labels
|
||||||
|
prove the reported incident's cause. The request JTI identifies the incoming
|
||||||
|
request; External operation IDs/generation/audit records remain their existing
|
||||||
|
provider-side mechanisms, not a cross-service trace automatically joined here.
|
||||||
|
|
||||||
|
## Refusal vocabulary and limits
|
||||||
|
|
||||||
|
Reasons distinguish scope resolution/comparison unavailable, attenuated
|
||||||
|
capability refusal, logical/parent/attachment/delegation scope exceeded,
|
||||||
|
empty scope, invalid scoped path, writable-scope requirements for commands,
|
||||||
|
child write-lease conflicts, unreadable cwd, read-only session, external root
|
||||||
|
symlink/change/move, external scope symlink, checkout output-root escape,
|
||||||
|
provider root escape, checkout search/enumeration refusal, provider symlink/
|
||||||
|
checked-target refusal, path out of scope, symlink target out of scope, and
|
||||||
|
read-only path. Original local messages remain local; arbitrary message text
|
||||||
|
is never parsed into a reason.
|
||||||
|
|
||||||
|
- `os_permission_denied`: PermissionDenied with an actual OS error code, without
|
||||||
|
a recognized policy marker. It does not determine whether modes, ACLs,
|
||||||
|
sandboxing, mount policy, or another OS mechanism caused the refusal.
|
||||||
|
- `unclassified_permission_denied`: custom PermissionDenied with neither typed
|
||||||
|
policy origin nor raw OS code. Do not label an arbitrary
|
||||||
|
`Error::new(PermissionDenied, "...")` as an OS refusal by guessing from text.
|
||||||
|
- External/remote providers that do not send a reason cannot supply distinctions
|
||||||
|
erased in their own binary. A registered provider's typed explanation is
|
||||||
|
reported data, not proof of what happened inside that provider.
|
||||||
|
- Authentication/JSON ingress failures before a typed Workdir operation and
|
||||||
|
target can be obtained continue using their existing rejection diagnostics;
|
||||||
|
no operation/body is guessed or decoded from rejected proof data.
|
||||||
|
|
||||||
|
## Regression proof boundaries
|
||||||
|
|
||||||
|
- Workdir provider fixtures exercise actual local authorization, broker scope/
|
||||||
|
capability/read-only/delegation/lease refusals and confinement. Permission
|
||||||
|
fixtures distinguish typed policy origins, OS-coded PermissionDenied, and
|
||||||
|
origin-unknown custom errors, including checkout's existing public mapping.
|
||||||
|
- Loopback HTTP fake exercises remote open/operation decoding and typed reason
|
||||||
|
forwarding, including missing-reason providers and hostile provider text.
|
||||||
|
External JSON/frame codecs exercise the bounded error envelope and reject
|
||||||
|
arbitrary reason strings; the Server's External adapter drives the real self
|
||||||
|
generated route through command-session dispatch and response/log conversion.
|
||||||
|
- Server router tests use signed fixture requests and capture actual tracing
|
||||||
|
events. They cover read/write/command_start/authorize_scope, session-open
|
||||||
|
failures, External command status failures, no-identity rejection, oversized
|
||||||
|
UTF-8 alias, oversized command/file/tool-call inputs, query/header/proof/key/
|
||||||
|
credential markers, and raw provider/host paths. Identity hashes and the
|
||||||
|
existing verified JTI fingerprint are checked against fixture identities.
|
||||||
|
- The Server API boundary proves internal fields are absent from body/schema.
|
||||||
|
Worker HTTP→WIP coverage verifies diagnostic wrappers do not change refusal
|
||||||
|
classification or induce retries; tools retain established checkout codes.
|
||||||
|
|
||||||
|
These are hermetic Rust/API/protocol fixtures, not a full product-process E2E,
|
||||||
|
not a deployed binary test, and not reproduction of the user's asset/request.
|
||||||
|
Validation commands and concrete counts are recorded on the implementation MR.
|
||||||
Reference in New Issue
Block a user