feat: add client-hosted external workdirs
This commit is contained in:
@@ -0,0 +1,599 @@
|
||||
//! Versioned websocket DTOs for a client-hosted External Workdir provider.
|
||||
//!
|
||||
//! Authentication belongs to the websocket upgrade and Backend grant
|
||||
//! authority. These frames intentionally contain only opaque identities,
|
||||
//! logical Workdir operations, generation fences, and bounded control data.
|
||||
//! They must never carry a provider host path, credential, or tunnel token.
|
||||
|
||||
use std::path::{Component, Path};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::http::{WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError};
|
||||
use crate::{BoundedReadLimits, WorkdirId, WorkdirPath, WorkdirSessionCapabilities};
|
||||
|
||||
pub const EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION: u16 = 1;
|
||||
/// Maximum serialized Backend-to-provider command frame size.
|
||||
pub const MAX_EXTERNAL_SERVER_FRAME_BYTES: usize = 1024 * 1024;
|
||||
/// Maximum serialized provider-to-Backend result frame size. Read bytes are
|
||||
/// represented as a JSON byte array, so this must exceed the semantic payload
|
||||
/// bound while remaining an explicit transport allocation ceiling.
|
||||
pub const MAX_EXTERNAL_PROVIDER_FRAME_BYTES: usize = 8 * 1024 * 1024;
|
||||
const MAX_PROTOCOL_ID_BYTES: usize = 128;
|
||||
const MAX_EXTERNAL_PATH_BYTES: usize = 4 * 1024;
|
||||
const MAX_EXTERNAL_PATTERN_BYTES: usize = 4 * 1024;
|
||||
const MAX_EXTERNAL_RESULT_ITEMS: usize = 10_000;
|
||||
const MAX_EXTERNAL_GREP_CONTEXT: usize = 100;
|
||||
const MAX_EXTERNAL_SCOPE_RULES: usize = 64;
|
||||
|
||||
/// Fail-closed wire version for the External Workdir provider protocol.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ExternalWorkdirProtocolVersion(u16);
|
||||
|
||||
impl ExternalWorkdirProtocolVersion {
|
||||
pub const CURRENT: Self = Self(EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION);
|
||||
|
||||
pub const fn as_u16(self) -> u16 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ExternalWorkdirProtocolVersion {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let version = u16::deserialize(deserializer)?;
|
||||
if version == EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION {
|
||||
Ok(Self::CURRENT)
|
||||
} else {
|
||||
Err(serde::de::Error::custom(format!(
|
||||
"unsupported External Workdir provider protocol version {version}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! protocol_id {
|
||||
($name:ident, $label:literal) => {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct $name(String);
|
||||
|
||||
impl $name {
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, String> {
|
||||
let value = value.into();
|
||||
if value.is_empty()
|
||||
|| value.len() > MAX_PROTOCOL_ID_BYTES
|
||||
|| value.trim() != value
|
||||
|| value.chars().any(char::is_control)
|
||||
{
|
||||
return Err(concat!($label, " is invalid").to_string());
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for $name {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = String::deserialize(deserializer)?;
|
||||
Self::new(value).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
protocol_id!(ExternalProviderInstanceId, "provider instance id");
|
||||
protocol_id!(ExternalWorkdirGrantId, "External Workdir grant id");
|
||||
protocol_id!(ExternalWorkdirOperationId, "External Workdir operation id");
|
||||
|
||||
/// First provider frame on a newly authenticated outbound websocket.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExternalWorkdirProviderRegistration {
|
||||
pub provider_instance_id: ExternalProviderInstanceId,
|
||||
pub grant_id: ExternalWorkdirGrantId,
|
||||
pub workdir_id: WorkdirId,
|
||||
/// Monotonic grant connection generation. Reconnect advances it and fences
|
||||
/// every frame from an older connection.
|
||||
pub generation: u64,
|
||||
pub capabilities: WorkdirSessionCapabilities,
|
||||
pub read_limits: BoundedReadLimits,
|
||||
}
|
||||
|
||||
fn is_root_relative(path: &WorkdirPath) -> bool {
|
||||
let path_value = path.as_str();
|
||||
let path = Path::new(path_value);
|
||||
path_value.len() <= MAX_EXTERNAL_PATH_BYTES
|
||||
&& !path.is_absolute()
|
||||
&& !path.components().any(|component| {
|
||||
matches!(
|
||||
component,
|
||||
Component::ParentDir | Component::RootDir | Component::Prefix(_)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_read_only_operation(operation: &WorkdirSessionOperation) -> Result<(), String> {
|
||||
let path_is_valid = match operation {
|
||||
WorkdirSessionOperation::AuthorizeScope(request) => {
|
||||
is_root_relative(&request.path)
|
||||
&& request.rules.len() <= MAX_EXTERNAL_SCOPE_RULES
|
||||
&& request
|
||||
.rules
|
||||
.iter()
|
||||
.all(|rule| is_root_relative(&rule.target))
|
||||
}
|
||||
WorkdirSessionOperation::ScopeRulesOverlap(request) => {
|
||||
is_root_relative(&request.left.target) && is_root_relative(&request.right.target)
|
||||
}
|
||||
WorkdirSessionOperation::Stat(request) => is_root_relative(&request.path),
|
||||
WorkdirSessionOperation::Read(request) => {
|
||||
is_root_relative(&request.path)
|
||||
&& request.limit <= 1_000_000
|
||||
&& request.max_bytes <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
|
||||
}
|
||||
WorkdirSessionOperation::List(request) => {
|
||||
is_root_relative(&request.path) && request.limit <= MAX_EXTERNAL_RESULT_ITEMS
|
||||
}
|
||||
WorkdirSessionOperation::Glob(request) => {
|
||||
is_root_relative(&request.path)
|
||||
&& request.pattern.len() <= MAX_EXTERNAL_PATTERN_BYTES
|
||||
&& request.limit <= MAX_EXTERNAL_RESULT_ITEMS
|
||||
}
|
||||
WorkdirSessionOperation::Grep(request) => {
|
||||
is_root_relative(&request.path)
|
||||
&& request.pattern.len() <= MAX_EXTERNAL_PATTERN_BYTES
|
||||
&& request
|
||||
.glob
|
||||
.as_ref()
|
||||
.is_none_or(|glob| glob.len() <= MAX_EXTERNAL_PATTERN_BYTES)
|
||||
&& request
|
||||
.file_type
|
||||
.as_ref()
|
||||
.is_none_or(|file_type| file_type.len() <= 128)
|
||||
&& request.before_context <= MAX_EXTERNAL_GREP_CONTEXT
|
||||
&& request.after_context <= MAX_EXTERNAL_GREP_CONTEXT
|
||||
&& request.limit <= MAX_EXTERNAL_RESULT_ITEMS
|
||||
&& request.offset <= 1_000_000
|
||||
}
|
||||
WorkdirSessionOperation::Write(_)
|
||||
| WorkdirSessionOperation::Edit(_)
|
||||
| WorkdirSessionOperation::CommandStart(_)
|
||||
| WorkdirSessionOperation::CommandStatus(_)
|
||||
| WorkdirSessionOperation::CommandOutput(_)
|
||||
| WorkdirSessionOperation::CommandCancel(_) => {
|
||||
return Err("operation is not available to a read-only External Workdir".to_string());
|
||||
}
|
||||
};
|
||||
if path_is_valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("External Workdir operation paths must be root-relative".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Validated read-only subset of the shared Workdir operation contract.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ExternalWorkdirOperation(WorkdirSessionOperation);
|
||||
|
||||
impl ExternalWorkdirOperation {
|
||||
pub fn into_inner(self) -> WorkdirSessionOperation {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn as_inner(&self) -> &WorkdirSessionOperation {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<WorkdirSessionOperation> for ExternalWorkdirOperation {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(operation: WorkdirSessionOperation) -> Result<Self, Self::Error> {
|
||||
validate_read_only_operation(&operation)?;
|
||||
Ok(Self(operation))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ExternalWorkdirOperation {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let operation = WorkdirSessionOperation::deserialize(deserializer)?;
|
||||
Self::try_from(operation).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
fn result_paths_fit<'a>(paths: impl IntoIterator<Item = &'a WorkdirPath>) -> bool {
|
||||
paths
|
||||
.into_iter()
|
||||
.try_fold(0_usize, |retained, path| {
|
||||
retained.checked_add(path.as_str().len().saturating_add(64))
|
||||
})
|
||||
.is_some_and(|retained| retained <= fs_operation::MAX_RESULT_PATH_BYTES)
|
||||
}
|
||||
|
||||
fn validate_read_only_result(result: &WorkdirSessionOperationResult) -> Result<(), String> {
|
||||
let result_is_valid = match result {
|
||||
WorkdirSessionOperationResult::AuthorizeScope
|
||||
| WorkdirSessionOperationResult::ScopeRulesOverlap { .. } => true,
|
||||
WorkdirSessionOperationResult::Stat(result) => is_root_relative(&result.path),
|
||||
WorkdirSessionOperationResult::Read(result) => {
|
||||
is_root_relative(&result.path)
|
||||
&& result.bytes.len() <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
|
||||
}
|
||||
WorkdirSessionOperationResult::List(result) => {
|
||||
result.entries.len() <= MAX_EXTERNAL_RESULT_ITEMS
|
||||
&& result
|
||||
.entries
|
||||
.iter()
|
||||
.all(|entry| is_root_relative(&entry.path))
|
||||
&& result_paths_fit(result.entries.iter().map(|entry| &entry.path))
|
||||
}
|
||||
WorkdirSessionOperationResult::Glob(result) => {
|
||||
result.paths.len() <= MAX_EXTERNAL_RESULT_ITEMS
|
||||
&& result.paths.iter().all(is_root_relative)
|
||||
&& result_paths_fit(&result.paths)
|
||||
}
|
||||
WorkdirSessionOperationResult::Grep(result) => {
|
||||
result.output.len() <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
|
||||
&& result.match_count <= MAX_EXTERNAL_RESULT_ITEMS
|
||||
&& result.matched_files <= MAX_EXTERNAL_RESULT_ITEMS
|
||||
}
|
||||
WorkdirSessionOperationResult::Write(_)
|
||||
| WorkdirSessionOperationResult::Edit(_)
|
||||
| WorkdirSessionOperationResult::CommandStart(_)
|
||||
| WorkdirSessionOperationResult::CommandStatus(_)
|
||||
| WorkdirSessionOperationResult::CommandOutput(_)
|
||||
| WorkdirSessionOperationResult::CommandCancel => {
|
||||
return Err(
|
||||
"operation result is not available to a read-only External Workdir".to_string(),
|
||||
);
|
||||
}
|
||||
};
|
||||
if result_is_valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("External Workdir result exceeds path or response bounds".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Validated read-only subset of the shared Workdir operation result contract.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ExternalWorkdirOperationResult(WorkdirSessionOperationResult);
|
||||
|
||||
impl ExternalWorkdirOperationResult {
|
||||
pub fn into_inner(self) -> WorkdirSessionOperationResult {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn as_inner(&self) -> &WorkdirSessionOperationResult {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<WorkdirSessionOperationResult> for ExternalWorkdirOperationResult {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(result: WorkdirSessionOperationResult) -> Result<Self, Self::Error> {
|
||||
validate_read_only_result(&result)?;
|
||||
Ok(Self(result))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ExternalWorkdirOperationResult {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let result = WorkdirSessionOperationResult::deserialize(deserializer)?;
|
||||
Self::try_from(result).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum ExternalWorkdirOperationOutcome {
|
||||
Completed {
|
||||
result: ExternalWorkdirOperationResult,
|
||||
},
|
||||
Failed {
|
||||
error: WorkdirTransportError,
|
||||
},
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExternalWorkdirRevokeReason {
|
||||
UserRequested,
|
||||
Expired,
|
||||
AuthorityWithdrawn,
|
||||
}
|
||||
|
||||
/// Frames sent by the authenticated Backend to the outbound provider.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum ExternalWorkdirServerMessage {
|
||||
Registered {
|
||||
generation: u64,
|
||||
heartbeat_interval_ms: u64,
|
||||
max_in_flight_operations: usize,
|
||||
},
|
||||
Operation {
|
||||
generation: u64,
|
||||
operation_id: ExternalWorkdirOperationId,
|
||||
operation: ExternalWorkdirOperation,
|
||||
},
|
||||
Cancel {
|
||||
generation: u64,
|
||||
operation_id: ExternalWorkdirOperationId,
|
||||
},
|
||||
Revoke {
|
||||
generation: u64,
|
||||
reason: ExternalWorkdirRevokeReason,
|
||||
},
|
||||
Heartbeat {
|
||||
generation: u64,
|
||||
sequence: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Frames sent by the client-hosted provider over its outbound connection.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum ExternalWorkdirProviderMessage {
|
||||
Register {
|
||||
registration: ExternalWorkdirProviderRegistration,
|
||||
},
|
||||
OperationResult {
|
||||
generation: u64,
|
||||
operation_id: ExternalWorkdirOperationId,
|
||||
outcome: ExternalWorkdirOperationOutcome,
|
||||
},
|
||||
Heartbeat {
|
||||
generation: u64,
|
||||
sequence: u64,
|
||||
},
|
||||
RevokeAcknowledged {
|
||||
generation: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Versioned server-to-provider websocket envelope.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExternalWorkdirServerFrame {
|
||||
pub version: ExternalWorkdirProtocolVersion,
|
||||
pub message: ExternalWorkdirServerMessage,
|
||||
}
|
||||
|
||||
impl ExternalWorkdirServerFrame {
|
||||
pub fn current(message: ExternalWorkdirServerMessage) -> Self {
|
||||
Self {
|
||||
version: ExternalWorkdirProtocolVersion::CURRENT,
|
||||
message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Versioned provider-to-server websocket envelope.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExternalWorkdirProviderFrame {
|
||||
pub version: ExternalWorkdirProtocolVersion,
|
||||
pub message: ExternalWorkdirProviderMessage,
|
||||
}
|
||||
|
||||
impl ExternalWorkdirProviderFrame {
|
||||
pub fn current(message: ExternalWorkdirProviderMessage) -> Self {
|
||||
Self {
|
||||
version: ExternalWorkdirProtocolVersion::CURRENT,
|
||||
message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::Value;
|
||||
|
||||
use super::*;
|
||||
use crate::{CommandRequest, GrepResult, ListRequest, ReadRequest, Workdir, WorkdirPath};
|
||||
|
||||
fn assert_no_provider_authority(value: &Value) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
for (key, value) in object {
|
||||
assert!(
|
||||
![
|
||||
"host_path",
|
||||
"root",
|
||||
"credential",
|
||||
"credentials",
|
||||
"token",
|
||||
"access_token",
|
||||
"bearer_token",
|
||||
"endpoint",
|
||||
"url",
|
||||
]
|
||||
.contains(&key.as_str()),
|
||||
"provider authority field leaked into protocol: {key}"
|
||||
);
|
||||
assert_no_provider_authority(value);
|
||||
}
|
||||
}
|
||||
Value::Array(values) => values.iter().for_each(assert_no_provider_authority),
|
||||
Value::String(value) => {
|
||||
assert!(!value.contains("/home/operator/private"));
|
||||
assert!(!value.contains("provider-secret"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_is_versioned_correlated_and_contains_no_provider_authority() {
|
||||
let workdir = Workdir::new("working-directory-1");
|
||||
let registration =
|
||||
ExternalWorkdirProviderFrame::current(ExternalWorkdirProviderMessage::Register {
|
||||
registration: ExternalWorkdirProviderRegistration {
|
||||
provider_instance_id: ExternalProviderInstanceId::new("provider-1").unwrap(),
|
||||
grant_id: ExternalWorkdirGrantId::new("grant-1").unwrap(),
|
||||
workdir_id: workdir.id().clone(),
|
||||
generation: 7,
|
||||
capabilities: WorkdirSessionCapabilities::READ_ONLY,
|
||||
read_limits: BoundedReadLimits::new(4096, 1024).unwrap(),
|
||||
},
|
||||
});
|
||||
let operation =
|
||||
ExternalWorkdirServerFrame::current(ExternalWorkdirServerMessage::Operation {
|
||||
generation: 7,
|
||||
operation_id: ExternalWorkdirOperationId::new("operation-1").unwrap(),
|
||||
operation: WorkdirSessionOperation::Read(ReadRequest {
|
||||
path: WorkdirPath::new("sessions/events.jsonl").unwrap(),
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
max_bytes: 1024,
|
||||
})
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
});
|
||||
|
||||
for value in [
|
||||
serde_json::to_value(registration).unwrap(),
|
||||
serde_json::to_value(operation).unwrap(),
|
||||
serde_json::to_value(ExternalWorkdirServerFrame::current(
|
||||
ExternalWorkdirServerMessage::Cancel {
|
||||
generation: 7,
|
||||
operation_id: ExternalWorkdirOperationId::new("operation-1").unwrap(),
|
||||
},
|
||||
))
|
||||
.unwrap(),
|
||||
serde_json::to_value(ExternalWorkdirServerFrame::current(
|
||||
ExternalWorkdirServerMessage::Revoke {
|
||||
generation: 7,
|
||||
reason: ExternalWorkdirRevokeReason::Expired,
|
||||
},
|
||||
))
|
||||
.unwrap(),
|
||||
serde_json::to_value(ExternalWorkdirProviderFrame::current(
|
||||
ExternalWorkdirProviderMessage::Heartbeat {
|
||||
generation: 7,
|
||||
sequence: 3,
|
||||
},
|
||||
))
|
||||
.unwrap(),
|
||||
] {
|
||||
assert_eq!(
|
||||
value.get("version").and_then(Value::as_u64),
|
||||
Some(EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION.into())
|
||||
);
|
||||
assert_no_provider_authority(&value);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_protocol_versions_and_invalid_read_limits_fail_closed() {
|
||||
let future = r#"{
|
||||
"version": 2,
|
||||
"message": {"kind": "heartbeat", "generation": 1, "sequence": 1}
|
||||
}"#;
|
||||
assert!(serde_json::from_str::<ExternalWorkdirServerFrame>(future).is_err());
|
||||
|
||||
let invalid_limits = r#"{
|
||||
"max_source_bytes": 1024,
|
||||
"max_response_bytes": 2048
|
||||
}"#;
|
||||
assert!(serde_json::from_str::<BoundedReadLimits>(invalid_limits).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_protocol_rejects_host_paths_and_command_payloads() {
|
||||
let absolute = WorkdirSessionOperation::Read(ReadRequest {
|
||||
path: WorkdirPath::new_scoped("/home/operator/private/session.log").unwrap(),
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
max_bytes: 1024,
|
||||
});
|
||||
assert!(ExternalWorkdirOperation::try_from(absolute).is_err());
|
||||
|
||||
let command = WorkdirSessionOperation::CommandStart(CommandRequest {
|
||||
command: "cat /home/operator/private/session.log".to_string(),
|
||||
timeout_secs: 1,
|
||||
output_limit: 1024,
|
||||
cwd: None,
|
||||
spill_dir: Some("/home/operator/private/output".into()),
|
||||
tool_call_id: None,
|
||||
});
|
||||
assert!(ExternalWorkdirOperation::try_from(command).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_rejects_unknown_nested_fields_and_unbounded_requests() {
|
||||
let unknown = r#"{
|
||||
"version": 1,
|
||||
"message": {
|
||||
"kind": "heartbeat",
|
||||
"generation": 1,
|
||||
"sequence": 1,
|
||||
"credential": "must-not-be-ignored"
|
||||
}
|
||||
}"#;
|
||||
assert!(serde_json::from_str::<ExternalWorkdirServerFrame>(unknown).is_err());
|
||||
|
||||
let mut nested = serde_json::to_value(ExternalWorkdirServerFrame::current(
|
||||
ExternalWorkdirServerMessage::Operation {
|
||||
generation: 1,
|
||||
operation_id: ExternalWorkdirOperationId::new("operation-1").unwrap(),
|
||||
operation: WorkdirSessionOperation::Read(ReadRequest {
|
||||
path: WorkdirPath::new("events.jsonl").unwrap(),
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
max_bytes: 1024,
|
||||
})
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
nested["message"]["operation"]["request"]["credential"] =
|
||||
Value::String("must-not-be-ignored".to_string());
|
||||
assert!(serde_json::from_value::<ExternalWorkdirServerFrame>(nested).is_err());
|
||||
|
||||
let unbounded = WorkdirSessionOperation::List(ListRequest {
|
||||
path: WorkdirPath::root(),
|
||||
limit: MAX_EXTERNAL_RESULT_ITEMS + 1,
|
||||
});
|
||||
assert!(ExternalWorkdirOperation::try_from(unbounded).is_err());
|
||||
|
||||
let oversized_result = WorkdirSessionOperationResult::Grep(GrepResult {
|
||||
output: "x".repeat(BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes + 1),
|
||||
match_count: 1,
|
||||
matched_files: 1,
|
||||
truncated: true,
|
||||
});
|
||||
assert!(ExternalWorkdirOperationResult::try_from(oversized_result).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_ids_are_bounded_and_control_free() {
|
||||
assert!(ExternalWorkdirOperationId::new("").is_err());
|
||||
assert!(ExternalWorkdirOperationId::new(" operation-1").is_err());
|
||||
assert!(ExternalWorkdirOperationId::new("operation\n1").is_err());
|
||||
assert!(ExternalWorkdirOperationId::new("x".repeat(129)).is_err());
|
||||
assert!(ExternalWorkdirOperationId::new("operation-1").is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
use crate::http::{
|
||||
WorkdirSessionOperation, WorkdirSessionOperationResult, dispatch_workdir_session_operation,
|
||||
};
|
||||
use crate::{
|
||||
BoundedReadLimits, CommandRequest, EditRequest, LocalWorkdirSession, ReadRequest, Workdir,
|
||||
WorkdirError, WorkdirPath, WorkdirSession, WorkdirSessionCapabilities,
|
||||
WorkdirSessionCapability, WriteRequest,
|
||||
};
|
||||
|
||||
fn external_session(root: &tempfile::TempDir, limits: BoundedReadLimits) -> LocalWorkdirSession {
|
||||
LocalWorkdirSession::external_read_only(Workdir::new("external-workdir-1"), root.path(), limits)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_local_provider_is_strictly_read_only() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::write(root.path().join("item.txt"), "original").unwrap();
|
||||
let session = external_session(&root, BoundedReadLimits::new(4096, 1024).unwrap());
|
||||
|
||||
assert_eq!(
|
||||
session.capabilities(),
|
||||
WorkdirSessionCapabilities::READ_ONLY
|
||||
);
|
||||
|
||||
let write = WorkdirSession::write(
|
||||
&session,
|
||||
WriteRequest {
|
||||
path: WorkdirPath::new("item.txt").unwrap(),
|
||||
content: b"changed".to_vec(),
|
||||
expected_hash: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
write,
|
||||
WorkdirError::Unsupported(WorkdirSessionCapability::Write)
|
||||
));
|
||||
|
||||
let edit = WorkdirSession::edit(
|
||||
&session,
|
||||
EditRequest {
|
||||
path: WorkdirPath::new("item.txt").unwrap(),
|
||||
old_string: "original".to_string(),
|
||||
new_string: "changed".to_string(),
|
||||
replace_all: false,
|
||||
expected_hash: [0; 32],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
edit,
|
||||
WorkdirError::Unsupported(WorkdirSessionCapability::Edit)
|
||||
));
|
||||
|
||||
let command = WorkdirSession::start_command(
|
||||
&session,
|
||||
CommandRequest {
|
||||
command: "echo changed > item.txt".to_string(),
|
||||
timeout_secs: 1,
|
||||
output_limit: 1024,
|
||||
cwd: None,
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
command,
|
||||
WorkdirError::Unsupported(WorkdirSessionCapability::Command)
|
||||
));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.path().join("item.txt")).unwrap(),
|
||||
"original"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn common_dispatcher_preserves_operation_result_pairing_and_capabilities() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::write(root.path().join("item.txt"), "visible").unwrap();
|
||||
let session = external_session(&root, BoundedReadLimits::new(4096, 1024).unwrap());
|
||||
|
||||
let result = dispatch_workdir_session_operation(
|
||||
&session,
|
||||
WorkdirSessionOperation::Read(ReadRequest {
|
||||
path: WorkdirPath::new("item.txt").unwrap(),
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
max_bytes: 1024,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
result,
|
||||
WorkdirSessionOperationResult::Read(ref result) if result.bytes == b"visible"
|
||||
));
|
||||
|
||||
let error = dispatch_workdir_session_operation(
|
||||
&session,
|
||||
WorkdirSessionOperation::Write(WriteRequest {
|
||||
path: WorkdirPath::new("item.txt").unwrap(),
|
||||
content: b"blocked".to_vec(),
|
||||
expected_hash: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
WorkdirError::Unsupported(WorkdirSessionCapability::Write)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_local_provider_rejects_absolute_and_parent_paths() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::NamedTempFile::new().unwrap();
|
||||
let session = external_session(&root, BoundedReadLimits::new(4096, 1024).unwrap());
|
||||
|
||||
assert!(WorkdirPath::new("../outside.txt").is_err());
|
||||
let absolute = WorkdirPath::new_scoped(outside.path().to_string_lossy()).unwrap();
|
||||
let error = WorkdirSession::read(
|
||||
&session,
|
||||
ReadRequest {
|
||||
path: absolute,
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
max_bytes: 1024,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, WorkdirError::InvalidPath(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_local_provider_enforces_source_and_response_bounds() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::write(root.path().join("large.log"), vec![b'x'; 2048]).unwrap();
|
||||
std::fs::write(root.path().join("small.log"), b"0123456789\n").unwrap();
|
||||
let session = external_session(&root, BoundedReadLimits::new(1024, 4).unwrap());
|
||||
|
||||
let error = WorkdirSession::read(
|
||||
&session,
|
||||
ReadRequest {
|
||||
path: WorkdirPath::new("large.log").unwrap(),
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
max_bytes: usize::MAX,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, WorkdirError::InvalidArgument(_)));
|
||||
|
||||
let bounded = WorkdirSession::read(
|
||||
&session,
|
||||
ReadRequest {
|
||||
path: WorkdirPath::new("small.log").unwrap(),
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
max_bytes: usize::MAX,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(bounded.bytes, b"0123");
|
||||
assert!(bounded.truncated);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn external_local_provider_rejects_symlink_roots_and_traversal() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
|
||||
symlink(
|
||||
outside.path().join("secret.txt"),
|
||||
root.path().join("outside.txt"),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(root.path().join("inside.txt"), "inside").unwrap();
|
||||
symlink(
|
||||
root.path().join("inside.txt"),
|
||||
root.path().join("inside-link.txt"),
|
||||
)
|
||||
.unwrap();
|
||||
let session = external_session(&root, BoundedReadLimits::new(4096, 1024).unwrap());
|
||||
|
||||
for path in ["outside.txt", "inside-link.txt"] {
|
||||
let error = WorkdirSession::read(
|
||||
&session,
|
||||
ReadRequest {
|
||||
path: WorkdirPath::new(path).unwrap(),
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
max_bytes: 1024,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
WorkdirError::SymlinkOutOfScope { .. } | WorkdirError::OutOfScope(_)
|
||||
));
|
||||
}
|
||||
|
||||
let parent = tempfile::tempdir().unwrap();
|
||||
let root_link = parent.path().join("shared-root");
|
||||
symlink(root.path(), &root_link).unwrap();
|
||||
let error = LocalWorkdirSession::external_read_only(
|
||||
Workdir::new("external-workdir-2"),
|
||||
&root_link,
|
||||
BoundedReadLimits::new(4096, 1024).unwrap(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, WorkdirError::Denied(_)));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test]
|
||||
async fn external_local_provider_pins_the_approved_root_across_directory_replacement() {
|
||||
let parent = tempfile::tempdir().unwrap();
|
||||
let approved = parent.path().join("shared");
|
||||
std::fs::create_dir(&approved).unwrap();
|
||||
std::fs::write(approved.join("item.txt"), "approved").unwrap();
|
||||
let session = LocalWorkdirSession::external_read_only(
|
||||
Workdir::new("external-workdir-pinned-root"),
|
||||
&approved,
|
||||
BoundedReadLimits::new(4096, 1024).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
std::fs::rename(&approved, parent.path().join("approved-original")).unwrap();
|
||||
std::fs::create_dir(&approved).unwrap();
|
||||
std::fs::write(approved.join("item.txt"), "replacement").unwrap();
|
||||
|
||||
let read = WorkdirSession::read(
|
||||
&session,
|
||||
ReadRequest {
|
||||
path: WorkdirPath::new("item.txt").unwrap(),
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
max_bytes: 1024,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(read.bytes, b"approved");
|
||||
}
|
||||
@@ -11,8 +11,8 @@ use crate::{
|
||||
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
|
||||
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
|
||||
ReadRequest, ReadResult, StatRequest, StatResult, WorkdirError, WorkdirId,
|
||||
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSessionCapabilities,
|
||||
WriteRequest, WriteResult,
|
||||
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
||||
WorkdirSessionCapabilities, WriteRequest, WriteResult,
|
||||
};
|
||||
|
||||
/// Opaque Runtime-owned identifier for one ephemeral Workdir session.
|
||||
@@ -54,7 +54,12 @@ pub struct OpenWorkdirSessionResponse {
|
||||
|
||||
/// One provider-side Workdir operation.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "operation", content = "request", rename_all = "snake_case")]
|
||||
#[serde(
|
||||
tag = "operation",
|
||||
content = "request",
|
||||
rename_all = "snake_case",
|
||||
deny_unknown_fields
|
||||
)]
|
||||
pub enum WorkdirSessionOperation {
|
||||
AuthorizeScope(WorkdirScopeAuthorizationRequest),
|
||||
ScopeRulesOverlap(WorkdirScopeOverlapRequest),
|
||||
@@ -80,7 +85,12 @@ pub struct WorkdirSessionOperationRequest {
|
||||
|
||||
/// Typed result paired with [`WorkdirSessionOperation`].
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "operation", content = "result", rename_all = "snake_case")]
|
||||
#[serde(
|
||||
tag = "operation",
|
||||
content = "result",
|
||||
rename_all = "snake_case",
|
||||
deny_unknown_fields
|
||||
)]
|
||||
pub enum WorkdirSessionOperationResult {
|
||||
AuthorizeScope,
|
||||
ScopeRulesOverlap { overlaps: bool },
|
||||
@@ -97,6 +107,72 @@ pub enum WorkdirSessionOperationResult {
|
||||
CommandCancel,
|
||||
}
|
||||
|
||||
/// Dispatch one typed operation against any provider-neutral Workdir session.
|
||||
///
|
||||
/// Runtime HTTP handlers, outbound External providers, and Backend brokers can
|
||||
/// share this operation/result pairing while keeping transport authorization,
|
||||
/// generation fencing, cancellation timeouts, and session lookup at their own
|
||||
/// boundaries.
|
||||
pub async fn dispatch_workdir_session_operation(
|
||||
session: &dyn WorkdirSession,
|
||||
operation: WorkdirSessionOperation,
|
||||
) -> Result<WorkdirSessionOperationResult, WorkdirError> {
|
||||
match operation {
|
||||
WorkdirSessionOperation::AuthorizeScope(request) => session
|
||||
.authorize_scope_path(request)
|
||||
.await
|
||||
.map(|()| WorkdirSessionOperationResult::AuthorizeScope),
|
||||
WorkdirSessionOperation::ScopeRulesOverlap(request) => session
|
||||
.scope_rules_overlap(request)
|
||||
.await
|
||||
.map(|overlaps| WorkdirSessionOperationResult::ScopeRulesOverlap { overlaps }),
|
||||
WorkdirSessionOperation::Stat(request) => session
|
||||
.stat(request)
|
||||
.await
|
||||
.map(WorkdirSessionOperationResult::Stat),
|
||||
WorkdirSessionOperation::Read(request) => session
|
||||
.read(request)
|
||||
.await
|
||||
.map(WorkdirSessionOperationResult::Read),
|
||||
WorkdirSessionOperation::Write(request) => session
|
||||
.write(request)
|
||||
.await
|
||||
.map(WorkdirSessionOperationResult::Write),
|
||||
WorkdirSessionOperation::Edit(request) => session
|
||||
.edit(request)
|
||||
.await
|
||||
.map(WorkdirSessionOperationResult::Edit),
|
||||
WorkdirSessionOperation::List(request) => session
|
||||
.list(request)
|
||||
.await
|
||||
.map(WorkdirSessionOperationResult::List),
|
||||
WorkdirSessionOperation::Glob(request) => session
|
||||
.glob(request)
|
||||
.await
|
||||
.map(WorkdirSessionOperationResult::Glob),
|
||||
WorkdirSessionOperation::Grep(request) => session
|
||||
.grep(request)
|
||||
.await
|
||||
.map(WorkdirSessionOperationResult::Grep),
|
||||
WorkdirSessionOperation::CommandStart(request) => session
|
||||
.start_command(request)
|
||||
.await
|
||||
.map(WorkdirSessionOperationResult::CommandStart),
|
||||
WorkdirSessionOperation::CommandStatus(handle) => session
|
||||
.command_status(handle)
|
||||
.await
|
||||
.map(WorkdirSessionOperationResult::CommandStatus),
|
||||
WorkdirSessionOperation::CommandOutput(request) => session
|
||||
.command_output(request)
|
||||
.await
|
||||
.map(WorkdirSessionOperationResult::CommandOutput),
|
||||
WorkdirSessionOperation::CommandCancel(handle) => session
|
||||
.cancel_command(handle)
|
||||
.await
|
||||
.map(|()| WorkdirSessionOperationResult::CommandCancel),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable, host-path-free error code crossing the Runtime boundary.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! bound to one Worker. Tools consume sessions; they do not own Workdir
|
||||
//! materialization or cleanup.
|
||||
|
||||
pub mod external;
|
||||
pub mod http;
|
||||
mod local;
|
||||
mod operation;
|
||||
@@ -12,6 +13,9 @@ mod router;
|
||||
mod scope;
|
||||
pub mod workspace;
|
||||
|
||||
#[cfg(test)]
|
||||
mod external_local_tests;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -20,10 +24,11 @@ use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
pub use fs_operation::{
|
||||
ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath, GlobRequest,
|
||||
GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, ListResult,
|
||||
ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
|
||||
BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath,
|
||||
GlobRequest, GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest,
|
||||
ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
|
||||
};
|
||||
pub use http::dispatch_workdir_session_operation;
|
||||
pub use local::{
|
||||
LocalWorkdirSession, SymlinkInfo, WorkdirSessionResource, direct_symlink, first_symlink,
|
||||
};
|
||||
|
||||
+260
-23
@@ -18,17 +18,17 @@ use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use manifest::{Permission, Scope, SharedScope, SymlinkPolicy};
|
||||
use manifest::{Permission, Scope, ScopeConfig, ScopeRule, SharedScope, SymlinkPolicy};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::{Mutex, broadcast, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::{
|
||||
CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest,
|
||||
CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest, EditResult,
|
||||
GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, ReadRequest,
|
||||
ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath,
|
||||
BoundedReadLimits, CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest,
|
||||
CommandRequest, CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest,
|
||||
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
|
||||
ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath,
|
||||
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
||||
WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirToolScopePermission, WriteRequest,
|
||||
WriteResult,
|
||||
@@ -203,26 +203,112 @@ impl CommandTelemetry {
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ScopeAccess(Arc<Scope>);
|
||||
struct ScopeAccess {
|
||||
scope: Arc<Scope>,
|
||||
root: PathBuf,
|
||||
pinned_root: Option<Arc<std::fs::File>>,
|
||||
reject_symlinks: bool,
|
||||
}
|
||||
|
||||
impl ScopeAccess {
|
||||
fn new(
|
||||
scope: Arc<Scope>,
|
||||
root: &Path,
|
||||
pinned_root: Option<Arc<std::fs::File>>,
|
||||
reject_symlinks: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
scope,
|
||||
root: root.to_path_buf(),
|
||||
pinned_root,
|
||||
reject_symlinks,
|
||||
}
|
||||
}
|
||||
|
||||
fn open_confined(&self, path: &Path) -> std::io::Result<std::fs::File> {
|
||||
let root = self.pinned_root.as_ref().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
"External Workdir root handle is unavailable",
|
||||
)
|
||||
})?;
|
||||
let relative = path.strip_prefix(&self.root).map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"path is outside provider root",
|
||||
)
|
||||
})?;
|
||||
fs_operation::open_beneath_no_symlinks_at(root, relative)
|
||||
}
|
||||
|
||||
fn path_is_confined(&self, logical: &Path, resolved: &Path) -> bool {
|
||||
!self.reject_symlinks
|
||||
|| (logical.starts_with(&self.root)
|
||||
&& resolved.starts_with(&self.root)
|
||||
&& fs_operation::first_symlink(logical).is_none())
|
||||
}
|
||||
}
|
||||
|
||||
impl fs_operation::FsAccessPolicy for ScopeAccess {
|
||||
fn is_readable(&self, path: &Path) -> bool {
|
||||
self.0.is_readable(path)
|
||||
self.scope.is_readable(path)
|
||||
}
|
||||
|
||||
fn is_writable(&self, path: &Path) -> bool {
|
||||
self.0.is_writable(path)
|
||||
self.scope.is_writable(path)
|
||||
}
|
||||
|
||||
fn open_read_file(&self, _logical: &Path, resolved: &Path) -> std::io::Result<std::fs::File> {
|
||||
if self.reject_symlinks {
|
||||
self.open_confined(resolved)
|
||||
} else {
|
||||
std::fs::File::open(resolved)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_metadata(&self, logical: &Path, resolved: &Path) -> std::io::Result<std::fs::Metadata> {
|
||||
if self.reject_symlinks {
|
||||
self.open_confined(resolved)?.metadata()
|
||||
} else {
|
||||
std::fs::symlink_metadata(logical)
|
||||
}
|
||||
}
|
||||
|
||||
fn open_read_dir(&self, _logical: &Path, resolved: &Path) -> std::io::Result<std::fs::ReadDir> {
|
||||
if !self.reject_symlinks {
|
||||
return std::fs::read_dir(resolved);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::os::fd::AsRawFd;
|
||||
|
||||
let directory = self.open_confined(resolved)?;
|
||||
if !directory.metadata()?.is_dir() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotADirectory,
|
||||
"path is not a directory",
|
||||
));
|
||||
}
|
||||
return std::fs::read_dir(format!("/proc/self/fd/{}", directory.as_raw_fd()));
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
"root-confined directory enumeration is unavailable on this platform",
|
||||
))
|
||||
}
|
||||
|
||||
fn is_readable_paths(&self, logical: &Path, resolved: &Path) -> bool {
|
||||
matches!(
|
||||
self.0.permission_at_paths(logical, resolved),
|
||||
Some(Permission::Read | Permission::Write)
|
||||
)
|
||||
self.path_is_confined(logical, resolved)
|
||||
&& matches!(
|
||||
self.scope.permission_at_paths(logical, resolved),
|
||||
Some(Permission::Read | Permission::Write)
|
||||
)
|
||||
}
|
||||
|
||||
fn is_writable_paths(&self, logical: &Path, resolved: &Path) -> bool {
|
||||
self.0.permission_at_paths(logical, resolved) == Some(Permission::Write)
|
||||
self.path_is_confined(logical, resolved)
|
||||
&& self.scope.permission_at_paths(logical, resolved) == Some(Permission::Write)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,9 +351,12 @@ fn rule_targets(
|
||||
struct LocalWorkdirSessionInner {
|
||||
workdir: Workdir,
|
||||
root: PathBuf,
|
||||
pinned_root: Option<Arc<std::fs::File>>,
|
||||
scope: SharedScope,
|
||||
cwd: PathBuf,
|
||||
capabilities: WorkdirSessionCapabilities,
|
||||
read_limits: Option<BoundedReadLimits>,
|
||||
reject_symlinks: bool,
|
||||
closed: AtomicBool,
|
||||
close_lock: Mutex<()>,
|
||||
next_command_id: AtomicU64,
|
||||
@@ -388,14 +477,113 @@ impl LocalWorkdirSession {
|
||||
capabilities: WorkdirSessionCapabilities,
|
||||
command_environment: BTreeMap<String, String>,
|
||||
resources: Vec<Arc<dyn WorkdirSessionResource>>,
|
||||
) -> Self {
|
||||
Self::materialized_bound_with_policy(
|
||||
workdir,
|
||||
root,
|
||||
cwd,
|
||||
scope,
|
||||
capabilities,
|
||||
command_environment,
|
||||
resources,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
/// Construct a strictly read-only provider session for an
|
||||
/// authority-assigned Workdir and an operator-selected local directory.
|
||||
///
|
||||
/// The selected directory is canonicalized and fixed for the lifetime of
|
||||
/// the session. A symlink selected as the root is rejected, and all
|
||||
/// operation paths are confined below the canonical root without following
|
||||
/// symbolic links. Read source and response sizes are provider-bounded.
|
||||
pub fn external_read_only(
|
||||
workdir: Workdir,
|
||||
directory: impl AsRef<Path>,
|
||||
read_limits: BoundedReadLimits,
|
||||
) -> Result<Self, WorkdirError> {
|
||||
read_limits.validate().map_err(WorkdirError::from)?;
|
||||
let directory = directory.as_ref();
|
||||
if !directory.is_absolute() {
|
||||
return Err(WorkdirError::InvalidArgument(
|
||||
"External Workdir directory must be absolute".to_string(),
|
||||
));
|
||||
}
|
||||
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(
|
||||
"External Workdir root must not be a symbolic link".to_string(),
|
||||
));
|
||||
}
|
||||
let root = directory
|
||||
.canonicalize()
|
||||
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?;
|
||||
if !root
|
||||
.metadata()
|
||||
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?
|
||||
.is_dir()
|
||||
{
|
||||
return Err(WorkdirError::InvalidArgument(
|
||||
"External Workdir root must be a directory".to_string(),
|
||||
));
|
||||
}
|
||||
let pinned_root = Arc::new(
|
||||
fs_operation::open_root_no_symlinks(&root)
|
||||
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?,
|
||||
);
|
||||
let scope = Scope::from_config(&ScopeConfig {
|
||||
allow: vec![ScopeRule {
|
||||
target: root.clone(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: SymlinkPolicy::Resolved,
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
})
|
||||
.map_err(|_| {
|
||||
WorkdirError::InvalidArgument(
|
||||
"External Workdir read scope could not be established".to_string(),
|
||||
)
|
||||
})?;
|
||||
Ok(Self::materialized_bound_with_policy(
|
||||
workdir,
|
||||
root.clone(),
|
||||
root,
|
||||
SharedScope::new(scope),
|
||||
WorkdirSessionCapabilities::READ_ONLY,
|
||||
BTreeMap::new(),
|
||||
Vec::new(),
|
||||
Some(pinned_root),
|
||||
Some(read_limits),
|
||||
true,
|
||||
))
|
||||
}
|
||||
|
||||
fn materialized_bound_with_policy(
|
||||
workdir: Workdir,
|
||||
root: PathBuf,
|
||||
cwd: PathBuf,
|
||||
scope: SharedScope,
|
||||
capabilities: WorkdirSessionCapabilities,
|
||||
command_environment: BTreeMap<String, String>,
|
||||
resources: Vec<Arc<dyn WorkdirSessionResource>>,
|
||||
pinned_root: Option<Arc<std::fs::File>>,
|
||||
read_limits: Option<BoundedReadLimits>,
|
||||
reject_symlinks: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(LocalWorkdirSessionInner {
|
||||
workdir,
|
||||
root,
|
||||
pinned_root,
|
||||
scope,
|
||||
cwd,
|
||||
capabilities,
|
||||
read_limits,
|
||||
reject_symlinks,
|
||||
closed: AtomicBool::new(false),
|
||||
close_lock: Mutex::new(()),
|
||||
next_command_id: AtomicU64::new(1),
|
||||
@@ -592,6 +780,37 @@ impl LocalWorkdirSession {
|
||||
self.inner.root.join(path.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_operation_path(&self, path: &WorkdirPath) -> Result<(), WorkdirError> {
|
||||
if !self.inner.reject_symlinks {
|
||||
return Ok(());
|
||||
}
|
||||
let path = Path::new(path.as_str());
|
||||
if path.is_absolute()
|
||||
|| path.components().any(|component| {
|
||||
matches!(
|
||||
component,
|
||||
std::path::Component::ParentDir
|
||||
| std::path::Component::RootDir
|
||||
| std::path::Component::Prefix(_)
|
||||
)
|
||||
})
|
||||
{
|
||||
return Err(WorkdirError::InvalidPath(
|
||||
"External Workdir paths must be root-relative".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scope_access(&self) -> ScopeAccess {
|
||||
ScopeAccess::new(
|
||||
self.inner.scope.snapshot(),
|
||||
&self.inner.root,
|
||||
self.inner.pinned_root.clone(),
|
||||
self.inner.reject_symlinks,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -609,7 +828,13 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
request: WorkdirScopeAuthorizationRequest,
|
||||
) -> Result<(), WorkdirError> {
|
||||
self.ensure_open()?;
|
||||
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(
|
||||
"External Workdir scope cannot traverse symbolic links".to_string(),
|
||||
));
|
||||
}
|
||||
let resolved = fs_operation::resolve_access_path(&logical)
|
||||
.map_err(|error| WorkdirError::io(&logical, error))?;
|
||||
let parent_permission = self
|
||||
@@ -687,7 +912,8 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||
self.ensure_capability(WorkdirSessionCapability::Read)?;
|
||||
let logical = request.path.clone();
|
||||
let access = ScopeAccess(self.inner.scope.snapshot());
|
||||
self.validate_operation_path(&request.path)?;
|
||||
let access = self.scope_access();
|
||||
fs_operation::run_stat(&self.inner.root, request, &access)
|
||||
.map_err(WorkdirError::from)
|
||||
.map_err(|error| sanitize_error(error, &logical))
|
||||
@@ -696,16 +922,23 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError> {
|
||||
self.ensure_capability(WorkdirSessionCapability::Read)?;
|
||||
let logical = request.path.clone();
|
||||
let access = ScopeAccess(self.inner.scope.snapshot());
|
||||
fs_operation::run_read(&self.inner.root, request, &access)
|
||||
.map_err(WorkdirError::from)
|
||||
.map_err(|error| sanitize_error(error, &logical))
|
||||
self.validate_operation_path(&request.path)?;
|
||||
let access = self.scope_access();
|
||||
match self.inner.read_limits {
|
||||
Some(limits) => {
|
||||
fs_operation::run_read_bounded(&self.inner.root, request, &access, limits)
|
||||
}
|
||||
None => fs_operation::run_read(&self.inner.root, request, &access),
|
||||
}
|
||||
.map_err(WorkdirError::from)
|
||||
.map_err(|error| sanitize_error(error, &logical))
|
||||
}
|
||||
|
||||
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
||||
self.ensure_capability(WorkdirSessionCapability::Write)?;
|
||||
let logical = request.path.clone();
|
||||
let access = ScopeAccess(self.inner.scope.snapshot());
|
||||
self.validate_operation_path(&request.path)?;
|
||||
let access = self.scope_access();
|
||||
fs_operation::run_write(&self.inner.root, request, &access)
|
||||
.map_err(WorkdirError::from)
|
||||
.map_err(|error| sanitize_error(error, &logical))
|
||||
@@ -714,7 +947,8 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError> {
|
||||
self.ensure_capability(WorkdirSessionCapability::Edit)?;
|
||||
let logical = request.path.clone();
|
||||
let access = ScopeAccess(self.inner.scope.snapshot());
|
||||
self.validate_operation_path(&request.path)?;
|
||||
let access = self.scope_access();
|
||||
fs_operation::run_edit(&self.inner.root, request, &access)
|
||||
.map_err(WorkdirError::from)
|
||||
.map_err(|error| sanitize_error(error, &logical))
|
||||
@@ -723,7 +957,8 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError> {
|
||||
self.ensure_capability(WorkdirSessionCapability::Read)?;
|
||||
let logical = request.path.clone();
|
||||
let access = ScopeAccess(self.inner.scope.snapshot());
|
||||
self.validate_operation_path(&request.path)?;
|
||||
let access = self.scope_access();
|
||||
fs_operation::run_list(&self.inner.root, request, &access)
|
||||
.map_err(WorkdirError::from)
|
||||
.map_err(|error| sanitize_error(error, &logical))
|
||||
@@ -733,7 +968,8 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
self.ensure_capability(WorkdirSessionCapability::Glob)?;
|
||||
let logical = request.path.clone();
|
||||
let base = self.resolve(&request.path);
|
||||
let access = ScopeAccess(self.inner.scope.snapshot());
|
||||
self.validate_operation_path(&request.path)?;
|
||||
let access = self.scope_access();
|
||||
fs_operation::run_glob(&self.inner.root, &base, request, &access)
|
||||
.map_err(WorkdirError::from)
|
||||
.map_err(|error| sanitize_error(error, &logical))
|
||||
@@ -743,7 +979,8 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
self.ensure_capability(WorkdirSessionCapability::Grep)?;
|
||||
let base = self.resolve(&request.path);
|
||||
let logical = request.path.clone();
|
||||
let access = ScopeAccess(self.inner.scope.snapshot());
|
||||
self.validate_operation_path(&request.path)?;
|
||||
let access = self.scope_access();
|
||||
fs_operation::run_grep(&self.inner.root, base, request, &access)
|
||||
.map_err(WorkdirError::from)
|
||||
.map_err(|error| sanitize_error(error, &logical))
|
||||
|
||||
@@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize};
|
||||
pub enum WorkingDirectoryMaterializerKind {
|
||||
#[default]
|
||||
RuntimeGitClone,
|
||||
ClientHostedExternal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -98,6 +99,13 @@ pub struct RuntimeWorkingDirectorySummary {
|
||||
pub occupied_by: Option<WorkingDirectoryOccupancy>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum WorkingDirectorySource {
|
||||
Repository { repository_key: String },
|
||||
ExternalGrant { grant_id: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkingDirectorySummary {
|
||||
@@ -105,7 +113,7 @@ pub struct WorkingDirectorySummary {
|
||||
/// Optional human-facing label. Never used for attachment routing.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
pub repository_key: String,
|
||||
pub source: WorkingDirectorySource,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub creation_selector: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -215,7 +223,9 @@ mod tests {
|
||||
let summary = WorkingDirectorySummary {
|
||||
working_directory_id: "wd-1".to_string(),
|
||||
display_name: Some("Checkout".to_string()),
|
||||
repository_key: "repo-1".to_string(),
|
||||
source: WorkingDirectorySource::Repository {
|
||||
repository_key: "repo-1".to_string(),
|
||||
},
|
||||
creation_selector: None,
|
||||
creation_ref: None,
|
||||
creation_tree: None,
|
||||
|
||||
Reference in New Issue
Block a user