fix: integrate structured Workdir refusal diagnostics

Merge independently approved T-731 source 8af492ff into current develop after T-732. No conflicts or source edits. Integration root locked/offline check, eight affected crates, Workdir default/http-client, Server 742+11 (one existing ignored), API schema freshness, fmt/diff passed. Historical Workdir403 remains unidentified; HTTP-only feature-gating limitation remains separate.
This commit is contained in:
2026-10-10 14:40:05 +09:00
34 changed files with 3028 additions and 220 deletions
+2 -8
View File
@@ -66,10 +66,7 @@ impl<'a> CheckedTarget<'a> {
} else {
policy.is_readable_paths(logical, logical)
}) {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"checkout target denied",
));
return Err(crate::FsDenialReason::CheckoutTargetDenied.into_io_error());
}
let parent = if relative.as_os_str().is_empty() {
None
@@ -294,10 +291,7 @@ impl FsAccessPolicy for CheckedCreate<'_, '_> {
if components.peek().is_some()
&& !self.target.policy.is_writable_paths(&parent, &parent)
{
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"Create parent denied",
));
return Err(crate::FsDenialReason::CheckoutCreateParentDenied.into_io_error());
}
}
// Nested mkdir may have effects before failure; never auto-retry.
+27 -22
View File
@@ -37,6 +37,26 @@ pub const MAX_RESULT_PATH_BYTES: usize = 1024 * 1024;
/// Provider-side ceiling for aggregate grep source bytes per operation.
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
/// `openat2` so resolution and open are one kernel-enforced operation. Other
/// 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;
if relative.is_absolute() {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"path is outside provider root",
));
return Err(crate::FsDenialReason::ProviderRootExceeded.into_io_error());
}
let relative = if relative.as_os_str().is_empty() {
CString::new(".").expect("static path")
@@ -122,10 +139,7 @@ pub fn open_beneath_no_symlinks_at(
if fd < 0 {
let error = std::io::Error::last_os_error();
if error.raw_os_error() == Some(libc::ELOOP) {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"symbolic links are not permitted by this provider",
));
return Err(crate::FsDenialReason::ProviderSymlinkDenied.into_io_error());
}
return Err(error);
}
@@ -146,12 +160,9 @@ pub fn open_beneath_no_symlinks_at(
#[cfg(target_os = "linux")]
pub fn open_beneath_no_symlinks(root: &Path, path: &Path) -> std::io::Result<std::fs::File> {
let relative = path.strip_prefix(root).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"path is outside provider root",
)
})?;
let relative = path
.strip_prefix(root)
.map_err(|_| crate::FsDenialReason::ProviderRootExceeded.into_io_error())?;
let root = open_root_no_symlinks(root)?;
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;
if relative.is_absolute() {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"path is outside provider root",
));
return Err(crate::FsDenialReason::ProviderRootExceeded.into_io_error());
}
let mut components = relative.components().peekable();
let mut parent = root.try_clone()?;
let mut file_name = None::<OsString>;
while let Some(component) = components.next() {
let std::path::Component::Normal(name) = component else {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"path is outside provider root",
));
return Err(crate::FsDenialReason::ProviderRootExceeded.into_io_error());
};
if components.peek().is_none() {
file_name = Some(name.to_os_string());
+53
View File
@@ -98,6 +98,24 @@ pub struct WorkdirOperationApiError {
pub diagnostics: Option<Vec<Diagnostic>>,
#[serde(skip, default = "default_repository_error_status")]
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 {
@@ -108,6 +126,8 @@ impl WorkdirOperationApiError {
message: error.message,
diagnostics: Some(error.diagnostics),
status: error.status,
workdir_log: None,
denial_reason: None,
}
}
@@ -119,6 +139,8 @@ impl WorkdirOperationApiError {
message: error.message,
diagnostics: None,
status,
workdir_log: None,
denial_reason: error.denial_reason,
}
}
}
@@ -13984,3 +14006,34 @@ mod typescript_tests {
.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());
}
}
+7
View File
@@ -421,6 +421,13 @@ fn checkout_known_refusals_have_typed_codes_without_changing_normal_tools() {
use workdir::{WorkdirError, WorkdirSessionCapability};
let cases = [
(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()),
"checkout_denied",
+87 -10
View File
@@ -38,17 +38,24 @@ pub enum ToolsError {
impl From<ToolsError> for ToolError {
fn from(err: ToolsError) -> Self {
match &err {
ToolsError::WorkdirSession(
workdir::WorkdirError::NotFound(_)
| workdir::WorkdirError::Io { .. }
| workdir::WorkdirError::Unavailable(_)
| workdir::WorkdirError::OperationFailed
| workdir::WorkdirError::Transport(_)
| workdir::WorkdirError::Conflict(_)
| workdir::WorkdirError::OutcomeUnknown(_),
) => ToolError::ExecutionFailed(err.to_string()),
ToolsError::WorkdirSession(error) => {
// Diagnostic context is transparent to the established normal-tool
// classification. Preserve the original display, including unknown
// outcome guidance, while classifying through nested wrappers.
match error.classification_source() {
workdir::WorkdirError::NotFound(_)
| workdir::WorkdirError::Io { .. }
| workdir::WorkdirError::Unavailable(_)
| workdir::WorkdirError::OperationFailed
| workdir::WorkdirError::Transport(_)
| workdir::WorkdirError::Conflict(_)
| workdir::WorkdirError::OutcomeUnknown(_) => {
ToolError::ExecutionFailed(err.to_string())
}
_ => ToolError::InvalidArgument(err.to_string()),
}
}
ToolsError::FileSystem(_)
| ToolsError::WorkdirSession(_)
| ToolsError::NotRead(_)
| ToolsError::ExternallyModified(_)
| ToolsError::StringNotFound { .. }
@@ -134,6 +141,76 @@ mod tests {
}
}
#[test]
fn transport_diagnostic_reasons_preserve_normal_tool_classification_and_display() {
use WorkdirTransportErrorCode as Code;
use workdir::{WorkdirDenialReason as Reason, WorkdirError};
for (code, execution_failure) in [
(Code::NotFound, true),
(Code::Conflict, true),
(Code::OutcomeUnknown, true),
(Code::Unsupported, false),
(Code::InvalidRequest, false),
(Code::Denied, false),
(Code::OutOfScope, false),
(Code::SymlinkOutOfScope, false),
(Code::BrokenSymlink, false),
(Code::SymlinkTargetIsDirectory, false),
(Code::ReadOnly, false),
(Code::IsDirectory, false),
(Code::SymlinkDirectoryNotTraversed, false),
(Code::UnknownCommand, false),
(Code::Unavailable, true),
(Code::Io, true),
(Code::Transport, true),
(Code::Internal, true),
] {
// Wire data may carry a reason independently of its public code.
// Neither enrichment nor nested wrappers may change normal-tool behavior.
let baseline_message = serde_json::from_value::<WorkdirTransportError>(
serde_json::json!({"code": code, "message": "safe fixture message"}),
)
.unwrap()
.into_workdir_error()
.to_string();
for depth in [0, 1, 2] {
let wire = serde_json::json!({
"code": code,
"message": "safe fixture message",
"denial_reason": (depth > 0).then_some(Reason::OsPermissionDenied),
});
let mut error = serde_json::from_value::<WorkdirTransportError>(wire)
.unwrap()
.into_workdir_error();
if depth == 2 {
error = WorkdirError::DenialContext {
reason: Reason::ReadOnlySession,
source: Box::new(error),
};
}
let expected_message = error.to_string();
assert_eq!(
expected_message, baseline_message,
"{code:?}, depth {depth}"
);
let tool_error = ToolError::from(ToolsError::from(error));
let (actual_execution_failure, actual_message) = match tool_error {
ToolError::ExecutionFailed(message) => (true, message),
ToolError::InvalidArgument(message) => (false, message),
other => panic!("unexpected classification for {code:?}: {other:?}"),
};
assert_eq!(
actual_execution_failure, execution_failure,
"{code:?}, depth {depth}"
);
assert_eq!(actual_message, expected_message, "{code:?}, depth {depth}");
if code == Code::OutcomeUnknown {
assert!(actual_message.contains("inspect before retrying"));
}
}
}
}
#[test]
fn remote_workdir_content_conflict_is_retryable_without_host_path() {
let transport = WorkdirTransportError::from_workdir_error(
+2 -1
View File
@@ -139,7 +139,8 @@ impl FileTarget {
pub(crate) fn checked_error(error: workdir::WorkdirError) -> ToolError {
use workdir::WorkdirError;
let code = match &error {
let code = match error.classification_source() {
WorkdirError::DenialContext { .. } => unreachable!("diagnostic context unwrapped"),
WorkdirError::Conflict(_) | WorkdirError::NotFound(_) => "checkout_stale",
WorkdirError::Denied { .. }
| WorkdirError::OutOfScope { .. }
+158 -1
View File
@@ -94,7 +94,9 @@ pub(crate) async fn execute_write(
.await
{
Ok(_) => Some(tracker.expected_workdir_hash(path)?),
Err(WorkdirError::NotFound(_)) => None,
Err(error) if matches!(error.classification_source(), WorkdirError::NotFound(_)) => {
None
}
Err(error) => return Err(ToolsError::from(error).into()),
}
};
@@ -190,6 +192,161 @@ mod tests {
assert_eq!(std::fs::read_to_string(&file).unwrap(), "hello\n");
}
/// Stat crosses the real JSON transport boundary; mutations still use the
/// checked local provider, so the assertions cover durable tool effects.
#[derive(Debug)]
struct StatErrorSession {
local: LocalWorkdirSession,
code: &'static str,
depth: u8,
stats: std::sync::atomic::AtomicUsize,
writes: std::sync::atomic::AtomicUsize,
}
#[async_trait]
impl workdir::WorkdirSession for StatErrorSession {
fn workdir(&self) -> &workdir::Workdir {
self.local.workdir()
}
fn capabilities(&self) -> workdir::WorkdirSessionCapabilities {
self.local.capabilities()
}
async fn stat(&self, _: StatRequest) -> Result<workdir::StatResult, WorkdirError> {
use workdir::{WorkdirDenialReason as Reason, http::WorkdirTransportError};
self.stats.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let wire = serde_json::json!({
"code": self.code,
"message": "safe fixture message",
"denial_reason": (self.depth > 0).then_some(Reason::OsPermissionDenied),
});
let mut error = serde_json::from_value::<WorkdirTransportError>(wire)
.unwrap()
.into_workdir_error();
if self.depth == 2 {
error = WorkdirError::DenialContext {
reason: Reason::ReadOnlySession,
source: Box::new(error),
};
}
Err(error)
}
async fn read(&self, r: workdir::ReadRequest) -> Result<workdir::ReadResult, WorkdirError> {
self.local.read(r).await
}
async fn write(
&self,
r: workdir::WriteRequest,
) -> Result<workdir::WriteResult, WorkdirError> {
self.writes
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.local.write(r).await
}
async fn edit(&self, r: workdir::EditRequest) -> Result<workdir::EditResult, WorkdirError> {
self.local.edit(r).await
}
async fn list(&self, r: workdir::ListRequest) -> Result<workdir::ListResult, WorkdirError> {
self.local.list(r).await
}
async fn glob(&self, r: workdir::GlobRequest) -> Result<workdir::GlobResult, WorkdirError> {
self.local.glob(r).await
}
async fn grep(&self, r: workdir::GrepRequest) -> Result<workdir::GrepResult, WorkdirError> {
self.local.grep(r).await
}
async fn start_command(
&self,
r: workdir::CommandRequest,
) -> Result<workdir::CommandHandle, WorkdirError> {
self.local.start_command(r).await
}
async fn command_status(
&self,
r: workdir::CommandHandle,
) -> Result<workdir::CommandStatus, WorkdirError> {
self.local.command_status(r).await
}
async fn command_output(
&self,
r: workdir::CommandOutputRequest,
) -> Result<workdir::CommandOutput, WorkdirError> {
self.local.command_output(r).await
}
async fn cancel_command(&self, r: workdir::CommandHandle) -> Result<(), WorkdirError> {
self.local.cancel_command(r).await
}
async fn close(&self) -> Result<(), WorkdirError> {
self.local.close().await
}
}
fn stat_error_session(dir: &TempDir, code: &'static str, depth: u8) -> Arc<StatErrorSession> {
Arc::new(StatErrorSession {
local: LocalWorkdirSession::new(
Scope::writable(dir.path()).unwrap(),
dir.path().to_path_buf(),
),
code,
depth,
stats: Default::default(),
writes: Default::default(),
})
}
#[tokio::test]
async fn write_not_found_diagnostics_preserve_create_behavior() {
for depth in [0, 1, 2] {
let dir = TempDir::new().unwrap();
let session = stat_error_session(&dir, "not_found", depth);
let (_, tool) = write_tool(session.clone(), Tracker::new())();
let output = tool
.execute(
r#"{"file_path":"new.txt","content":"hello\n"}"#,
Default::default(),
)
.await
.unwrap_or_else(|error| panic!("depth {depth}: {error:?}"));
assert!(output.summary.contains("Created new.txt"), "{output:?}");
assert_eq!(
std::fs::read_to_string(dir.path().join("new.txt")).unwrap(),
"hello\n"
);
assert_eq!(session.stats.load(std::sync::atomic::Ordering::SeqCst), 1);
assert_eq!(session.writes.load(std::sync::atomic::Ordering::SeqCst), 1);
}
}
#[tokio::test]
async fn write_stat_failures_do_not_create_or_retry_with_diagnostics() {
for code in ["denied", "read_only", "conflict", "outcome_unknown"] {
for depth in [0, 1, 2] {
let dir = TempDir::new().unwrap();
let session = stat_error_session(&dir, code, depth);
let (_, tool) = write_tool(session.clone(), Tracker::new())();
let error = tool
.execute(
r#"{"file_path":"new.txt","content":"hello"}"#,
Default::default(),
)
.await
.unwrap_err();
if matches!(code, "conflict" | "outcome_unknown") {
assert!(
matches!(error, ToolError::ExecutionFailed(_)),
"{code}, depth {depth}: {error:?}"
);
} else {
assert!(
matches!(error, ToolError::InvalidArgument(_)),
"{code}, depth {depth}: {error:?}"
);
}
assert!(!dir.path().join("new.txt").exists());
assert_eq!(session.stats.load(std::sync::atomic::Ordering::SeqCst), 1);
assert_eq!(session.writes.load(std::sync::atomic::Ordering::SeqCst), 0);
}
}
}
#[tokio::test]
async fn write_existing_requires_prior_read() {
let (dir, fs, tracker) = setup();
+3 -2
View File
@@ -148,8 +148,9 @@ impl CheckoutSearchRequest {
if !std::path::Path::new(self.operation.path().as_str())
.starts_with(self.output_root.as_str())
{
return Err(WorkdirError::Denied(
"checkout search must stay beneath its output root".into(),
return Err(WorkdirError::denied(
crate::WorkdirDenialReason::CheckoutOutputRootExceeded,
"checkout search must stay beneath its output root",
));
}
if self.scope_layers.len() > 16
+9 -1
View File
@@ -751,7 +751,15 @@ async fn checkout_search_native_skips_symlinked_ignore_sources_without_outside_e
)))
.await
.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 [
(".ignore", "root-ignore-visible.txt"),
+44 -3
View File
@@ -497,18 +497,24 @@ impl<'de> Deserialize<'de> for ExternalWorkdirOperationResult {
#[serde(deny_unknown_fields)]
pub struct ExternalWorkdirOperationError {
code: crate::http::WorkdirTransportErrorCode,
#[serde(default, skip_serializing_if = "Option::is_none")]
denial_reason: Option<crate::WorkdirDenialReason>,
}
impl ExternalWorkdirOperationError {
/// Collapse provider failures to a closed error code. Provider-authored
/// diagnostics never cross the External Workdir trust boundary.
/// Keep only closed error codes and typed refusal reasons. Provider-authored
/// text never crosses the External Workdir trust boundary.
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 {
WorkdirTransportError {
code: self.code,
denial_reason: self.denial_reason,
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]
fn provider_failures_are_closed_and_drop_provider_authored_diagnostics() {
let error = ExternalWorkdirOperationError::from_transport_error(WorkdirTransportError {
code: crate::http::WorkdirTransportErrorCode::Internal,
denial_reason: None,
message: "/home/operator/private provider-secret\ncontrol".to_string(),
});
let frame = ExternalWorkdirProviderFrame::current(
+31 -3
View File
@@ -357,9 +357,17 @@ async fn external_local_provider_rejects_symlink_roots_and_traversal() {
)
.await
.unwrap_err();
let classification = match &error {
WorkdirError::DenialContext { source, .. } => source.as_ref(),
error => error,
};
assert_ne!(
error.denial_reason(),
Some(crate::WorkdirDenialReason::OsPermissionDenied)
);
assert!(
matches!(
error,
classification,
WorkdirError::SymlinkOutOfScope { .. } | WorkdirError::OutOfScope(_)
),
"unexpected error for {path}: {error:?}"
@@ -375,6 +383,10 @@ async fn external_local_provider_rejects_symlink_roots_and_traversal() {
BoundedReadLimits::new(4096, 1024).unwrap(),
)
.unwrap_err();
assert_eq!(
error.denial_reason(),
Some(crate::WorkdirDenialReason::ExternalRootSymlink)
);
assert!(matches!(error, WorkdirError::Denied(_)));
}
@@ -408,9 +420,17 @@ async fn external_read_write_provider_rejects_symlink_mutation_escape() {
)
.await
.unwrap_err();
let classification = match &error {
WorkdirError::DenialContext { source, .. } => source.as_ref(),
error => error,
};
assert_ne!(
error.denial_reason(),
Some(crate::WorkdirDenialReason::OsPermissionDenied)
);
assert!(
matches!(
error,
classification,
WorkdirError::SymlinkOutOfScope { .. }
| WorkdirError::OutOfScope(_)
| WorkdirError::Denied(_)
@@ -559,7 +579,15 @@ async fn external_grep_honors_gitignore_above_a_nested_search_root() {
)
.await
.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")]
+44 -5
View File
@@ -269,6 +269,9 @@ impl WorkdirTransportErrorCode {
pub struct WorkdirTransportError {
pub code: WorkdirTransportErrorCode,
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 {
@@ -276,6 +279,11 @@ impl WorkdirTransportError {
pub fn from_workdir_error(error: &WorkdirError) -> Self {
use WorkdirTransportErrorCode as Code;
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(_) => (
Code::OutcomeUnknown,
"Workdir operation may have effects; inspect before retrying",
@@ -289,6 +297,7 @@ impl WorkdirTransportError {
return Self {
code: Code::Unsupported,
message: format!("Workdir capability {capability:?} is not available"),
denial_reason: None,
};
}
WorkdirError::UnsupportedOperation(_) => {
@@ -346,12 +355,13 @@ impl WorkdirTransportError {
Self {
code,
message: message.to_string(),
denial_reason: error.denial_reason(),
}
}
pub fn into_workdir_error(self) -> WorkdirError {
use WorkdirTransportErrorCode as Code;
match self.code {
let error = match self.code {
Code::NotFound => WorkdirError::NotFound("<remote>".into()),
Code::Conflict => WorkdirError::Conflict(self.message),
Code::OutcomeUnknown => WorkdirError::OutcomeUnknown(
@@ -359,7 +369,11 @@ impl WorkdirTransportError {
),
Code::Unsupported => WorkdirError::UnsupportedOperation(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::SymlinkOutOfScope => WorkdirError::SymlinkOutOfScope {
path: "<remote>".into(),
@@ -390,7 +404,18 @@ impl WorkdirTransportError {
},
Code::Transport => WorkdirError::Transport(self.message),
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
}
}
@@ -691,10 +716,23 @@ mod client {
"mismatched response after checkout mutation".into(),
)),
Ok(_) => Err(Self::mismatch("checkout_execute")),
Err(WorkdirError::Unavailable(_) | WorkdirError::Transport(_)) if mutation => {
Err(WorkdirError::OutcomeUnknown(
Err(error)
if mutation
&& matches!(
error.classification_source(),
WorkdirError::Unavailable(_) | WorkdirError::Transport(_)
) =>
{
let outcome = WorkdirError::OutcomeUnknown(
"checkout transport failed; effects may have occurred".into(),
))
);
Err(match error.denial_reason() {
Some(reason) => WorkdirError::DenialContext {
reason,
source: Box::new(outcome),
},
None => outcome,
})
}
Err(error) => Err(error),
}
@@ -949,6 +987,7 @@ mod tests {
] {
let transport = WorkdirTransportError {
code,
denial_reason: None,
message: if code == WorkdirTransportErrorCode::Conflict {
"The target file's content or existence changed since it was last observed; read the file again before retrying"
.to_string()
+154 -3
View File
@@ -211,7 +211,8 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
}) {
Ok(())
} else {
Err(WorkdirError::Denied(
Err(WorkdirError::denied(
WorkdirDenialReason::ScopeResolutionUnavailable,
"Workdir provider cannot establish resolved scope authority".to_string(),
))
}
@@ -221,7 +222,8 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
&self,
_request: WorkdirScopeOverlapRequest,
) -> Result<bool, WorkdirError> {
Err(WorkdirError::Denied(
Err(WorkdirError::denied(
WorkdirDenialReason::ScopeComparisonUnavailable,
"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>;
/// 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)]
pub enum WorkdirError {
#[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")]
SessionClosed,
@@ -398,6 +515,40 @@ pub enum WorkdirError {
}
impl WorkdirError {
/// The error used for operational classification, ignoring only diagnostic
/// context (including nested context). Keep the original error for display
/// and `denial_reason`: enrichment must not alter behavior or retry policy.
pub fn classification_source(&self) -> &Self {
let mut error = self;
while let Self::DenialContext { source, .. } = error {
error = source;
}
error
}
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 {
Self::Io {
path: path.to_path_buf(),
+139 -24
View File
@@ -213,6 +213,51 @@ struct ScopeAccess {
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 {
fn new(
scope: Arc<Scope>,
@@ -238,8 +283,8 @@ impl ScopeAccess {
)
})?;
let relative = path.strip_prefix(&self.root).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
policy_permission_denied(
crate::WorkdirDenialReason::ProviderRootExceeded,
"path is outside provider root",
)
})?;
@@ -288,8 +333,8 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
if logical.starts_with(&self.root) {
Ok(logical.to_path_buf())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
Err(policy_permission_denied(
crate::WorkdirDenialReason::ProviderRootExceeded,
"path is outside provider root",
))
}
@@ -394,8 +439,8 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
)
})?;
let relative = resolved.strip_prefix(&self.root).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
policy_permission_denied(
crate::WorkdirDenialReason::ProviderRootExceeded,
"path is outside provider root",
)
})?;
@@ -491,8 +536,8 @@ impl SearchAccess {
if self.read_allowed(logical, resolved) {
Ok(())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
Err(policy_permission_denied(
crate::WorkdirDenialReason::CheckoutSearchPathDenied,
"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> {
if !self.enumeration_allowed(logical, resolved) {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
return Err(policy_permission_denied(
crate::WorkdirDenialReason::CheckoutEnumerationDenied,
"checkout directory enumeration denied",
));
}
@@ -648,7 +693,8 @@ impl ExternalWorkdirRoot {
let selected_metadata = std::fs::symlink_metadata(directory)
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?;
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(),
));
}
@@ -678,7 +724,8 @@ impl ExternalWorkdirRoot {
if (selected_metadata.dev(), selected_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(),
));
}
@@ -691,7 +738,8 @@ impl ExternalWorkdirRoot {
let path = std::fs::read_link(format!("/proc/self/fd/{}", handle.as_raw_fd()))
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?;
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(),
));
}
@@ -1334,7 +1382,8 @@ impl WorkdirSession for LocalWorkdirSession {
self.validate_operation_path(&request.path)?;
let logical = self.inner.root.join(request.path.as_str());
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(),
));
}
@@ -1353,10 +1402,13 @@ impl WorkdirSession for LocalWorkdirSession {
WorkdirToolScopePermission::Write => parent_permission == Some(Permission::Write),
};
if !parent_allows {
return Err(WorkdirError::Denied(format!(
"Workdir path `{}` exceeds the provider attachment scope",
request.path
)));
return Err(WorkdirError::denied(
crate::WorkdirDenialReason::AttachmentScopeExceeded,
format!(
"Workdir path `{}` exceeds the provider attachment scope",
request.path
),
));
}
let allowed = request.rules.iter().any(|rule| {
if request.permission == WorkdirToolScopePermission::Write
@@ -1383,10 +1435,13 @@ impl WorkdirSession for LocalWorkdirSession {
if allowed {
Ok(())
} else {
Err(WorkdirError::Denied(format!(
"Workdir path `{}` is outside the provider-resolved delegated scope",
request.path
)))
Err(WorkdirError::denied(
crate::WorkdirDenialReason::DelegatedScopeExceeded,
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 relative = Path::new(logical.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(|| {
WorkdirError::InvalidPath("checkout search path is not UTF8".into())
})?)?;
@@ -2088,7 +2148,10 @@ fn sanitize_error(error: WorkdirError, logical: &WorkdirPath) -> WorkdirError {
WorkdirError::Io { source, .. }
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!(
"I/O operation failed for {logical}: {}",
@@ -2587,6 +2650,58 @@ mod tests {
use std::fs;
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]
fn checkout_error_mapping_distinguishes_absence_from_stale_execution_at_all_boundaries() {
let path = WorkdirPath::new("missing").unwrap();
+6 -1
View File
@@ -166,7 +166,12 @@ impl RoutedSessionState {
for handle in handles {
match self.session.command_status(handle.clone()).await {
Ok(status) => self.mark_terminal(&handle, status),
Err(WorkdirError::UnknownCommand(_)) => {
Err(error)
if matches!(
error.classification_source(),
WorkdirError::UnknownCommand(_)
) =>
{
self.active_commands
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
+103 -46
View File
@@ -240,8 +240,18 @@ impl WorkdirScopeLease {
.expect("scoped command set mutex poisoned")
.remove(&command_id);
}
(Ok(()), Err(WorkdirError::UnknownCommand(_)))
| (Err(WorkdirError::UnknownCommand(_)), Err(WorkdirError::UnknownCommand(_))) => {
(cancel, Err(error))
if matches!(
error.classification_source(),
WorkdirError::UnknownCommand(_)
) && match &cancel {
Ok(()) => true,
Err(error) => matches!(
error.classification_source(),
WorkdirError::UnknownCommand(_)
),
} =>
{
self.broker.authority.publish_terminal_if_missing(
&command_id,
CommandStatus::Cancelled,
@@ -595,9 +605,10 @@ impl ScopedWorkdirSession {
if self.capabilities.supports(required) {
Ok(())
} else {
Err(WorkdirError::Denied(format!(
"scoped Workdir tools do not permit {operation}"
)))
Err(WorkdirError::denied(
crate::WorkdirDenialReason::ScopedCapabilityDenied,
format!("scoped Workdir tools do not permit {operation}"),
))
}
}
@@ -612,9 +623,12 @@ impl ScopedWorkdirSession {
.iter()
.any(|rule| rule_allows_path(rule, path, permission))
{
return Err(WorkdirError::Denied(format!(
"logical workdir path `{path}` is outside the scoped {permission:?} scope"
)));
return Err(WorkdirError::denied(
crate::WorkdirDenialReason::LogicalScopeExceeded,
format!(
"logical workdir path `{path}` is outside the scoped {permission:?} scope"
),
));
}
}
Ok(())
@@ -626,9 +640,17 @@ impl ScopedWorkdirSession {
}
let joined = Path::new(self.cwd.as_str()).join(path.as_str());
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(
@@ -739,9 +761,10 @@ impl ScopedWorkdirSession {
})
.await?
{
return Err(WorkdirError::Denied(format!(
"path `{path}` is leased to child Workdir tools"
)));
return Err(WorkdirError::denied(
crate::WorkdirDenialReason::ChildWriteLeaseConflict,
format!("path `{path}` is leased to child Workdir tools"),
));
}
}
Ok(())
@@ -792,8 +815,9 @@ impl ScopedWorkdirSession {
) -> Result<WorkdirSessionCapabilities, WorkdirError> {
self.ensure_active()?;
if rules.is_empty() {
return Err(WorkdirError::Denied(
"workdir tool scope requires at least one logical scope rule".into(),
return Err(WorkdirError::denied(
crate::WorkdirDenialReason::EmptyScope,
"workdir tool scope requires at least one logical scope rule",
));
}
let writable = rules
@@ -804,22 +828,25 @@ impl ScopedWorkdirSession {
&& (!self.capabilities.supports(WorkdirSessionCapability::Write)
|| !self.capabilities.supports(WorkdirSessionCapability::Edit)))
{
return Err(WorkdirError::Denied(
"parent Workdir session cannot scope the requested capabilities".into(),
return Err(WorkdirError::denied(
crate::WorkdirDenialReason::ParentCapabilityDenied,
"parent Workdir session cannot scope the requested capabilities",
));
}
if command {
if !writable {
return Err(WorkdirError::Denied(
"command execution requires a writable scoped path".into(),
return Err(WorkdirError::denied(
crate::WorkdirDenialReason::CommandRequiresWritableScope,
"command execution requires a writable scoped path",
));
}
if !self
.capabilities
.supports(WorkdirSessionCapability::Command)
{
return Err(WorkdirError::Denied(
"parent Workdir session does not support Command".into(),
return Err(WorkdirError::denied(
crate::WorkdirDenialReason::ParentCapabilityDenied,
"parent Workdir session does not support Command",
));
}
}
@@ -829,10 +856,13 @@ impl ScopedWorkdirSession {
.iter()
.any(|parent| rule_contains_rule(parent, requested))
{
return Err(WorkdirError::Denied(format!(
"logical workdir scope `{}` exceeds the parent tool scope",
requested.target
)));
return Err(WorkdirError::denied(
crate::WorkdirDenialReason::ParentScopeExceeded,
format!(
"logical workdir scope `{}` exceeds the parent tool scope",
requested.target
),
));
}
}
}
@@ -866,10 +896,13 @@ impl ScopedWorkdirSession {
.iter()
.any(|rule| rule_allows_path(rule, &request.cwd, WorkdirToolScopePermission::Read))
{
return Err(WorkdirError::Denied(format!(
"scoped tool cwd `{}` is outside the readable scope",
request.cwd
)));
return Err(WorkdirError::denied(
crate::WorkdirDenialReason::CwdOutsideReadableScope,
format!(
"scoped tool cwd `{}` is outside the readable scope",
request.cwd
),
));
}
self.ensure_scope_targets_are_authorized(&request.rules)
.await?;
@@ -913,10 +946,13 @@ impl ScopedWorkdirSession {
})
.await?
{
return Err(WorkdirError::Denied(format!(
"scoped write path `{}` overlaps an active child scope after provider resolution",
requested.target
)));
return Err(WorkdirError::denied(
crate::WorkdirDenialReason::ChildWriteLeaseConflict,
format!(
"scoped write path `{}` overlaps an active child scope after provider resolution",
requested.target
),
));
}
}
}
@@ -1491,7 +1527,10 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
request: WorkdirScopeAuthorizationRequest,
) -> Result<(), WorkdirError> {
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
}
@@ -1554,11 +1593,17 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
}
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> {
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> {
@@ -1574,22 +1619,34 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
}
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> {
Err(WorkdirError::Denied("read-only workdir session".into()))
Err(WorkdirError::denied(
crate::WorkdirDenialReason::ReadOnlySession,
"read-only workdir session",
))
}
async fn command_output(
&self,
_request: CommandOutputRequest,
) -> 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> {
Err(WorkdirError::Denied("read-only workdir session".into()))
Err(WorkdirError::denied(
crate::WorkdirDenialReason::ReadOnlySession,
"read-only workdir session",
))
}
async fn close(&self) -> Result<(), WorkdirError> {
@@ -2273,7 +2330,7 @@ mod tests {
assert!(matches!(
child.read(read("link")).await,
Err(WorkdirError::Denied(message))
if message.contains("provider-resolved delegated scope")
if message.message.contains("provider-resolved delegated scope")
));
}
@@ -2299,7 +2356,7 @@ mod tests {
assert!(matches!(
child.read(read("escape")).await,
Err(WorkdirError::Denied(message))
if message.contains("provider-resolved delegated scope")
if message.message.contains("provider-resolved delegated scope")
));
}
@@ -2400,7 +2457,7 @@ mod tests {
.scope(request("alias-b", WorkdirToolScopePermission::Write))
.await,
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!(
parent
@@ -2411,7 +2468,7 @@ mod tests {
})
.await,
Err(WorkdirError::Denied(message))
if message.contains("leased to child Workdir tools")
if message.message.contains("leased to child Workdir tools")
));
}
@@ -2430,7 +2487,7 @@ mod tests {
assert!(matches!(
child.scope(expanded).await,
Err(WorkdirError::Denied(message))
if message.contains("exceeds the parent tool scope")
if message.message.contains("exceeds the parent tool scope")
));
}
@@ -2499,7 +2556,7 @@ mod tests {
.write(write("secret/parent", "must-be-blocked"))
.await,
Err(WorkdirError::Denied(message))
if message.contains("leased to child Workdir tools")
if message.message.contains("leased to child Workdir tools")
));
assert_eq!(
fs::read_to_string(root.path().join("secret/from-child")).unwrap(),
@@ -0,0 +1,185 @@
//! Wire diagnostics must not change command disappearance or lease release.
//! Fake command handles only; no OS process is started, polled, or cancelled.
use async_trait::async_trait;
use std::{path::Path, sync::Arc};
use tempfile::TempDir;
use workdir::*;
#[derive(Debug)]
struct CommandFailureSession {
local: LocalWorkdirSession,
code: &'static str,
depth: u8,
cancel_ok: bool,
}
impl CommandFailureSession {
fn new(root: &Path, code: &'static str, depth: u8, cancel_ok: bool) -> Arc<Self> {
Arc::new(Self {
local: LocalWorkdirSession::new(
manifest::Scope::writable(root).unwrap(),
root.to_path_buf(),
),
code,
depth,
cancel_ok,
})
}
fn failure(&self) -> WorkdirError {
let mut error = serde_json::from_value::<http::WorkdirTransportError>(serde_json::json!({
"code": self.code, "message": "safe fixture error",
"denial_reason": (self.depth > 0).then_some(WorkdirDenialReason::ReadOnlySession),
}))
.unwrap()
.into_workdir_error();
if self.depth == 2 {
error = WorkdirError::DenialContext {
reason: WorkdirDenialReason::OsPermissionDenied,
source: Box::new(error),
};
}
error
}
}
#[async_trait]
impl WorkdirSession for CommandFailureSession {
fn workdir(&self) -> &Workdir {
self.local.workdir()
}
fn capabilities(&self) -> WorkdirSessionCapabilities {
self.local.capabilities()
}
async fn authorize_scope_path(
&self,
r: WorkdirScopeAuthorizationRequest,
) -> Result<(), WorkdirError> {
self.local.authorize_scope_path(r).await
}
async fn scope_rules_overlap(
&self,
r: WorkdirScopeOverlapRequest,
) -> Result<bool, WorkdirError> {
self.local.scope_rules_overlap(r).await
}
async fn stat(&self, r: StatRequest) -> Result<StatResult, WorkdirError> {
self.local.stat(r).await
}
async fn read(&self, r: ReadRequest) -> Result<ReadResult, WorkdirError> {
self.local.read(r).await
}
async fn write(&self, r: WriteRequest) -> Result<WriteResult, WorkdirError> {
self.local.write(r).await
}
async fn edit(&self, r: EditRequest) -> Result<EditResult, WorkdirError> {
self.local.edit(r).await
}
async fn list(&self, r: ListRequest) -> Result<ListResult, WorkdirError> {
self.local.list(r).await
}
async fn glob(&self, r: GlobRequest) -> Result<GlobResult, WorkdirError> {
self.local.glob(r).await
}
async fn grep(&self, r: GrepRequest) -> Result<GrepResult, WorkdirError> {
self.local.grep(r).await
}
async fn start_command(&self, _: CommandRequest) -> Result<CommandHandle, WorkdirError> {
Ok(CommandHandle("fake-command".into()))
}
async fn command_status(&self, _: CommandHandle) -> Result<CommandStatus, WorkdirError> {
Err(self.failure())
}
async fn command_output(&self, _: CommandOutputRequest) -> Result<CommandOutput, WorkdirError> {
Err(self.failure())
}
async fn cancel_command(&self, _: CommandHandle) -> Result<(), WorkdirError> {
if self.cancel_ok {
Ok(())
} else {
Err(self.failure())
}
}
async fn close(&self) -> Result<(), WorkdirError> {
self.local.close().await
}
}
fn command() -> CommandRequest {
CommandRequest {
command: "fake command, never executed".into(),
timeout_secs: 1,
output_limit: 100,
cwd: WorkdirPath::root(),
spill_dir: None,
tool_call_id: None,
}
}
fn scope() -> WorkdirToolScope {
WorkdirToolScope {
rules: vec![WorkdirToolScopeRule {
target: WorkdirPath::root(),
permission: WorkdirToolScopePermission::Write,
recursive: true,
symlink_policy: Default::default(),
}],
command: true,
cwd: WorkdirPath::root(),
}
}
#[tokio::test]
async fn command_diagnostics_preserve_detach_disappearance_and_busy_guard() {
for code in ["unknown_command", "unavailable"] {
for depth in [0, 1, 2] {
let dir = TempDir::new().unwrap();
let router = WorkdirSessionRouter::new();
let alias = WorkdirAttachmentAlias::new("fixture").unwrap();
router
.attach(
alias.clone(),
CommandFailureSession::new(dir.path(), code, depth, false),
)
.unwrap();
let session = router.resolve(Some("fixture")).unwrap().session;
session.start_command(command()).await.unwrap();
let result = router.detach(&alias).await;
if code == "unknown_command" {
result.unwrap_or_else(|error| panic!("depth {depth}: {error:?}"));
assert!(router.resolve(Some("fixture")).is_err());
} else {
assert!(matches!(result, Err(WorkdirError::Conflict(_))));
assert!(router.resolve(Some("fixture")).is_ok());
}
}
}
}
#[tokio::test]
async fn command_diagnostics_preserve_scope_close_and_write_lease_guard() {
for code in ["unknown_command", "unavailable"] {
for depth in [0, 1, 2] {
for cancel_ok in [false, true] {
let dir = TempDir::new().unwrap();
let broker = WorkdirToolBroker::new(CommandFailureSession::new(
dir.path(),
code,
depth,
cancel_ok,
));
let child = broker.scope(scope()).await.unwrap();
child.start_command(command()).await.unwrap();
let result = child.close().await;
if code == "unknown_command" {
result.unwrap_or_else(|error| {
panic!("depth {depth}, cancel_ok={cancel_ok}: {error:?}")
});
assert!(!child.is_active());
broker.scope(scope()).await.unwrap().close().await.unwrap();
} else {
assert!(result.is_err());
// Closing revokes new work immediately, but failed cleanup
// must retain the write lease until ownership is resolved.
assert!(!child.is_active());
assert!(broker.scope(scope()).await.is_err());
}
}
}
}
}
+333
View File
@@ -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();
}
+288
View File
@@ -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,218 @@
#![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, Instant},
};
use workdir::{
StatRequest, Workdir, WorkdirDenialReason as Reason, WorkdirPath, WorkdirSession,
http::{
OpenWorkdirSessionRequest, RemoteWorkdirSession, WorkdirTransportError,
WorkdirTransportErrorCode as Code,
},
};
fn reply(listener: &TcpListener, status: &str, body: &str) {
listener.set_nonblocking(true).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let (mut stream, _) = loop {
match listener.accept() {
Ok(connection) => break connection,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
assert!(Instant::now() < deadline, "fake provider accept timed out");
thread::yield_now();
}
Err(error) => panic!("fake provider accept failed: {error}"),
}
};
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();
}
}
#[tokio::test]
async fn remote_checkout_diagnostics_preserve_mutation_outcome_and_read_failures() {
use workdir::{CheckoutOperation as Op, CheckoutRequest, WorkdirSessionCapability};
for code in [
Code::Unavailable,
Code::Transport,
Code::Denied,
Code::Conflict,
Code::OutcomeUnknown,
] {
for reason in [None, Some(Reason::ReadOnlySession)] {
for operation in [
Op::Read {
offset: 0,
limit: 1,
max_bytes: 10,
},
Op::Create {
path: WorkdirPath::new("new.txt").unwrap(),
content: b"fixture".to_vec(),
},
Op::Write {
content: b"fixture".to_vec(),
expected_hash: [0; 32],
},
Op::Edit {
old_string: "old".into(),
new_string: "new".into(),
replace_all: false,
expected_hash: [0; 32],
},
] {
let mutation = operation.capability() != WorkdirSessionCapability::Read;
let expected_code =
if mutation && matches!(code, Code::Unavailable | Code::Transport) {
Code::OutcomeUnknown
} else {
code
};
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let error = serde_json::json!({"code":code, "message":"/secret/host provider-secret", "denial_reason":reason});
let server = thread::spawn(move || {
reply(
&listener,
"200 OK",
r#"{"session_id":"s","workdir_id":"w","capabilities":{"bits":63}}"#,
);
reply(
&listener,
&format!("{} Fixture failure", code.http_status()),
&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 error = remote
.checkout_execute(CheckoutRequest {
target: WorkdirPath::root(),
validator: vec![1],
operation,
})
.await
.unwrap_err();
server.join().unwrap();
let forwarded = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(
forwarded.code, expected_code,
"{code:?}, {reason:?}, mutation={mutation}: {error:?}"
);
assert_eq!(error.denial_reason(), reason);
assert_eq!(forwarded.denial_reason, reason);
assert_eq!(forwarded.code.http_status(), expected_code.http_status());
if expected_code == Code::OutcomeUnknown {
assert!(forwarded.message.contains("inspect before retrying"));
}
assert!(
!serde_json::to_string(&forwarded)
.unwrap()
.contains("secret")
);
}
}
}
}
+1
View File
@@ -2359,6 +2359,7 @@ impl RuntimeHttpWorkdirError {
Self {
status,
payload: WorkdirTransportError {
denial_reason: None,
code,
message: message.into(),
},
+14 -9
View File
@@ -191,15 +191,20 @@ impl Provider {
};
let observation = match selected.session.checkout_observe(target.clone()).await {
Ok(o) => o,
Err(
workdir::WorkdirError::Denied(_)
| workdir::WorkdirError::OutOfScope(_)
| workdir::WorkdirError::NotFound(_)
| workdir::WorkdirError::SessionClosed
| workdir::WorkdirError::UnsupportedOperation(_)
| workdir::WorkdirError::Unsupported(_),
) => return Ok(None),
Err(_) => {
Err(failure) => {
// Internal diagnostic enrichment is transparent to WIP's existing
// unavailable/refusal classification, including remote providers.
if matches!(
failure.classification_source(),
workdir::WorkdirError::Denied(_)
| workdir::WorkdirError::OutOfScope(_)
| workdir::WorkdirError::NotFound(_)
| workdir::WorkdirError::SessionClosed
| workdir::WorkdirError::UnsupportedOperation(_)
| workdir::WorkdirError::Unsupported(_)
) {
return Ok(None);
}
return Err(error(
ProtocolErrorCode::Internal,
"checkout observation unavailable",
+40
View File
@@ -36,6 +36,7 @@ struct ProviderState {
requests: Mutex<Vec<(String, String)>>,
operations: Mutex<Vec<WorkdirSessionOperation>>,
write_fault: Mutex<Option<WriteResponseFault>>,
observe_refusal: Mutex<bool>,
}
impl ProviderState {
@@ -49,6 +50,7 @@ impl ProviderState {
return Err((
StatusCode::UNAUTHORIZED,
AxumJson(WorkdirTransportError {
denial_reason: None,
code: WorkdirTransportErrorCode::Denied,
message: "provider authorization denied".into(),
}),
@@ -140,6 +142,16 @@ async fn operate(
.lock()
.unwrap()
.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 =
dispatch_workdir_session_operation(state.session.as_ref(), request.operation).await;
match result {
@@ -225,6 +237,7 @@ impl HttpFixture {
requests: Mutex::new(Vec::new()),
operations: Mutex::new(Vec::new()),
write_fault: Mutex::new(None),
observe_refusal: Mutex::new(false),
});
let app = Router::new()
.route(
@@ -758,3 +771,30 @@ async fn remote_checkout_list_pages_and_direct_inspect_read_use_provider_without
.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 {
status,
body: serde_json::to_string(&workdir::http::WorkdirTransportError {
denial_reason: None,
code,
message: message.to_string(),
})
+14 -3
View File
@@ -794,6 +794,10 @@ pub struct RuntimeSessionAttachment {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RuntimeRegistryError {
WorkdirSessionOpenFailed {
runtime_id: String,
transport: workdir::http::WorkdirTransportError,
},
InvalidIdentifier {
kind: &'static str,
value: String,
@@ -823,11 +827,19 @@ impl RuntimeRegistryError {
worker.worker_id, worker.runtime_id
),
Self::RuntimeOperationFailed { message, .. } => message.clone(),
Self::WorkdirSessionOpenFailed { transport, .. } => transport.message.clone(),
}
}
pub fn into_error(self) -> Error {
match self {
Self::WorkdirSessionOpenFailed {
runtime_id,
transport,
} => Error::WorkdirSessionOpenFailed {
runtime_id,
transport,
},
Self::InvalidIdentifier { kind, value } => Error::InvalidRuntimeIdentifier {
kind: kind.to_string(),
value,
@@ -1885,10 +1897,9 @@ impl RuntimeRegistry {
runtime
.open_workdir_session(working_directory_id, owner_worker_id)
.await
.map_err(|error| RuntimeRegistryError::RuntimeOperationFailed {
.map_err(|error| RuntimeRegistryError::WorkdirSessionOpenFailed {
runtime_id: runtime_id.to_string(),
code: "workdir_session_open_failed".to_string(),
message: error.to_string(),
transport: workdir::http::WorkdirTransportError::from_workdir_error(&error),
})
}
+5
View File
@@ -106,6 +106,11 @@ pub enum Error {
code: 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}`")]
RuntimeCapabilityUnsupported {
runtime_id: String,
+231 -67
View File
@@ -1225,12 +1225,23 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
"mismatched checkout mutation response".into(),
)),
Ok(_) => Err(Self::mismatch("checkout_execute")),
Err(workdir::WorkdirError::Unavailable(_) | workdir::WorkdirError::Transport(_))
if mutation =>
Err(error)
if mutation
&& matches!(
error.classification_source(),
workdir::WorkdirError::Unavailable(_) | workdir::WorkdirError::Transport(_)
) =>
{
Err(workdir::WorkdirError::OutcomeUnknown(
let outcome = workdir::WorkdirError::OutcomeUnknown(
"checkout provider response lost; inspect effects before retry".into(),
))
);
Err(match error.denial_reason() {
Some(reason) => workdir::WorkdirError::DenialContext {
reason,
source: Box::new(outcome),
},
None => outcome,
})
}
Err(error) => Err(error),
}
@@ -11436,6 +11447,15 @@ impl server_api::ServerApi for ServerApiContractService {
server_api::CurrentWorkerWorkdirOperationResponse,
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(
State(
self.workspace_api()
@@ -11443,22 +11463,18 @@ impl server_api::ServerApi for ServerApiContractService {
.clone(),
),
AxumPath(ScopedWorkspacePath { workspace_id }),
current_worker_contract_headers(&context)
.map_err(server_api::WorkdirOperationApiError::api)?,
current_worker_contract_headers(&context).map_err(|error| {
let mut error = server_api::WorkdirOperationApiError::api(error);
error.workdir_log = Some(fallback_log);
error
})?,
Json(WorkspaceWorkdirSessionOperationRequest {
target_workdir: request.target_workdir,
operation: request.operation,
}),
)
.await
.map_err(|error| match error {
InternalWorkdirOperationError::Api(error) => {
server_api::WorkdirOperationApiError::api(error.into_repository_api_error())
}
InternalWorkdirOperationError::Provider(error) => {
server_api::WorkdirOperationApiError::provider(error)
}
})?;
.map_err(InternalWorkdirOperationError::into_contract_error)?;
Ok(server_api::CurrentWorkerWorkdirOperationResponse(response))
}
@@ -11700,6 +11716,10 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
async fn log_failed_api_response(request: Request, next: Next) -> Response {
let method = request.method().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 status = response.status();
@@ -11720,11 +11740,23 @@ async fn log_failed_api_response(request: Request, next: Next) -> Response {
.get::<ApiErrorLog>()
.or(generated_error.as_ref());
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!(
target: "yoi::api",
event = event.event,
method = event.method,
path = event.path,
method = workdir_method.as_deref().unwrap_or(event.method),
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,
kind = event.kind.unwrap_or("unknown"),
message = event.message.unwrap_or(""),
@@ -17202,9 +17234,10 @@ fn ticket_notification_delivery_error_category(
Err(RuntimeRegistryError::UnknownRuntime(_)) => Some("unknown_runtime"),
Err(RuntimeRegistryError::UnknownHost(_)) => Some("unknown_host"),
Err(RuntimeRegistryError::UnknownWorker { .. }) => Some("unknown_worker"),
Err(RuntimeRegistryError::RuntimeOperationFailed { .. }) => {
Some("runtime_operation_failed")
}
Err(
RuntimeRegistryError::WorkdirSessionOpenFailed { .. }
| RuntimeRegistryError::RuntimeOperationFailed { .. },
) => Some("runtime_operation_failed"),
}
}
@@ -18195,6 +18228,33 @@ fn validated_current_worker_attachment(
enum InternalWorkdirOperationError {
Api(ApiError),
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 {
@@ -18217,21 +18277,35 @@ impl From<WorkdirTransportError> for InternalWorkdirOperationError {
impl IntoResponse for InternalWorkdirOperationError {
fn into_response(self) -> Response {
match self {
Self::Api(error) => error.into_response(),
Self::Provider(error) => {
let status = StatusCode::from_u16(error.code.http_status())
.expect("Workdir transport error status is valid");
let log = ApiErrorLog {
kind: format!("workdir_session_operation_{}", error.code.as_str()),
message: error.message.clone(),
diagnostics: Vec::new(),
};
let mut response = (status, Json(error)).into_response();
response.extensions_mut().insert(log);
response
}
}
let error = self.into_contract_error();
let status = StatusCode::from_u16(api_macros::HttpError::status_code(&error))
.expect("Workdir error status is valid");
let log = ApiErrorLog::from_workdir_operation_error(&error, status);
let mut response = (status, Json(error)).into_response();
response.extensions_mut().insert(log);
response
}
}
fn workdir_operation_label(operation: &WorkdirSessionOperation) -> &'static str {
match operation {
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",
}
}
@@ -18241,19 +18315,35 @@ async fn scoped_execute_current_worker_workdir_operation(
headers: HeaderMap,
Json(request): Json<WorkspaceWorkdirSessionOperationRequest>,
) -> 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)?;
context.stage = "worker_identity";
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 result = match request.operation {
WorkdirSessionOperation::CommandStart(command) => {
let session_lock = current_worker_session_lock(&api, &worker);
let _session_guard = session_lock.lock().await;
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 = "session_open";
let source = open_current_worker_workdir_session_locked(&api, &worker, &link).await?;
context.stage = "provider_dispatch";
let provider_handle = source
.start_command(command)
.await
.map_err(|error| current_worker_workdir_operation_error(&worker, error))?;
.map_err(workdir_operation_transport_error)?;
context.stage = "command_registration";
let registered_source = api
.workdir_sessions
.lock()
@@ -18279,31 +18369,31 @@ async fn scoped_execute_current_worker_workdir_operation(
}
WorkdirSessionOperation::CommandStatus(external_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
.command_status(provider_handle)
.await
.map(WorkdirSessionOperationResult::CommandStatus)
.map_err(|error| current_worker_workdir_operation_error(&worker, error))?
.map_err(workdir_operation_transport_error)?
}
WorkdirSessionOperation::CommandOutput(mut output) => {
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;
session
.command_output(output)
.await
.map(WorkdirSessionOperationResult::CommandOutput)
.map_err(|error| current_worker_workdir_operation_error(&worker, error))?
.map_err(workdir_operation_transport_error)?
}
WorkdirSessionOperation::CommandCancel(external_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
.cancel_command(provider_handle)
.await
.map(|()| WorkdirSessionOperationResult::CommandCancel)
.map_err(|error| current_worker_workdir_operation_error(&worker, error))?
.map_err(workdir_operation_transport_error)?
}
operation @ (WorkdirSessionOperation::AuthorizeScope(_)
| WorkdirSessionOperation::ScopeRulesOverlap(_)
@@ -18320,14 +18410,23 @@ async fn scoped_execute_current_worker_workdir_operation(
| WorkdirSessionOperation::Grep(_)) => {
let session_lock = current_worker_session_lock(&api, &worker);
let _session_guard = session_lock.lock().await;
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 = "session_open";
let source = open_current_worker_workdir_session_locked(&api, &worker, &link).await?;
context.stage = "provider_dispatch";
execute_workdir_session_operation(&source, operation)
.await
.map_err(|error| current_worker_workdir_operation_error(&worker, error))?
.map_err(workdir_operation_transport_error)?
}
};
Ok(Json(result))
}.await;
result.map_err(|error| InternalWorkdirOperationError::Contextual {
error: Box::new(error),
context,
})
}
fn current_worker_command_session(
@@ -18335,26 +18434,27 @@ fn current_worker_command_session(
worker: &RuntimeWorkerRef,
target_workdir: &str,
external_handle: &CommandHandle,
context: &mut server_api::WorkdirOperationLogContext,
) -> 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
.workdir_sessions
.lock()
.expect("Workdir session registry lock poisoned")
.command(worker, target_workdir, external_handle)
.ok_or_else(|| {
InternalWorkdirOperationError::Provider(current_worker_workdir_operation_error(
worker,
InternalWorkdirOperationError::Provider(workdir_operation_transport_error(
workdir::WorkdirError::UnknownCommand(external_handle.0.clone()),
))
})?;
context.stage = "provider_dispatch";
Ok((command.source, command.provider_handle))
}
fn current_worker_workdir_operation_error(
_worker: &RuntimeWorkerRef,
error: workdir::WorkdirError,
) -> WorkdirTransportError {
fn workdir_operation_transport_error(error: workdir::WorkdirError) -> WorkdirTransportError {
WorkdirTransportError::from_workdir_error(&error)
}
@@ -23453,6 +23553,7 @@ async fn serve_external_workdir_provider(
Some(ExternalProviderCommand::Operation { operation_id, operation, response }) => {
if revoking {
let _ = response.send(Err(WorkdirTransportError {
denial_reason: None,
code: workdir::http::WorkdirTransportErrorCode::Unavailable,
message: "External Workdir provider shutdown is in progress".to_string(),
}));
@@ -23460,6 +23561,7 @@ async fn serve_external_workdir_provider(
}
if pending.len() >= 16 {
let _ = response.send(Err(WorkdirTransportError {
denial_reason: None,
code: workdir::http::WorkdirTransportErrorCode::Unavailable,
message: "External Workdir provider is at its in-flight operation limit".to_string(),
}));
@@ -23470,6 +23572,7 @@ async fn serve_external_workdir_provider(
Ok(operation) => operation,
Err(message) => {
let _ = response.send(Err(WorkdirTransportError {
denial_reason: None,
code: workdir::http::WorkdirTransportErrorCode::InvalidRequest,
message,
}));
@@ -23526,6 +23629,7 @@ async fn serve_external_workdir_provider(
Err(error.into_transport_error())
}
ExternalWorkdirOperationOutcome::Cancelled => Err(WorkdirTransportError {
denial_reason: None,
code: workdir::http::WorkdirTransportErrorCode::Unavailable,
message: "External Workdir operation was cancelled".to_string(),
}),
@@ -24007,6 +24111,9 @@ fn workdir_runtime_failure_code(error: &RuntimeRegistryError) -> String {
RuntimeRegistryError::UnknownRuntime(_) | RuntimeRegistryError::UnknownHost(_) => {
"runtime_unavailable".to_string()
}
RuntimeRegistryError::WorkdirSessionOpenFailed { .. } => {
"workdir_session_open_failed".to_string()
}
RuntimeRegistryError::RuntimeOperationFailed { code, .. } => code.clone(),
RuntimeRegistryError::InvalidIdentifier { .. } => "invalid_runtime".to_string(),
RuntimeRegistryError::UnknownWorker { .. } => "runtime_workdir_lookup_failed".to_string(),
@@ -24518,6 +24625,7 @@ fn runtime_reports_workdir_not_found(result: &crate::hosts::RuntimeWorkingDirect
fn classify_workdir_provider_error(error: &RuntimeRegistryError) -> (&'static str, bool) {
match error {
RuntimeRegistryError::WorkdirSessionOpenFailed { .. } => ("provider_unavailable", true),
RuntimeRegistryError::UnknownRuntime(_) => ("runtime_unavailable", true),
RuntimeRegistryError::RuntimeOperationFailed { code, .. } => {
let category =
@@ -32556,6 +32664,8 @@ struct ApiErrorLog {
kind: String,
message: String,
diagnostics: Vec<RuntimeDiagnostic>,
workdir_log: Option<server_api::WorkdirOperationLogContext>,
denial_reason: Option<workdir::WorkdirDenialReason>,
}
impl ApiErrorLog {
@@ -32568,9 +32678,11 @@ impl ApiErrorLog {
kind: format!("workdir_session_operation_{}", code.as_str()),
message: sanitize_backend_error(&error.message),
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(),
error.error.as_deref().unwrap_or_else(|| {
status
@@ -32579,11 +32691,25 @@ impl ApiErrorLog {
}),
&error.message,
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());
// This can also fail after a command was dispatched. Do not claim
// a pre-dispatch refusal or absence of effects in the safe message.
log.message = "Workdir operation failed".to_string();
log.diagnostics.clear();
}
log
}
fn from_repository_error(error: &server_api::RepositoryApiError) -> Self {
Self {
workdir_log: None,
denial_reason: None,
kind: error
.diagnostics
.first()
@@ -32616,6 +32742,11 @@ impl From<merge_request::MergeRequestError> for ApiError {
impl From<Error> for ApiError {
fn from(error: Error) -> Self {
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 {
code: code.clone(),
severity: HostDiagnosticSeverity::Error,
@@ -32834,13 +32965,18 @@ fn api_error_status(error: &Error) -> StatusCode {
StatusCode::SERVICE_UNAVAILABLE
}
Error::WorkspaceSigningIdentity { .. } => StatusCode::SERVICE_UNAVAILABLE,
Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY,
Error::WorkdirSessionOpenFailed { .. } | Error::RuntimeOperationFailed { .. } => {
StatusCode::BAD_GATEWAY
}
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn api_error_response_message(error: &Error) -> String {
match error {
Error::WorkdirSessionOpenFailed { transport, .. } => {
format!("workdir_session_open_failed: {}", transport.message)
}
Error::RuntimeOperationFailed { code, message, .. } => {
format!("{code}: {}", sanitize_backend_error(message))
}
@@ -32853,6 +32989,8 @@ impl IntoResponse for ApiError {
let status = api_error_status(&self.error);
let response_message = api_error_response_message(&self.error);
let log = ApiErrorLog {
workdir_log: None,
denial_reason: None,
kind: self
.diagnostics
.first()
@@ -32890,6 +33028,7 @@ mod tests {
mod drive_api_tests;
mod drive_tests;
mod drive_web_roundtrip_tests;
mod external_checkout_tests;
mod subject_spawn_tests;
mod subjektiv_jobs_tests;
mod ticket_evidence_tests;
@@ -33155,6 +33294,7 @@ mod tests {
assert!(matches!(operation, WorkdirSessionOperation::Read(_)));
response
.send(Err(WorkdirTransportError {
denial_reason: None,
code: workdir::http::WorkdirTransportErrorCode::NotFound,
message: "logical file was not found".to_string(),
}))
@@ -35153,6 +35293,13 @@ mod tests {
#[derive(Clone, Default)]
struct WorkdirlessFixtureRuntime {
workdir_session_factory: Option<
Arc<
dyn Fn(&str) -> std::result::Result<WorkdirSessionHandle, workdir::WorkdirError>
+ Send
+ Sync,
>,
>,
input_requests: Arc<Mutex<Vec<WorkerInputRequest>>>,
workers: Arc<Mutex<Vec<InternalWorkerSummary>>>,
spawn_requests: Arc<Mutex<Vec<WorkerSpawnRequest>>>,
@@ -35291,6 +35438,27 @@ mod tests {
}
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(
&self,
worker_id: &str,
@@ -36446,6 +36614,8 @@ mod tests {
.parse::<Uri>()
.expect("valid URI");
let error = ApiErrorLog {
workdir_log: None,
denial_reason: None,
kind: "ticket_backend_error".to_string(),
message: "sqlite error: FOREIGN KEY constraint failed".to_string(),
diagnostics: vec![RuntimeDiagnostic {
@@ -36587,6 +36757,7 @@ mod tests {
),
] {
let response = InternalWorkdirOperationError::Provider(WorkdirTransportError {
denial_reason: None,
code,
message: "safe provider message".to_string(),
})
@@ -36606,14 +36777,11 @@ mod tests {
#[tokio::test]
async fn local_and_remote_validation_errors_share_workspace_classification() {
let worker = RuntimeWorkerRef::new("runtime", "worker");
let local = current_worker_workdir_operation_error(
&worker,
workdir::WorkdirError::InvalidGlob("[".to_string()),
);
let remote = current_worker_workdir_operation_error(
&worker,
let local =
workdir_operation_transport_error(workdir::WorkdirError::InvalidGlob("[".to_string()));
let remote = workdir_operation_transport_error(
WorkdirTransportError {
denial_reason: None,
code: workdir::http::WorkdirTransportErrorCode::InvalidRequest,
message: "Workdir operation request is invalid".to_string(),
}
@@ -36639,10 +36807,7 @@ mod tests {
path: PathBuf::from("/host/private/worktree/secret.txt"),
source: std::io::Error::other("token=secret"),
};
let public = current_worker_workdir_operation_error(
&RuntimeWorkerRef::new("runtime", "worker"),
error,
);
let public = workdir_operation_transport_error(error);
let response = InternalWorkdirOperationError::Provider(public).into_response();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let text = String::from_utf8(body.to_vec()).unwrap();
@@ -36651,10 +36816,7 @@ mod tests {
let decoded: WorkdirTransportError = serde_json::from_str(&text).unwrap();
assert_eq!(decoded.code, workdir::http::WorkdirTransportErrorCode::Io);
let remote = current_worker_workdir_operation_error(
&RuntimeWorkerRef::new("runtime", "worker"),
workdir::WorkdirError::OperationFailed,
);
let remote = workdir_operation_transport_error(workdir::WorkdirError::OperationFailed);
let response = InternalWorkdirOperationError::Provider(remote).into_response();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
@@ -38254,6 +38416,7 @@ mod tests {
.uri(format!("/api/w/{TEST_WORKSPACE_ID}/worker-control/workers"))
// Unit fixture stands below proof middleware, so inject its verified subject.
.extension(crate::worker_source::VerifiedRuntimeRequestSource {
token_id_hash: None,
runtime_id: fixture.controller.runtime_id.clone(),
worker_id: Some(fixture.controller.worker_id.clone()),
})
@@ -38577,6 +38740,7 @@ mod tests {
.method("POST")
.uri(format!("/api/w/{TEST_WORKSPACE_ID}/worker-control/workers"))
.extension(crate::worker_source::VerifiedRuntimeRequestSource {
token_id_hash: None,
runtime_id: controller.runtime_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>) {
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 writer = logs.clone();
let subscriber = tracing_subscriber::fmt()
@@ -32,18 +38,41 @@ async fn request_with_logs(app: Router, request: Request<Body>) -> (StatusCode,
.await
.unwrap();
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();
assert_eq!(
content_type,
"application/json",
"status={status}, body={}",
String::from_utf8_lossy(&body)
);
let body: Value = serde_json::from_slice(&body).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}");
}
let events = text
let events: Vec<Value> = text
.lines()
.map(|line| serde_json::from_str::<Value>(line).unwrap())
.filter(|event| event["event"] == "api_error")
.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)
}
@@ -278,17 +307,50 @@ async fn generated_workdir_provider_errors_preserve_response_and_log_details() {
)
};
let app = build_router(fixture.api.clone());
for (code, status, message) in [
(Code::Denied, 403, "Workdir operation was denied"),
(Code::OutOfScope, 403, "Workdir path is out of scope"),
for (code, status, message, reason) in [
(Code::Denied, 403, "Workdir operation was denied", None),
(
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,
403,
"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 Some(ExternalProviderCommand::Operation {
@@ -305,6 +367,7 @@ async fn generated_workdir_provider_errors_preserve_response_and_log_details() {
));
response
.send(Err(WorkdirTransportError {
denial_reason: reason,
code,
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]["method"], "POST");
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 Some(ExternalProviderCommand::Operation { response, .. }) = commands.recv().await
@@ -374,8 +453,358 @@ async fn generated_workdir_api_errors_preserve_response_and_log_details() {
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body, serde_json::to_value(expected).unwrap());
assert_eq!(events.len(), 1, "{events:?}");
assert_eq!(events[0]["kind"], "forbidden");
assert_eq!(events[0]["message"], body["message"]);
assert_eq!(events[0]["kind"], "workdir_session_operation_api_403");
assert_eq!(events[0]["message"], "Workdir operation failed");
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]["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,
),
(
// Scoped forwarding validates this READ_ONLY parent's capabilities
// before provider resolution. Local now supports scope resolution;
// denying write rules is not a missing-resolver failure.
json!({"operation": "authorize_scope", "request": {"path": "secret.txt", "permission": "write", "rules": [{"target": "", "permission": "write", "recursive": true}]}}),
"authorize_scope",
"denied",
Reason::ParentCapabilityDenied,
),
];
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:?}"
);
}
// Control: the same READ_ONLY provider can authorize read rules through
// scoped forwarding and native resolution. Keep a capability refusal
// distinct from an unavailable scope resolver; successful authorization
// must not emit an api_error or mutate the filesystem.
let read_scope = 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": "authorize_scope", "request": {
"path": "", "permission": "read",
"rules": [{"target": "", "permission": "read", "recursive": true}],
}},
}))
.unwrap(),
);
let (status, body, events) = request_with_logs(app, read_scope).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(
body,
serde_json::to_value(WorkdirSessionOperationResult::AuthorizeScope).unwrap(),
);
assert!(
events.is_empty(),
"successful scope authorization logged as failure: {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"));
}
}
#[tokio::test]
async fn self_workdir_post_start_registration_failure_does_not_claim_pre_dispatch_refusal() {
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 (sender, mut commands) = tokio::sync::mpsc::channel(1);
let connection = Arc::new(ExternalProviderConnection {
grant_id: "logging-grant".to_string(),
workdir_id: fixture.main_workdir_id.clone(),
provider_instance_id: "logging-provider".to_string(),
generation: 1,
expires_at: None,
capabilities: workdir::WorkdirSessionCapabilities::ALL,
admission: Arc::new(tokio::sync::Semaphore::new(1)),
shutdown_confirmed: Arc::new(std::sync::atomic::AtomicBool::new(false)),
sender,
});
fixture.runtime.workdir_session_factory = Some(Arc::new(move |_| {
Ok(Arc::new(ExternalProviderWorkdirSession::new(
connection.clone(),
None,
)) as WorkdirSessionHandle)
}));
fixture
.api
.runtime
.register_or_replace(fixture.runtime.clone());
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": "command_start", "request": {
"command": "body-secret", "timeout_secs": 1, "output_limit": 1024,
"cwd": "", "spill_dir": null, "tool_call_id": "body-secret"
}}
}))
.unwrap(),
);
// Observe command dispatch, then lose only the fixture registry entry before
// completing start. No OS process is launched, no sleep or retry is needed.
let provider = async {
let Some(ExternalProviderCommand::Operation {
operation,
response,
..
}) = commands.recv().await
else {
panic!("expected provider start operation");
};
assert!(matches!(
operation,
WorkdirSessionOperation::CommandStart(_)
));
fixture
.api
.workdir_sessions
.lock()
.unwrap()
.remove_attachment(&fixture.worker)
.unwrap();
response
.send(Ok(WorkdirSessionOperationResult::CommandStart(
CommandHandle("fixture-provider-handle".into()),
)))
.unwrap();
};
let ((status, body, events), ()) = tokio::join!(
request_with_logs(build_router(fixture.api.clone()), request),
provider
);
assert_eq!(status, StatusCode::BAD_GATEWAY, "{body}");
assert!(
body["message"]
.as_str()
.unwrap()
.starts_with("workdir_session_registration_failed:")
);
assert_eq!(events.len(), 1, "{events:?}");
assert_eq!(events[0]["operation"], "command_start");
assert_eq!(events[0]["workdir_stage"], "command_registration");
assert_eq!(events[0]["message"], "Workdir operation failed");
assert_eq!(events[0]["denial_reason"], Value::Null);
assert_workdir_log_identity(
&events[0],
&fixture,
"checkout",
Some(&fixture.main_workdir_id),
);
assert!(
commands.try_recv().is_err(),
"post-dispatch failure was retried"
);
}
@@ -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()))
.layer(Extension(
crate::worker_source::VerifiedRuntimeRequestSource {
token_id_hash: None,
runtime_id: workers[0].runtime_id.clone(),
worker_id: Some(workers[0].worker_id.clone()),
},
@@ -0,0 +1,129 @@
//! External codec -> actual session dispatch -> mutation safety -> HTTP error.
//! Channel fixture only; no product process or real mutation.
use super::*;
use workdir::{
CheckoutOperation as Op, CheckoutRequest, WorkdirDenialReason as Reason, WorkdirPath,
WorkdirSession, WorkdirSessionCapability, external::ExternalWorkdirOperationOutcome,
http::WorkdirTransportErrorCode as Code,
};
#[tokio::test]
async fn external_checkout_diagnostics_preserve_mutation_outcome_and_read_failures() {
for code in [
Code::Unavailable,
Code::Transport,
Code::Denied,
Code::Conflict,
Code::OutcomeUnknown,
] {
for reason in [None, Some(Reason::ReadOnlySession)] {
for operation in [
Op::Read {
offset: 0,
limit: 1,
max_bytes: 10,
},
Op::Create {
path: WorkdirPath::new("new.txt").unwrap(),
content: b"fixture".to_vec(),
},
Op::Write {
content: b"fixture".to_vec(),
expected_hash: [0; 32],
},
Op::Edit {
old_string: "old".into(),
new_string: "new".into(),
replace_all: false,
expected_hash: [0; 32],
},
] {
let mutation = operation.capability() != WorkdirSessionCapability::Read;
let expected_code =
if mutation && matches!(code, Code::Unavailable | Code::Transport) {
Code::OutcomeUnknown
} else {
code
};
let (sender, mut commands) = tokio::sync::mpsc::channel(1);
let connection = Arc::new(ExternalProviderConnection {
grant_id: "fixture-grant".into(),
workdir_id: "fixture-workdir".into(),
provider_instance_id: "fixture-provider".into(),
generation: 1,
expires_at: None,
capabilities: workdir::WorkdirSessionCapabilities::ALL,
admission: Arc::new(tokio::sync::Semaphore::new(16)),
shutdown_confirmed: Arc::new(std::sync::atomic::AtomicBool::new(false)),
sender,
});
let session = Arc::new(ExternalProviderWorkdirSession::new(connection, None));
let caller = tokio::spawn(async move {
session
.checkout_execute(CheckoutRequest {
target: WorkdirPath::root(),
validator: vec![1],
operation,
})
.await
});
let command =
tokio::time::timeout(std::time::Duration::from_secs(5), commands.recv())
.await
.unwrap()
.unwrap();
let ExternalProviderCommand::Operation {
operation,
response,
..
} = command
else {
panic!("expected dispatched operation");
};
assert!(matches!(
operation,
WorkdirSessionOperation::CheckoutExecute(_)
));
let failed = serde_json::from_value::<ExternalWorkdirOperationOutcome>(json!({
"outcome":"failed", "error":{"code":code, "denial_reason":reason},
}))
.unwrap();
let ExternalWorkdirOperationOutcome::Failed { error } = failed else {
panic!("expected fixture failure");
};
response.send(Err(error.into_transport_error())).unwrap();
let error = tokio::time::timeout(std::time::Duration::from_secs(5), caller)
.await
.unwrap()
.unwrap()
.unwrap_err();
assert!(
matches!(
commands.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty
| tokio::sync::mpsc::error::TryRecvError::Disconnected)
),
"must not retry"
);
let forwarded = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(
forwarded.code, expected_code,
"{code:?}, {reason:?}, mutation={mutation}: {error:?}"
);
assert_eq!(error.denial_reason(), reason);
assert_eq!(forwarded.denial_reason, reason);
if expected_code == Code::OutcomeUnknown {
assert!(forwarded.message.contains("inspect before retrying"));
}
let response = InternalWorkdirOperationError::Provider(forwarded).into_response();
assert_eq!(response.status().as_u16(), expected_code.http_status());
let body = axum::body::to_bytes(response.into_body(), 2048)
.await
.unwrap();
let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(body["code"], expected_code.as_str());
assert!(body.get("denial_reason").is_none());
}
}
}
}
@@ -12,6 +12,8 @@ use crate::store::ControlPlaneStore;
pub struct VerifiedRuntimeRequestSource {
pub runtime_id: 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.
@@ -268,6 +270,7 @@ pub(crate) async fn verify_runtime_request_source_proof_with_clock(
})?;
}
Ok(VerifiedRuntimeRequestSource {
token_id_hash: Some(diagnostic_id_hash(&claims.jti)),
runtime_id: claims.iss,
worker_id: claims.worker_id,
})
+2
View File
@@ -836,6 +836,7 @@ where
.unwrap_or_else(|_| ExternalWorkdirOperationOutcome::Failed {
error: ExternalWorkdirOperationError::from_transport_error(
WorkdirTransportError {
denial_reason: None,
code: workdir::http::WorkdirTransportErrorCode::Internal,
message: "External Workdir operation executor failed".to_string(),
},
@@ -953,6 +954,7 @@ async fn execute_operation(
Ok(result) => ExternalWorkdirOperationOutcome::Completed { result },
Err(message) => ExternalWorkdirOperationOutcome::Failed {
error: ExternalWorkdirOperationError::from_transport_error(WorkdirTransportError {
denial_reason: None,
code: workdir::http::WorkdirTransportErrorCode::InvalidRequest,
message,
}),