chore: merge develop while preserving console interactions

This commit is contained in:
2026-09-30 12:17:49 +09:00
83 changed files with 7969 additions and 949 deletions
+99 -1
View File
@@ -15,7 +15,8 @@ use thiserror::Error;
pub use glob::run_glob;
pub use local::{
resolve_access_path, run_edit, run_list, run_read, run_read_bounded, run_stat, run_write,
resolve_access_path, run_edit, run_list, run_read, run_read_bounded, run_read_bytes,
run_read_bytes_bounded, run_stat, run_write,
};
pub use operation::*;
pub use search::run_grep;
@@ -569,6 +570,103 @@ mod tests {
}
}
#[test]
fn bounded_byte_reads_preserve_binary_ranges_and_fence_versions() {
let directory = tempfile::tempdir().unwrap();
let root = directory.path().canonicalize().unwrap();
let original = b"\x89PNG\r\n\x1a\n\0line\n\xfftail";
std::fs::write(root.join("image.bin"), original).unwrap();
let access = RootAccess(root.clone());
let limits = BoundedReadLimits::new(1024, 8).unwrap();
let first = run_read_bytes_bounded(
&root,
ReadBytesRequest {
path: FsPath::new("image.bin").unwrap(),
offset: 0,
max_bytes: 8,
expected_hash: None,
},
&access,
limits,
)
.unwrap();
assert_eq!(first.bytes, original[..8]);
assert_eq!(first.total_bytes, original.len() as u64);
assert!(!first.eof);
let second = run_read_bytes_bounded(
&root,
ReadBytesRequest {
path: FsPath::new("image.bin").unwrap(),
offset: 8,
max_bytes: 8,
expected_hash: Some(first.content_hash),
},
&access,
limits,
)
.unwrap();
assert_eq!(second.bytes, original[8..16]);
assert_eq!(second.content_hash, first.content_hash);
std::fs::write(root.join("image.bin"), b"different bytes long!").unwrap();
let error = run_read_bytes_bounded(
&root,
ReadBytesRequest {
path: FsPath::new("image.bin").unwrap(),
offset: 16,
max_bytes: 8,
expected_hash: Some(first.content_hash),
},
&access,
limits,
)
.expect_err("a later chunk must not cross file versions");
assert!(matches!(error, FsError::Conflict(_)));
}
#[test]
fn bounded_byte_reads_reject_request_and_source_limit_overflow() {
let directory = tempfile::tempdir().unwrap();
let root = directory.path().canonicalize().unwrap();
std::fs::write(root.join("image.bin"), [0_u8; 17]).unwrap();
let access = RootAccess(root.clone());
let limits = BoundedReadLimits::new(16, 8).unwrap();
let response_error = run_read_bytes_bounded(
&root,
ReadBytesRequest {
path: FsPath::new("image.bin").unwrap(),
offset: 0,
max_bytes: 9,
expected_hash: None,
},
&access,
limits,
)
.expect_err("requests above the response ceiling must be rejected");
assert!(
matches!(response_error, FsError::InvalidArgument(message) if message.contains("8 bytes"))
);
let source_error = run_read_bytes_bounded(
&root,
ReadBytesRequest {
path: FsPath::new("image.bin").unwrap(),
offset: 0,
max_bytes: 8,
expected_hash: None,
},
&access,
limits,
)
.expect_err("sources above the provider ceiling must be rejected");
assert!(
matches!(source_error, FsError::InvalidArgument(message) if message.contains("provider limit 16"))
);
}
#[test]
fn cancellation_stops_a_read_already_in_progress() {
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
+134 -2
View File
@@ -7,8 +7,9 @@ use sha2::{Digest, Sha256};
use crate::{
AtomicWriteMode, BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind,
FsAccessPolicy, FsError, FsPath, ListEntry, ListRequest, ListResult, ReadRequest, ReadResult,
StatRequest, StatResult, WriteRequest, WriteResult, direct_symlink,
FsAccessPolicy, FsError, FsPath, ListEntry, ListRequest, ListResult, ReadBytesRequest,
ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
direct_symlink,
};
const READ_BUFFER_BYTES: usize = 16 * 1024;
@@ -195,6 +196,137 @@ fn run_read_with_limits(
})
}
pub fn run_read_bytes(
root: &Path,
request: ReadBytesRequest,
access: &dyn FsAccessPolicy,
) -> Result<ReadBytesResult, FsError> {
run_read_bytes_with_limits(root, request, access, None)
}
pub fn run_read_bytes_bounded(
root: &Path,
request: ReadBytesRequest,
access: &dyn FsAccessPolicy,
limits: BoundedReadLimits,
) -> Result<ReadBytesResult, FsError> {
run_read_bytes_with_limits(root, request, access, Some(limits))
}
fn run_read_bytes_with_limits(
root: &Path,
request: ReadBytesRequest,
access: &dyn FsAccessPolicy,
limits: Option<BoundedReadLimits>,
) -> Result<ReadBytesResult, FsError> {
let logical = request.path;
if request.max_bytes == 0 {
return Err(FsError::InvalidArgument(
"byte read max_bytes must be greater than zero".to_string(),
));
}
if let Some(limits) = limits
&& request.max_bytes > limits.max_response_bytes
{
return Err(FsError::InvalidArgument(format!(
"byte read response limit is {} bytes",
limits.max_response_bytes
)));
}
access
.check_cancelled()
.map_err(|error| map_io(&logical, error))?;
let path = resolve(root, &logical)?;
let target = require_access(&path, &logical, access, false, false)?;
let file = access
.open_read_file(&path, &target)
.map_err(|error| map_io(&logical, error))?;
let metadata = file.metadata().map_err(|error| map_io(&logical, error))?;
if metadata.is_dir() {
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
}
if !metadata.is_file() {
return Err(FsError::InvalidArgument(
"byte read source must be a regular file".to_string(),
));
}
if let Some(limits) = limits
&& metadata.len() > limits.max_source_bytes
{
return Err(FsError::InvalidArgument(format!(
"byte read source exceeds provider limit {}",
limits.max_source_bytes
)));
}
let mut bytes = Vec::with_capacity(request.max_bytes.min(READ_BUFFER_BYTES));
let mut source_bytes = 0_u64;
let mut hasher = Sha256::new();
let mut reader = BufReader::with_capacity(READ_BUFFER_BYTES, file);
let mut buffer = [0_u8; READ_BUFFER_BYTES];
loop {
access
.check_cancelled()
.map_err(|error| map_io(&logical, error))?;
let read_limit = limits
.map(|limits| {
limits
.max_source_bytes
.saturating_sub(source_bytes)
.saturating_add(1)
.min(READ_BUFFER_BYTES as u64) as usize
})
.unwrap_or(READ_BUFFER_BYTES);
let read = reader
.read(&mut buffer[..read_limit])
.map_err(|error| map_io(&logical, error))?;
if read == 0 {
break;
}
let chunk_start = source_bytes;
source_bytes = source_bytes.saturating_add(read as u64);
if let Some(limits) = limits
&& source_bytes > limits.max_source_bytes
{
return Err(FsError::InvalidArgument(format!(
"byte read source exceeds provider limit {}",
limits.max_source_bytes
)));
}
hasher.update(&buffer[..read]);
if bytes.len() < request.max_bytes && source_bytes > request.offset {
let start = request.offset.saturating_sub(chunk_start).min(read as u64) as usize;
let retained = (request.max_bytes - bytes.len()).min(read - start);
bytes.extend_from_slice(&buffer[start..start + retained]);
}
}
let content_hash: ContentHash = hasher.finalize().into();
if request
.expected_hash
.is_some_and(|expected| expected != content_hash)
{
return Err(FsError::Conflict(logical.as_str().to_string()));
}
if request.offset > source_bytes {
return Err(FsError::InvalidArgument(format!(
"byte offset {} exceeds file size {source_bytes}",
request.offset
)));
}
let eof = request.offset.saturating_add(bytes.len() as u64) == source_bytes;
Ok(ReadBytesResult {
path: logical,
bytes,
offset: request.offset,
total_bytes: source_bytes,
content_hash,
eof,
})
}
pub fn run_write(
root: &Path,
request: WriteRequest,
+30
View File
@@ -207,6 +207,36 @@ pub struct ReadResult {
pub truncated: bool,
}
/// Binary-safe byte range read with an optional whole-file version fence.
///
/// Unlike [`ReadRequest`], offsets and limits are bytes rather than lines. The
/// provider still hashes the complete bounded source so callers can reject
/// chunks from different file versions.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ReadBytesRequest {
pub path: FsPath,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub offset: u64,
#[schemars(range(min = 1, max = 9_007_199_254_740_991_usize))]
pub max_bytes: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_hash: Option<ContentHash>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ReadBytesResult {
pub path: FsPath,
pub bytes: Vec<u8>,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub offset: u64,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub total_bytes: u64,
pub content_hash: ContentHash,
pub eof: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct WriteRequest {
+61 -1
View File
@@ -838,6 +838,56 @@ pub struct SessionSnapshot {
pub entries: Vec<SessionSnapshotEntry>,
}
/// One user-visible conversation turn. A turn starts at a canonical user entry
/// and contains every public committed entry until the next user entry.
/// `turn_id` is the stable `entry_id` of that first user entry.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct SessionConversationTurn {
pub turn_id: String,
pub entries: Vec<SessionSnapshotEntry>,
}
/// The immediate parent lineage and the stable real-user boundary adopted from it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct SessionHistoryLineageBoundary {
pub lineage_id: String,
/// Canonical public form of the final user-visible turn at the exact
/// provider boundary. Entries after that boundary are excluded.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub adopted_through_turn: Option<SessionConversationTurn>,
}
/// Bounded backward page over the adopted conversation lineage.
///
/// Turns remain chronological within a page. `next_cursor` is an opaque,
/// identity-bound boundary for the page immediately preceding `turns`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct SessionHistoryPage {
pub session_id: String,
/// Stable identity of the adopted active lineage used to fence stale pages.
pub lineage_id: String,
/// Older lineage identities that the active lineage fully adopted through
/// one or more consecutive Compact rotations. Clients may retain already
/// loaded turns when their prior lineage appears here, while replacing the
/// cursor with this page's boundary.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub compact_ancestor_lineage_ids: Vec<String>,
/// Immediate parent relationship used to retain only the adopted prefix
/// across fork/rewind refreshes that have no newest-page overlap.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_lineage: Option<SessionHistoryLineageBoundary>,
pub turns: Vec<SessionConversationTurn>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
pub has_more: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
@@ -963,8 +1013,17 @@ pub enum Event {
/// activated for a turn. Broadcast to every subscribed client so TUI / GUI
/// instances show the same user line that reconnect snapshots replay.
UserMessage {
/// Stable durable history identity. Older mixed-version producers omit it.
#[serde(default, skip_serializing_if = "Option::is_none")]
entry_id: Option<String>,
segments: Vec<Segment>,
},
/// A canonical assistant/tool entry has committed to durable history.
/// Streaming events remain the in-flight authority; this event supplies the
/// stable identity used to reconcile that live block with paged history.
SessionEntryCommitted {
entry: SessionSnapshotEntry,
},
/// One agent-injected system item committed to history.
///
/// Carries the JSON form of `session_store::SystemItem`. Covers
@@ -2765,6 +2824,7 @@ mod tests {
#[test]
fn event_user_message_roundtrip() {
let event = Event::UserMessage {
entry_id: Some("entry-1".to_string()),
segments: vec![Segment::text("hello 世界")],
};
let json = serde_json::to_string(&event).unwrap();
@@ -2775,7 +2835,7 @@ mod tests {
let decoded: Event = serde_json::from_str(&json).unwrap();
match decoded {
Event::UserMessage { segments } => {
Event::UserMessage { segments, .. } => {
assert_eq!(segments.len(), 1);
match &segments[0] {
Segment::Text { content } => assert_eq!(content, "hello 世界"),
+10 -6
View File
@@ -11,12 +11,13 @@ use crate::{
InternalWorkerRef, InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method,
PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef, PendingSubmissionSummary,
PendingSubmissionsSnapshot, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult,
ScopeRule, Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole,
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
SubmissionDisposition, SymlinkPolicy, ToolResultDisposition, TurnResult,
UploadedFileAvailability, UploadedFileRef, WorkerBusyState, WorkerCommandAcknowledgement,
WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind, WorkerEvent,
WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus,
ScopeRule, Segment, SessionContentPart, SessionConversationTurn, SessionEntryProvenance,
SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, SessionSnapshot,
SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition,
SymlinkPolicy, ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef,
WorkerBusyState, WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState,
WorkerStateSnapshot, WorkerStatus,
subscription::{
EventSubscriptionSelector, MAX_CORRELATION_ID_BYTES, MAX_REJECTION_MESSAGE_BYTES,
MAX_RESOURCE_ID_BYTES, MAX_SUBSCRIPTION_COLLECTION_ITEMS,
@@ -104,6 +105,9 @@ pub fn generated_protocol_types() -> String {
push_decl::<SessionToolAttachment>(&cfg, &mut output);
push_decl::<SessionSnapshotEntryData>(&cfg, &mut output);
push_decl::<SessionSnapshotEntry>(&cfg, &mut output);
push_decl::<SessionConversationTurn>(&cfg, &mut output);
push_decl::<SessionHistoryLineageBoundary>(&cfg, &mut output);
push_decl::<SessionHistoryPage>(&cfg, &mut output);
push_decl::<PendingSubmissionSummary>(&cfg, &mut output);
push_decl::<PendingSubmissionsSnapshot>(&cfg, &mut output);
push_decl::<SubmissionDisposition>(&cfg, &mut output);
+70 -1
View File
@@ -655,6 +655,41 @@ pub struct WorkerSessionRequest {
pub workspace_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerSessionHistoryRequest {
pub workspace_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkerSessionHistoryUnavailableReason {
RetentionMissing,
ActivePointerMissing,
CorruptLog,
MigrationRequired,
RetentionExpired,
StorageUnavailable,
InvalidCursor,
ResourceLimit,
Unsupported,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "availability", rename_all = "snake_case")]
pub enum WorkerSessionHistoryAvailability {
Page {
page: protocol::SessionHistoryPage,
},
Unavailable {
reason: WorkerSessionHistoryUnavailableReason,
message: String,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetainedSessionIdentity {
pub session_id: String,
@@ -718,6 +753,17 @@ pub trait RuntimeApi {
#[query] request: WorkerSessionRequest,
) -> Result<WorkerSessionAvailability, RuntimeApiError>;
#[get(
"/v1/workers/{worker_id}/session/history",
status = 200,
error_status = 400
)]
async fn worker_session_history(
&self,
#[path] worker_id: String,
#[query] request: WorkerSessionHistoryRequest,
) -> Result<WorkerSessionHistoryAvailability, RuntimeApiError>;
#[post("/v1/workers", status = 200, error_status = 400)]
async fn create_worker(
&self,
@@ -1053,6 +1099,17 @@ mod tests {
Ok(WorkerSessionAvailability::LiveProtocol)
}
async fn worker_session_history(
&self,
_worker_id: String,
_request: WorkerSessionHistoryRequest,
) -> Result<WorkerSessionHistoryAvailability, RuntimeApiError> {
Ok(WorkerSessionHistoryAvailability::Unavailable {
reason: WorkerSessionHistoryUnavailableReason::Unsupported,
message: "unsupported".to_string(),
})
}
async fn create_worker(
&self,
_request: CreateWorkerRequest,
@@ -1174,10 +1231,22 @@ mod tests {
assert_eq!(value["message"], "snapshot exceeds limit");
}
#[test]
fn worker_session_history_availability_has_closed_tagged_wire_shape() {
let value = serde_json::to_value(WorkerSessionHistoryAvailability::Unavailable {
reason: WorkerSessionHistoryUnavailableReason::InvalidCursor,
message: "cursor is stale".to_string(),
})
.unwrap();
assert_eq!(value["availability"], "unavailable");
assert_eq!(value["reason"], "invalid_cursor");
assert_eq!(value["message"], "cursor is stale");
}
#[test]
fn contract_inventory_is_complete_and_unique() {
let operations = RuntimeApiMetadata::OPERATIONS;
assert_eq!(operations.len(), 16);
assert_eq!(operations.len(), 17);
let mut routes = operations
.iter()
.map(|operation| (format!("{:?}", operation.method), operation.path))
+82 -10
View File
@@ -389,6 +389,20 @@ pub struct WorkspaceWorkerSessionResponse {
pub observation: runtime_api::WorkerSessionAvailability,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceWorkerSessionHistoryQuery {
pub cursor: Option<String>,
pub limit: Option<usize>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct WorkspaceWorkerSessionHistoryResponse {
pub subject: WorkspaceWorkerSubject,
#[serde(flatten)]
pub observation: runtime_api::WorkerSessionHistoryAvailability,
}
#[api(reqwest, axum, openapi)]
pub trait ServerApi {
#[get("/health", status = 200, error_status = 400)]
@@ -1141,6 +1155,20 @@ pub trait ServerApi {
#[path] worker_id: String,
) -> Result<WorkspaceWorkerSessionResponse, ServerApiError>;
#[get(
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/session/history",
status = 200,
error_status = 400,
openapi = false
)]
async fn worker_session_history(
&self,
#[path] workspace_id: String,
#[path] runtime_id: String,
#[path] worker_id: String,
#[query] query: WorkspaceWorkerSessionHistoryQuery,
) -> Result<WorkspaceWorkerSessionHistoryResponse, ServerApiError>;
#[get(
"/api/workers",
status = 200,
@@ -5262,27 +5290,71 @@ impl From<workdir::workspace::WorkingDirectorySummary> for WorkingDirectorySumma
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExternalWorkdirPermissionLevel {
Read,
Write,
Command,
}
impl ExternalWorkdirPermissionLevel {
pub const fn permissions(self) -> ExternalWorkdirPermissions {
match self {
Self::Read => ExternalWorkdirPermissions::READ,
Self::Write => ExternalWorkdirPermissions::WRITE,
Self::Command => ExternalWorkdirPermissions::COMMAND,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ExternalWorkdirPermissions {
/// Read files and use read-only discovery operations such as Glob and Grep.
/// Every supported External Workdir permission level enables this field.
pub read: bool,
/// Create or update files with Write and Edit. Existing-file mutation also
/// requires `read` so callers can satisfy the read-before-write contract.
/// Create or update files with Write and Edit. WRITE includes READ so callers
/// can satisfy the read-before-write contract.
pub write: bool,
/// Execute commands as the sharing CLI user on the provider host. This is
/// explicit authority and does not imply either filesystem category.
/// Execute commands as the sharing CLI user on the provider host. COMMAND
/// includes WRITE and READ, remains explicit, and is not sandboxed.
pub command: bool,
}
impl ExternalWorkdirPermissions {
pub const READ: Self = Self {
read: true,
write: false,
command: false,
};
pub const WRITE: Self = Self {
read: true,
write: true,
command: false,
};
pub const COMMAND: Self = Self {
read: true,
write: true,
command: true,
};
/// Returns the single hierarchical level represented by a grant permission
/// object. Partial combinations remain valid for attachment/session
/// projections, but are not valid authority for creating an External grant.
pub const fn grant_level(self) -> Option<ExternalWorkdirPermissionLevel> {
match (self.read, self.write, self.command) {
(true, false, false) => Some(ExternalWorkdirPermissionLevel::Read),
(true, true, false) => Some(ExternalWorkdirPermissionLevel::Write),
(true, true, true) => Some(ExternalWorkdirPermissionLevel::Command),
_ => None,
}
}
}
impl Default for ExternalWorkdirPermissions {
fn default() -> Self {
Self {
read: true,
write: false,
command: false,
}
Self::READ
}
}
@@ -11462,7 +11534,7 @@ mod openapi_artifact_tests {
];
// Session observation retains its existing explicit non-OpenAPI boundary independently of
// the Runtime/Worker-source signed operation inventory above.
const OTHER_OPENAPI_EXCLUDED: &[&str] = &["worker_session"];
const OTHER_OPENAPI_EXCLUDED: &[&str] = &["worker_session", "worker_session_history"];
const DOCUMENTED_SIGNED_INTERNAL: &[&str] = &["workspace_runtime_config"];
const BROWSER_ONLY: &[&str] = &["merge_request_selector_repair"];
+17 -5
View File
@@ -37,23 +37,31 @@ use std::path::Path;
/// Read an existing retained file without updating its access timestamp.
/// Observation fails closed when the platform cannot provide that guarantee.
pub(crate) fn read_without_atime(path: &Path) -> io::Result<Vec<u8>> {
read_without_atime_bounded(path, u64::MAX)
}
/// Read at most `max_bytes + 1` retained bytes without updating access time.
/// The extra byte lets callers prove that the authoritative file exceeded a
/// limit without first racing a separate metadata length observation.
pub(crate) fn read_without_atime_bounded(path: &Path, max_bytes: u64) -> io::Result<Vec<u8>> {
#[cfg(any(target_os = "linux", target_os = "android"))]
{
use std::fs::OpenOptions;
use std::io::Read;
use std::os::unix::fs::OpenOptionsExt;
let mut file = OpenOptions::new()
let file = OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOATIME)
.open(path)?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
file.take(max_bytes.saturating_add(1))
.read_to_end(&mut bytes)?;
Ok(bytes)
}
#[cfg(not(any(target_os = "linux", target_os = "android")))]
{
let _ = path;
let _ = (path, max_bytes);
Err(io::Error::new(
io::ErrorKind::Unsupported,
"read-only retained observation requires no-atime file reads",
@@ -88,8 +96,12 @@ pub use history::{
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
pub use paste_artifact::PasteArtifactLimits;
pub use public_snapshot::{
DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES, RetainedSessionIdentity, RetainedSessionSnapshot,
RetainedSnapshotReadError, project_session_snapshot, read_retained_session_snapshot,
DEFAULT_RETAINED_HISTORY_MAX_ENTRIES, DEFAULT_RETAINED_HISTORY_MAX_RESPONSE_BYTES,
DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES, DEFAULT_RETAINED_HISTORY_MAX_SEGMENTS,
DEFAULT_RETAINED_HISTORY_PAGE_TURNS, DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES,
MAX_RETAINED_HISTORY_PAGE_TURNS, RetainedHistoryReadError, RetainedHistoryReadLimits,
RetainedSessionIdentity, RetainedSessionSnapshot, RetainedSnapshotReadError,
project_session_snapshot, read_retained_session_history_page, read_retained_session_snapshot,
};
pub use segment::{
SegmentStartState, append_entry, append_system_item, classify_logged_history_entry,
File diff suppressed because it is too large Load Diff
+3
View File
@@ -33,6 +33,9 @@ pub enum StoreError {
#[error("log corrupted at line {line}: {message}")]
Corrupt { line: usize, message: String },
#[error("retained observation read limit exceeded")]
ReadLimitExceeded,
#[error("paste artifact storage is unavailable")]
PasteArtifactUnsupported,
@@ -25,7 +25,7 @@ use crate::{
use protocol::{PasteArtifactRef, UploadedFileRef};
use serde::{Deserialize, Serialize};
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
@@ -50,6 +50,106 @@ struct SessionManifest {
session_id: SessionId,
}
const RETAINED_BACKWARD_READ_CHUNK_BYTES: usize = 64 * 1024;
#[derive(Debug)]
pub(crate) struct RetainedLogRecord {
pub start_offset: u64,
pub end_offset: u64,
pub entry: LogEntry,
}
pub(crate) struct RetainedSegmentReader {
file: File,
read_end: u64,
buffer_start: u64,
buffer: Vec<u8>,
}
impl RetainedSegmentReader {
pub fn previous_record(
&mut self,
max_additional_bytes: u64,
) -> Result<(Option<RetainedLogRecord>, u64), StoreError> {
let mut read_bytes = 0_u64;
loop {
if !self.buffer.is_empty() {
if self.buffer.last() != Some(&b'\n') {
return Err(StoreError::Corrupt {
line: 0,
message: "retained Segment cursor is not at a record boundary".to_string(),
});
}
let prior_newline = self.buffer[..self.buffer.len() - 1]
.iter()
.rposition(|byte| *byte == b'\n');
if let Some(newline) =
prior_newline.or((self.buffer_start == 0).then_some(usize::MAX))
{
let record_start_in_buffer = if newline == usize::MAX {
0
} else {
newline + 1
};
let record_start = self.buffer_start + record_start_in_buffer as u64;
let record_end = self.buffer_start + self.buffer.len() as u64;
let record =
self.buffer[record_start_in_buffer..self.buffer.len() - 1].to_vec();
self.buffer.truncate(record_start_in_buffer);
self.read_end = record_start;
if record.is_empty() {
return Err(StoreError::Corrupt {
line: 0,
message: "empty retained Segment log record".to_string(),
});
}
let entry = serde_json::from_slice(&record)?;
return Ok((
Some(RetainedLogRecord {
start_offset: record_start,
end_offset: record_end,
entry,
}),
read_bytes,
));
}
} else if self.read_end == 0 {
return Ok((None, read_bytes));
}
if self.buffer_start == 0 && !self.buffer.is_empty() {
return Err(StoreError::Corrupt {
line: 0,
message: "retained Segment log does not end at a record boundary".to_string(),
});
}
if read_bytes >= max_additional_bytes {
return Err(StoreError::ReadLimitExceeded);
}
let end = if self.buffer.is_empty() {
self.read_end
} else {
self.buffer_start
};
let available = max_additional_bytes - read_bytes;
let chunk_len = end
.min(RETAINED_BACKWARD_READ_CHUNK_BYTES as u64)
.min(available);
if chunk_len == 0 {
return Err(StoreError::ReadLimitExceeded);
}
let start = end - chunk_len;
self.file.seek(SeekFrom::Start(start))?;
let mut chunk = vec![0_u8; chunk_len as usize];
self.file.read_exact(&mut chunk)?;
read_bytes += chunk_len;
chunk.extend_from_slice(&self.buffer);
self.buffer = chunk;
self.buffer_start = start;
}
}
}
impl WorkerSessionStore {
/// Open the Session store rooted at `<worker-aggregate>/session`.
pub fn new(root: impl Into<PathBuf>) -> Result<Self, StoreError> {
@@ -119,6 +219,19 @@ impl WorkerSessionStore {
session_id: SessionId,
segment_id: SegmentId,
) -> Result<Vec<LogEntry>, StoreError> {
self.read_all_read_only_bounded(session_id, segment_id, u64::MAX)
.map(|(entries, _)| entries)
}
/// Read one retained Segment under an actual byte bound without touching
/// access time. The file descriptor read, rather than a preceding stat, is
/// the limit authority so concurrent appends cannot evade accounting.
pub fn read_all_read_only_bounded(
&self,
session_id: SessionId,
segment_id: SegmentId,
max_bytes: u64,
) -> Result<(Vec<LogEntry>, u64), StoreError> {
let retained_session_id = self.session_id.lock().map_err(|_| StoreError::Corrupt {
line: 0,
message: "Worker Session identity lock poisoned".to_string(),
@@ -131,14 +244,210 @@ impl WorkerSessionStore {
});
}
let path = self.log_path(segment_id);
let bytes = crate::read_without_atime(&path).map_err(|error| {
let bytes = crate::read_without_atime_bounded(&path, max_bytes).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
StoreError::NotFound(segment_id)
} else {
StoreError::Io(error)
}
})?;
parse_jsonl(&bytes)
let byte_len = bytes.len() as u64;
if byte_len > max_bytes {
return Err(StoreError::ReadLimitExceeded);
}
Ok((parse_jsonl(&bytes)?, byte_len))
}
pub(crate) fn read_first_log_record_read_only_bounded(
&self,
session_id: SessionId,
segment_id: SegmentId,
max_bytes: u64,
) -> Result<(Option<RetainedLogRecord>, u64, u64), StoreError> {
self.validate_retained_session(session_id)?;
let file = open_retained_file(&self.log_path(segment_id)).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
StoreError::NotFound(segment_id)
} else {
StoreError::Io(error)
}
})?;
let file_len = file.metadata()?.len();
if file_len == 0 {
return Ok((None, 0, 0));
}
let mut reader = BufReader::new(file.take(max_bytes.saturating_add(1)));
let mut record = Vec::new();
let read_bytes = reader.read_until(b'\n', &mut record)? as u64;
if read_bytes > max_bytes || record.last() != Some(&b'\n') {
return Err(StoreError::ReadLimitExceeded);
}
record.pop();
if record.is_empty() {
return Err(StoreError::Corrupt {
line: 0,
message: "empty retained Segment log record".to_string(),
});
}
let entry = serde_json::from_slice(&record)?;
Ok((
Some(RetainedLogRecord {
start_offset: 0,
end_offset: read_bytes,
entry,
}),
read_bytes,
file_len,
))
}
pub(crate) fn read_next_log_record_read_only_bounded(
&self,
session_id: SessionId,
segment_id: SegmentId,
start_offset: u64,
max_bytes: u64,
) -> Result<(Option<RetainedLogRecord>, u64), StoreError> {
self.validate_retained_session(session_id)?;
let mut file = open_retained_file(&self.log_path(segment_id)).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
StoreError::NotFound(segment_id)
} else {
StoreError::Io(error)
}
})?;
let file_len = file.metadata()?.len();
if start_offset >= file_len {
return Ok((None, 0));
}
file.seek(SeekFrom::Start(start_offset))?;
let mut reader = BufReader::new(file.take(max_bytes.saturating_add(1)));
let mut record = Vec::new();
let read_bytes = reader.read_until(b'\n', &mut record)? as u64;
if read_bytes > max_bytes || record.last() != Some(&b'\n') {
return Err(StoreError::ReadLimitExceeded);
}
record.pop();
if record.is_empty() {
return Err(StoreError::Corrupt {
line: 0,
message: "empty retained Segment log record".to_string(),
});
}
let entry = serde_json::from_slice(&record)?;
Ok((
Some(RetainedLogRecord {
start_offset,
end_offset: start_offset + read_bytes,
entry,
}),
read_bytes,
))
}
pub(crate) fn read_log_record_range_read_only_bounded(
&self,
session_id: SessionId,
segment_id: SegmentId,
start_offset: u64,
end_offset: u64,
max_bytes: u64,
) -> Result<RetainedLogRecord, StoreError> {
self.validate_retained_session(session_id)?;
let record_len =
end_offset
.checked_sub(start_offset)
.ok_or_else(|| StoreError::Corrupt {
line: 0,
message: "retained Segment record range is reversed".to_string(),
})?;
if record_len == 0 || record_len > max_bytes {
return Err(StoreError::ReadLimitExceeded);
}
let mut file = open_retained_file(&self.log_path(segment_id)).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
StoreError::NotFound(segment_id)
} else {
StoreError::Io(error)
}
})?;
if end_offset > file.metadata()?.len() {
return Err(StoreError::Corrupt {
line: 0,
message: "retained Segment record range exceeds the committed log".to_string(),
});
}
file.seek(SeekFrom::Start(start_offset))?;
let mut record = vec![0_u8; record_len as usize];
file.read_exact(&mut record)?;
if record.last() != Some(&b'\n') || record[..record.len() - 1].contains(&b'\n') {
return Err(StoreError::Corrupt {
line: 0,
message: "retained Segment cursor does not identify one record".to_string(),
});
}
record.pop();
let entry = serde_json::from_slice(&record)?;
Ok(RetainedLogRecord {
start_offset,
end_offset,
entry,
})
}
pub(crate) fn open_retained_segment_reader(
&self,
session_id: SessionId,
segment_id: SegmentId,
before_offset: u64,
) -> Result<RetainedSegmentReader, StoreError> {
self.validate_retained_session(session_id)?;
let mut file = open_retained_file(&self.log_path(segment_id)).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
StoreError::NotFound(segment_id)
} else {
StoreError::Io(error)
}
})?;
let file_len = file.metadata()?.len();
if before_offset > file_len {
return Err(StoreError::Corrupt {
line: 0,
message: "retained Segment cursor is beyond the committed log".to_string(),
});
}
if before_offset > 0 {
file.seek(SeekFrom::Start(before_offset - 1))?;
let mut boundary = [0_u8; 1];
file.read_exact(&mut boundary)?;
if boundary[0] != b'\n' {
return Err(StoreError::Corrupt {
line: 0,
message: "retained Segment cursor is not at a record boundary".to_string(),
});
}
}
Ok(RetainedSegmentReader {
file,
read_end: before_offset,
buffer_start: before_offset,
buffer: Vec::new(),
})
}
fn validate_retained_session(&self, requested: SessionId) -> Result<(), StoreError> {
let retained_session_id = self.session_id.lock().map_err(|_| StoreError::Corrupt {
line: 0,
message: "Worker Session identity lock poisoned".to_string(),
})?;
if *retained_session_id != Some(requested) {
return Err(StoreError::Corrupt {
line: 0,
message: "active Worker Session identity does not match retained manifest"
.to_string(),
});
}
Ok(())
}
pub fn segment_log_len(&self, segment_id: SegmentId) -> Result<u64, StoreError> {
@@ -649,6 +958,25 @@ impl Store for WorkerSessionStore {
}
}
fn open_retained_file(path: &Path) -> std::io::Result<File> {
#[cfg(any(target_os = "linux", target_os = "android"))]
{
use std::os::unix::fs::OpenOptionsExt;
OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOATIME)
.open(path)
}
#[cfg(not(any(target_os = "linux", target_os = "android")))]
{
let _ = path;
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"read-only retained observation requires no-atime file reads",
))
}
}
fn segment_log_paths(root: &Path) -> Result<Vec<(SegmentId, PathBuf)>, StoreError> {
let segments = root.join(SEGMENTS_DIR);
if !segments.exists() {
+1 -1
View File
@@ -117,7 +117,7 @@ async fn in_process_host_runs_text_and_read_tool_then_shuts_down() {
.expect("protocol event")
.expect("worker event")
{
Event::UserMessage { segments }
Event::UserMessage { segments, .. }
if format!("{segments:?}").contains("read the probe") =>
{
saw_user_message = true;
+92 -21
View File
@@ -7,12 +7,14 @@ use agen::tool::{
};
use async_trait::async_trait;
use serde::Deserialize;
use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter};
use workdir::{ReadBytesRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter};
use crate::error::ToolsError;
/// Maximum image body accepted for one model request.
pub const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024;
/// One binary response remains within the External Workdir transport bound.
const IMAGE_READ_CHUNK_BYTES: usize = 1024 * 1024;
const DESCRIPTION: &str = "Attach an image from the selected Workdir attachment to the next model request. \
The path must be logical and Workdir-relative. Supported formats: PNG, JPEG, GIF, and WebP. \
@@ -47,42 +49,111 @@ impl Tool for ViewImageTool {
workdir::WorkdirSessionCapability::Read,
)?;
let path = WorkdirPath::new(&input.path).map_err(ToolsError::from)?;
let result = selected
.session
.read(ReadRequest {
path: path.clone(),
offset: 0,
limit: usize::MAX,
// The scoped provider enforces this cap while reading, rather
// than allocating an unbounded binary body first.
max_bytes: MAX_IMAGE_BYTES + 1,
})
let bytes = read_image_bytes(selected.session.as_ref(), path.clone())
.await
.map_err(ToolsError::from)?;
if result.truncated || result.bytes.len() > MAX_IMAGE_BYTES {
return Err(ToolError::InvalidArgument(format!(
"image exceeds the {MAX_IMAGE_BYTES}-byte limit"
)));
}
let mime_type = detect_image_mime(&result.bytes).ok_or_else(|| {
let mime_type = detect_image_mime(&bytes).ok_or_else(|| {
ToolError::InvalidArgument(
"unsupported image; expected PNG, JPEG, GIF, or WebP bytes".to_string(),
)
})?;
let bytes = result.bytes.len();
let byte_count = bytes.len();
Ok(ToolOutput {
summary: format!("Attached image {path} ({mime_type}, {bytes} bytes)"),
summary: format!("Attached image {path} ({mime_type}, {byte_count} bytes)"),
content: None,
attachments: vec![Attachment::Image(ImageAttachment::new(
mime_type,
Arc::<[u8]>::from(result.bytes),
Arc::<[u8]>::from(bytes),
))],
})
}
}
async fn read_image_bytes(
session: &dyn workdir::WorkdirSession,
path: WorkdirPath,
) -> Result<Vec<u8>, workdir::WorkdirError> {
let mut bytes = Vec::new();
let mut offset = 0_u64;
let mut expected_hash = None;
let mut expected_total = None;
loop {
let remaining = expected_total
.map(|total: u64| total.saturating_sub(offset) as usize)
.unwrap_or(IMAGE_READ_CHUNK_BYTES);
let request_bytes = remaining.min(IMAGE_READ_CHUNK_BYTES).max(1);
let result = session
.read_bytes(ReadBytesRequest {
path: path.clone(),
offset,
max_bytes: request_bytes,
expected_hash,
})
.await?;
if result.path != path || result.offset != offset {
return Err(workdir::WorkdirError::Transport(
"bounded binary read returned a mismatched path or offset".to_string(),
));
}
if result.bytes.len() > request_bytes {
return Err(workdir::WorkdirError::Transport(
"bounded binary read exceeded the requested response size".to_string(),
));
}
let end = result
.offset
.checked_add(result.bytes.len() as u64)
.ok_or_else(|| {
workdir::WorkdirError::Transport(
"bounded binary read returned an invalid byte range".to_string(),
)
})?;
if end > result.total_bytes || result.eof != (end == result.total_bytes) {
return Err(workdir::WorkdirError::Transport(
"bounded binary read returned a truncated or invalid byte range".to_string(),
));
}
if result.total_bytes > MAX_IMAGE_BYTES as u64 {
return Err(workdir::WorkdirError::InvalidArgument(format!(
"image exceeds the {MAX_IMAGE_BYTES}-byte limit"
)));
}
if expected_total.is_some_and(|total| total != result.total_bytes)
|| expected_hash.is_some_and(|hash| hash != result.content_hash)
{
return Err(workdir::WorkdirError::Conflict(
"image changed while it was being read; retry ViewImage".to_string(),
));
}
if expected_total.is_none() {
bytes
.try_reserve_exact(result.total_bytes as usize)
.map_err(|_| workdir::WorkdirError::OperationFailed)?;
expected_total = Some(result.total_bytes);
expected_hash = Some(result.content_hash);
}
if result.bytes.is_empty() && !result.eof {
return Err(workdir::WorkdirError::Transport(
"bounded binary read made no progress".to_string(),
));
}
bytes.extend_from_slice(&result.bytes);
offset = end;
if result.eof {
if bytes.len() as u64 != result.total_bytes {
return Err(workdir::WorkdirError::Transport(
"bounded binary read returned a partial image".to_string(),
));
}
return Ok(bytes);
}
}
}
pub fn view_image_tool(session: WorkdirSessionHandle) -> ToolDefinition {
routed_view_image_tool(crate::routing::singleton_router(session))
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 828 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 B

+326 -3
View File
@@ -18,7 +18,9 @@ use tools::{
Tracker, core_builtin_tools, routed_builtin_tools, routed_view_image_tool, view_image_tool,
};
use workdir::{
LocalWorkdirSession, WorkdirAttachmentAlias, WorkdirSessionHandle, WorkdirSessionRouter,
LocalWorkdirSession, WorkdirAttachmentAlias, WorkdirPath, WorkdirSessionHandle,
WorkdirSessionRouter, WorkdirToolBroker, WorkdirToolScope, WorkdirToolScopePermission,
WorkdirToolScopeRule,
};
fn scope_with_spill(workspace: &Path, spill: &Path) -> Scope {
@@ -94,6 +96,33 @@ fn setup_routed() -> (
(left, right, spill, router, Registry::new(definitions))
}
fn valid_gif_with_len(target_len: usize) -> Vec<u8> {
let fixture = include_bytes!("fixtures/view-image/valid.gif");
assert_eq!(
fixture.last(),
Some(&0x3b),
"GIF fixture must end in trailer"
);
let mut bytes = fixture[..fixture.len() - 1].to_vec();
bytes.extend_from_slice(&[0x21, 0xfe]);
let mut remaining = target_len
.checked_sub(bytes.len() + 2)
.expect("target must leave room for comment terminator and GIF trailer");
while remaining > 0 {
let mut block = remaining.min(256);
if remaining - block == 1 {
block -= 1;
}
assert!(block >= 2);
bytes.push((block - 1) as u8);
bytes.extend(std::iter::repeat_n(b'x', block - 1));
remaining -= block;
}
bytes.extend_from_slice(&[0, 0x3b]);
assert_eq!(bytes.len(), target_len);
bytes
}
async fn call(tool: &Arc<dyn Tool>, input: serde_json::Value) -> agen::tool::ToolOutput {
tool.execute(&input.to_string(), Default::default())
.await
@@ -399,6 +428,301 @@ async fn detach_and_reattach_same_alias_requires_a_fresh_read() {
router.close_all().await.unwrap();
}
#[derive(Debug)]
struct ExternalValidatingSession {
inner: LocalWorkdirSession,
truncate_first_binary_response: bool,
mutate_after_first_binary_response: Option<std::path::PathBuf>,
}
#[async_trait::async_trait]
impl workdir::WorkdirSession for ExternalValidatingSession {
fn workdir(&self) -> &workdir::Workdir {
self.inner.workdir()
}
fn capabilities(&self) -> workdir::WorkdirSessionCapabilities {
self.inner.capabilities()
}
async fn stat(
&self,
request: workdir::StatRequest,
) -> Result<workdir::StatResult, workdir::WorkdirError> {
self.inner.stat(request).await
}
async fn read(
&self,
request: workdir::ReadRequest,
) -> Result<workdir::ReadResult, workdir::WorkdirError> {
let operation = workdir::http::WorkdirSessionOperation::Read(request);
let operation = workdir::external::ExternalWorkdirOperation::try_from(operation)
.map_err(workdir::WorkdirError::UnsupportedOperation)?;
let workdir::http::WorkdirSessionOperation::Read(request) = operation.into_inner() else {
unreachable!("validated Read changed operation kind")
};
self.inner.read(request).await
}
async fn read_bytes(
&self,
request: workdir::ReadBytesRequest,
) -> Result<workdir::ReadBytesResult, workdir::WorkdirError> {
let operation = workdir::http::WorkdirSessionOperation::ReadBytes(request);
let operation = workdir::external::ExternalWorkdirOperation::try_from(operation)
.map_err(workdir::WorkdirError::UnsupportedOperation)?;
let workdir::http::WorkdirSessionOperation::ReadBytes(request) = operation.into_inner()
else {
unreachable!("validated ReadBytes changed operation kind")
};
let result = self.inner.read_bytes(request).await?;
let result = workdir::external::ExternalWorkdirOperationResult::try_from(
workdir::http::WorkdirSessionOperationResult::ReadBytes(result),
)
.map_err(workdir::WorkdirError::UnsupportedOperation)?;
let workdir::http::WorkdirSessionOperationResult::ReadBytes(mut result) =
result.into_inner()
else {
unreachable!("validated ReadBytes result changed operation kind")
};
if result.offset == 0 {
if let Some(path) = &self.mutate_after_first_binary_response {
std::fs::write(path, vec![b'z'; result.total_bytes as usize]).unwrap();
}
if self.truncate_first_binary_response {
result.bytes.pop();
}
}
Ok(result)
}
async fn write(
&self,
request: workdir::WriteRequest,
) -> Result<workdir::WriteResult, workdir::WorkdirError> {
self.inner.write(request).await
}
async fn edit(
&self,
request: workdir::EditRequest,
) -> Result<workdir::EditResult, workdir::WorkdirError> {
self.inner.edit(request).await
}
async fn list(
&self,
request: workdir::ListRequest,
) -> Result<workdir::ListResult, workdir::WorkdirError> {
self.inner.list(request).await
}
async fn glob(
&self,
request: workdir::GlobRequest,
) -> Result<workdir::GlobResult, workdir::WorkdirError> {
self.inner.glob(request).await
}
async fn grep(
&self,
request: workdir::GrepRequest,
) -> Result<workdir::GrepResult, workdir::WorkdirError> {
self.inner.grep(request).await
}
async fn start_command(
&self,
request: workdir::CommandRequest,
) -> Result<workdir::CommandHandle, workdir::WorkdirError> {
self.inner.start_command(request).await
}
async fn command_status(
&self,
handle: workdir::CommandHandle,
) -> Result<workdir::CommandStatus, workdir::WorkdirError> {
self.inner.command_status(handle).await
}
async fn command_output(
&self,
request: workdir::CommandOutputRequest,
) -> Result<workdir::CommandOutput, workdir::WorkdirError> {
self.inner.command_output(request).await
}
async fn cancel_command(
&self,
handle: workdir::CommandHandle,
) -> Result<(), workdir::WorkdirError> {
self.inner.cancel_command(handle).await
}
async fn close(&self) -> Result<(), workdir::WorkdirError> {
self.inner.close().await
}
}
#[tokio::test]
async fn external_view_image_uses_a_protocol_bounded_request() {
let dir = TempDir::new().unwrap();
let spill = TempDir::new().unwrap();
let scope = scope_with_spill(dir.path(), spill.path());
let session: WorkdirSessionHandle = Arc::new(ExternalValidatingSession {
inner: LocalWorkdirSession::new(scope, dir.path().to_path_buf()),
truncate_first_binary_response: false,
mutate_after_first_binary_response: None,
});
let png = include_bytes!("fixtures/view-image/valid.png");
assert!(png.contains(&0));
assert!(png.iter().any(|byte| *byte >= 0x80));
std::fs::write(dir.path().join("image.png"), png).unwrap();
let (_meta, tool) = view_image_tool(session)();
let output = call(&tool, json!({ "path": "image.png" })).await;
let agen::tool::Attachment::Image(image) = &output.attachments[0];
assert_eq!(image.mime_type(), "image/png");
assert_eq!(image.data(), png);
}
#[tokio::test]
async fn external_view_image_rejects_truncated_and_cross_version_chunks() {
let dir = TempDir::new().unwrap();
let spill = TempDir::new().unwrap();
let image_path = dir.path().join("image.png");
let scope = scope_with_spill(dir.path(), spill.path());
std::fs::write(&image_path, include_bytes!("fixtures/view-image/valid.png")).unwrap();
let truncated: WorkdirSessionHandle = Arc::new(ExternalValidatingSession {
inner: LocalWorkdirSession::new(scope.clone(), dir.path().to_path_buf()),
truncate_first_binary_response: true,
mutate_after_first_binary_response: None,
});
let (_meta, tool) = view_image_tool(truncated)();
let error = call_err(&tool, json!({ "path": "image.png" }))
.await
.to_string();
assert!(
error.contains("truncated") || error.contains("partial"),
"{error}"
);
let large = valid_gif_with_len(1024 * 1024 + 32);
std::fs::write(&image_path, large).unwrap();
let changing: WorkdirSessionHandle = Arc::new(ExternalValidatingSession {
inner: LocalWorkdirSession::new(scope, dir.path().to_path_buf()),
truncate_first_binary_response: false,
mutate_after_first_binary_response: Some(image_path),
});
let (_meta, tool) = view_image_tool(changing)();
let error = call_err(&tool, json!({ "path": "image.png" }))
.await
.to_string();
assert!(error.contains("changed"), "{error}");
}
#[tokio::test]
async fn view_image_enforces_the_ten_mib_boundary_without_partial_attachments() {
const LIMIT: usize = 10 * 1024 * 1024;
let dir = TempDir::new().unwrap();
let spill = TempDir::new().unwrap();
let scope = scope_with_spill(dir.path(), spill.path());
let session: WorkdirSessionHandle =
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
let (_meta, tool) = view_image_tool(session)();
let mut image = valid_gif_with_len(LIMIT);
std::fs::write(dir.path().join("boundary.gif"), &image).unwrap();
let output = call(&tool, json!({ "path": "boundary.gif" })).await;
let agen::tool::Attachment::Image(attachment) = &output.attachments[0];
assert_eq!(attachment.mime_type(), "image/gif");
assert_eq!(attachment.data(), image);
image.push(0);
std::fs::write(dir.path().join("boundary.gif"), image).unwrap();
let error = call_err(&tool, json!({ "path": "boundary.gif" }))
.await
.to_string();
assert!(error.contains("10485760-byte limit"), "{error}");
}
#[tokio::test]
async fn view_image_attaches_valid_supported_image_fixtures() {
let dir = TempDir::new().unwrap();
let spill = TempDir::new().unwrap();
let scope = scope_with_spill(dir.path(), spill.path());
let session: WorkdirSessionHandle =
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
let (_meta, tool) = view_image_tool(session)();
let fixtures: [(&str, &[u8], &str); 4] = [
(
"valid.png",
include_bytes!("fixtures/view-image/valid.png"),
"image/png",
),
(
"valid.jpg",
include_bytes!("fixtures/view-image/valid.jpg"),
"image/jpeg",
),
(
"valid.gif",
include_bytes!("fixtures/view-image/valid.gif"),
"image/gif",
),
(
"valid.webp",
include_bytes!("fixtures/view-image/valid.webp"),
"image/webp",
),
];
for (name, fixture, mime) in fixtures {
std::fs::write(dir.path().join(name), fixture).unwrap();
let output = call(&tool, json!({ "path": name })).await;
let agen::tool::Attachment::Image(image) = &output.attachments[0];
assert_eq!(image.mime_type(), mime);
assert_eq!(image.data(), fixture);
}
}
#[tokio::test]
async fn delegated_view_image_preserves_caller_relative_path_identity() {
let dir = TempDir::new().unwrap();
std::fs::create_dir(dir.path().join("docs")).unwrap();
let fixture = include_bytes!("fixtures/view-image/valid.png");
std::fs::write(dir.path().join("docs/image.png"), fixture).unwrap();
let source: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(
Scope::writable(dir.path()).unwrap(),
dir.path().to_path_buf(),
));
let broker = WorkdirToolBroker::new(source);
let lease = broker
.scope(WorkdirToolScope {
rules: vec![WorkdirToolScopeRule {
target: WorkdirPath::new("docs").unwrap(),
permission: WorkdirToolScopePermission::Read,
recursive: true,
symlink_policy: Default::default(),
}],
cwd: WorkdirPath::new("docs").unwrap(),
command: false,
})
.await
.unwrap();
let (_meta, tool) = view_image_tool(lease.tool_session())();
let output = call(&tool, json!({ "path": "image.png" })).await;
assert!(output.summary.contains("image.png"));
assert!(!output.summary.contains("docs/image.png"));
let agen::tool::Attachment::Image(image) = &output.attachments[0];
assert_eq!(image.data(), fixture);
lease.close().await.unwrap();
broker.close().await.unwrap();
}
#[tokio::test]
async fn view_image_reads_scoped_bytes_into_durable_tool_detail() {
let dir = TempDir::new().unwrap();
@@ -406,7 +730,7 @@ async fn view_image_reads_scoped_bytes_into_durable_tool_detail() {
let scope = scope_with_spill(dir.path(), spill.path());
let session: WorkdirSessionHandle =
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
let png = b"\x89PNG\r\n\x1a\nprivate-image-body";
let png = include_bytes!("fixtures/view-image/valid.png");
std::fs::write(dir.path().join("image.png"), png).unwrap();
let definition = view_image_tool(session);
let (_meta, tool) = definition();
@@ -417,7 +741,6 @@ async fn view_image_reads_scoped_bytes_into_durable_tool_detail() {
assert_eq!(image.mime_type(), "image/png");
assert_eq!(image.data(), png);
let serialized = serde_json::to_string(&output).unwrap();
assert!(!serialized.contains("private-image-body"));
assert!(serialized.contains("attachments"));
let restored: agen::tool::ToolOutput = serde_json::from_str(&serialized).unwrap();
let agen::tool::Attachment::Image(restored_image) = &restored.attachments[0];
+8 -1
View File
@@ -1148,7 +1148,7 @@ impl App {
Event::PendingSubmissionsChanged { pending } => {
self.pending_submissions = pending;
}
Event::UserMessage { segments } => {
Event::UserMessage { segments, .. } => {
self.turn_index += 1;
self.blocks.push(Block::TurnHeader {
turn: self.turn_index,
@@ -1168,6 +1168,9 @@ impl App {
}
self.assistant_streaming = false;
}
// The TUI already renders assistant/tool streaming events. This commit
// marker exists so identity-aware clients can reconcile durable history.
Event::SessionEntryCommitted { .. } => {}
Event::SystemItem { item } => {
self.apply_system_item(&item);
self.assistant_streaming = false;
@@ -3391,6 +3394,7 @@ mod completion_flow_tests {
]),
});
app.handle_worker_event(Event::UserMessage {
entry_id: None,
segments: vec![Segment::text("first persisted message")],
});
@@ -3409,6 +3413,7 @@ mod completion_flow_tests {
assert_eq!(input_text(&app), "");
app.handle_worker_event(Event::UserMessage {
entry_id: None,
segments: submitted,
});
// Simulate run-derived attachment display after the submitted user line.
@@ -3449,6 +3454,7 @@ mod completion_flow_tests {
let mut app = App::new("test".into());
let submitted = submit_text(&mut app, "original submit");
app.handle_worker_event(Event::UserMessage {
entry_id: None,
segments: submitted,
});
for c in "draft while running".chars() {
@@ -3477,6 +3483,7 @@ mod completion_flow_tests {
let mut app = App::new("test".into());
let submitted = submit_text(&mut app, "normal run");
app.handle_worker_event(Event::UserMessage {
entry_id: None,
segments: submitted,
});
app.handle_worker_event(Event::RunEnd { result });
+5 -1
View File
@@ -1688,6 +1688,7 @@ mod tests {
);
connection.observe_worker_event(&Event::UserMessage {
entry_id: None,
segments: vec![Segment::text("inspect"), Segment::UploadedFile { file }],
});
assert!(connection.pending_attachments.is_empty());
@@ -1779,7 +1780,10 @@ mod tests {
assert!(!reconcile_attachment_submission(
&mut pending,
&mut awaiting,
&Event::UserMessage { segments },
&Event::UserMessage {
entry_id: None,
segments,
},
));
assert_eq!(awaiting, expected);
assert!(pending.is_empty());
+61 -5
View File
@@ -12,7 +12,7 @@ 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;
pub const EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION: u16 = 2;
/// 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
@@ -155,6 +155,12 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<()
&& request.limit <= 1_000_000
&& request.max_bytes <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
}
WorkdirSessionOperation::ReadBytes(request) => {
is_root_relative(&request.path)
&& request.offset <= BoundedReadLimits::EXTERNAL_DEFAULT.max_source_bytes
&& (1..=BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes)
.contains(&request.max_bytes)
}
WorkdirSessionOperation::List(request) => {
is_root_relative(&request.path) && request.limit <= MAX_EXTERNAL_RESULT_ITEMS
}
@@ -275,6 +281,15 @@ fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<()
is_root_relative(&result.path)
&& result.bytes.len() <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
}
WorkdirSessionOperationResult::ReadBytes(result) => {
let returned_end = result.offset.checked_add(result.bytes.len() as u64);
is_root_relative(&result.path)
&& result.bytes.len() <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
&& result.total_bytes <= BoundedReadLimits::EXTERNAL_DEFAULT.max_source_bytes
&& returned_end.is_some_and(|end| {
end <= result.total_bytes && result.eof == (end == result.total_bytes)
})
}
WorkdirSessionOperationResult::List(result) => {
result.entries.len() <= MAX_EXTERNAL_RESULT_ITEMS
&& result
@@ -482,8 +497,8 @@ mod tests {
use super::*;
use crate::{
CommandRequest, EditRequest, GrepResult, ListRequest, ReadRequest, Workdir, WorkdirPath,
WriteRequest,
CommandRequest, EditRequest, GrepResult, ListRequest, ReadBytesRequest, ReadBytesResult,
ReadRequest, Workdir, WorkdirPath, WriteRequest,
};
fn assert_no_provider_authority(value: &Value) {
@@ -614,7 +629,7 @@ mod tests {
#[test]
fn unsupported_protocol_versions_and_invalid_read_limits_fail_closed() {
let future = r#"{
"version": 2,
"version": 3,
"message": {"kind": "heartbeat", "generation": 1, "sequence": 1}
}"#;
assert!(serde_json::from_str::<ExternalWorkdirServerFrame>(future).is_err());
@@ -726,10 +741,51 @@ mod tests {
);
}
#[test]
fn protocol_bounds_binary_read_requests_and_rejects_partial_results() {
let path = WorkdirPath::new("image.png").unwrap();
let bounded = WorkdirSessionOperation::ReadBytes(ReadBytesRequest {
path: path.clone(),
offset: 0,
max_bytes: BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes,
expected_hash: None,
});
assert!(ExternalWorkdirOperation::try_from(bounded).is_ok());
let oversized = WorkdirSessionOperation::ReadBytes(ReadBytesRequest {
path: path.clone(),
offset: 0,
max_bytes: BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes + 1,
expected_hash: None,
});
assert!(ExternalWorkdirOperation::try_from(oversized).is_err());
let hash = [7; 32];
let complete = WorkdirSessionOperationResult::ReadBytes(ReadBytesResult {
path: path.clone(),
bytes: vec![1, 2, 3],
offset: 0,
total_bytes: 3,
content_hash: hash,
eof: true,
});
assert!(ExternalWorkdirOperationResult::try_from(complete).is_ok());
let partial_marked_complete = WorkdirSessionOperationResult::ReadBytes(ReadBytesResult {
path,
bytes: vec![1, 2],
offset: 0,
total_bytes: 3,
content_hash: hash,
eof: true,
});
assert!(ExternalWorkdirOperationResult::try_from(partial_marked_complete).is_err());
}
#[test]
fn protocol_rejects_unknown_nested_fields_and_unbounded_requests() {
let unknown = r#"{
"version": 1,
"version": 2,
"message": {
"kind": "heartbeat",
"generation": 1,
+22 -3
View File
@@ -11,9 +11,9 @@ use serde::{Deserialize, Serialize};
use crate::{
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
ReadRequest, ReadResult, StatRequest, StatResult, WorkdirError, WorkdirId,
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
WorkdirSessionCapabilities, WriteRequest, WriteResult,
ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult,
WorkdirError, WorkdirId, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest,
WorkdirSession, WorkdirSessionCapabilities, WriteRequest, WriteResult,
};
/// Opaque Runtime-owned identifier for one ephemeral Workdir session.
@@ -66,6 +66,7 @@ pub enum WorkdirSessionOperation {
ScopeRulesOverlap(WorkdirScopeOverlapRequest),
Stat(StatRequest),
Read(ReadRequest),
ReadBytes(ReadBytesRequest),
Write(WriteRequest),
Edit(EditRequest),
List(ListRequest),
@@ -97,6 +98,7 @@ pub enum WorkdirSessionOperationResult {
ScopeRulesOverlap { overlaps: bool },
Stat(StatResult),
Read(ReadResult),
ReadBytes(ReadBytesResult),
Write(WriteResult),
Edit(EditResult),
List(ListResult),
@@ -135,6 +137,10 @@ pub async fn dispatch_workdir_session_operation(
.read(request)
.await
.map(WorkdirSessionOperationResult::Read),
WorkdirSessionOperation::ReadBytes(request) => session
.read_bytes(request)
.await
.map(WorkdirSessionOperationResult::ReadBytes),
WorkdirSessionOperation::Write(request) => session
.write(request)
.await
@@ -572,6 +578,19 @@ mod client {
}
}
async fn read_bytes(
&self,
request: ReadBytesRequest,
) -> Result<ReadBytesResult, WorkdirError> {
match self
.operate(WorkdirSessionOperation::ReadBytes(request))
.await?
{
WorkdirSessionOperationResult::ReadBytes(result) => Ok(result),
_ => Err(Self::mismatch("read_bytes")),
}
}
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
match self
.operate(WorkdirSessionOperation::Write(request))
+10 -1
View File
@@ -27,7 +27,8 @@ use tokio::sync::broadcast;
pub use fs_operation::{
BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath,
GlobRequest, GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest,
ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
ListResult, ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest,
StatResult, WriteRequest, WriteResult,
};
pub use http::dispatch_workdir_session_operation;
pub use local::{
@@ -220,6 +221,14 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError>;
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError>;
async fn read_bytes(
&self,
_request: ReadBytesRequest,
) -> Result<ReadBytesResult, WorkdirError> {
Err(WorkdirError::UnsupportedOperation(
"bounded binary reads are not supported by this provider".to_string(),
))
}
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError>;
async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError>;
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError>;
+19 -4
View File
@@ -30,10 +30,10 @@ use crate::{
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,
ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult, Workdir,
WorkdirError, WorkdirPath, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest,
WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionCapability,
WorkdirToolScopePermission, WriteRequest, WriteResult,
};
#[cfg(test)]
use crate::{EntryKind, WriteOutcome};
@@ -1261,6 +1261,21 @@ impl WorkdirSession for LocalWorkdirSession {
.map_err(|error| sanitize_error(error, &logical))
}
async fn read_bytes(&self, request: ReadBytesRequest) -> Result<ReadBytesResult, WorkdirError> {
self.ensure_capability(WorkdirSessionCapability::Read)?;
let logical = request.path.clone();
self.validate_operation_path(&request.path)?;
let access = self.scope_access();
match self.inner.read_limits {
Some(limits) => {
fs_operation::run_read_bytes_bounded(&self.inner.root, request, &access, limits)
}
None => fs_operation::run_read_bytes(&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();
+9 -3
View File
@@ -16,9 +16,10 @@ use tokio::sync::broadcast;
use crate::{
CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest,
CommandSnapshot, CommandStatus, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest,
GrepResult, ListRequest, ListResult, ReadRequest, ReadResult, StatRequest, StatResult, Workdir,
WorkdirError, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
GrepResult, ListRequest, ListResult, ReadBytesRequest, ReadBytesResult, ReadRequest,
ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirScopeAuthorizationRequest,
WorkdirScopeOverlapRequest, WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionHandle,
WriteRequest, WriteResult,
};
/// Stable Worker-local routing key for one attached Workdir.
@@ -257,6 +258,11 @@ impl WorkdirSession for RoutedWorkdirSession {
self.state.session.read(request).await
}
async fn read_bytes(&self, request: ReadBytesRequest) -> Result<ReadBytesResult, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.read_bytes(request).await
}
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.write(request).await
+33 -1
View File
@@ -6,7 +6,8 @@ use std::sync::{Arc, Mutex, Weak};
use async_trait::async_trait;
use fs_operation::{
EditRequest, EditResult, FsPath, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest,
ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
ListResult, ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest,
StatResult, WriteRequest, WriteResult,
};
use manifest::SymlinkPolicy;
use schemars::JsonSchema;
@@ -1011,6 +1012,21 @@ impl WorkdirSession for ScopedWorkdirSession {
self.source.read(request).await
}
async fn read_bytes(
&self,
mut request: ReadBytesRequest,
) -> Result<ReadBytesResult, WorkdirError> {
let caller_path = request.path.clone();
let path = self
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
.await?;
self.ensure_read(&path, WorkdirSessionCapability::Read)?;
request.path = path;
let mut result = self.source.read_bytes(request).await?;
result.path = caller_path;
Ok(result)
}
async fn write(&self, mut request: WriteRequest) -> Result<WriteResult, WorkdirError> {
let _scope_guard = self.scope_lock.lock().await;
let path = self
@@ -1276,6 +1292,10 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
self.inner.read(request).await
}
async fn read_bytes(&self, request: ReadBytesRequest) -> Result<ReadBytesResult, WorkdirError> {
self.inner.read_bytes(request).await
}
async fn write(&self, _request: WriteRequest) -> Result<WriteResult, WorkdirError> {
Err(WorkdirError::Denied("read-only workdir session".into()))
}
@@ -1656,6 +1676,15 @@ mod tests {
}
}
fn read_bytes(path: &str) -> ReadBytesRequest {
ReadBytesRequest {
path: fs_path(path),
offset: 0,
max_bytes: 1024,
expected_hash: None,
}
}
fn write(path: &str, content: &str) -> WriteRequest {
WriteRequest {
path: fs_path(path),
@@ -1887,6 +1916,9 @@ mod tests {
child.read(read("readme.md")).await.unwrap().bytes,
b"visible"
);
let binary = child.read_bytes(read_bytes("readme.md")).await.unwrap();
assert_eq!(binary.path, fs_path("readme.md"));
assert_eq!(binary.bytes, b"visible");
assert!(matches!(
child.write(write("new.md", "no")).await,
Err(WorkdirError::Denied(_))
+24
View File
@@ -477,6 +477,13 @@ pub struct WorkerSessionObservationRequest {
pub worker_ref: WorkerRef,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkerSessionHistoryRequest {
pub worker_ref: WorkerRef,
pub cursor: Option<String>,
pub limit: Option<usize>,
}
pub trait WorkerExecutionBackend: Send + Sync + 'static {
fn backend_id(&self) -> &str;
@@ -497,6 +504,16 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
}
}
fn worker_session_history(
&self,
_request: WorkerSessionHistoryRequest,
) -> runtime_api::WorkerSessionHistoryAvailability {
runtime_api::WorkerSessionHistoryAvailability::Unavailable {
reason: runtime_api::WorkerSessionHistoryUnavailableReason::Unsupported,
message: "session history paging is not supported by this Runtime".to_string(),
}
}
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult;
/// Read-only restore validation performed before any live controller,
@@ -726,6 +743,13 @@ impl WorkerExecutionBackendRef {
self.backend.worker_session(request)
}
pub(crate) fn worker_session_history(
&self,
request: WorkerSessionHistoryRequest,
) -> runtime_api::WorkerSessionHistoryAvailability {
self.backend.worker_session_history(request)
}
pub(crate) fn spawn_worker(
&self,
request: WorkerExecutionSpawnRequest,
@@ -260,6 +260,35 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
.map_err(api_error)
}
async fn worker_session_history(
&self,
worker_id: String,
request: runtime_api::WorkerSessionHistoryRequest,
) -> Result<runtime_api::WorkerSessionHistoryAvailability, runtime_api::RuntimeApiError> {
let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?;
let scope = auth_workspace_scope(&self.state, auth_extension().as_ref())
.map_err(api_error)?
.ok_or_else(|| {
runtime_api::RuntimeApiError::new(
StatusCode::FORBIDDEN.as_u16(),
"runtime_worker_session_scope_required",
"Worker Session history requires a Workspace-scoped capability",
)
})?;
if scope.workspace_id != request.workspace_id {
return Err(runtime_api::RuntimeApiError::new(
StatusCode::FORBIDDEN.as_u16(),
"runtime_worker_session_workspace_scope_mismatch",
"Worker Session Workspace scope does not match the authenticated capability",
));
}
self.state
.runtime
.worker_session_history_scoped(&scope, &worker_ref, request.cursor, request.limit)
.map_err(RuntimeHttpRestError::runtime)
.map_err(api_error)
}
async fn create_worker(
&self,
value: runtime_api::CreateWorkerRequest,
+34 -1
View File
@@ -16,7 +16,8 @@ use crate::execution::{
WorkerExecutionBackend, WorkerExecutionBackendRef, WorkerExecutionHandle,
WorkerExecutionOperation, WorkerExecutionResult, WorkerExecutionSpawnRequest,
WorkerExecutionSpawnResult, WorkerExecutionStopRequest, WorkerLifecycleOperationId,
WorkerSessionObservationRequest, WorkspaceConfigFetchRequest, WorkspaceConfigFetchResult,
WorkerSessionHistoryRequest, WorkerSessionObservationRequest, WorkspaceConfigFetchRequest,
WorkspaceConfigFetchResult,
};
#[cfg(feature = "fs-store")]
use crate::fs_store::{
@@ -1613,6 +1614,38 @@ impl Runtime {
})
}
/// Read one bounded page from the adopted durable Session lineage without
/// restoring the Worker or modifying model context.
pub fn worker_session_history_scoped(
&self,
scope: &RuntimeWorkspaceScope,
worker_ref: &WorkerRef,
cursor: Option<String>,
limit: Option<usize>,
) -> Result<runtime_api::WorkerSessionHistoryAvailability, RuntimeError> {
let operation_lock = self.worker_operation_lock(worker_ref.worker_id)?;
let _operation_guard = operation_lock
.lock()
.map_err(|_| RuntimeError::StatePoisoned)?;
self.ensure_worker_in_workspace(scope, worker_ref)?;
let backend = {
let state = self.lock()?;
state.worker(worker_ref)?;
state.execution_backend.clone()
};
Ok(match backend {
Some(backend) => backend.worker_session_history(WorkerSessionHistoryRequest {
worker_ref: worker_ref.clone(),
cursor,
limit,
}),
None => runtime_api::WorkerSessionHistoryAvailability::Unavailable {
reason: runtime_api::WorkerSessionHistoryUnavailableReason::Unsupported,
message: "session history paging is not supported by this Runtime".to_string(),
},
})
}
/// Attach a live execution through a workspace-scoped Runtime authorization context.
pub fn restore_worker_scoped(
&self,
@@ -172,6 +172,15 @@ pub trait RuntimeWorkerFactory: Send + Sync + 'static {
Err(session_store::RetainedSnapshotReadError::RetentionMissing)
}
fn retained_session_history_page(
&self,
_worker_ref: &WorkerRef,
_cursor: Option<&str>,
_limit: Option<usize>,
) -> Result<protocol::SessionHistoryPage, session_store::RetainedHistoryReadError> {
Err(session_store::RetainedHistoryReadError::RetentionMissing)
}
async fn spawn_controller(
&self,
request: WorkerExecutionSpawnRequest,
@@ -1027,6 +1036,25 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
)
}
fn retained_session_history_page(
&self,
worker_ref: &WorkerRef,
cursor: Option<&str>,
limit: Option<usize>,
) -> Result<protocol::SessionHistoryPage, session_store::RetainedHistoryReadError> {
let aggregate_dir = self
.worker_aggregate_dir(worker_ref)
.map_err(|_| session_store::RetainedHistoryReadError::RetentionMissing)?;
let worker_name = Self::runtime_worker_name_for_ref(worker_ref);
session_store::read_retained_session_history_page(
&aggregate_dir,
&worker_name,
cursor,
limit,
session_store::RetainedHistoryReadLimits::default(),
)
}
fn observe_workspace_prompt_projection(
&self,
projection: worker::WorkspacePromptProjection,
@@ -2216,6 +2244,48 @@ where
}
}
fn worker_session_history(
&self,
request: crate::execution::WorkerSessionHistoryRequest,
) -> runtime_api::WorkerSessionHistoryAvailability {
match self.factory.retained_session_history_page(
&request.worker_ref,
request.cursor.as_deref(),
request.limit,
) {
Ok(page) => runtime_api::WorkerSessionHistoryAvailability::Page { page },
Err(error) => {
let reason = match error {
session_store::RetainedHistoryReadError::RetentionMissing => {
runtime_api::WorkerSessionHistoryUnavailableReason::RetentionMissing
}
session_store::RetainedHistoryReadError::ActivePointerMissing => {
runtime_api::WorkerSessionHistoryUnavailableReason::ActivePointerMissing
}
session_store::RetainedHistoryReadError::MigrationRequired => {
runtime_api::WorkerSessionHistoryUnavailableReason::MigrationRequired
}
session_store::RetainedHistoryReadError::CorruptLog => {
runtime_api::WorkerSessionHistoryUnavailableReason::CorruptLog
}
session_store::RetainedHistoryReadError::StorageUnavailable => {
runtime_api::WorkerSessionHistoryUnavailableReason::StorageUnavailable
}
session_store::RetainedHistoryReadError::InvalidCursor => {
runtime_api::WorkerSessionHistoryUnavailableReason::InvalidCursor
}
session_store::RetainedHistoryReadError::ResourceLimit => {
runtime_api::WorkerSessionHistoryUnavailableReason::ResourceLimit
}
};
runtime_api::WorkerSessionHistoryAvailability::Unavailable {
reason,
message: error.to_string(),
}
}
}
}
fn observe_workspace_prompt_projection(
&self,
projection: worker::WorkspacePromptProjection,
@@ -18,8 +18,8 @@ use workdir::workspace::WorkspaceWorkdirSessionOperationRequest;
use workdir::{
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirError,
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult, Workdir,
WorkdirError, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
};
@@ -357,6 +357,13 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
}
}
async fn read_bytes(&self, request: ReadBytesRequest) -> Result<ReadBytesResult, WorkdirError> {
match self.operate(WorkdirSessionOperation::ReadBytes(request))? {
WorkdirSessionOperationResult::ReadBytes(result) => Ok(result),
_ => Err(Self::mismatch("read_bytes")),
}
}
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
match self.operate(WorkdirSessionOperation::Write(request))? {
WorkdirSessionOperationResult::Write(result) => Ok(result),
+6 -3
View File
@@ -619,8 +619,8 @@ mod tests {
let (committed_tx, committed_rx) = mpsc::channel();
let commit_thread = thread::spawn(move || {
// This mirrors Worker::append_entry ordering: clear in-flight first,
// then publish the finalized AssistantItem. AssistantItem entries
// are mirror-only and are not delivered as live entry events.
// then publish the finalized AssistantItem. Identity-aware clients
// reconcile this durable commit with the preceding streamed block.
in_flight_for_commit.clear_for_committed_item_then(&assistant_item, || {
sink_for_commit.publish(assistant_entry);
});
@@ -639,7 +639,10 @@ mod tests {
in_flight_snapshot.blocks.as_slice(),
[InFlightBlock::Text { text, finished: true }] if text == "done"
));
assert!(entry_rx.try_recv().is_err());
assert!(matches!(
entry_rx.try_recv(),
Ok(LogEntry::AnnotatedAssistantItem { .. })
));
let post_commit_guard = in_flight.snapshot_guard();
assert!(snapshot_from_guard(&post_commit_guard).is_empty());
}
+38 -2
View File
@@ -34,7 +34,41 @@ pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> {
session_store::public_snapshot::project_current_session_snapshot(&[entry]);
Some(Event::SegmentRotated { session })
}
LogEntry::AnnotatedUserInput { segments, .. } => Some(Event::UserMessage { segments }),
LogEntry::AnnotatedUserInput {
ts,
segments,
history,
extensions,
} => {
let projected = session_store::public_snapshot::project_current_session_snapshot(&[
LogEntry::AnnotatedUserInput {
ts,
segments: segments.clone(),
history,
extensions,
},
]);
let entry_id = projected
.entries
.iter()
.find(|entry| {
matches!(
entry.data,
protocol::SessionSnapshotEntryData::UserInput { .. }
)
})
.map(|entry| entry.entry_id.clone());
Some(Event::UserMessage { entry_id, segments })
}
entry
@ (LogEntry::AnnotatedAssistantItem { .. } | LogEntry::AnnotatedToolResult { .. }) => {
let mut projected =
session_store::public_snapshot::project_current_session_snapshot(&[entry]);
projected
.entries
.pop()
.map(|entry| Event::SessionEntryCommitted { entry })
}
LogEntry::AnnotatedSystemItem { entry, .. } => {
let value = serde_json::to_value(&entry.item).expect("SystemItem is Serialize");
Some(Event::SystemItem { item: value })
@@ -94,7 +128,9 @@ mod tests {
.expect("UserInput must be live-relevant");
match event {
Event::UserMessage { segments: echoed } => assert_eq!(echoed, segments),
Event::UserMessage {
segments: echoed, ..
} => assert_eq!(echoed, segments),
other => panic!("expected UserMessage, got {other:?}"),
}
}
+6 -7
View File
@@ -92,14 +92,11 @@ impl SegmentLogSink {
/// - `LogEntry::AnnotatedSegmentStart` → `Event::SegmentRotated` on the wire.
/// - `LogEntry::AnnotatedUserInput` → `Event::UserMessage`.
/// - `LogEntry::AnnotatedSystemItem` → `Event::SystemItem`.
/// - assistant/tool commits → `Event::SessionEntryCommitted`.
/// - `LogEntry::Invoke` → `Event::InvokeStart`.
/// Everything else (AssistantItem, ToolResult, TurnEnd,
/// RunCompleted, RunErrored, PausedTurnAbandoned, LlmUsage, Extension,
/// ConfigChanged) is reflected in the mirror so reconnect snapshots stay accurate,
/// but is not sent live — the streaming events (TextDelta /
/// ToolCallStart / ToolResult / TurnEnd / etc.) already provide
/// that data, and re-broadcasting it as a typed entry would just
/// double-render every block on the client side.
/// Operational checkpoints remain mirror-only. Assistant/tool streaming
/// events are still the in-flight authority; the committed-entry event adds
/// the durable stable identity needed to reconcile retained history.
pub fn publish(&self, entry: LogEntry) {
let mut mirror = self
.inner
@@ -122,6 +119,8 @@ impl SegmentLogSink {
entry,
LogEntry::AnnotatedSegmentStart { .. }
| LogEntry::AnnotatedUserInput { .. }
| LogEntry::AnnotatedAssistantItem { .. }
| LogEntry::AnnotatedToolResult { .. }
| LogEntry::AnnotatedSystemItem { .. }
| LogEntry::Invoke { .. }
)
+3
View File
@@ -50,7 +50,10 @@ zeroize.workspace = true
webauthn-rs = { workspace = true }
[dev-dependencies]
agen.workspace = true
tempfile.workspace = true
tools.workspace = true
yoi = { path = "../yoi" }
tower = { workspace = true, features = ["util"] }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] }
+72
View File
@@ -1188,6 +1188,19 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
})
}
fn worker_session_history(
&self,
_workspace_id: &str,
_worker_ref: &EmbeddedWorkerRef,
_cursor: Option<String>,
_limit: Option<usize>,
) -> Result<runtime_api::WorkerSessionHistoryAvailability, String> {
Ok(runtime_api::WorkerSessionHistoryAvailability::Unavailable {
reason: runtime_api::WorkerSessionHistoryUnavailableReason::Unsupported,
message: "session history paging is not supported by this Runtime".to_string(),
})
}
fn observation_source(
&self,
_worker_id: &str,
@@ -2017,6 +2030,31 @@ impl RuntimeRegistry {
})
}
pub fn worker_session_history(
&self,
workspace_id: &str,
worker: &RuntimeWorkerRef,
cursor: Option<String>,
limit: Option<usize>,
) -> Result<runtime_api::WorkerSessionHistoryAvailability, RuntimeRegistryError> {
validate_backend_identifier("runtime_id", &worker.runtime_id)?;
validate_backend_identifier("worker_id", &worker.worker_id)?;
let runtime = self.runtime(&worker.runtime_id)?;
let worker_ref =
EmbeddedWorkerRef::new(EmbeddedWorkerId::parse(&worker.worker_id).ok_or_else(
|| RuntimeRegistryError::UnknownWorker {
worker: worker.clone(),
},
)?);
runtime
.worker_session_history(workspace_id, &worker_ref, cursor, limit)
.map_err(|message| RuntimeRegistryError::RuntimeOperationFailed {
runtime_id: worker.runtime_id.clone(),
code: "worker_session_history_failed".to_string(),
message,
})
}
pub fn observation_source(
&self,
worker: &RuntimeWorkerRef,
@@ -2909,6 +2947,19 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
.map_err(|error| error.to_string())
}
fn worker_session_history(
&self,
workspace_id: &str,
worker_ref: &EmbeddedWorkerRef,
cursor: Option<String>,
limit: Option<usize>,
) -> Result<runtime_api::WorkerSessionHistoryAvailability, String> {
let scope = RuntimeWorkspaceScope::new(workspace_id, "embedded-backend");
self.runtime
.worker_session_history_scoped(&scope, worker_ref, cursor, limit)
.map_err(|error| error.to_string())
}
fn observation_source(
&self,
worker_id: &str,
@@ -4924,6 +4975,27 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
.map_err(|diagnostic| diagnostic.message)
}
fn worker_session_history(
&self,
workspace_id: &str,
worker_ref: &EmbeddedWorkerRef,
cursor: Option<String>,
limit: Option<usize>,
) -> Result<runtime_api::WorkerSessionHistoryAvailability, String> {
let worker_id = worker_ref.worker_id.to_string();
let request = runtime_api::WorkerSessionHistoryRequest {
workspace_id: workspace_id.to_string(),
cursor,
limit,
};
self.run_runtime_api(
self.request_timeout,
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
move |client| async move { client.worker_session_history(worker_id, request).await },
)
.map_err(|diagnostic| diagnostic.message)
}
fn observation_source(
&self,
worker_id: &str,
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
pub mod workdir_share;
+32 -43
View File
@@ -4,10 +4,11 @@ mod objective_cli;
mod plugin_cli;
mod session_cli;
mod ticket_cli;
mod workdir_share;
mod worker_cleanup_cli;
mod workspace_bootstrap;
use yoi::workdir_share;
use std::collections::BTreeMap;
use std::fmt;
use std::fs;
@@ -623,7 +624,7 @@ fn parse_workdir_args(
let mut ttl = None;
let mut read_only = false;
let mut read_write = false;
let mut explicit_permissions: Option<server_api::ExternalWorkdirPermissions> = None;
let mut explicit_permission_level = None;
let mut non_interactive = false;
let mut index = 0;
while index < args.len() {
@@ -668,29 +669,22 @@ fn parse_workdir_args(
index += 2;
}
"--permission" => {
let category = required_option_value(args, index, "--permission")?;
let permissions =
explicit_permissions.get_or_insert(server_api::ExternalWorkdirPermissions {
read: false,
write: false,
command: false,
});
let enabled = match category.to_ascii_lowercase().as_str() {
"read" => &mut permissions.read,
"write" => &mut permissions.write,
"command" => &mut permissions.command,
let level = required_option_value(args, index, "--permission")?;
if explicit_permission_level.is_some() {
return Err(ParseError(
"--permission selects one level and may only be provided once".to_string(),
));
}
explicit_permission_level = Some(match level.to_ascii_lowercase().as_str() {
"read" => server_api::ExternalWorkdirPermissionLevel::Read,
"write" => server_api::ExternalWorkdirPermissionLevel::Write,
"command" => server_api::ExternalWorkdirPermissionLevel::Command,
_ => {
return Err(ParseError(format!(
"unknown --permission category `{category}`; expected read, write, or command"
"unknown --permission level `{level}`; expected read, write, or command"
)));
}
};
if *enabled {
return Err(ParseError(format!(
"--permission {category} may only be provided once"
)));
}
*enabled = true;
});
index += 2;
}
"--read-only" => {
@@ -742,21 +736,18 @@ fn parse_workdir_args(
"--read-only conflicts with --read-write".to_string(),
));
}
if explicit_permissions.is_some() && (read_only || read_write) {
if explicit_permission_level.is_some() && (read_only || read_write) {
return Err(ParseError(
"--permission conflicts with legacy --read-only/--read-write".to_string(),
));
}
let permissions = explicit_permissions.unwrap_or(server_api::ExternalWorkdirPermissions {
read: true,
write: read_write,
command: false,
});
if permissions.write && !permissions.read {
return Err(ParseError(
"--permission write requires --permission read for read-before-write".to_string(),
));
}
let permissions = explicit_permission_level
.map(server_api::ExternalWorkdirPermissionLevel::permissions)
.unwrap_or(if read_write {
server_api::ExternalWorkdirPermissions::WRITE
} else {
server_api::ExternalWorkdirPermissions::READ
});
if non_interactive && workspace_id.is_none() {
return Err(ParseError(
"--non-interactive requires --workspace-id".to_string(),
@@ -1900,7 +1891,7 @@ Usage:
yoi --backend <URL> [--workspace-id <ID>] panel
yoi [--backend <URL>] init --display-name <NAME> --repository-key <KEY> [--repository <PATH>] [--default-ref <REF>]
yoi [--backend <URL>] login [--no-wait]
yoi [--backend <URL>] workdir share <PATH> [--workspace-id <ID>] [--permission <read|write|command>]... [--read-only|--read-write] [--ttl <TTL>] [--display-name <NAME>] [--non-interactive]
yoi [--backend <URL>] workdir share <PATH> [--workspace-id <ID>] [--permission <read|write|command>] [--read-only|--read-write] [--ttl <TTL>] [--display-name <NAME>] [--non-interactive]
yoi <HOST_COMMAND> [OPTIONS]
Target selection:
@@ -1946,7 +1937,7 @@ Options:
fn print_workdir_help() {
println!(
"yoi workdir share\n\nUsage:\n yoi [--backend <URL>] workdir share <PATH> [OPTIONS]\n\nThe foreground CLI provides one local directory over an authenticated outbound Backend connection.\nInteractive mode is the default: choose an accessible Workspace, READ/WRITE/COMMAND categories, and TTL, then explicitly confirm before any grant is created.\nREAD enables Read/Glob/Grep and related observation operations. WRITE enables Write/Edit and requires READ for read-before-write. COMMAND is independent, disabled by default, and runs with the sharing CLI user's host authority; it is not sandboxed.\nRepeat --permission to choose explicit categories in automation. Legacy --read-only and --read-write remain file-only aliases and conflict with --permission.\nOmitting --ttl means unlimited automatic lifetime, but stop, disconnect, Ctrl-C, and revoke still fail closed. TTL accepts s, m, or h suffixes from 60s through 24h.\nNo host path is sent to the Backend. Ctrl-C explicitly revokes the grant before exit.\n\nOptions:\n --workspace-id <ID> Preselect a Workspace; required with --non-interactive\n --permission <CATEGORY> Enable read, write, or command; repeat per category\n --read-only Legacy alias for --permission read\n --read-write Legacy alias for read + write (commands remain disabled)\n --ttl <TTL> Automatic expiry; omission is unlimited\n --display-name <NAME> Backend-visible label (default: External Workdir)\n --non-interactive Never prompt; requires --workspace-id\n -h, --help Print help\n"
"yoi workdir share\n\nUsage:\n yoi [--backend <URL>] workdir share <PATH> [OPTIONS]\n\nThe foreground CLI provides one local directory over an authenticated outbound Backend connection.\nInteractive mode is the default: choose an accessible Workspace, one READ/WRITE/COMMAND permission level, and TTL, then explicitly confirm before any grant is created.\nREAD enables Read/Glob/Grep and related observation operations. WRITE includes READ and adds Write/Edit. COMMAND includes WRITE and READ, adds command start/status/output/cancel, and runs with the sharing CLI user's host authority; it is not sandboxed.\nUse --permission once to choose the level in automation. Legacy --read-only and --read-write retain the READ and WRITE meanings and conflict with --permission.\nOmitting --ttl means unlimited automatic lifetime, but stop, disconnect, Ctrl-C, and revoke still fail closed. TTL accepts s, m, or h suffixes from 60s through 24h.\nNo host path is sent to the Backend. Ctrl-C explicitly revokes the grant before exit.\n\nOptions:\n --workspace-id <ID> Preselect a Workspace; required with --non-interactive\n --permission <LEVEL> Grant read, write (includes read), or command (includes write + read)\n --read-only Legacy alias for --permission read\n --read-write Legacy alias for --permission write\n --ttl <TTL> Automatic expiry; omission is unlimited\n --display-name <NAME> Backend-visible label (default: External Workdir)\n --non-interactive Never prompt; requires --workspace-id\n -h, --help Print help\n"
);
}
@@ -2038,7 +2029,7 @@ mod tests {
}
#[test]
fn workdir_share_parses_independent_command_permission() {
fn workdir_share_parses_command_as_the_full_hierarchical_level() {
let args = vec![
"share".to_string(),
".".to_string(),
@@ -2056,11 +2047,7 @@ mod tests {
};
assert_eq!(
options.permissions,
server_api::ExternalWorkdirPermissions {
read: false,
write: false,
command: true,
}
server_api::ExternalWorkdirPermissions::COMMAND
);
}
@@ -2131,17 +2118,19 @@ mod tests {
.contains("conflicts")
);
let write_without_read = vec![
let multiple_levels = vec![
"share".to_string(),
".".to_string(),
"--permission".to_string(),
"write".to_string(),
"--permission".to_string(),
"command".to_string(),
];
assert!(
parse_workdir_args(&write_without_read, &target)
parse_workdir_args(&multiple_levels, &target)
.unwrap_err()
.0
.contains("requires --permission read")
.contains("selects one level")
);
let missing_workspace = TargetSelection {
+162 -76
View File
@@ -25,7 +25,7 @@ use workdir::{
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct WorkdirShareOptions {
pub struct WorkdirShareOptions {
pub path: PathBuf,
pub workspace_id: Option<String>,
pub backend_url: String,
@@ -37,6 +37,39 @@ pub(crate) struct WorkdirShareOptions {
const PROVIDER_OPERATION_TIMEOUT: Duration = Duration::from_secs(30);
fn validate_provider_registration(message: Message, generation: u64) -> Result<(), String> {
let registered = match message {
Message::Text(text) => text,
Message::Close(Some(frame)) => {
let reason = frame.reason.trim();
return Err(if reason.is_empty() {
"Backend rejected the External Workdir provider registration".to_string()
} else {
format!("Backend rejected the External Workdir provider registration: {reason}")
});
}
Message::Close(None) => {
return Err("Backend rejected the External Workdir provider registration".to_string());
}
_ => {
return Err(
"Backend returned an invalid External Workdir registration frame".to_string(),
);
}
};
let frame = serde_json::from_str::<ExternalWorkdirServerFrame>(&registered)
.map_err(|error| format!("Backend returned an invalid External Workdir frame: {error}"))?;
if matches!(
frame.message,
ExternalWorkdirServerMessage::Registered { generation: registered_generation, .. }
if registered_generation == generation
) {
Ok(())
} else {
Err("Backend rejected the External Workdir provider registration".to_string())
}
}
fn provider_operation_timeout(operation: &workdir::http::WorkdirSessionOperation) -> Duration {
if matches!(
operation,
@@ -49,7 +82,7 @@ fn provider_operation_timeout(operation: &workdir::http::WorkdirSessionOperation
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProviderEnd {
pub enum ProviderEnd {
Disconnected,
Revoked,
Interrupted,
@@ -140,47 +173,42 @@ fn authenticated_server_api_client(
Ok(ServerApiClient::with_client(base_url, http_client))
}
fn permissions_label(permissions: server_api::ExternalWorkdirPermissions) -> String {
let mut enabled = Vec::new();
if permissions.read {
enabled.push("READ");
fn permissions_label(permissions: server_api::ExternalWorkdirPermissions) -> &'static str {
match permissions.grant_level() {
Some(server_api::ExternalWorkdirPermissionLevel::Read) => "READ",
Some(server_api::ExternalWorkdirPermissionLevel::Write) => "WRITE (includes READ)",
Some(server_api::ExternalWorkdirPermissionLevel::Command) => {
"COMMAND (includes WRITE + READ)"
}
None => "INVALID",
}
if permissions.write {
enabled.push("WRITE");
}
if permissions.command {
enabled.push("COMMAND");
}
enabled.join(", ")
}
fn validate_permissions(permissions: server_api::ExternalWorkdirPermissions) -> Result<(), String> {
if permissions.write && !permissions.read {
if permissions.grant_level().is_none() {
return Err(
"WRITE requires READ so existing files can satisfy read-before-write; select both categories"
"select exactly one permission level: READ, WRITE (includes READ), or COMMAND (includes WRITE + READ)"
.to_string(),
);
}
if !permissions.read && !permissions.command {
return Err("select at least one of READ or COMMAND".to_string());
}
Ok(())
}
fn workdir_capabilities(
permissions: server_api::ExternalWorkdirPermissions,
) -> workdir::WorkdirSessionCapabilities {
let mut capabilities = workdir::WorkdirSessionCapabilities::EMPTY;
if permissions.read {
capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::READ_ONLY);
match permissions.grant_level() {
Some(server_api::ExternalWorkdirPermissionLevel::Read) => {
workdir::WorkdirSessionCapabilities::READ_ONLY
}
Some(server_api::ExternalWorkdirPermissionLevel::Write) => {
workdir::WorkdirSessionCapabilities::READ_WRITE
}
Some(server_api::ExternalWorkdirPermissionLevel::Command) => {
workdir::WorkdirSessionCapabilities::ALL
}
None => workdir::WorkdirSessionCapabilities::EMPTY,
}
if permissions.write {
capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::WRITE_ONLY);
}
if permissions.command {
capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::COMMAND_ONLY);
}
capabilities
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -193,7 +221,7 @@ struct ResolvedWorkdirShareOptions {
permissions: server_api::ExternalWorkdirPermissions,
}
pub(crate) fn parse_ttl(value: &str) -> Result<Duration, String> {
pub fn parse_ttl(value: &str) -> Result<Duration, String> {
let (number, multiplier) = if let Some(value) = value.strip_suffix('h') {
(value, 60 * 60)
} else if let Some(value) = value.strip_suffix('m') {
@@ -302,14 +330,14 @@ fn configure_interactively(
writeln!(
output,
"Permissions are independent categories: READ (Read/Glob/Grep), WRITE (Write/Edit), and COMMAND (host command lifecycle). WRITE requires READ for read-before-write. COMMAND runs with this CLI user's host authority and is not sandboxed."
"Permission levels: READ (Read/Glob/Grep), WRITE (includes READ + Write/Edit), or COMMAND (includes WRITE + READ + host command lifecycle). COMMAND runs with this CLI user's host authority and is not sandboxed."
)
.map_err(|error| format!("failed to write prompt: {error}"))?;
let Some(permission_input) = read_prompt_line(
input,
output,
&format!(
"Permissions [comma-separated READ/WRITE/COMMAND; current {}; q to cancel]: ",
"Permission level [READ/WRITE/COMMAND; current {}; q to cancel]: ",
permissions_label(options.permissions)
),
)?
@@ -320,28 +348,20 @@ fn configure_interactively(
return Ok(None);
}
if !permission_input.is_empty() {
let mut permissions = server_api::ExternalWorkdirPermissions {
read: false,
write: false,
command: false,
};
for category in permission_input
.split([',', ' '])
.filter(|category| !category.is_empty())
{
match category.to_ascii_lowercase().as_str() {
"r" | "read" | "read-only" | "read_only" => permissions.read = true,
"w" | "write" | "read-write" | "read_write" => permissions.write = true,
"c" | "command" => permissions.command = true,
_ => {
return Err(format!(
"unknown permission category `{category}`; use READ, WRITE, or COMMAND"
));
}
options.permissions = match permission_input.to_ascii_lowercase().as_str() {
"r" | "read" | "read-only" | "read_only" => {
server_api::ExternalWorkdirPermissions::READ
}
}
validate_permissions(permissions)?;
options.permissions = permissions;
"w" | "write" | "read-write" | "read_write" => {
server_api::ExternalWorkdirPermissions::WRITE
}
"c" | "command" => server_api::ExternalWorkdirPermissions::COMMAND,
_ => {
return Err(format!(
"unknown permission level `{permission_input}`; use READ, WRITE, or COMMAND"
));
}
};
}
writeln!(output, "TTL: {}", ttl_label(options.ttl))
@@ -437,7 +457,7 @@ async fn resolve_share_options(
configure_interactively(options, &workspaces, &mut input, &mut output)
}
pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> {
pub async fn run(options: WorkdirShareOptions) -> Result<(), String> {
let Some(options) = resolve_share_options(options).await? else {
eprintln!("External Workdir share cancelled; no grant was created.");
return Ok(());
@@ -649,7 +669,7 @@ async fn serve_provider_connection(
.max_message_size(Some(workdir::external::MAX_EXTERNAL_SERVER_FRAME_BYTES))
.max_frame_size(Some(workdir::external::MAX_EXTERNAL_SERVER_FRAME_BYTES));
let connection = connect_async_with_config(request, Some(websocket_config), false);
let (mut socket, _) = tokio::select! {
let (socket, _) = tokio::select! {
signal = tokio::signal::ctrl_c() => {
signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?;
revoke(server_api_client, &options.workspace_id, &grant.grant_id).await?;
@@ -671,11 +691,40 @@ async fn serve_provider_connection(
read_limits: BoundedReadLimits::EXTERNAL_DEFAULT,
},
});
let end =
serve_external_workdir_provider_socket(socket, registration, session, generation, async {
tokio::signal::ctrl_c()
.await
.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))
})
.await?;
if end == ProviderEnd::Interrupted {
revoke(server_api_client, &options.workspace_id, &grant.grant_id).await?;
}
Ok(end)
}
/// Run the production External Workdir provider protocol over an established WebSocket.
///
/// The CLI uses this pump after opening its authenticated Backend connection. Keeping the
/// protocol pump separate lets composed tests exercise the same registration, operation,
/// cancellation, and result-forwarding implementation without spawning a second CLI process.
pub async fn serve_external_workdir_provider_socket<S, I>(
mut socket: tokio_tungstenite::WebSocketStream<S>,
registration: ExternalWorkdirProviderFrame,
session: &LocalWorkdirSession,
generation: u64,
interrupt: I,
) -> Result<ProviderEnd, String>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
I: std::future::Future<Output = Result<(), String>>,
{
tokio::pin!(interrupt);
send_provider_frame(&mut socket, &registration).await?;
let registered = tokio::select! {
signal = tokio::signal::ctrl_c() => {
signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?;
revoke(server_api_client, &options.workspace_id, &grant.grant_id).await?;
signal = &mut interrupt => {
signal?;
return Ok(ProviderEnd::Interrupted);
}
registered = tokio::time::timeout(Duration::from_secs(10), socket.next()) => {
@@ -685,18 +734,7 @@ async fn serve_provider_connection(
.map_err(|error| format!("External Workdir provider registration failed: {error}"))?
}
};
let Message::Text(registered) = registered else {
return Err("Backend returned an invalid External Workdir registration frame".to_string());
};
let frame = serde_json::from_str::<ExternalWorkdirServerFrame>(&registered)
.map_err(|error| format!("Backend returned an invalid External Workdir frame: {error}"))?;
if !matches!(
frame.message,
ExternalWorkdirServerMessage::Registered { generation: registered_generation, .. }
if registered_generation == generation
) {
return Err("Backend rejected the External Workdir provider registration".to_string());
}
validate_provider_registration(registered, generation)?;
println!("Connection: online (generation {generation})");
let (completion_sender, mut completions) =
@@ -704,10 +742,9 @@ async fn serve_provider_connection(
let mut operations = HashMap::<String, OperationTask>::new();
loop {
tokio::select! {
signal = tokio::signal::ctrl_c() => {
signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?;
signal = &mut interrupt => {
signal?;
stop_provider_operations(session, &mut operations, &mut completions).await?;
revoke(server_api_client, &options.workspace_id, &grant.grant_id).await?;
return Ok(ProviderEnd::Interrupted);
}
completion = completions.recv() => {
@@ -916,7 +953,7 @@ async fn execute_operation(
Ok(result) => ExternalWorkdirOperationOutcome::Completed { result },
Err(message) => ExternalWorkdirOperationOutcome::Failed {
error: ExternalWorkdirOperationError::from_transport_error(WorkdirTransportError {
code: workdir::http::WorkdirTransportErrorCode::Unsupported,
code: workdir::http::WorkdirTransportErrorCode::InvalidRequest,
message,
}),
},
@@ -1060,6 +1097,23 @@ mod tests {
}
}
#[test]
fn provider_registration_surfaces_backend_close_reason() {
use tokio_tungstenite::tungstenite::protocol::CloseFrame;
use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
let error = validate_provider_registration(
Message::Close(Some(CloseFrame {
code: CloseCode::Policy,
reason: "provider read limits source=1024 response=512; Backend requires source=2048 response=1024".into(),
})),
1,
)
.unwrap_err();
assert!(error.contains("provider read limits"), "{error}");
assert!(error.contains("Backend requires"), "{error}");
}
#[test]
fn interactive_share_defaults_to_first_workspace_read_only_and_unlimited() {
let workspaces = [workspace("workspace-a", "Alpha")];
@@ -1085,12 +1139,12 @@ mod tests {
}
#[test]
fn interactive_share_requires_explicit_write_and_command_selection_and_confirmation() {
fn interactive_share_selects_one_hierarchical_level_and_requires_confirmation() {
let workspaces = [
workspace("workspace-a", "Alpha"),
workspace("workspace-b", "Beta"),
];
let mut input = std::io::Cursor::new(b"2\nREAD,WRITE,COMMAND\n15m\ny\n".to_vec());
let mut input = std::io::Cursor::new(b"2\nCOMMAND\n15m\ny\n".to_vec());
let mut output = Vec::new();
let selected =
@@ -1110,7 +1164,39 @@ mod tests {
assert_eq!(selected.ttl, Some(Duration::from_secs(900)));
let output = String::from_utf8(output).unwrap();
assert!(output.contains("not sandboxed"));
assert!(output.contains("Permissions: READ, WRITE, COMMAND"));
assert!(output.contains("Permissions: COMMAND (includes WRITE + READ)"));
}
#[test]
fn share_permission_levels_map_to_exact_existing_capability_bits() {
let bits = |permissions| {
serde_json::to_value(workdir_capabilities(permissions)).unwrap()["bits"]
.as_u64()
.unwrap()
};
assert_eq!(bits(server_api::ExternalWorkdirPermissions::READ), 25);
assert_eq!(bits(server_api::ExternalWorkdirPermissions::WRITE), 31);
assert_eq!(bits(server_api::ExternalWorkdirPermissions::COMMAND), 63);
for invalid in [
server_api::ExternalWorkdirPermissions {
read: false,
write: false,
command: true,
},
server_api::ExternalWorkdirPermissions {
read: false,
write: true,
command: false,
},
server_api::ExternalWorkdirPermissions {
read: true,
write: false,
command: true,
},
] {
assert!(validate_permissions(invalid).is_err());
}
}
#[test]