From c29c1dfc2e5fa40ec5c213a17317e4e98d60bc06 Mon Sep 17 00:00:00 2001 From: Seongjae Date: Mon, 21 Sep 2026 13:46:16 +0900 Subject: [PATCH 1/3] P1: add optional read/find range and pagination arguments. Agents can fetch the next window without lifting the 1 MiB / 10_000 caps or changing whole-file version hashes. Co-authored-by: Cursor --- crates/domain/src/execution.rs | 32 +++++ crates/domain/src/files.rs | 62 +++++++++ crates/domain/src/lib.rs | 13 +- crates/runner/src/apply.rs | 18 ++- crates/runner/src/files.rs | 167 +++++++++++++++++++++---- crates/runner/src/lib.rs | 55 ++++++-- crates/runner/src/uds.rs | 20 ++- crates/runner/src/wire.rs | 34 +++-- crates/runner/tests/fs_watch.rs | 15 ++- crates/runner/tests/uds_runner.rs | 33 +++-- crates/server/src/mcp.rs | 37 +++++- crates/server/tests/process.rs | 34 +++++ crates/server/tests/protocol_compat.rs | 5 + crates/server/tests/read_find.rs | 126 +++++++++++++++++++ docs/agent-integration.md | 2 +- docs/codex-reuse.md | 2 +- docs/error-codes.md | 2 +- docs/execution-substrate.md | 4 +- docs/ko/agent-integration.md | 2 +- docs/ko/codex-reuse.md | 2 +- docs/ko/error-codes.md | 2 +- docs/ko/execution-substrate.md | 4 +- docs/ko/runner-isolation.md | 2 +- docs/runner-isolation.md | 2 +- docs/translations.json | 20 +-- 25 files changed, 604 insertions(+), 91 deletions(-) diff --git a/crates/domain/src/execution.rs b/crates/domain/src/execution.rs index 54a9d5d..75ca227 100644 --- a/crates/domain/src/execution.rs +++ b/crates/domain/src/execution.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::approval::ApprovalsMode; use crate::error::ErrorCode; +use crate::files::{DEFAULT_FIND_LIMIT, DEFAULT_READ_LIMIT}; /// Advertised PTY size. Must match the isolated PTY adapter default. pub const PTY_INITIAL_ROWS: u16 = 24; @@ -112,6 +113,16 @@ pub struct FileOperationInfo { pub available: bool, } +/// Advertised file-window contract. Always present; availability is +/// still `files.read.available` / `files.find.available`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct FileCapabilityInfo { + pub read_range: bool, + pub find_pagination: bool, + pub read_max_bytes: u32, + pub find_max_paths: u32, +} + /// Effective file-tool eligibility. Nested so later capability fields /// can be additive without renaming `available`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -119,6 +130,7 @@ pub struct FileExecutionInfo { pub read: FileOperationInfo, pub find: FileOperationInfo, pub patch: FileOperationInfo, + pub capabilities: FileCapabilityInfo, } /// Static eligibility of a managed process in this workspace. @@ -212,6 +224,12 @@ impl WorkspaceExecutionInfo { patch: FileOperationInfo { available: permissions.write && environment.file_write_supported, }, + capabilities: FileCapabilityInfo { + read_range: true, + find_pagination: true, + read_max_bytes: DEFAULT_READ_LIMIT, + find_max_paths: DEFAULT_FIND_LIMIT, + }, }; let process_available = permissions.exec && environment.exec_supported; let process = ProcessExecutionInfo { @@ -318,10 +336,24 @@ mod tests { assert_eq!(exec.files.read.available, read); assert_eq!(exec.files.find.available, find); assert_eq!(exec.files.patch.available, patch); + assert!(exec.files.capabilities.read_range); + assert!(exec.files.capabilities.find_pagination); + assert_eq!(exec.files.capabilities.read_max_bytes, DEFAULT_READ_LIMIT); + assert_eq!(exec.files.capabilities.find_max_paths, DEFAULT_FIND_LIMIT); let json = serde_json::to_value(exec).unwrap(); assert_eq!(json["files"]["read"]["available"], read); assert_eq!(json["files"]["find"]["available"], find); assert_eq!(json["files"]["patch"]["available"], patch); + assert_eq!(json["files"]["capabilities"]["read_range"], true); + assert_eq!(json["files"]["capabilities"]["find_pagination"], true); + assert_eq!( + json["files"]["capabilities"]["read_max_bytes"].as_u64(), + Some(u64::from(DEFAULT_READ_LIMIT)) + ); + assert_eq!( + json["files"]["capabilities"]["find_max_paths"].as_u64(), + Some(u64::from(DEFAULT_FIND_LIMIT)) + ); } #[test] diff --git a/crates/domain/src/files.rs b/crates/domain/src/files.rs index be9c811..8b295ea 100644 --- a/crates/domain/src/files.rs +++ b/crates/domain/src/files.rs @@ -4,10 +4,25 @@ use serde::{Deserialize, Serialize}; use crate::ids::{WorkId, WorkspaceId}; use crate::work::CoordinationHint; +/// Per-call byte cap for `read`. Omitted `limit` uses this value. +pub const DEFAULT_READ_LIMIT: u32 = 1024 * 1024; +/// Per-call path cap for `find`. Omitted `limit` uses this value. +pub const DEFAULT_FIND_LIMIT: u32 = 10_000; + +fn is_zero_u64(value: &u64) -> bool { + *value == 0 +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct ReadParams { pub workspace_id: WorkspaceId, pub path: String, + /// Byte offset into the file. Omitted is 0. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub offset: Option, + /// Byte window size. Omitted is [`DEFAULT_READ_LIMIT`]. Max is the same. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub work_id: Option, } @@ -18,6 +33,11 @@ pub struct ReadResult { pub content: String, pub version: String, pub truncated: bool, + /// Start of this window in file bytes. Omitted from JSON when 0. + #[serde(default, skip_serializing_if = "is_zero_u64")] + pub offset: u64, + /// File bytes included in this window. Not `content.len()` after UTF-8 lossy. + pub byte_count: u64, #[serde(default, skip_serializing_if = "Option::is_none")] pub coordination: Option, } @@ -27,6 +47,12 @@ pub struct FindParams { pub workspace_id: WorkspaceId, #[serde(default)] pub glob: Option, + /// Index into the sorted matching path list. Omitted is 0. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub offset: Option, + /// Path window size. Omitted is [`DEFAULT_FIND_LIMIT`]. Max is the same. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub work_id: Option, } @@ -35,6 +61,42 @@ pub struct FindParams { pub struct FindResult { pub paths: Vec, pub truncated: bool, + /// Start of this window in the sorted path list. Omitted from JSON when 0. + #[serde(default, skip_serializing_if = "is_zero_u64")] + pub offset: u64, #[serde(default, skip_serializing_if = "Option::is_none")] pub coordination: Option, } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn omitted_read_params_default_to_first_window() { + let params: ReadParams = serde_json::from_value(json!({ + "workspace_id": "demo", + "path": "a.txt" + })) + .unwrap(); + assert!(params.offset.is_none()); + assert!(params.limit.is_none()); + } + + #[test] + fn zero_offset_is_omitted_from_read_json() { + let result = ReadResult { + path: "a.txt".into(), + content: "hi".into(), + version: "sha256:x".into(), + truncated: false, + offset: 0, + byte_count: 2, + coordination: None, + }; + let json = serde_json::to_value(&result).unwrap(); + assert!(json.get("offset").is_none()); + assert_eq!(json["byte_count"], 2); + } +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index a5c2ef3..6d7b6e4 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -23,12 +23,15 @@ pub use error::{ }; pub use execution::{ ClientEnvironmentKind, CommandSandboxState, EffectivePermissionInfo, EnvironmentExecutionInfo, - FileExecutionInfo, FileOperationInfo, IsolationInfo, NetworkEnforcementState, NetworkInfo, - NetworkPolicyState, ProcessCapabilityInfo, ProcessDisconnectAction, ProcessExecutionInfo, - ProcessLifetimeInfo, ProcessLifetimeOwner, ProcessRestartRecovery, PtyCapabilityInfo, - WorkspaceExecutionInfo, WorkspaceSerializationInfo, PTY_INITIAL_COLS, PTY_INITIAL_ROWS, + FileCapabilityInfo, FileExecutionInfo, FileOperationInfo, IsolationInfo, + NetworkEnforcementState, NetworkInfo, NetworkPolicyState, ProcessCapabilityInfo, + ProcessDisconnectAction, ProcessExecutionInfo, ProcessLifetimeInfo, ProcessLifetimeOwner, + ProcessRestartRecovery, PtyCapabilityInfo, WorkspaceExecutionInfo, WorkspaceSerializationInfo, + PTY_INITIAL_COLS, PTY_INITIAL_ROWS, +}; +pub use files::{ + FindParams, FindResult, ReadParams, ReadResult, DEFAULT_FIND_LIMIT, DEFAULT_READ_LIMIT, }; -pub use files::{FindParams, FindResult, ReadParams, ReadResult}; pub use ids::{ApprovalId, IntentId, OperationId, OperationKey, ProcessId, WorkId, WorkspaceId}; pub use info::{workspace_info, WorkspaceInfo, WorkspaceInfoParams}; pub use intent::{DeliveryPolicy, IntentKind, IntentState, UserIntent}; diff --git a/crates/runner/src/apply.rs b/crates/runner/src/apply.rs index d08263f..da6cf40 100644 --- a/crates/runner/src/apply.rs +++ b/crates/runner/src/apply.rs @@ -8,20 +8,32 @@ use crate::process::InProcessRunner; use crate::PathSandbox; impl InProcessRunner { - pub async fn read_file(&self, ws: &Workspace, path: &str) -> Result { + pub async fn read_file( + &self, + ws: &Workspace, + path: &str, + offset: Option, + limit: Option, + ) -> Result { ws.require_file_read()?; self.touch_watch(ws); - PathSandbox::new(ws.clone()).read_file(path).await + PathSandbox::new(ws.clone()) + .read_file_window(path, offset, limit) + .await } pub async fn find_files( &self, ws: &Workspace, glob: Option<&str>, + offset: Option, + limit: Option, ) -> Result { ws.require_file_read()?; self.touch_watch(ws); - PathSandbox::new(ws.clone()).find(glob).await + PathSandbox::new(ws.clone()) + .find_window(glob, offset, limit) + .await } pub async fn file_version(&self, ws: &Workspace, path: &str) -> Result { diff --git a/crates/runner/src/files.rs b/crates/runner/src/files.rs index 1d8f9c0..ff50fb0 100644 --- a/crates/runner/src/files.rs +++ b/crates/runner/src/files.rs @@ -1,12 +1,12 @@ -use codespace_domain::{ErrorBody, ErrorCode, FindResult, ReadResult}; +use codespace_domain::{ + ErrorBody, ErrorCode, FindResult, ReadResult, DEFAULT_FIND_LIMIT, DEFAULT_READ_LIMIT, +}; use codespace_fs::{self, FsError}; use sha2::{Digest, Sha256}; use crate::PathSandbox; pub const VERSION_ABSENT: &str = "absent"; -pub const DEFAULT_READ_LIMIT: usize = 1024 * 1024; -pub const DEFAULT_FIND_LIMIT: usize = 10_000; impl PathSandbox { pub fn version_of(bytes: &[u8]) -> String { @@ -27,14 +27,17 @@ impl PathSandbox { } pub async fn read_file(&self, relative: &str) -> Result { - self.read_file_limited(relative, DEFAULT_READ_LIMIT).await + self.read_file_window(relative, None, None).await } - pub async fn read_file_limited( + pub async fn read_file_window( &self, relative: &str, - limit: usize, + offset: Option, + limit: Option, ) -> Result { + let limit = resolve_limit(limit, DEFAULT_READ_LIMIT, "read")?; + let offset = offset.unwrap_or(0); let path = self.resolve(relative)?; let meta = codespace_fs::metadata(&path).await.map_err(fs_error_body)?; if meta.is_symlink { @@ -50,32 +53,30 @@ impl PathSandbox { )); } let bytes = codespace_fs::read(&path).await.map_err(fs_error_body)?; - let truncated = bytes.len() > limit; - if truncated && limit == 0 { - return Err(ErrorBody::new( - ErrorCode::OutputLimit, - "read output limit is zero", - )); - } - let slice = if truncated { &bytes[..limit] } else { &bytes }; + let (slice, truncated) = byte_window(&bytes, offset, limit); Ok(ReadResult { path: normalize_rel(relative), content: String::from_utf8_lossy(slice).into_owned(), version: Self::version_of(&bytes), truncated, + offset, + byte_count: slice.len() as u64, coordination: None, }) } pub async fn find(&self, glob: Option<&str>) -> Result { - self.find_limited(glob, DEFAULT_FIND_LIMIT).await + self.find_window(glob, None, None).await } - pub async fn find_limited( + pub async fn find_window( &self, glob: Option<&str>, - limit: usize, + offset: Option, + limit: Option, ) -> Result { + let limit = resolve_limit(limit, DEFAULT_FIND_LIMIT, "find")?; + let offset = offset.unwrap_or(0); // Operator-registered `workspace.root` is the trust anchor. If the // registration path is itself a symlink, canonicalize resolves that // root only. Descendants under it are never followed. @@ -101,18 +102,54 @@ impl PathSandbox { paths.push(rel); } paths.sort(); - let truncated = walked.truncated || paths.len() > limit; - if paths.len() > limit { - paths.truncate(limit); - } + let (page, truncated) = path_window(&paths, offset, limit, walked.truncated); Ok(FindResult { - paths, + paths: page, truncated, + offset, coordination: None, }) } } +fn resolve_limit(requested: Option, max: u32, what: &str) -> Result { + let limit = requested.unwrap_or(max); + if limit == 0 || limit > max { + return Err(ErrorBody::new( + ErrorCode::OutputLimit, + format!("{what} limit must be between 1 and {max}"), + )); + } + Ok(limit as usize) +} + +fn byte_window(bytes: &[u8], offset: u64, limit: usize) -> (&[u8], bool) { + let len = bytes.len() as u64; + if offset >= len { + return (&[], false); + } + let start = offset as usize; + let end = start.saturating_add(limit).min(bytes.len()); + (&bytes[start..end], (end as u64) < len) +} + +fn path_window( + paths: &[String], + offset: u64, + limit: usize, + walk_truncated: bool, +) -> (Vec, bool) { + let len = paths.len() as u64; + if offset >= len { + return (Vec::new(), walk_truncated); + } + let start = offset as usize; + let remaining = paths.len() - start; + let truncated = walk_truncated || remaining > limit; + let end = start.saturating_add(limit).min(paths.len()); + (paths[start..end].to_vec(), truncated) +} + pub(crate) fn fs_error_body(err: FsError) -> ErrorBody { match err { FsError::NotFound => ErrorBody::new(ErrorCode::FileNotFound, err.message()), @@ -171,6 +208,8 @@ mod tests { assert!(first.version.starts_with("sha256:")); assert!(!first.truncated); assert_eq!(first.path, "a.txt"); + assert_eq!(first.offset, 0); + assert_eq!(first.byte_count, 5); assert_eq!(s.version("missing").await.unwrap(), VERSION_ABSENT); } @@ -179,10 +218,21 @@ mod tests { let dir = tempdir().unwrap(); std::fs::write(dir.path().join("big.txt"), "abcdef").unwrap(); let s = sandbox(dir.path()); - let result = s.read_file_limited("big.txt", 3).await.unwrap(); + let result = s.read_file_window("big.txt", None, Some(3)).await.unwrap(); assert_eq!(result.content, "abc"); assert!(result.truncated); + assert_eq!(result.offset, 0); + assert_eq!(result.byte_count, 3); assert_eq!(result.version, PathSandbox::version_of(b"abcdef")); + let rest = s + .read_file_window("big.txt", Some(result.offset + result.byte_count), Some(3)) + .await + .unwrap(); + assert_eq!(rest.content, "def"); + assert!(!rest.truncated); + assert_eq!(rest.offset, 3); + assert_eq!(rest.byte_count, 3); + assert_eq!(rest.version, result.version); } #[tokio::test] @@ -198,9 +248,15 @@ mod tests { assert!(all.paths.iter().all(|p| !p.starts_with('/'))); let rs = s.find(Some("*.rs")).await.unwrap(); assert_eq!(rs.paths, vec!["a.rs".to_string()]); - let limited = s.find_limited(None, 1).await.unwrap(); + let limited = s.find_window(None, None, Some(1)).await.unwrap(); assert!(limited.truncated); assert_eq!(limited.paths.len(), 1); + assert_eq!(limited.offset, 0); + let page = s.find_window(None, Some(1), Some(1)).await.unwrap(); + assert!(!page.truncated); + assert_eq!(page.offset, 1); + assert_eq!(page.paths.len(), 1); + assert_ne!(page.paths, limited.paths); } #[tokio::test] @@ -298,4 +354,67 @@ mod tests { let err = s.find(None).await.unwrap_err(); assert_eq!(err.code, ErrorCode::FileOperationFailed); } + + #[tokio::test] + async fn read_window_past_eof_is_empty() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("a.txt"), "hi").unwrap(); + let s = sandbox(dir.path()); + let result = s.read_file_window("a.txt", Some(10), None).await.unwrap(); + assert_eq!(result.content, ""); + assert!(!result.truncated); + assert_eq!(result.offset, 10); + assert_eq!(result.byte_count, 0); + assert_eq!(result.version, PathSandbox::version_of(b"hi")); + } + + #[tokio::test] + async fn read_limit_zero_or_above_cap_is_output_limit() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("a.txt"), "hi").unwrap(); + let s = sandbox(dir.path()); + let zero = s + .read_file_window("a.txt", None, Some(0)) + .await + .unwrap_err(); + assert_eq!(zero.code, ErrorCode::OutputLimit); + let over = s + .read_file_window("a.txt", None, Some(DEFAULT_READ_LIMIT + 1)) + .await + .unwrap_err(); + assert_eq!(over.code, ErrorCode::OutputLimit); + } + + #[tokio::test] + async fn find_limit_zero_or_above_cap_is_output_limit() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("a.txt"), "a").unwrap(); + let s = sandbox(dir.path()); + let zero = s.find_window(None, None, Some(0)).await.unwrap_err(); + assert_eq!(zero.code, ErrorCode::OutputLimit); + let over = s + .find_window(None, None, Some(DEFAULT_FIND_LIMIT + 1)) + .await + .unwrap_err(); + assert_eq!(over.code, ErrorCode::OutputLimit); + } + + #[tokio::test] + async fn read_lossy_utf8_reports_file_byte_count() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("bin"), [0x61, 0xff, 0x62]).unwrap(); + let s = sandbox(dir.path()); + let first = s.read_file_window("bin", None, Some(2)).await.unwrap(); + assert!(first.truncated); + assert_eq!(first.byte_count, 2); + assert_ne!(first.content.len() as u64, first.byte_count); + let rest = s + .read_file_window("bin", Some(first.offset + first.byte_count), Some(2)) + .await + .unwrap(); + assert_eq!(rest.content, "b"); + assert!(!rest.truncated); + assert_eq!(rest.byte_count, 1); + assert_eq!(rest.version, first.version); + } } diff --git a/crates/runner/src/lib.rs b/crates/runner/src/lib.rs index cdd8d3b..2430e52 100644 --- a/crates/runner/src/lib.rs +++ b/crates/runner/src/lib.rs @@ -118,7 +118,8 @@ pub use api::{ RunnerExecRequest, RunnerExecResult, RunnerProcessStatus, RunnerReadProcess, RunnerReadResult, RunnerResizeResult, RunnerWriteStdin, DEFAULT_TIMEOUT_MS, MAX_OUTPUT_BYTES, }; -pub use files::{DEFAULT_FIND_LIMIT, DEFAULT_READ_LIMIT, VERSION_ABSENT}; +pub use codespace_domain::{DEFAULT_FIND_LIMIT, DEFAULT_READ_LIMIT}; +pub use files::VERSION_ABSENT; pub use patch_helper::ensure_helper_for_tests; pub use process::{ InProcessRunner, RetentionPolicy, ShellRelease, DEFAULT_COMPLETED_TTL, DEFAULT_MAX_COMPLETED, @@ -149,11 +150,15 @@ pub trait Runner: Send + Sync { &self, ws: &Workspace, path: &str, + offset: Option, + limit: Option, ) -> impl std::future::Future> + Send; fn find( &self, ws: &Workspace, glob: Option<&str>, + offset: Option, + limit: Option, ) -> impl std::future::Future> + Send; fn version( &self, @@ -203,12 +208,28 @@ pub trait Runner: Send + Sync { } impl Runner for InProcessRunner { - async fn read(&self, ws: &Workspace, path: &str) -> Result { - self.read_file(ws, path).await.map_err(RunnerError::from) + async fn read( + &self, + ws: &Workspace, + path: &str, + offset: Option, + limit: Option, + ) -> Result { + self.read_file(ws, path, offset, limit) + .await + .map_err(RunnerError::from) } - async fn find(&self, ws: &Workspace, glob: Option<&str>) -> Result { - self.find_files(ws, glob).await.map_err(RunnerError::from) + async fn find( + &self, + ws: &Workspace, + glob: Option<&str>, + offset: Option, + limit: Option, + ) -> Result { + self.find_files(ws, glob, offset, limit) + .await + .map_err(RunnerError::from) } async fn version(&self, ws: &Workspace, path: &str) -> Result { @@ -286,17 +307,29 @@ impl RuntimeBackend { } impl Runner for RuntimeBackend { - async fn read(&self, ws: &Workspace, path: &str) -> Result { + async fn read( + &self, + ws: &Workspace, + path: &str, + offset: Option, + limit: Option, + ) -> Result { match self { - Self::InProcess(runner) => runner.read(ws, path).await, - Self::Uds(runner) => runner.read(ws, path).await, + Self::InProcess(runner) => runner.read(ws, path, offset, limit).await, + Self::Uds(runner) => runner.read(ws, path, offset, limit).await, } } - async fn find(&self, ws: &Workspace, glob: Option<&str>) -> Result { + async fn find( + &self, + ws: &Workspace, + glob: Option<&str>, + offset: Option, + limit: Option, + ) -> Result { match self { - Self::InProcess(runner) => runner.find(ws, glob).await, - Self::Uds(runner) => runner.find(ws, glob).await, + Self::InProcess(runner) => runner.find(ws, glob, offset, limit).await, + Self::Uds(runner) => runner.find(ws, glob, offset, limit).await, } } diff --git a/crates/runner/src/uds.rs b/crates/runner/src/uds.rs index a3b1ffc..0db9f20 100644 --- a/crates/runner/src/uds.rs +++ b/crates/runner/src/uds.rs @@ -296,11 +296,19 @@ fn unexpected(result: RunnerOpResult) -> RunnerError { } impl Runner for UdsRunner { - async fn read(&self, ws: &Workspace, path: &str) -> Result { + async fn read( + &self, + ws: &Workspace, + path: &str, + offset: Option, + limit: Option, + ) -> Result { match self .call(RunnerOp::Read { workspace: ws.clone(), path: path.to_string(), + offset, + limit, }) .await? { @@ -309,11 +317,19 @@ impl Runner for UdsRunner { } } - async fn find(&self, ws: &Workspace, glob: Option<&str>) -> Result { + async fn find( + &self, + ws: &Workspace, + glob: Option<&str>, + offset: Option, + limit: Option, + ) -> Result { match self .call(RunnerOp::Find { workspace: ws.clone(), glob: glob.map(str::to_string), + offset, + limit, }) .await? { diff --git a/crates/runner/src/wire.rs b/crates/runner/src/wire.rs index 238b8db..1b0d7d1 100644 --- a/crates/runner/src/wire.rs +++ b/crates/runner/src/wire.rs @@ -19,7 +19,7 @@ use crate::{ RunnerResizeResult, RunnerWriteStdin, ShellRelease, }; -pub const WIRE_PROTOCOL: u32 = 5; +pub const WIRE_PROTOCOL: u32 = 6; const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024; const MAX_REPLAY: usize = 32; @@ -114,10 +114,18 @@ pub enum RunnerOp { Read { workspace: Workspace, path: String, + #[serde(default)] + offset: Option, + #[serde(default)] + limit: Option, }, Find { workspace: Workspace, glob: Option, + #[serde(default)] + offset: Option, + #[serde(default)] + limit: Option, }, Version { workspace: Workspace, @@ -323,12 +331,22 @@ pub async fn read_frame( async fn dispatch(runner: &InProcessRunner, op: RunnerOp) -> Result { let result = match op { - RunnerOp::Read { workspace, path } => runner - .read(&workspace, &path) + RunnerOp::Read { + workspace, + path, + offset, + limit, + } => runner + .read(&workspace, &path, offset, limit) .await .map(RunnerOpResult::Read), - RunnerOp::Find { workspace, glob } => runner - .find(&workspace, glob.as_deref()) + RunnerOp::Find { + workspace, + glob, + offset, + limit, + } => runner + .find(&workspace, glob.as_deref(), offset, limit) .await .map(RunnerOpResult::Find), RunnerOp::Version { workspace, path } => runner @@ -393,8 +411,8 @@ mod tests { } #[test] - fn wire_protocol_is_v5() { - assert_eq!(WIRE_PROTOCOL, 5); + fn wire_protocol_is_v6() { + assert_eq!(WIRE_PROTOCOL, 6); } #[tokio::test] @@ -450,6 +468,8 @@ mod tests { RunnerOp::Read { workspace: ws.clone(), path: "a.txt".into(), + offset: None, + limit: None, }, ), ) diff --git a/crates/runner/tests/fs_watch.rs b/crates/runner/tests/fs_watch.rs index 3932712..9b4fcbf 100644 --- a/crates/runner/tests/fs_watch.rs +++ b/crates/runner/tests/fs_watch.rs @@ -159,7 +159,10 @@ async fn coalesced_writes_still_read_current_bytes() { std::fs::write(dir.path().join("keep.txt"), i.to_string()).unwrap(); } let _ = drain_for(&mut rx, Duration::from_millis(400)).await; - let read = runner.read(&ws, "keep.txt").await.expect("read"); + let read = runner + .read(&ws, "keep.txt", None, None) + .await + .expect("read"); assert_eq!(read.content, "20"); } @@ -299,7 +302,10 @@ async fn symlink_replacement_invalidates_the_name() { .await .expect("destination name invalidated"); - let err = runner.read(&ws, "keep.txt").await.expect_err("symlink"); + let err = runner + .read(&ws, "keep.txt", None, None) + .await + .expect_err("symlink"); match err { codespace_runner::RunnerError::Execution(body) => { assert_eq!(body.code, ErrorCode::SymlinkRejected); @@ -362,7 +368,10 @@ async fn special_file_name_is_still_invalidated() { .await .expect("special-file name invalidated"); - let err = runner.read(&ws, "pipe.fifo").await.expect_err("special"); + let err = runner + .read(&ws, "pipe.fifo", None, None) + .await + .expect_err("special"); match err { codespace_runner::RunnerError::Execution(body) => { assert_eq!(body.code, ErrorCode::SpecialFileRejected); diff --git a/crates/runner/tests/uds_runner.rs b/crates/runner/tests/uds_runner.rs index d553202..0aef327 100644 --- a/crates/runner/tests/uds_runner.rs +++ b/crates/runner/tests/uds_runner.rs @@ -57,7 +57,7 @@ async fn uds_runner_read_and_exec_over_length_prefix() { let dir = tempdir().unwrap(); std::fs::write(dir.path().join("a.txt"), "hi").unwrap(); let ws = workspace(dir.path()); - let read = runner.read(&ws, "a.txt").await.unwrap(); + let read = runner.read(&ws, "a.txt", None, None).await.unwrap(); assert_eq!(read.content, "hi"); let process_id = ProcessId("proc-uds".into()); @@ -112,7 +112,7 @@ async fn uds_runner_read_and_exec_over_length_prefix() { } #[tokio::test] -async fn uds_process_status_echo_and_protocol_5_hello() { +async fn uds_process_status_echo_and_protocol_6_hello() { let (client, server) = UnixStream::pair().expect("unix pair"); let (worker, events) = host_worker(); tokio::spawn(async move { @@ -121,7 +121,7 @@ async fn uds_process_status_echo_and_protocol_5_hello() { .expect("serve runner"); }); let runner = UdsRunner::from_stream(client, Arc::new(|_| {})); - runner.handshake().await.expect("hello protocol 5"); + runner.handshake().await.expect("hello protocol 6"); let dir = tempdir().unwrap(); let ws = workspace(dir.path()); let process_id = ProcessId("proc-uds-status".into()); @@ -478,23 +478,38 @@ async fn uds_read_filesystem_errors_keep_product_codes() { let ws = workspace(dir.path()); assert_eq!( - execution_code(&runner.read(&ws, "missing.txt").await.unwrap_err()), + execution_code( + &runner + .read(&ws, "missing.txt", None, None) + .await + .unwrap_err() + ), ErrorCode::FileNotFound ); assert_eq!( - execution_code(&runner.read(&ws, "foo/bar.txt").await.unwrap_err()), + execution_code( + &runner + .read(&ws, "foo/bar.txt", None, None) + .await + .unwrap_err() + ), ErrorCode::PathNotDirectory ); assert_eq!( - execution_code(&runner.read(&ws, "../outside").await.unwrap_err()), + execution_code( + &runner + .read(&ws, "../outside", None, None) + .await + .unwrap_err() + ), ErrorCode::PathEscape ); assert_eq!( - execution_code(&runner.read(&ws, "link").await.unwrap_err()), + execution_code(&runner.read(&ws, "link", None, None).await.unwrap_err()), ErrorCode::SymlinkRejected ); assert_eq!( - execution_code(&runner.read(&ws, "pipe.fifo").await.unwrap_err()), + execution_code(&runner.read(&ws, "pipe.fifo", None, None).await.unwrap_err()), ErrorCode::SpecialFileRejected ); } @@ -513,7 +528,7 @@ async fn uds_find_on_deleted_root_is_file_operation_failed() { let ws = workspace(dir.path()); drop(dir); assert_eq!( - execution_code(&runner.find(&ws, None).await.unwrap_err()), + execution_code(&runner.find(&ws, None, None, None).await.unwrap_err()), ErrorCode::FileOperationFailed ); } diff --git a/crates/server/src/mcp.rs b/crates/server/src/mcp.rs index 0dfc5d9..dfbcbae 100644 --- a/crates/server/src/mcp.rs +++ b/crates/server/src/mcp.rs @@ -56,6 +56,13 @@ CodeSpace is an execution-only MCP and never calls a model. Use workspace-relative paths for file tools. workspace_id and work_id are \ selectors, not credentials. +read and find accept optional offset and limit. Omitted arguments return \ +the first window: 1 MiB for read, 10000 sorted paths for find. Per-call \ +caps are the same. truncated means more remains; continue read at offset \ +plus byte_count, and find at offset plus the returned path count. version \ +hashes the whole file, not the window. A limit of 0 or above the cap \ +returns OUTPUT_LIMIT. + exec_command accepts argv; there is no implicit shell. It runs in the \ workspace cwd and returns a server-minted process_id. Ending an MCP request \ does not terminate the process. @@ -170,7 +177,7 @@ impl CodeSpace { #[tool( name = "read", - description = "Read a relative workspace file and return content plus a sha256 version. Rejects symlinks, special files, and path escape." + description = "Read a relative workspace file and return content plus a sha256 version of the whole file. Optional offset and limit select a byte window (default 0 and 1 MiB, max 1 MiB per call). truncated means more bytes remain after this window. Continue at offset plus byte_count. Rejects symlinks, special files, and path escape." )] async fn read( &self, @@ -181,7 +188,7 @@ impl CodeSpace { .get(¶ms.workspace_id.0) .map_err(err_json)?; self.runner - .read(ws, ¶ms.path) + .read(ws, ¶ms.path, params.offset, params.limit) .await .map(|mut result| { result.coordination = self.hint(¶ms.workspace_id.0, params.work_id.as_ref()); @@ -192,7 +199,7 @@ impl CodeSpace { #[tool( name = "find", - description = "List relative file paths in a workspace. Does not follow symlinks." + description = "List relative file paths in a workspace. Does not follow symlinks. Optional offset and limit page the sorted path list (default 0 and 10000, max 10000 per call). truncated means more paths remain or the walk was capped. Continue at offset plus the returned path count." )] async fn find( &self, @@ -203,7 +210,7 @@ impl CodeSpace { .get(¶ms.workspace_id.0) .map_err(err_json)?; self.runner - .find(ws, params.glob.as_deref()) + .find(ws, params.glob.as_deref(), params.offset, params.limit) .await .map(|mut result| { result.coordination = self.hint(¶ms.workspace_id.0, params.work_id.as_ref()); @@ -1054,7 +1061,9 @@ mod tests { } fn assert_instructions_cover_execution_contract(text: &str) { - assert!(text.contains("never calls a model"), "{text}"); + assert!(text.contains("optional offset and limit"), "{text}"); + assert!(text.contains("byte_count"), "{text}"); + assert!(text.contains("OUTPUT_LIMIT"), "{text}"); assert!( text.contains("Ending an MCP request does not terminate the process"), "{text}" @@ -1152,6 +1161,8 @@ mod tests { assert!( exec.files.read.available && exec.files.find.available && exec.files.patch.available ); + assert!(exec.files.capabilities.read_range); + assert!(exec.files.capabilities.find_pagination); assert!(exec.process.available); let tty = &exec .process @@ -1179,6 +1190,22 @@ mod tests { json["execution"]["process"]["capabilities"]["lifetime"]["restart_recovery"], "none" ); + assert_eq!( + json["execution"]["files"]["capabilities"]["read_range"], + true + ); + assert_eq!( + json["execution"]["files"]["capabilities"]["find_pagination"], + true + ); + assert_eq!( + json["execution"]["files"]["capabilities"]["read_max_bytes"], + 1048576 + ); + assert_eq!( + json["execution"]["files"]["capabilities"]["find_max_paths"], + 10000 + ); assert!(json.get("environment_id").is_none()); assert!(!json.to_string().contains("\"environment_id\"")); } diff --git a/crates/server/tests/process.rs b/crates/server/tests/process.rs index b95d97a..4ae5fe8 100644 --- a/crates/server/tests/process.rs +++ b/crates/server/tests/process.rs @@ -711,6 +711,36 @@ async fn exec_command_schema_has_optional_tty_and_live_tools_unchanged() { "process_resize result schema must include ok, rows, cols: {resize_out_dumped}" ); + let read_tool = tools + .iter() + .find(|tool| tool.name.as_ref() == TOOL_READ) + .expect("read"); + let read_in = serde_json::to_value(&read_tool.input_schema) + .unwrap() + .to_string(); + assert!( + read_in.contains("offset") && read_in.contains("limit"), + "read input schema must include offset and limit: {read_in}" + ); + let read_out = serde_json::to_value(read_tool.output_schema.as_ref()) + .unwrap() + .to_string(); + assert!( + read_out.contains("byte_count") && read_out.contains("truncated"), + "read result schema must include byte_count and truncated: {read_out}" + ); + let find_tool = tools + .iter() + .find(|tool| tool.name.as_ref() == TOOL_FIND) + .expect("find"); + let find_in = serde_json::to_value(&find_tool.input_schema) + .unwrap() + .to_string(); + assert!( + find_in.contains("offset") && find_in.contains("limit"), + "find input schema must include offset and limit: {find_in}" + ); + let exec = tools .iter() .find(|tool| tool.name.as_ref() == TOOL_EXEC_COMMAND) @@ -786,6 +816,10 @@ async fn exec_command_schema_has_optional_tty_and_live_tools_unchanged() { assert_eq!(exec["files"]["read"]["available"], true); assert_eq!(exec["files"]["find"]["available"], true); assert_eq!(exec["files"]["patch"]["available"], true); + assert_eq!(exec["files"]["capabilities"]["read_range"], true); + assert_eq!(exec["files"]["capabilities"]["find_pagination"], true); + assert_eq!(exec["files"]["capabilities"]["read_max_bytes"], 1048576); + assert_eq!(exec["files"]["capabilities"]["find_max_paths"], 10000); assert_eq!(exec["process"]["available"], true); assert_eq!(exec["process"]["capabilities"]["tty"]["supported"], true); assert_eq!( diff --git a/crates/server/tests/protocol_compat.rs b/crates/server/tests/protocol_compat.rs index 185edb1..0b9f589 100644 --- a/crates/server/tests/protocol_compat.rs +++ b/crates/server/tests/protocol_compat.rs @@ -318,6 +318,11 @@ async fn http_forced_2025_11_25_process_resize_caps() { assert_eq!(lifetime["runner_disconnect"], "terminate"); assert_eq!(lifetime["gateway_shutdown"], "terminate"); assert_eq!(lifetime["restart_recovery"], "none"); + let files = &body["execution"]["files"]["capabilities"]; + assert_eq!(files["read_range"], true); + assert_eq!(files["find_pagination"], true); + assert_eq!(files["read_max_bytes"], 1048576); + assert_eq!(files["find_max_paths"], 10000); client.cancel().await.expect("cancel http"); } diff --git a/crates/server/tests/read_find.rs b/crates/server/tests/read_find.rs index d926228..27ec2f2 100644 --- a/crates/server/tests/read_find.rs +++ b/crates/server/tests/read_find.rs @@ -54,6 +54,8 @@ async fn read_and_find_use_versions_and_relative_paths() { assert_eq!(body["content"], "hi"); assert_eq!(body["path"], "hello.txt"); assert_eq!(body["truncated"], false); + assert_eq!(body["byte_count"], 2); + assert!(body.get("offset").is_none()); let version = body["version"].as_str().unwrap().to_string(); assert!(version.starts_with("sha256:")); @@ -173,3 +175,127 @@ async fn read_and_find_use_versions_and_relative_paths() { client.cancel().await.expect("cancel"); } + +#[tokio::test] +async fn read_and_find_paginate_windows() { + let root = tempfile::tempdir().unwrap(); + let ws = root.path().join("ws"); + std::fs::create_dir(&ws).unwrap(); + std::fs::write(ws.join("big.txt"), "abcdef").unwrap(); + std::fs::write(ws.join("a.rs"), "a").unwrap(); + std::fs::write(ws.join("b.rs"), "b").unwrap(); + let cfg = root.path().join("workspaces.json"); + std::fs::write( + &cfg, + serde_json::json!({ + "workspaces": { + "demo": { "root": ws, "profile": "read-only" } + } + }) + .to_string(), + ) + .unwrap(); + + let bin = env!("CARGO_BIN_EXE_codespace-mcp"); + let client = () + .serve( + TokioChildProcess::new(Command::new(bin).configure(|cmd| { + cmd.env("CODESPACE_CONFIG", &cfg); + })) + .expect("spawn"), + ) + .await + .expect("init"); + + let info = client + .call_tool( + CallToolRequestParams::new(TOOL_WORKSPACE_INFO) + .with_arguments(object!({ "workspace_id": "demo" })), + ) + .await + .expect("workspace_info"); + let caps = + &info.structured_content.clone().expect("structured")["execution"]["files"]["capabilities"]; + assert_eq!(caps["read_range"], true); + assert_eq!(caps["find_pagination"], true); + assert_eq!(caps["read_max_bytes"], 1048576); + assert_eq!(caps["find_max_paths"], 10000); + + let first = client + .call_tool( + CallToolRequestParams::new(TOOL_READ).with_arguments(object!({ + "workspace_id": "demo", + "path": "big.txt", + "limit": 3 + })), + ) + .await + .expect("read window"); + let body = first.structured_content.clone().expect("structured"); + assert_eq!(body["content"], "abc"); + assert_eq!(body["truncated"], true); + assert_eq!(body["byte_count"], 3); + assert!(body.get("offset").is_none()); + let version = body["version"].clone(); + + let rest = client + .call_tool( + CallToolRequestParams::new(TOOL_READ).with_arguments(object!({ + "workspace_id": "demo", + "path": "big.txt", + "offset": 3, + "limit": 3 + })), + ) + .await + .expect("read rest"); + let rest_body = rest.structured_content.clone().expect("structured"); + assert_eq!(rest_body["content"], "def"); + assert_eq!(rest_body["truncated"], false); + assert_eq!(rest_body["offset"], 3); + assert_eq!(rest_body["byte_count"], 3); + assert_eq!(rest_body["version"], version); + + let page = client + .call_tool( + CallToolRequestParams::new(TOOL_FIND).with_arguments(object!({ + "workspace_id": "demo", + "limit": 1 + })), + ) + .await + .expect("find page"); + let page_body = page.structured_content.clone().expect("structured"); + assert_eq!(page_body["truncated"], true); + assert_eq!(page_body["paths"].as_array().unwrap().len(), 1); + assert!(page_body.get("offset").is_none()); + + let next = client + .call_tool( + CallToolRequestParams::new(TOOL_FIND).with_arguments(object!({ + "workspace_id": "demo", + "offset": 1, + "limit": 2 + })), + ) + .await + .expect("find rest"); + let next_body = next.structured_content.clone().expect("structured"); + assert_eq!(next_body["offset"], 1); + assert_eq!(next_body["truncated"], false); + assert_eq!(next_body["paths"].as_array().unwrap().len(), 2); + + let bad = client + .call_tool( + CallToolRequestParams::new(TOOL_READ).with_arguments(object!({ + "workspace_id": "demo", + "path": "big.txt", + "limit": 0 + })), + ) + .await; + let bad_text = format!("{bad:?}"); + assert!(bad_text.contains("OUTPUT_LIMIT"), "{bad_text}"); + + client.cancel().await.expect("cancel"); +} diff --git a/docs/agent-integration.md b/docs/agent-integration.md index f5783f5..1e6f6b8 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -35,7 +35,7 @@ Use a disposable project for this example. Create `hello.txt` with `hi` followed } ``` -Keep the returned `version`. Paths are relative to the registered root. `find` accepts a path glob, not a content search query. `read` returns at most 1 MiB and `find` returns a bounded list; their `truncated` flag means you did not receive the whole result. There is no public range-read or pagination argument. +Keep the returned `version`; it hashes the whole file, not the returned window. Paths are relative to the registered root. `find` accepts a path glob, not a content search query. `read` and `find` accept optional `offset` and `limit`. Omitted arguments return the first window: 1 MiB for `read`, 10000 sorted paths for `find`. Those values are also the per-call caps; `OUTPUT_LIMIT` is returned for `limit` 0 or above the cap. `truncated` means more remains after this window. Continue `read` at `offset + byte_count` (not `content.len()` after UTF-8 lossy conversion), and `find` at `offset` plus the returned path count. Advertised caps are in `execution.files.capabilities`. Replace `VERSION_FROM_READ` below with the exact version returned by `read`. V4A is Codex’s text patch format, using markers such as `*** Begin Patch` and `*** Update File`. This is a complete V4A patch, with JSON newline escapes: diff --git a/docs/codex-reuse.md b/docs/codex-reuse.md index 2bf42fa..3c642cc 100644 --- a/docs/codex-reuse.md +++ b/docs/codex-reuse.md @@ -35,7 +35,7 @@ Agent → CodeSpace MCP/policy/store → Runner contract Core crates have no direct Codex dependencies. Adapter crates are separate Cargo workspaces to accommodate the pinned upstream workspace dependencies. The filesystem and PTY adapters are library dependencies of the Runner; patch and Linux sandbox operations use helper processes. An isolated Cargo workspace alone does not create a process or security boundary. -The Linux sandbox helper is binary-only. `codespace-linux-sandbox-protocol` contains its CodeSpace-owned handshake data and no Codex types. Worker UDS protocol version 5 and sandbox-helper protocol version 1 are separate contracts. +The Linux sandbox helper is binary-only. `codespace-linux-sandbox-protocol` contains its CodeSpace-owned handshake data and no Codex types. Worker UDS protocol version 6 and sandbox-helper protocol version 1 are separate contracts. diff --git a/docs/error-codes.md b/docs/error-codes.md index f6a2561..13a5f58 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -40,7 +40,7 @@ Errors use uppercase identifiers and a message. An `operation_id` may be present | `PROCESS_NOT_FOUND` | Process handle missing, expired, or stdin already closed on write | | `PROCESS_NOT_TTY` | `process_resize` on a pipe-backed (`tty: false`) process | | `PROCESS_NOT_RUNNING` | `process_resize` on a handle that exists but is not running | -| `OUTPUT_LIMIT` | Helper output exceeded its bound; process reads instead discard old bytes | +| `OUTPUT_LIMIT` | Helper output exceeded its bound; also `read`/`find` when `limit` is 0 or above the advertised cap. Process reads instead discard old bytes | | `TIMEOUT` | A managed command or helper exceeded its time limit | | `WORK_NOT_FOUND` | Unknown logical work | | `WORK_CLOSED` | Operation requires an open work | diff --git a/docs/execution-substrate.md b/docs/execution-substrate.md index af7048b..f7650ab 100644 --- a/docs/execution-substrate.md +++ b/docs/execution-substrate.md @@ -56,7 +56,7 @@ The Runner may start a recursive filesystem watcher for a workspace on the first `expected_versions` and `VERSION_CONFLICT` remain the authoritative apply guard. Missing, coalesced, or restarted watch events must never make `apply_patch` succeed when the on-disk hash no longer matches. Overflow, receive failure (including a lagged subscriber), or an unclassifiable event yields `ResyncRequired` on the same epoch (treat any consumer cache as fully untrusted). Restarting the watcher increments `epoch` and also emits `ResyncRequired` only after a replacement watcher is running. This substrate does not classify self-generated versus external writes, keep a lossless event ledger, or send watch events over UDS. -`find` stays a bounded glob walk. It is not a watch API. +`find` stays a bounded glob walk. It is not a watch API. `read` and `find` accept optional `offset` and `limit`; omitted arguments return the first window. Per-call caps remain 1 MiB and 10000 paths. `version` hashes the whole file. @@ -69,7 +69,7 @@ When the operator sets workspace `approvals` to `confirm`, a policy-allowed `app ## What remains unimplemented -File range/pagination arguments, durable process recovery, container/remote dispatch, and a resource queue scheduler remain absent. MCP `fs/watch`, UDS watch events, and write-cause classification are not provided. Richer internal types and negotiated protocol flags do not imply those features are callable. +Durable process recovery, container/remote dispatch, and a resource queue scheduler remain absent. MCP `fs/watch`, UDS watch events, and write-cause classification are not provided. Richer internal types and negotiated protocol flags do not imply those features are callable. ## Maintaining the boundary diff --git a/docs/ko/agent-integration.md b/docs/ko/agent-integration.md index 705d183..f05bd98 100644 --- a/docs/ko/agent-integration.md +++ b/docs/ko/agent-integration.md @@ -35,7 +35,7 @@ MCP 클라이언트 SDK로 stdio 또는 Streamable HTTP를 초기화하고, 초 } ``` -응답의 `version`을 보관합니다. 경로는 등록된 루트 기준 상대 경로입니다. `find`는 파일 내용이 아닌 경로 glob으로 검색합니다. `read`는 최대 1 MiB, `find`는 제한된 개수의 경로를 반환합니다. `truncated`가 참이면 전체 결과를 받지 못한 것이며, 공개 API에는 범위 읽기나 페이지 지정 인자가 없습니다. +응답의 `version`을 보관합니다. 이 값은 반환된 창이 아니라 **전체 파일**의 sha256입니다. 경로는 등록된 루트 기준 상대 경로입니다. `find`는 파일 내용이 아닌 경로 glob으로 검색합니다. `read`와 `find`는 선택적 `offset`·`limit`을 받습니다. 생략하면 첫 창입니다. `read`는 1 MiB, `find`는 정렬된 경로 10000개이며, 그 값이 호출당 상한이기도 합니다. `limit`가 0이거나 상한을 넘으면 `OUTPUT_LIMIT`입니다. `truncated`가 참이면 이 창 뒤에 더 있습니다. `read`의 다음 창은 `offset + byte_count`입니다(`content.len()`이 아님). `find`는 `offset`에 반환된 경로 개수를 더합니다. 광고된 상한은 `execution.files.capabilities`입니다. 아래 `VERSION_FROM_READ`를 실제 `read` 응답의 버전으로 바꾸세요. V4A는 `*** Begin Patch`, `*** Update File` 같은 표식을 사용하는 Codex 텍스트 패치 형식입니다. 아래 예시는 JSON의 줄바꿈 이스케이프를 사용하는 완전한 패치입니다. diff --git a/docs/ko/codex-reuse.md b/docs/ko/codex-reuse.md index 9155b78..31760fb 100644 --- a/docs/ko/codex-reuse.md +++ b/docs/ko/codex-reuse.md @@ -40,7 +40,7 @@ CodeSpace는 특정 버전에 고정한 Codex 소스의 실행 라이브러리 핵심 crate에는 직접적인 Codex 의존성이 없습니다. 어댑터는 고정된 업스트림의 workspace 의존성을 수용하기 위해 별도의 Cargo workspace로 구성합니다. 파일 시스템·PTY 어댑터는 Runner의 라이브러리 의존성이며, 패치와 Linux 샌드박스는 도우미 프로세스를 사용합니다. Cargo workspace를 분리하는 것만으로 프로세스나 보안 경계가 생기지는 않습니다. -Linux 샌드박스 도우미는 실행 파일만 제공합니다. `codespace-linux-sandbox-protocol`에는 CodeSpace가 정의한 핸드셰이크 데이터만 있고 Codex 타입은 없습니다. worker의 UDS 프로토콜 버전 5와 샌드박스 도우미 프로토콜 버전 1은 별개의 계약입니다. +Linux 샌드박스 도우미는 실행 파일만 제공합니다. `codespace-linux-sandbox-protocol`에는 CodeSpace가 정의한 핸드셰이크 데이터만 있고 Codex 타입은 없습니다. worker의 UDS 프로토콜 버전 6과 샌드박스 도우미 프로토콜 버전 1은 별개의 계약입니다. diff --git a/docs/ko/error-codes.md b/docs/ko/error-codes.md index fb2e130..f7d6dff 100644 --- a/docs/ko/error-codes.md +++ b/docs/ko/error-codes.md @@ -43,7 +43,7 @@ | `PROCESS_NOT_FOUND` | 프로세스 핸들이 없거나 만료됨. 입력 시 stdin이 이미 닫힌 경우도 포함 | | `PROCESS_NOT_TTY` | 파이프(`tty: false`) 프로세스에 `process_resize`를 호출함 | | `PROCESS_NOT_RUNNING` | 핸들은 있으나 실행 중이 아닌 프로세스에 `process_resize`를 호출함 | -| `OUTPUT_LIMIT` | 도우미 출력 상한 초과. 프로세스 출력 조회는 오래된 바이트를 버리는 방식 | +| `OUTPUT_LIMIT` | 도우미 출력 상한 초과. `read`/`find`에서 `limit`가 0이거나 광고된 상한을 넘을 때도 사용. 프로세스 출력 조회는 오래된 바이트를 버리는 방식 | | `TIMEOUT` | 관리 명령 또는 도우미의 제한 시간 초과 | | `WORK_NOT_FOUND` | 논리적 작업을 찾을 수 없음 | | `WORK_CLOSED` | 열린 작업에만 가능한 동작 | diff --git a/docs/ko/execution-substrate.md b/docs/ko/execution-substrate.md index 8a00d60..40e3eee 100644 --- a/docs/ko/execution-substrate.md +++ b/docs/ko/execution-substrate.md @@ -63,7 +63,7 @@ Runner는 해당 작업 공간에서 처음 `read`·`find`·`version`·`apply_pa 변경 전제는 여전히 `expected_versions`와 `VERSION_CONFLICT`입니다. 놓친·합쳐진·재시작된 watch 이벤트 때문에 디스크 해시가 달라진 `apply_patch`가 성공해서는 안 됩니다. 오버플로, 수신 실패(뒤처진 구독자 포함), 분류할 수 없는 이벤트는 같은 epoch에서 `ResyncRequired`를 내며 소비자 캐시 전체를 신뢰하지 않아야 합니다. 감시자를 다시 시작할 때는 교체 감시자가 살아 있는 뒤에만 `epoch`가 증가하고 `ResyncRequired`를 냅니다. 이 기반은 자체 apply와 외부 편집을 구분하지 않고, 손실 없는 이벤트 원장을 두지 않으며, UDS로 watch 이벤트를 보내지 않습니다. -`find`는 제한된 glob 탐색입니다. watch API가 아닙니다. +`find`는 제한된 glob 탐색입니다. watch API가 아닙니다. `read`와 `find`는 선택적 `offset`·`limit`을 받으며, 생략하면 첫 창입니다. 호출당 상한은 1 MiB와 경로 10000개입니다. `version`은 전체 파일 해시입니다. @@ -78,7 +78,7 @@ Runner는 해당 작업 공간에서 처음 `read`·`find`·`version`·`apply_pa ## 아직 제공하지 않는 기능 -파일 범위·페이지 인자, 영속적인 프로세스 복구, 컨테이너·원격 실행, 자원 큐 스케줄러도 제공하지 않습니다. MCP `fs/watch`, UDS watch 이벤트, 쓰기 원인 분류도 제공하지 않습니다. 내부 타입이나 협상된 프로토콜 플래그가 존재한다고 해당 기능을 호출할 수 있는 것은 아닙니다. +영속적인 프로세스 복구, 컨테이너·원격 실행, 자원 큐 스케줄러도 제공하지 않습니다. MCP `fs/watch`, UDS watch 이벤트, 쓰기 원인 분류도 제공하지 않습니다. 내부 타입이나 협상된 프로토콜 플래그가 존재한다고 해당 기능을 호출할 수 있는 것은 아닙니다. ## 구현 경계 유지 diff --git a/docs/ko/runner-isolation.md b/docs/ko/runner-isolation.md index 82cd493..833d00a 100644 --- a/docs/ko/runner-isolation.md +++ b/docs/ko/runner-isolation.md @@ -13,7 +13,7 @@ `in-process`는 `codespace-mcp` 안에서 프로세스를 관리합니다. `uds`(Unix domain socket, Unix 도메인 소켓)는 같은 호스트의 `codespace-codex-runtime` 안에서 같은 관리 코드를 실행합니다. worker는 시작 시 Codex의 프로세스 보호 설정을 적용하고 전용 Unix 소켓을 엽니다. worker 자체를 보호하는 것과 실행할 명령에 샌드박스를 적용하는 것은 별개입니다. -게이트웨이는 임시 디렉터리 또는 `CODESPACE_RUNNER_DIR` 아래에 권한 0700의 고유 디렉터리를 만듭니다. 내부 통신은 u32 길이 접두부, 버전 5 핸드셰이크, 요청 ID, 프로세스 종료 이벤트를 사용하는 CodeSpace JSON입니다. Codex App Server RPC가 아닙니다. 같은 연결에서의 요청 재처리는 재접속 복구를 뜻하지 않습니다. +게이트웨이는 임시 디렉터리 또는 `CODESPACE_RUNNER_DIR` 아래에 권한 0700의 고유 디렉터리를 만듭니다. 내부 통신은 u32 길이 접두부, 버전 6 핸드셰이크, 요청 ID, 프로세스 종료 이벤트를 사용하는 CodeSpace JSON입니다. Codex App Server RPC가 아닙니다. 같은 연결에서의 요청 재처리는 재접속 복구를 뜻하지 않습니다. 관리 프로세스의 수명은 MCP 연결이 아니라 러너 인스턴스가 소유합니다. Streamable HTTP나 MCP 클라이언트 끊김은 프로세스를 유지합니다. 게이트웨이와 worker의 UDS 연결이 끊기거나 게이트웨이가 종료되면(stdio EOF 포함) 관리 중인 worker와 자식 프로세스가 종료되고 핸들이 사라집니다. 재시작은 `process_id`를 복구하지 않습니다. diff --git a/docs/runner-isolation.md b/docs/runner-isolation.md index f0ca43e..01f1bf1 100644 --- a/docs/runner-isolation.md +++ b/docs/runner-isolation.md @@ -10,7 +10,7 @@ There are three separate questions: which process runs the work, whether command `in-process` runs the supervisor inside `codespace-mcp`. `uds` (Unix domain socket) runs that supervisor inside `codespace-codex-runtime` on the same host. The worker starts with Codex process hardening and binds a private Unix socket. Hardening the worker does not sandbox its commands. -The gateway creates a unique 0700 directory beneath its temporary directory or `CODESPACE_RUNNER_DIR`. The internal protocol is CodeSpace JSON with a u32 length prefix, handshake version 5, request IDs, and process-exit events. It is not Codex App Server RPC. Same-connection replay is not reconnect recovery. +The gateway creates a unique 0700 directory beneath its temporary directory or `CODESPACE_RUNNER_DIR`. The internal protocol is CodeSpace JSON with a u32 length prefix, handshake version 6, request IDs, and process-exit events. It is not Codex App Server RPC. Same-connection replay is not reconnect recovery. Managed process lifetime is owned by the runner instance, not the MCP connection. Streamable HTTP or MCP client disconnect keeps processes running. Gateway/worker UDS disconnect or gateway shutdown (including stdio EOF) ends the owned worker and its children; process handles are lost. Restart does not restore `process_id`. diff --git a/docs/translations.json b/docs/translations.json index f32412d..3766df2 100644 --- a/docs/translations.json +++ b/docs/translations.json @@ -100,8 +100,8 @@ "재시도와-복구", "파일-읽기와-패치" ], - "source_sha256": "066783790544a79361a1367f2a567901df4d48468d44817da77456db3cd5a171", - "translation_sha256": "c9b0f682c684f4503b8197bc5b75fe97ce38a5f2efbe6a3865960bdde79f158a" + "source_sha256": "e442068f587480507ea9f855abe476821135d3d63fd6b74ec17398ebfb02a609", + "translation_sha256": "1e9296c7990e65d0620e6f983d9fb6a6dd543e69aaef3f39b0a73ea775f2adee" }, { "id": "operations", @@ -303,8 +303,8 @@ "확인-홀드", "훅과-스킬" ], - "source_sha256": "ceefd1314c3bbf318630d6aee519e180d7b044d6bbbf6b995b1d79267430871e", - "translation_sha256": "8a6dff78d6cc08d6b425b4097592b78a71a4c116900578fc57557648b8c4d443" + "source_sha256": "f80f1281487af44fff16c9ab1b74697d2b9d87cd8a70664e690144705fb91053", + "translation_sha256": "782fbc1147facc68810e3f212f217f2a9e3b89a205ab87389dafdf4225770640" }, { "id": "protocol-compatibility", @@ -445,8 +445,8 @@ "컨테이너-실험-구성과-검증", "호스트와-worker-실행" ], - "source_sha256": "065e07e43a16452e27a3c269fbf735e76ac1797eba850efbb29a2cb2656f18df", - "translation_sha256": "6795cc0eb1a25dc7a60f998ffc218e5745756ced88806bb76bd9d7be7893e03f" + "source_sha256": "a9a1ab3276b7d88eabc5cda340bdc56b09d24140bf341e1763f0f05d7967709e", + "translation_sha256": "e88efd529b27bb635ff4eba69ef08bb84bae807678582d55b12cfc466086ee97" }, { "id": "error-codes", @@ -474,8 +474,8 @@ "전송-실패", "전송-실패-작업-없음" ], - "source_sha256": "9b765468d3045a3705f376a108aee22bf9d36efc8b094a7b3fa2047ca1596cae", - "translation_sha256": "75aed3982430e609f0dcea9f92a66cce9ededd7ed112a481819e8fce73da3197" + "source_sha256": "a366946fea6e73966595001b72221ae5b9cfe84ddfd77cabba928c4283168fce", + "translation_sha256": "6b97e91bfd708101aedde02ed0ac6f965a09a1136062faa6f5fa3d107d4f26bf" }, { "id": "codex-reuse", @@ -554,8 +554,8 @@ "핀-6b9826e의-후보", "핵심-대-어댑터" ], - "source_sha256": "f30c4a2bb1de3094733bb042db156db14a4e8eec3a651abdfcc92e3823296d66", - "translation_sha256": "5f389f7fc23213ea87fc508d1dfdf2b23d6d704130752c0263f0288e2c0911cd" + "source_sha256": "9de92581d56525ae1ba0f028cce8c7fd7237747a98c593d4268bd1f4829ad471", + "translation_sha256": "b295940bb3eff4a8120639a8aee5dab0b738f4364601430dba93fb144e1df14a" }, { "id": "upstream-lock", From 5957b771b5003fd2c2e23a94f0e1e07c34afc860 Mon Sep 17 00:00:00 2001 From: Seongjae Date: Mon, 21 Sep 2026 14:42:14 +0900 Subject: [PATCH 2/3] Pin the live UDS hello contract to protocol 6. The worker already speaks WIRE_PROTOCOL 6; the leftover literal 5 was failing CI. Co-authored-by: Cursor --- crates/codex-runtime/tests/runtime_binary.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/codex-runtime/tests/runtime_binary.rs b/crates/codex-runtime/tests/runtime_binary.rs index e87fa83..8291873 100644 --- a/crates/codex-runtime/tests/runtime_binary.rs +++ b/crates/codex-runtime/tests/runtime_binary.rs @@ -138,7 +138,7 @@ async fn hello_on_live_socket() { match parsed.result { Some(RunnerOpResult::Hello { protocol }) => { assert_eq!(protocol, WIRE_PROTOCOL); - assert_eq!(protocol, 5); + assert_eq!(protocol, 6); } other => panic!("unexpected hello {other:?}"), } From e08ca8ec0c7a95b6bce94389ef4547c40fffd1c7 Mon Sep 17 00:00:00 2001 From: Seongjae Date: Mon, 21 Sep 2026 16:34:51 +0900 Subject: [PATCH 3/3] Split find pagination from walk caps and mint next_offset. truncated now means only more observed content remains. content_lossy, incomplete, and listing_version make window follow-up safe without empty-page retries. Co-authored-by: Cursor --- crates/domain/src/files.rs | 59 +++++++++++++ crates/runner/src/files.rs | 137 +++++++++++++++++++++++++++---- crates/server/src/mcp.rs | 23 ++++-- crates/server/tests/process.rs | 29 ++++++- crates/server/tests/read_find.rs | 27 +++++- docs/agent-integration.md | 2 +- docs/execution-substrate.md | 2 +- docs/ko/agent-integration.md | 2 +- docs/ko/execution-substrate.md | 2 +- docs/translations.json | 8 +- 10 files changed, 256 insertions(+), 35 deletions(-) diff --git a/crates/domain/src/files.rs b/crates/domain/src/files.rs index 8b295ea..60a4d1c 100644 --- a/crates/domain/src/files.rs +++ b/crates/domain/src/files.rs @@ -38,6 +38,12 @@ pub struct ReadResult { pub offset: u64, /// File bytes included in this window. Not `content.len()` after UTF-8 lossy. pub byte_count: u64, + /// True when this window is not valid UTF-8 and `content` used replacement decoding. + #[serde(default)] + pub content_lossy: bool, + /// Present only when more observed file bytes remain after this window. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_offset: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub coordination: Option, } @@ -64,6 +70,15 @@ pub struct FindResult { /// Start of this window in the sorted path list. Omitted from JSON when 0. #[serde(default, skip_serializing_if = "is_zero_u64")] pub offset: u64, + /// Walk hit an internal bound; the matching set is not known to be complete. + #[serde(default)] + pub incomplete: bool, + /// Identity of this observation's sorted matching set, not the returned page. + #[serde(default)] + pub listing_version: String, + /// Present only when more observed matching paths remain after this page. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_offset: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub coordination: Option, } @@ -93,10 +108,54 @@ mod tests { truncated: false, offset: 0, byte_count: 2, + content_lossy: false, + next_offset: None, coordination: None, }; let json = serde_json::to_value(&result).unwrap(); assert!(json.get("offset").is_none()); + assert!(json.get("next_offset").is_none()); assert_eq!(json["byte_count"], 2); + assert_eq!(json["content_lossy"], false); + } + + #[test] + fn omitted_result_fields_default_on_deserialize() { + let read: ReadResult = serde_json::from_value(json!({ + "path": "a.txt", + "content": "hi", + "version": "sha256:x", + "truncated": false, + "byte_count": 2 + })) + .unwrap(); + assert!(!read.content_lossy); + assert!(read.next_offset.is_none()); + let find: FindResult = serde_json::from_value(json!({ + "paths": ["a.txt"], + "truncated": false + })) + .unwrap(); + assert!(!find.incomplete); + assert!(find.listing_version.is_empty()); + assert!(find.next_offset.is_none()); + } + + #[test] + fn zero_offset_is_omitted_from_find_json() { + let result = FindResult { + paths: vec!["a.txt".into()], + truncated: false, + offset: 0, + incomplete: false, + listing_version: "sha256:x".into(), + next_offset: None, + coordination: None, + }; + let json = serde_json::to_value(&result).unwrap(); + assert!(json.get("offset").is_none()); + assert!(json.get("next_offset").is_none()); + assert_eq!(json["incomplete"], false); + assert_eq!(json["listing_version"], "sha256:x"); } } diff --git a/crates/runner/src/files.rs b/crates/runner/src/files.rs index ff50fb0..7c39d90 100644 --- a/crates/runner/src/files.rs +++ b/crates/runner/src/files.rs @@ -54,6 +54,7 @@ impl PathSandbox { } let bytes = codespace_fs::read(&path).await.map_err(fs_error_body)?; let (slice, truncated) = byte_window(&bytes, offset, limit); + let content_lossy = std::str::from_utf8(slice).is_err(); Ok(ReadResult { path: normalize_rel(relative), content: String::from_utf8_lossy(slice).into_owned(), @@ -61,6 +62,8 @@ impl PathSandbox { truncated, offset, byte_count: slice.len() as u64, + content_lossy, + next_offset: truncated.then_some(offset + slice.len() as u64), coordination: None, }) } @@ -102,11 +105,14 @@ impl PathSandbox { paths.push(rel); } paths.sort(); - let (page, truncated) = path_window(&paths, offset, limit, walked.truncated); + let page = path_window(&paths, offset, limit, walked.truncated); Ok(FindResult { - paths: page, - truncated, + paths: page.paths, + truncated: page.truncated, offset, + incomplete: page.incomplete, + listing_version: listing_version(&paths, page.incomplete), + next_offset: page.next_offset, coordination: None, }) } @@ -133,21 +139,49 @@ fn byte_window(bytes: &[u8], offset: u64, limit: usize) -> (&[u8], bool) { (&bytes[start..end], (end as u64) < len) } -fn path_window( - paths: &[String], - offset: u64, - limit: usize, - walk_truncated: bool, -) -> (Vec, bool) { +struct PathPage { + paths: Vec, + truncated: bool, + incomplete: bool, + next_offset: Option, +} + +fn listing_version(paths: &[String], incomplete: bool) -> String { + let mut hasher = Sha256::new(); + hasher.update(if incomplete { + b"incomplete\0".as_slice() + } else { + b"complete\0".as_slice() + }); + for path in paths { + hasher.update(path.as_bytes()); + hasher.update(b"\0"); + } + format!("sha256:{}", hex::encode(hasher.finalize())) +} + +fn path_window(paths: &[String], offset: u64, limit: usize, walk_truncated: bool) -> PathPage { + let incomplete = walk_truncated; let len = paths.len() as u64; if offset >= len { - return (Vec::new(), walk_truncated); + return PathPage { + paths: Vec::new(), + truncated: false, + incomplete, + next_offset: None, + }; } let start = offset as usize; let remaining = paths.len() - start; - let truncated = walk_truncated || remaining > limit; + let truncated = remaining > limit; let end = start.saturating_add(limit).min(paths.len()); - (paths[start..end].to_vec(), truncated) + let page = paths[start..end].to_vec(); + PathPage { + next_offset: truncated.then_some(offset + page.len() as u64), + paths: page, + truncated, + incomplete, + } } pub(crate) fn fs_error_body(err: FsError) -> ErrorBody { @@ -210,6 +244,8 @@ mod tests { assert_eq!(first.path, "a.txt"); assert_eq!(first.offset, 0); assert_eq!(first.byte_count, 5); + assert!(!first.content_lossy); + assert!(first.next_offset.is_none()); assert_eq!(s.version("missing").await.unwrap(), VERSION_ABSENT); } @@ -223,15 +259,19 @@ mod tests { assert!(result.truncated); assert_eq!(result.offset, 0); assert_eq!(result.byte_count, 3); + assert!(!result.content_lossy); + assert_eq!(result.next_offset, Some(3)); assert_eq!(result.version, PathSandbox::version_of(b"abcdef")); let rest = s - .read_file_window("big.txt", Some(result.offset + result.byte_count), Some(3)) + .read_file_window("big.txt", result.next_offset, Some(3)) .await .unwrap(); assert_eq!(rest.content, "def"); assert!(!rest.truncated); assert_eq!(rest.offset, 3); assert_eq!(rest.byte_count, 3); + assert!(!rest.content_lossy); + assert!(rest.next_offset.is_none()); assert_eq!(rest.version, result.version); } @@ -250,12 +290,21 @@ mod tests { assert_eq!(rs.paths, vec!["a.rs".to_string()]); let limited = s.find_window(None, None, Some(1)).await.unwrap(); assert!(limited.truncated); + assert!(!limited.incomplete); assert_eq!(limited.paths.len(), 1); assert_eq!(limited.offset, 0); - let page = s.find_window(None, Some(1), Some(1)).await.unwrap(); + assert_eq!(limited.next_offset, Some(1)); + assert!(limited.listing_version.starts_with("sha256:")); + let page = s + .find_window(None, limited.next_offset, Some(1)) + .await + .unwrap(); assert!(!page.truncated); + assert!(!page.incomplete); assert_eq!(page.offset, 1); assert_eq!(page.paths.len(), 1); + assert!(page.next_offset.is_none()); + assert_eq!(page.listing_version, limited.listing_version); assert_ne!(page.paths, limited.paths); } @@ -365,6 +414,8 @@ mod tests { assert!(!result.truncated); assert_eq!(result.offset, 10); assert_eq!(result.byte_count, 0); + assert!(!result.content_lossy); + assert!(result.next_offset.is_none()); assert_eq!(result.version, PathSandbox::version_of(b"hi")); } @@ -406,15 +457,71 @@ mod tests { let s = sandbox(dir.path()); let first = s.read_file_window("bin", None, Some(2)).await.unwrap(); assert!(first.truncated); + assert!(first.content_lossy); assert_eq!(first.byte_count, 2); + assert_eq!(first.next_offset, Some(2)); assert_ne!(first.content.len() as u64, first.byte_count); let rest = s - .read_file_window("bin", Some(first.offset + first.byte_count), Some(2)) + .read_file_window("bin", first.next_offset, Some(2)) .await .unwrap(); assert_eq!(rest.content, "b"); assert!(!rest.truncated); + assert!(!rest.content_lossy); assert_eq!(rest.byte_count, 1); + assert!(rest.next_offset.is_none()); assert_eq!(rest.version, first.version); } + + #[tokio::test] + async fn read_window_split_utf8() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("hangul.txt"), "가").unwrap(); + let s = sandbox(dir.path()); + let first = s + .read_file_window("hangul.txt", None, Some(1)) + .await + .unwrap(); + assert!(first.content_lossy); + assert_eq!(first.byte_count, 1); + assert_eq!(first.next_offset, Some(1)); + assert_eq!(first.version, PathSandbox::version_of("가".as_bytes())); + } + + #[test] + fn find_walk_truncated_cannot_return_retry_loop() { + let paths = vec!["a".into(), "b".into()]; + let page = path_window(&paths, 2, 10, true); + assert!(page.paths.is_empty()); + assert!(!page.truncated); + assert!(page.incomplete); + assert!(page.next_offset.is_none()); + } + + #[test] + fn listing_version_includes_completeness() { + let paths = vec!["a.rs".into()]; + assert_ne!( + listing_version(&paths, false), + listing_version(&paths, true) + ); + assert!(listing_version(&paths, false).starts_with("sha256:")); + } + + #[tokio::test] + async fn find_listing_version_changes_between_pages() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("a.rs"), "a").unwrap(); + std::fs::write(dir.path().join("b.rs"), "b").unwrap(); + let s = sandbox(dir.path()); + let first = s.find_window(None, None, Some(1)).await.unwrap(); + assert_eq!(first.paths, vec!["a.rs".to_string()]); + assert_eq!(first.next_offset, Some(1)); + std::fs::write(dir.path().join("0.rs"), "z").unwrap(); + let second = s + .find_window(None, first.next_offset, Some(1)) + .await + .unwrap(); + assert_ne!(second.listing_version, first.listing_version); + } } diff --git a/crates/server/src/mcp.rs b/crates/server/src/mcp.rs index dfbcbae..fcf0e77 100644 --- a/crates/server/src/mcp.rs +++ b/crates/server/src/mcp.rs @@ -58,10 +58,16 @@ selectors, not credentials. read and find accept optional offset and limit. Omitted arguments return \ the first window: 1 MiB for read, 10000 sorted paths for find. Per-call \ -caps are the same. truncated means more remains; continue read at offset \ -plus byte_count, and find at offset plus the returned path count. version \ -hashes the whole file, not the window. A limit of 0 or above the cap \ -returns OUTPUT_LIMIT. +caps are the same. truncated means more observed content remains after \ +this window. Continue only when next_offset is present. Do not compute \ +the next window from content.len() or retry an empty page at the same \ +offset. Byte windows may split UTF-8 sequences; content_lossy=true means \ +content contains replacement decoding and must not be used for exact \ +reconstruction. find.incomplete=true means the walk was capped so the \ +matching set is not known to be complete. listing_version identifies \ +this observation's sorted path set; if it changes between pages, restart \ +from offset 0. version hashes the whole file, not the window. A limit of \ +0 or above the cap returns OUTPUT_LIMIT. exec_command accepts argv; there is no implicit shell. It runs in the \ workspace cwd and returns a server-minted process_id. Ending an MCP request \ @@ -177,7 +183,7 @@ impl CodeSpace { #[tool( name = "read", - description = "Read a relative workspace file and return content plus a sha256 version of the whole file. Optional offset and limit select a byte window (default 0 and 1 MiB, max 1 MiB per call). truncated means more bytes remain after this window. Continue at offset plus byte_count. Rejects symlinks, special files, and path escape." + description = "Read a relative workspace file and return content plus a sha256 version of the whole file. Optional offset and limit select a byte window (default 0 and 1 MiB, max 1 MiB per call). truncated means more bytes remain after this window; continue only at next_offset. Byte windows may split UTF-8 sequences. content_lossy=true means content contains replacement decoding and must not be used for exact reconstruction. Rejects symlinks, special files, and path escape." )] async fn read( &self, @@ -199,7 +205,7 @@ impl CodeSpace { #[tool( name = "find", - description = "List relative file paths in a workspace. Does not follow symlinks. Optional offset and limit page the sorted path list (default 0 and 10000, max 10000 per call). truncated means more paths remain or the walk was capped. Continue at offset plus the returned path count." + description = "List relative file paths in a workspace. Does not follow symlinks. Optional offset and limit page the sorted path list (default 0 and 10000, max 10000 per call). truncated means more observed matching paths remain; continue only at next_offset. incomplete=true means the walk was capped so the matching set is not known to be complete. listing_version identifies this observation's sorted path set. Do not retry an empty page at the same offset." )] async fn find( &self, @@ -1062,7 +1068,10 @@ mod tests { fn assert_instructions_cover_execution_contract(text: &str) { assert!(text.contains("optional offset and limit"), "{text}"); - assert!(text.contains("byte_count"), "{text}"); + assert!(text.contains("next_offset"), "{text}"); + assert!(text.contains("content_lossy"), "{text}"); + assert!(text.contains("incomplete"), "{text}"); + assert!(text.contains("listing_version"), "{text}"); assert!(text.contains("OUTPUT_LIMIT"), "{text}"); assert!( text.contains("Ending an MCP request does not terminate the process"), diff --git a/crates/server/tests/process.rs b/crates/server/tests/process.rs index 4ae5fe8..a9c3c2e 100644 --- a/crates/server/tests/process.rs +++ b/crates/server/tests/process.rs @@ -715,6 +715,11 @@ async fn exec_command_schema_has_optional_tty_and_live_tools_unchanged() { .iter() .find(|tool| tool.name.as_ref() == TOOL_READ) .expect("read"); + let read_desc = read_tool.description.as_deref().unwrap_or(""); + assert!( + read_desc.contains("next_offset") && read_desc.contains("content_lossy"), + "read description must mention next_offset and content_lossy: {read_desc}" + ); let read_in = serde_json::to_value(&read_tool.input_schema) .unwrap() .to_string(); @@ -726,13 +731,23 @@ async fn exec_command_schema_has_optional_tty_and_live_tools_unchanged() { .unwrap() .to_string(); assert!( - read_out.contains("byte_count") && read_out.contains("truncated"), - "read result schema must include byte_count and truncated: {read_out}" + read_out.contains("byte_count") + && read_out.contains("truncated") + && read_out.contains("content_lossy") + && read_out.contains("next_offset"), + "read result schema must include byte_count, truncated, content_lossy, next_offset: {read_out}" ); let find_tool = tools .iter() .find(|tool| tool.name.as_ref() == TOOL_FIND) .expect("find"); + let find_desc = find_tool.description.as_deref().unwrap_or(""); + assert!( + find_desc.contains("next_offset") + && find_desc.contains("incomplete") + && find_desc.contains("listing_version"), + "find description must mention next_offset, incomplete, listing_version: {find_desc}" + ); let find_in = serde_json::to_value(&find_tool.input_schema) .unwrap() .to_string(); @@ -740,6 +755,16 @@ async fn exec_command_schema_has_optional_tty_and_live_tools_unchanged() { find_in.contains("offset") && find_in.contains("limit"), "find input schema must include offset and limit: {find_in}" ); + let find_out = serde_json::to_value(find_tool.output_schema.as_ref()) + .unwrap() + .to_string(); + assert!( + find_out.contains("incomplete") + && find_out.contains("listing_version") + && find_out.contains("next_offset") + && find_out.contains("truncated"), + "find result schema must include incomplete, listing_version, next_offset, truncated: {find_out}" + ); let exec = tools .iter() diff --git a/crates/server/tests/read_find.rs b/crates/server/tests/read_find.rs index 27ec2f2..574bb0b 100644 --- a/crates/server/tests/read_find.rs +++ b/crates/server/tests/read_find.rs @@ -55,7 +55,9 @@ async fn read_and_find_use_versions_and_relative_paths() { assert_eq!(body["path"], "hello.txt"); assert_eq!(body["truncated"], false); assert_eq!(body["byte_count"], 2); + assert_eq!(body["content_lossy"], false); assert!(body.get("offset").is_none()); + assert!(body.get("next_offset").is_none()); let version = body["version"].as_str().unwrap().to_string(); assert!(version.starts_with("sha256:")); @@ -75,7 +77,8 @@ async fn read_and_find_use_versions_and_relative_paths() { ) .await .expect("find"); - let paths = found.structured_content.unwrap()["paths"] + let found_body = found.structured_content.clone().expect("structured"); + let paths = found_body["paths"] .as_array() .unwrap() .iter() @@ -83,6 +86,13 @@ async fn read_and_find_use_versions_and_relative_paths() { .collect::>(); assert_eq!(paths, vec!["hello.txt".to_string()]); assert!(paths.iter().all(|p| !p.starts_with('/'))); + assert_eq!(found_body["truncated"], false); + assert_eq!(found_body["incomplete"], false); + assert!(found_body["listing_version"] + .as_str() + .unwrap() + .starts_with("sha256:")); + assert!(found_body.get("next_offset").is_none()); let missing = client .call_tool( @@ -235,7 +245,9 @@ async fn read_and_find_paginate_windows() { assert_eq!(body["content"], "abc"); assert_eq!(body["truncated"], true); assert_eq!(body["byte_count"], 3); + assert_eq!(body["content_lossy"], false); assert!(body.get("offset").is_none()); + assert_eq!(body["next_offset"], 3); let version = body["version"].clone(); let rest = client @@ -243,7 +255,7 @@ async fn read_and_find_paginate_windows() { CallToolRequestParams::new(TOOL_READ).with_arguments(object!({ "workspace_id": "demo", "path": "big.txt", - "offset": 3, + "offset": body["next_offset"], "limit": 3 })), ) @@ -254,6 +266,8 @@ async fn read_and_find_paginate_windows() { assert_eq!(rest_body["truncated"], false); assert_eq!(rest_body["offset"], 3); assert_eq!(rest_body["byte_count"], 3); + assert_eq!(rest_body["content_lossy"], false); + assert!(rest_body.get("next_offset").is_none()); assert_eq!(rest_body["version"], version); let page = client @@ -267,14 +281,18 @@ async fn read_and_find_paginate_windows() { .expect("find page"); let page_body = page.structured_content.clone().expect("structured"); assert_eq!(page_body["truncated"], true); + assert_eq!(page_body["incomplete"], false); assert_eq!(page_body["paths"].as_array().unwrap().len(), 1); assert!(page_body.get("offset").is_none()); + assert_eq!(page_body["next_offset"], 1); + let listing_version = page_body["listing_version"].as_str().unwrap().to_string(); + assert!(listing_version.starts_with("sha256:")); let next = client .call_tool( CallToolRequestParams::new(TOOL_FIND).with_arguments(object!({ "workspace_id": "demo", - "offset": 1, + "offset": page_body["next_offset"], "limit": 2 })), ) @@ -283,7 +301,10 @@ async fn read_and_find_paginate_windows() { let next_body = next.structured_content.clone().expect("structured"); assert_eq!(next_body["offset"], 1); assert_eq!(next_body["truncated"], false); + assert_eq!(next_body["incomplete"], false); assert_eq!(next_body["paths"].as_array().unwrap().len(), 2); + assert!(next_body.get("next_offset").is_none()); + assert_eq!(next_body["listing_version"], listing_version); let bad = client .call_tool( diff --git a/docs/agent-integration.md b/docs/agent-integration.md index 1e6f6b8..4a82911 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -35,7 +35,7 @@ Use a disposable project for this example. Create `hello.txt` with `hi` followed } ``` -Keep the returned `version`; it hashes the whole file, not the returned window. Paths are relative to the registered root. `find` accepts a path glob, not a content search query. `read` and `find` accept optional `offset` and `limit`. Omitted arguments return the first window: 1 MiB for `read`, 10000 sorted paths for `find`. Those values are also the per-call caps; `OUTPUT_LIMIT` is returned for `limit` 0 or above the cap. `truncated` means more remains after this window. Continue `read` at `offset + byte_count` (not `content.len()` after UTF-8 lossy conversion), and `find` at `offset` plus the returned path count. Advertised caps are in `execution.files.capabilities`. +Keep the returned `version`; it hashes the whole file, not the returned window. Paths are relative to the registered root. `find` accepts a path glob, not a content search query. `read` and `find` accept optional `offset` and `limit`. Omitted arguments return the first window: 1 MiB for `read`, 10000 sorted paths for `find`. Those values are also the per-call caps; `OUTPUT_LIMIT` is returned for `limit` 0 or above the cap. `truncated` means more observed content remains after this window; continue only at `next_offset`. Do not compute the next window from `content.len()` or retry an empty page at the same offset. Byte windows may split UTF-8 sequences; `content_lossy=true` means `content` contains replacement decoding and must not be used for exact reconstruction. `find.incomplete=true` means the walk was capped so the matching set is not known to be complete. `listing_version` identifies this observation's sorted path set; if it changes between pages, restart from offset 0. Advertised caps are in `execution.files.capabilities`. Replace `VERSION_FROM_READ` below with the exact version returned by `read`. V4A is Codex’s text patch format, using markers such as `*** Begin Patch` and `*** Update File`. This is a complete V4A patch, with JSON newline escapes: diff --git a/docs/execution-substrate.md b/docs/execution-substrate.md index f7650ab..8ae46c1 100644 --- a/docs/execution-substrate.md +++ b/docs/execution-substrate.md @@ -56,7 +56,7 @@ The Runner may start a recursive filesystem watcher for a workspace on the first `expected_versions` and `VERSION_CONFLICT` remain the authoritative apply guard. Missing, coalesced, or restarted watch events must never make `apply_patch` succeed when the on-disk hash no longer matches. Overflow, receive failure (including a lagged subscriber), or an unclassifiable event yields `ResyncRequired` on the same epoch (treat any consumer cache as fully untrusted). Restarting the watcher increments `epoch` and also emits `ResyncRequired` only after a replacement watcher is running. This substrate does not classify self-generated versus external writes, keep a lossless event ledger, or send watch events over UDS. -`find` stays a bounded glob walk. It is not a watch API. `read` and `find` accept optional `offset` and `limit`; omitted arguments return the first window. Per-call caps remain 1 MiB and 10000 paths. `version` hashes the whole file. +`find` stays a bounded glob walk. It is not a watch API. `read` and `find` accept optional `offset` and `limit`; omitted arguments return the first window. Per-call caps remain 1 MiB and 10000 paths. `truncated` means more observed bytes or paths remain after this window; continue only at `next_offset`. `content_lossy` marks replacement UTF-8 decoding. `incomplete` marks a capped walk, not another page. `listing_version` identifies the observed sorted matching set for this call, not the page. `version` hashes the whole file. Watch events remain invalidation hints and are not listing identity. diff --git a/docs/ko/agent-integration.md b/docs/ko/agent-integration.md index f05bd98..88d7cbc 100644 --- a/docs/ko/agent-integration.md +++ b/docs/ko/agent-integration.md @@ -35,7 +35,7 @@ MCP 클라이언트 SDK로 stdio 또는 Streamable HTTP를 초기화하고, 초 } ``` -응답의 `version`을 보관합니다. 이 값은 반환된 창이 아니라 **전체 파일**의 sha256입니다. 경로는 등록된 루트 기준 상대 경로입니다. `find`는 파일 내용이 아닌 경로 glob으로 검색합니다. `read`와 `find`는 선택적 `offset`·`limit`을 받습니다. 생략하면 첫 창입니다. `read`는 1 MiB, `find`는 정렬된 경로 10000개이며, 그 값이 호출당 상한이기도 합니다. `limit`가 0이거나 상한을 넘으면 `OUTPUT_LIMIT`입니다. `truncated`가 참이면 이 창 뒤에 더 있습니다. `read`의 다음 창은 `offset + byte_count`입니다(`content.len()`이 아님). `find`는 `offset`에 반환된 경로 개수를 더합니다. 광고된 상한은 `execution.files.capabilities`입니다. +응답의 `version`을 보관합니다. 이 값은 반환된 창이 아니라 **전체 파일**의 sha256입니다. 경로는 등록된 루트 기준 상대 경로입니다. `find`는 파일 내용이 아닌 경로 glob으로 검색합니다. `read`와 `find`는 선택적 `offset`·`limit`을 받습니다. 생략하면 첫 창입니다. `read`는 1 MiB, `find`는 정렬된 경로 10000개이며, 그 값이 호출당 상한이기도 합니다. `limit`가 0이거나 상한을 넘으면 `OUTPUT_LIMIT`입니다. `truncated`가 참이면 이 창 뒤에 관측된 내용이 더 있다는 뜻이며, `next_offset`이 있을 때만 이어 읽습니다. `content.len()`으로 다음 창을 계산하거나 같은 offset의 빈 페이지를 재시도하지 않습니다. 바이트 창은 UTF-8 시퀀스를 자를 수 있습니다. `content_lossy=true`이면 `content`가 치환 디코딩을 포함하므로 원문을 그대로 재구성하면 안 됩니다. `find.incomplete=true`는 walk가 상한에 걸려 전체 매칭 집합을 보장하지 않는다는 뜻입니다. `listing_version`은 이번 관측의 정렬된 경로 집합 식별자이며, 페이지 사이에 바뀌면 offset 0부터 다시 시작합니다. 광고된 상한은 `execution.files.capabilities`입니다. 아래 `VERSION_FROM_READ`를 실제 `read` 응답의 버전으로 바꾸세요. V4A는 `*** Begin Patch`, `*** Update File` 같은 표식을 사용하는 Codex 텍스트 패치 형식입니다. 아래 예시는 JSON의 줄바꿈 이스케이프를 사용하는 완전한 패치입니다. diff --git a/docs/ko/execution-substrate.md b/docs/ko/execution-substrate.md index 40e3eee..705d3e3 100644 --- a/docs/ko/execution-substrate.md +++ b/docs/ko/execution-substrate.md @@ -63,7 +63,7 @@ Runner는 해당 작업 공간에서 처음 `read`·`find`·`version`·`apply_pa 변경 전제는 여전히 `expected_versions`와 `VERSION_CONFLICT`입니다. 놓친·합쳐진·재시작된 watch 이벤트 때문에 디스크 해시가 달라진 `apply_patch`가 성공해서는 안 됩니다. 오버플로, 수신 실패(뒤처진 구독자 포함), 분류할 수 없는 이벤트는 같은 epoch에서 `ResyncRequired`를 내며 소비자 캐시 전체를 신뢰하지 않아야 합니다. 감시자를 다시 시작할 때는 교체 감시자가 살아 있는 뒤에만 `epoch`가 증가하고 `ResyncRequired`를 냅니다. 이 기반은 자체 apply와 외부 편집을 구분하지 않고, 손실 없는 이벤트 원장을 두지 않으며, UDS로 watch 이벤트를 보내지 않습니다. -`find`는 제한된 glob 탐색입니다. watch API가 아닙니다. `read`와 `find`는 선택적 `offset`·`limit`을 받으며, 생략하면 첫 창입니다. 호출당 상한은 1 MiB와 경로 10000개입니다. `version`은 전체 파일 해시입니다. +`find`는 제한된 glob 탐색입니다. watch API가 아닙니다. `read`와 `find`는 선택적 `offset`·`limit`을 받으며, 생략하면 첫 창입니다. 호출당 상한은 1 MiB와 경로 10000개입니다. `truncated`는 이 창 뒤에 관측된 바이트나 경로가 더 있다는 뜻이며, `next_offset`이 있을 때만 이어 읽습니다. `content_lossy`는 UTF-8 치환 디코딩을 표시합니다. `incomplete`는 다음 페이지가 아니라 walk가 상한에 걸렸다는 뜻입니다. `listing_version`은 반환된 페이지가 아니라 이번 호출에서 관측한 정렬된 매칭 집합의 식별자입니다. `version`은 전체 파일 해시입니다. watch 이벤트는 무효화 힌트이며 목록 식별자가 아닙니다. diff --git a/docs/translations.json b/docs/translations.json index 3766df2..0c0073d 100644 --- a/docs/translations.json +++ b/docs/translations.json @@ -100,8 +100,8 @@ "재시도와-복구", "파일-읽기와-패치" ], - "source_sha256": "e442068f587480507ea9f855abe476821135d3d63fd6b74ec17398ebfb02a609", - "translation_sha256": "1e9296c7990e65d0620e6f983d9fb6a6dd543e69aaef3f39b0a73ea775f2adee" + "source_sha256": "45bf2a02b2080ae47d33d0c5ce3d9a5881ee7aec765d85a206573f3d9cb9c8a1", + "translation_sha256": "f189f125e0def02665ff01ddc45abd5fef372d44cfddd7bf5345a19c8ec9cbfd" }, { "id": "operations", @@ -303,8 +303,8 @@ "확인-홀드", "훅과-스킬" ], - "source_sha256": "f80f1281487af44fff16c9ab1b74697d2b9d87cd8a70664e690144705fb91053", - "translation_sha256": "782fbc1147facc68810e3f212f217f2a9e3b89a205ab87389dafdf4225770640" + "source_sha256": "c577ea9c3ac8b682a40b440a152383eb6e930e7436a740c76f7f4d6f961de832", + "translation_sha256": "8e6bb3c55e8a1656f1cd5c05a4ef2715a6ea2d4b9dbac01134e82e857ced33de" }, { "id": "protocol-compatibility",