feat: add client-hosted external workdirs

This commit is contained in:
2026-09-22 06:49:15 +09:00
parent dc135ee1de
commit ce9534f3a2
45 changed files with 6024 additions and 311 deletions
+599
View File
@@ -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());
}
}
+257
View File
@@ -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");
}
+80 -4
View File
@@ -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")]
+8 -3
View File
@@ -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
View File
@@ -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))
+12 -2
View File
@@ -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,