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:
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user