diff --git a/crates/xgen-adapter-filesystem/src/lib.rs b/crates/xgen-adapter-filesystem/src/lib.rs index d67f2be..81d4946 100644 --- a/crates/xgen-adapter-filesystem/src/lib.rs +++ b/crates/xgen-adapter-filesystem/src/lib.rs @@ -22,8 +22,9 @@ pub use apply_patch::{ }; pub use query::{ FilesystemQueryAdapter, FilesystemQueryVerifier, MAX_DIRECTORY_SCAN_ENTRIES, - MAX_LIST_DIRECTORY_ENTRIES, MAX_QUERY_OUTPUT_CANONICAL_BYTES, MAX_SEARCH_FILE_BYTES, - MAX_SEARCH_MATCHES, MAX_SEARCH_PREVIEW_BYTES, MAX_SEARCH_QUERY_BYTES, + MAX_LIST_DIRECTORY_ENTRIES, MAX_QUERY_OUTPUT_CANONICAL_BYTES, + MAX_READ_TEXT_LINES_CONTENT_BYTES, MAX_READ_TEXT_LINES_COUNT, MAX_READ_TEXT_LINES_FILE_BYTES, + MAX_SEARCH_FILE_BYTES, MAX_SEARCH_MATCHES, MAX_SEARCH_PREVIEW_BYTES, MAX_SEARCH_QUERY_BYTES, MAX_SEARCH_QUERY_UNICODE_SCALARS, MAX_SEARCH_TOTAL_BYTES, MAX_SEARCH_VISITED_ENTRIES, }; pub use read_text::{ @@ -62,6 +63,12 @@ pub const SEARCH_TEXT_CAPABILITY_ID: &str = "xgeny.fs/search-text"; pub const SEARCH_TEXT_CONTRACT_VERSION: &str = "1.0.0"; /// Exact operation name used in the root-bound search Instance. pub const SEARCH_TEXT_OPERATION_REF: &str = "searchText"; +/// Exact public Capability identifier for one bounded line range of a UTF-8 text file. +pub const READ_TEXT_LINES_CAPABILITY_ID: &str = "xgeny.fs/read-text-lines"; +/// Exact immutable read-text-lines contract version. +pub const READ_TEXT_LINES_CONTRACT_VERSION: &str = "1.0.0"; +/// Exact operation name used in the root-bound line-range read Instance. +pub const READ_TEXT_LINES_OPERATION_REF: &str = "readTextLines"; /// Exact public Capability identifier for one atomic UTF-8 file replacement. pub const WRITE_ATOMIC_CAPABILITY_ID: &str = "xgeny.fs/write-atomic"; /// Exact immutable write-atomic contract version. @@ -206,6 +213,12 @@ impl WorkspaceRoot { workspace_binding(&self.workspace_id, SEARCH_TEXT_OPERATION_REF) } + /// Return the exact root-bound binding for the read-text-lines adapter. + #[must_use] + pub fn read_text_lines_binding(&self) -> InstanceBinding { + workspace_binding(&self.workspace_id, READ_TEXT_LINES_OPERATION_REF) + } + /// Return the exact root-bound binding for atomic writes. #[must_use] pub fn write_atomic_binding(&self) -> InstanceBinding { @@ -236,6 +249,12 @@ impl WorkspaceRoot { FilesystemQueryAdapter::search_text(self.clone()) } + /// Construct the bounded line-range text read adapter. + #[must_use] + pub fn read_text_lines_adapter(&self) -> FilesystemQueryAdapter { + FilesystemQueryAdapter::read_text_lines(self.clone()) + } + /// Construct the bounded atomic UTF-8 write adapter. #[must_use] pub fn write_atomic_adapter(&self) -> WriteAtomicAdapter { diff --git a/crates/xgen-adapter-filesystem/src/query.rs b/crates/xgen-adapter-filesystem/src/query.rs index 64bd52b..63d7ab3 100644 --- a/crates/xgen-adapter-filesystem/src/query.rs +++ b/crates/xgen-adapter-filesystem/src/query.rs @@ -18,8 +18,9 @@ use xgen_workgraph::EffectClass; use crate::path::{RelativePath, canonical_resource, parse_canonical}; use crate::{ - LIST_DIRECTORY_CAPABILITY_ID, LIST_DIRECTORY_CONTRACT_VERSION, SEARCH_TEXT_CAPABILITY_ID, - SEARCH_TEXT_CONTRACT_VERSION, STAT_CAPABILITY_ID, STAT_CONTRACT_VERSION, WorkspaceRoot, + LIST_DIRECTORY_CAPABILITY_ID, LIST_DIRECTORY_CONTRACT_VERSION, READ_TEXT_LINES_CAPABILITY_ID, + READ_TEXT_LINES_CONTRACT_VERSION, SEARCH_TEXT_CAPABILITY_ID, SEARCH_TEXT_CONTRACT_VERSION, + STAT_CAPABILITY_ID, STAT_CONTRACT_VERSION, WorkspaceRoot, }; /// Maximum entries returned by one list-directory observation. @@ -40,7 +41,13 @@ pub const MAX_SEARCH_FILE_BYTES: usize = 256 * 1024; pub const MAX_SEARCH_TOTAL_BYTES: usize = 8 * 1024 * 1024; /// Maximum UTF-8 bytes retained in one match preview. pub const MAX_SEARCH_PREVIEW_BYTES: usize = 512; -/// Maximum canonical JCS bytes emitted by one list/stat/search tool output, including its digest. +/// Maximum lines requested by one read-text-lines observation. +pub const MAX_READ_TEXT_LINES_COUNT: u64 = 2_000; +/// Maximum file size scanned by one read-text-lines observation. +pub const MAX_READ_TEXT_LINES_FILE_BYTES: usize = 8 * 1024 * 1024; +/// Maximum JSON-escaped bytes of the content returned by one read-text-lines observation. +pub const MAX_READ_TEXT_LINES_CONTENT_BYTES: usize = 64 * 1024; +/// Maximum canonical JCS bytes emitted by one query tool output, including its digest. pub const MAX_QUERY_OUTPUT_CANONICAL_BYTES: usize = 128 * 1024; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -48,6 +55,7 @@ enum QueryOperation { ListDirectory, Stat, SearchText, + ReadTextLines, } impl QueryOperation { @@ -56,6 +64,7 @@ impl QueryOperation { Self::ListDirectory => LIST_DIRECTORY_CAPABILITY_ID, Self::Stat => STAT_CAPABILITY_ID, Self::SearchText => SEARCH_TEXT_CAPABILITY_ID, + Self::ReadTextLines => READ_TEXT_LINES_CAPABILITY_ID, } } @@ -64,6 +73,7 @@ impl QueryOperation { Self::ListDirectory => LIST_DIRECTORY_CONTRACT_VERSION, Self::Stat => STAT_CONTRACT_VERSION, Self::SearchText => SEARCH_TEXT_CONTRACT_VERSION, + Self::ReadTextLines => READ_TEXT_LINES_CONTRACT_VERSION, } } @@ -72,6 +82,7 @@ impl QueryOperation { Self::ListDirectory => "list-directory-output", Self::Stat => "stat-output", Self::SearchText => "search-text-output", + Self::ReadTextLines => "read-text-lines-output", } } @@ -80,6 +91,23 @@ impl QueryOperation { Self::ListDirectory => "xgeny.fs/list-directory/failure/v1", Self::Stat => "xgeny.fs/stat/failure/v1", Self::SearchText => "xgeny.fs/search-text/failure/v1", + Self::ReadTextLines => "xgeny.fs/read-text-lines/failure/v1", + } + } + + const fn argument_count(self) -> usize { + match self { + Self::ListDirectory | Self::Stat => 1, + Self::SearchText => 2, + Self::ReadTextLines => 3, + } + } + + /// Output fields including the appended digest. + const fn output_field_count(self) -> usize { + match self { + Self::ListDirectory | Self::Stat | Self::SearchText => 3, + Self::ReadTextLines => 6, } } } @@ -113,6 +141,13 @@ impl FilesystemQueryAdapter { } } + pub(crate) const fn read_text_lines(root: WorkspaceRoot) -> Self { + Self { + root, + operation: QueryOperation::ReadTextLines, + } + } + /// Construct a verifier pinned to the same workspace and exact operation binding. #[must_use] pub fn verifier(&self) -> FilesystemQueryVerifier { @@ -147,6 +182,7 @@ impl EffectAdapter for FilesystemQueryAdapter { directory: Arc::clone(&self.root.directory), relative_path: arguments.path, query: arguments.query, + lines: arguments.lines, operation: self.operation, })) } @@ -242,6 +278,7 @@ struct PreparedFilesystemQuery { directory: Arc, relative_path: RelativePath, query: Option, + lines: Option, operation: QueryOperation, } @@ -252,6 +289,7 @@ impl std::fmt::Debug for PreparedFilesystemQuery { .field("directory", &"") .field("relative_path", &"") .field("query", &self.query.as_ref().map(|_| "")) + .field("lines", &self.lines) .field("operation", &self.operation) .finish() } @@ -269,6 +307,12 @@ impl PreparedAdapterInvocation for PreparedFilesystemQuery { .as_deref() .expect("search preparation always retains a query"), ), + QueryOperation::ReadTextLines => read_text_lines( + &self.directory, + &self.relative_path, + self.lines + .expect("line-range preparation always retains a range"), + ), }; match observation.and_then(ObservedOutput::finish) { Ok(observation) => AdapterExecutionObservation::SucceededWithOutput { @@ -289,6 +333,14 @@ impl PreparedAdapterInvocation for PreparedFilesystemQuery { struct ParsedArguments { path: RelativePath, query: Option, + lines: Option, +} + +/// One-based first line and requested line count of a read-text-lines observation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct LineRange { + start_line: u64, + line_count: u64, } struct ObservedOutput { @@ -394,6 +446,7 @@ enum QueryFailure { TooManyEntries, Limit, Encode, + Decode, } impl QueryFailure { @@ -404,6 +457,7 @@ impl QueryFailure { Self::TooManyEntries => "too-many-entries", Self::Limit => "limit", Self::Encode => "encode", + Self::Decode => "decode", }; sha256_digest(format!("{}/{code}", operation.failure_domain()).as_bytes()) } @@ -414,6 +468,7 @@ fn binding_for(root: &WorkspaceRoot, operation: QueryOperation) -> xgen_domain:: QueryOperation::ListDirectory => root.list_directory_binding(), QueryOperation::Stat => root.stat_binding(), QueryOperation::SearchText => root.search_text_binding(), + QueryOperation::ReadTextLines => root.read_text_lines_binding(), } } @@ -491,14 +546,7 @@ fn parse_arguments( ) -> Result { let object = arguments .as_object() - .filter(|object| { - object.len() - == if operation == QueryOperation::SearchText { - 2 - } else { - 1 - } - }) + .filter(|object| object.len() == operation.argument_count()) .ok_or(AdapterPrepareFailure::InvalidMaterial)?; let path = object .get("path") @@ -521,7 +569,25 @@ fn parse_arguments( } else { None }; - Ok(ParsedArguments { path, query }) + let lines = if operation == QueryOperation::ReadTextLines { + let start_line = object + .get("startLine") + .and_then(Value::as_u64) + .filter(|line| *line >= 1) + .ok_or(AdapterPrepareFailure::InvalidMaterial)?; + let line_count = object + .get("lineCount") + .and_then(Value::as_u64) + .filter(|count| (1..=MAX_READ_TEXT_LINES_COUNT).contains(count)) + .ok_or(AdapterPrepareFailure::InvalidMaterial)?; + Some(LineRange { + start_line, + line_count, + }) + } else { + None + }; + Ok(ParsedArguments { path, query, lines }) } fn list_directory(root: &Dir, path: &RelativePath) -> Result { @@ -630,6 +696,93 @@ fn search_text( bounded_collection_output("matches", state.matches, state.truncated) } +fn read_text_lines( + root: &Dir, + path: &RelativePath, + range: LineRange, +) -> Result { + let mut file = open_regular_file(root, path)?; + let metadata = file.metadata().map_err(|_| QueryFailure::Open)?; + if metadata.len() > u64::try_from(MAX_READ_TEXT_LINES_FILE_BYTES).expect("limit fits u64") { + return Err(QueryFailure::Limit); + } + let mut bytes = Vec::with_capacity( + usize::try_from(metadata.len()) + .unwrap_or(MAX_READ_TEXT_LINES_FILE_BYTES) + .min(MAX_READ_TEXT_LINES_FILE_BYTES) + .saturating_add(1), + ); + file.by_ref() + .take(u64::try_from(MAX_READ_TEXT_LINES_FILE_BYTES + 1).expect("read limit fits u64")) + .read_to_end(&mut bytes) + .map_err(|_| QueryFailure::Read)?; + if bytes.len() > MAX_READ_TEXT_LINES_FILE_BYTES { + return Err(QueryFailure::Limit); + } + let content = String::from_utf8(bytes).map_err(|_| QueryFailure::Decode)?; + line_range_output(&content, range) +} + +/// Select whole lines, each with its original terminator, so consecutive ranges concatenate to +/// the file. Selection stops before the JSON-escaped content would exceed its byte limit; a first +/// line that alone exceeds it is cut at a character boundary. Either case sets `truncated`. +fn line_range_output(content: &str, range: LineRange) -> Result { + let total_lines = + u64::try_from(content.split_inclusive('\n').count()).map_err(|_| QueryFailure::Limit)?; + let skip = usize::try_from(range.start_line - 1).unwrap_or(usize::MAX); + let take = usize::try_from(range.line_count).map_err(|_| QueryFailure::Limit)?; + let mut selected = String::new(); + let mut selected_size = 0_usize; + let mut end_line = range.start_line - 1; + let mut truncated = false; + for line in content.split_inclusive('\n').skip(skip).take(take) { + let line_size = json_escaped_size(line); + if selected_size + line_size <= MAX_READ_TEXT_LINES_CONTENT_BYTES { + selected.push_str(line); + selected_size += line_size; + end_line += 1; + continue; + } + truncated = true; + if selected.is_empty() { + let mut cut = 0; + for (index, character) in line.char_indices() { + let size = json_escaped_char_size(character); + if selected_size + size > MAX_READ_TEXT_LINES_CONTENT_BYTES { + break; + } + selected_size += size; + cut = index + character.len_utf8(); + } + selected.push_str(&line[..cut]); + end_line += 1; + } + break; + } + Ok(ObservedOutput { + payload: json!({ + "content": selected, + "startLine": range.start_line, + "endLine": end_line, + "totalLines": total_lines, + "truncated": truncated, + }), + }) +} + +fn json_escaped_size(text: &str) -> usize { + text.chars().map(json_escaped_char_size).sum() +} + +/// Size of one character inside an RFC 8785 JSON string. +const fn json_escaped_char_size(character: char) -> usize { + match character { + '"' | '\\' | '\u{8}' | '\u{c}' | '\n' | '\r' | '\t' => 2, + '\u{0}'..='\u{1f}' => 6, + _ => character.len_utf8(), + } +} + fn search_match_key(candidate: &Value) -> (&str, u64, u64) { ( candidate["path"] @@ -878,7 +1031,7 @@ fn inspect_output( } let object = output .as_object() - .filter(|object| object.len() == 3) + .filter(|object| object.len() == operation.output_field_count()) .ok_or(VerificationPortFailure::ResponseUnverifiable)?; let claimed = object .get("digest") @@ -909,9 +1062,44 @@ fn validate_output_shape( QueryOperation::ListDirectory => validate_list_output(object, workspace_id), QueryOperation::Stat => validate_stat_output(object), QueryOperation::SearchText => validate_search_output(object, workspace_id), + QueryOperation::ReadTextLines => validate_lines_output(object), } } +fn validate_lines_output(object: &Map) -> Result<(), VerificationPortFailure> { + let content = object + .get("content") + .and_then(Value::as_str) + .filter(|content| json_escaped_size(content) <= MAX_READ_TEXT_LINES_CONTENT_BYTES) + .ok_or(VerificationPortFailure::ResponseUnverifiable)?; + let start_line = object + .get("startLine") + .and_then(Value::as_u64) + .filter(|line| *line >= 1) + .ok_or(VerificationPortFailure::ResponseUnverifiable)?; + let end_line = object + .get("endLine") + .and_then(Value::as_u64) + .ok_or(VerificationPortFailure::ResponseUnverifiable)?; + let total_lines = object + .get("totalLines") + .and_then(Value::as_u64) + .ok_or(VerificationPortFailure::ResponseUnverifiable)?; + object + .get("truncated") + .and_then(Value::as_bool) + .ok_or(VerificationPortFailure::ResponseUnverifiable)?; + let returned = u64::try_from(content.split_inclusive('\n').count()) + .map_err(|_| VerificationPortFailure::ResponseUnverifiable)?; + if returned > MAX_READ_TEXT_LINES_COUNT + || end_line.checked_add(1) != start_line.checked_add(returned) + || (returned > 0 && end_line > total_lines) + { + return Err(VerificationPortFailure::ResponseUnverifiable); + } + Ok(()) +} + fn validate_list_output( object: &Map, workspace_id: &crate::WorkspaceId, @@ -1273,6 +1461,254 @@ mod tests { assert!(state.scanned_bytes <= "second".len()); } + fn lines(start_line: u64, line_count: u64) -> LineRange { + LineRange { + start_line, + line_count, + } + } + + #[test] + fn line_ranges_keep_terminators_and_concatenate_to_the_file() { + let fixture = Fixture::new(); + let text = "one\r\ntwo\n\nfour\nfive"; + fs::write(fixture.directory.path().join("file.txt"), text).unwrap(); + let path = fixture.relative("file.txt"); + + let first = read_text_lines(&fixture.root.directory, &path, lines(1, 2)) + .unwrap() + .finish() + .unwrap() + .output; + let rest = read_text_lines(&fixture.root.directory, &path, lines(3, 10)) + .unwrap() + .finish() + .unwrap() + .output; + + assert_eq!(first["content"], "one\r\ntwo\n"); + assert_eq!(first["startLine"], 1); + assert_eq!(first["endLine"], 2); + assert_eq!(first["totalLines"], 5); + assert_eq!(first["truncated"], false); + assert_eq!(rest["content"], "\nfour\nfive"); + assert_eq!(rest["endLine"], 5); + assert_eq!(rest["truncated"], false); + assert_eq!( + format!( + "{}{}", + first["content"].as_str().unwrap(), + rest["content"].as_str().unwrap() + ), + text + ); + } + + #[test] + fn a_range_past_the_end_is_empty_and_reports_the_line_count() { + let fixture = Fixture::new(); + fs::write(fixture.directory.path().join("file.txt"), "a\nb\n").unwrap(); + fs::write(fixture.directory.path().join("empty.txt"), "").unwrap(); + + let past = read_text_lines( + &fixture.root.directory, + &fixture.relative("file.txt"), + lines(9, 5), + ) + .unwrap() + .finish() + .unwrap(); + let empty = read_text_lines( + &fixture.root.directory, + &fixture.relative("empty.txt"), + lines(1, 5), + ) + .unwrap() + .finish() + .unwrap() + .output; + + assert_eq!(past.output["content"], ""); + assert_eq!(past.output["endLine"], 8); + assert_eq!(past.output["totalLines"], 2); + assert!( + inspect_output( + &past.output, + &past.digest, + &fixture.root.workspace_id, + QueryOperation::ReadTextLines, + ) + .unwrap() + .postcondition + ); + assert_eq!(empty["content"], ""); + assert_eq!(empty["endLine"], 0); + assert_eq!(empty["totalLines"], 0); + } + + #[test] + fn line_ranges_stop_at_the_escaped_content_limit() { + let fixture = Fixture::new(); + // Each line escapes to 1_024 bytes: 1_020 plain characters, a quote, and a newline. + let line = format!("{}\"\n", "x".repeat(1_020)); + fs::write(fixture.directory.path().join("wide.txt"), line.repeat(100)).unwrap(); + let control_line = "\u{1}".repeat(MAX_READ_TEXT_LINES_CONTENT_BYTES); + fs::write( + fixture.directory.path().join("long.txt"), + format!("{control_line}\nnext\n"), + ) + .unwrap(); + + let wide = read_text_lines( + &fixture.root.directory, + &fixture.relative("wide.txt"), + lines(1, 100), + ) + .unwrap() + .finish() + .unwrap(); + let long = read_text_lines( + &fixture.root.directory, + &fixture.relative("long.txt"), + lines(1, 2), + ) + .unwrap() + .finish() + .unwrap(); + + assert_eq!(wide.output["endLine"], 64); + assert_eq!(wide.output["totalLines"], 100); + assert_eq!(wide.output["truncated"], true); + assert_eq!(long.output["endLine"], 1); + assert_eq!(long.output["truncated"], true); + assert_eq!( + long.output["content"].as_str().unwrap().chars().count(), + MAX_READ_TEXT_LINES_CONTENT_BYTES / 6 + ); + for finished in [&wide, &long] { + assert!( + serde_jcs::to_vec(&finished.output).unwrap().len() + <= MAX_QUERY_OUTPUT_CANONICAL_BYTES + ); + assert!( + inspect_output( + &finished.output, + &finished.digest, + &fixture.root.workspace_id, + QueryOperation::ReadTextLines, + ) + .unwrap() + .postcondition + ); + } + } + + #[test] + fn line_ranges_read_files_past_the_whole_file_limit_but_fail_closed_beyond_the_scan_limit() { + let fixture = Fixture::new(); + let large = "line\n".repeat(20_000); + fs::write(fixture.directory.path().join("large.txt"), &large).unwrap(); + fs::write( + fixture.directory.path().join("huge.txt"), + vec![b'x'; MAX_READ_TEXT_LINES_FILE_BYTES + 1], + ) + .unwrap(); + fs::write(fixture.directory.path().join("binary.bin"), [0xff, 0xfe]).unwrap(); + + let tail = read_text_lines( + &fixture.root.directory, + &fixture.relative("large.txt"), + lines(19_999, 5), + ) + .unwrap() + .finish() + .unwrap() + .output; + + assert!(large.len() > crate::MAX_READ_TEXT_BYTES); + assert_eq!(tail["content"], "line\nline\n"); + assert_eq!(tail["totalLines"], 20_000); + assert!(matches!( + read_text_lines( + &fixture.root.directory, + &fixture.relative("huge.txt"), + lines(1, 1) + ), + Err(QueryFailure::Limit) + )); + assert!(matches!( + read_text_lines( + &fixture.root.directory, + &fixture.relative("binary.bin"), + lines(1, 1) + ), + Err(QueryFailure::Decode) + )); + assert!(matches!( + read_text_lines(&fixture.root.directory, &fixture.relative("."), lines(1, 1)), + Err(QueryFailure::Open) + )); + } + + #[test] + fn line_range_parser_requires_a_bounded_one_based_range() { + let fixture = Fixture::new(); + let parsed = parse_arguments( + &json!({"path": "workspace:fixture/a.txt", "startLine": 3, "lineCount": 2000}), + &fixture.root, + QueryOperation::ReadTextLines, + ) + .unwrap(); + assert_eq!(parsed.lines, Some(lines(3, 2_000))); + for arguments in [ + json!({"path": "workspace:fixture/a.txt"}), + json!({"path": "workspace:fixture/a.txt", "startLine": 0, "lineCount": 1}), + json!({"path": "workspace:fixture/a.txt", "startLine": 1, "lineCount": 0}), + json!({"path": "workspace:fixture/a.txt", "startLine": 1, "lineCount": 2001}), + json!({"path": "workspace:fixture/a.txt", "startLine": 1.5, "lineCount": 1}), + json!({"path": "workspace:fixture/a.txt", "startLine": 1, "lineCount": 1, "x": 1}), + ] { + assert_eq!( + parse_arguments(&arguments, &fixture.root, QueryOperation::ReadTextLines) + .map(|_| ()), + Err(AdapterPrepareFailure::InvalidMaterial) + ); + } + } + + #[test] + fn line_range_inspection_rejects_inconsistent_line_numbers() { + let fixture = Fixture::new(); + fs::write(fixture.directory.path().join("file.txt"), "a\nb\nc\n").unwrap(); + let finished = read_text_lines( + &fixture.root.directory, + &fixture.relative("file.txt"), + lines(2, 1), + ) + .unwrap() + .finish() + .unwrap(); + for (field, value) in [ + ("endLine", json!(3)), + ("startLine", json!(0)), + ("totalLines", json!(1)), + ("content", json!("b\nc\n")), + ] { + let mut tampered = finished.output.clone(); + tampered[field] = value; + assert!( + inspect_output( + &tampered, + &finished.digest, + &fixture.root.workspace_id, + QueryOperation::ReadTextLines, + ) + .is_err(), + "{field} tampering must fail closed" + ); + } + } + #[cfg(unix)] #[test] fn queries_never_follow_workspace_symlinks() { @@ -1293,6 +1729,14 @@ mod tests { ) .is_err() ); + assert!( + read_text_lines( + &fixture.root.directory, + &fixture.relative("linked/secret.txt"), + lines(1, 1) + ) + .is_err() + ); let root = list_directory(&fixture.root.directory, &fixture.relative(".")) .unwrap() .finish() diff --git a/crates/xgen-cli/src/allow_path.rs b/crates/xgen-cli/src/allow_path.rs index 8510dca..5156773 100644 --- a/crates/xgen-cli/src/allow_path.rs +++ b/crates/xgen-cli/src/allow_path.rs @@ -8,8 +8,9 @@ use thiserror::Error; use xgen_adapter_filesystem::{ APPLY_PATCH_CAPABILITY_ID, APPLY_PATCH_CONTRACT_VERSION, FILESYSTEM_READ_SCOPE, LIST_DIRECTORY_CAPABILITY_ID, LIST_DIRECTORY_CONTRACT_VERSION, MAX_APPLY_PATCH_BYTES, - MAX_APPLY_PATCH_EDITS, MAX_SEARCH_QUERY_BYTES, MAX_SEARCH_QUERY_UNICODE_SCALARS, - MAX_WRITE_ATOMIC_BYTES, READ_TEXT_CAPABILITY_ID, READ_TEXT_CONTRACT_VERSION, + MAX_APPLY_PATCH_EDITS, MAX_READ_TEXT_LINES_COUNT, MAX_SEARCH_QUERY_BYTES, + MAX_SEARCH_QUERY_UNICODE_SCALARS, MAX_WRITE_ATOMIC_BYTES, READ_TEXT_CAPABILITY_ID, + READ_TEXT_CONTRACT_VERSION, READ_TEXT_LINES_CAPABILITY_ID, READ_TEXT_LINES_CONTRACT_VERSION, SEARCH_TEXT_CAPABILITY_ID, SEARCH_TEXT_CONTRACT_VERSION, STAT_CAPABILITY_ID, STAT_CONTRACT_VERSION, WRITE_ATOMIC_CAPABILITY_ID, WRITE_ATOMIC_CONTRACT_VERSION, WorkspaceResourceResolver, @@ -102,6 +103,18 @@ impl WorkspaceReadAuthorization { | (STAT_CAPABILITY_ID, STAT_CONTRACT_VERSION) => { object.len() == 1 && self.authorizes_file_or_descendant(path) } + (READ_TEXT_LINES_CAPABILITY_ID, READ_TEXT_LINES_CONTRACT_VERSION) => { + object.len() == 3 + && object + .get("startLine") + .and_then(Value::as_u64) + .is_some_and(|line| line >= 1) + && object + .get("lineCount") + .and_then(Value::as_u64) + .is_some_and(|count| (1..=MAX_READ_TEXT_LINES_COUNT).contains(&count)) + && self.authorizes_file_or_descendant(path) + } (LIST_DIRECTORY_CAPABILITY_ID, LIST_DIRECTORY_CONTRACT_VERSION) => { object.len() == 1 && self.authorizes_directory_or_descendant(path) } @@ -137,6 +150,7 @@ impl WorkspaceReadAuthorization { capability.contract_version.as_str(), ) { (READ_TEXT_CAPABILITY_ID, READ_TEXT_CONTRACT_VERSION) + | (READ_TEXT_LINES_CAPABILITY_ID, READ_TEXT_LINES_CONTRACT_VERSION) | (STAT_CAPABILITY_ID, STAT_CONTRACT_VERSION) => { self.authorizes_file_or_descendant(resource) } @@ -438,6 +452,27 @@ mod tests { ); } + #[test] + fn line_range_reads_follow_file_scope_and_a_bounded_range() { + let catalog = authorization(&["big.py"], &["src"]); + let lines = capability(READ_TEXT_LINES_CAPABILITY_ID); + for path in ["workspace:fixture/big.py", "workspace:fixture/src/lib.rs"] { + assert!(catalog.authorizes_material( + &lines, + &json!({"path": path, "startLine": 1, "lineCount": 2000}), + )); + } + assert!(catalog.authorizes_resource(&lines, "workspace:fixture/big.py")); + for arguments in [ + json!({"path": "workspace:fixture/other.py", "startLine": 1, "lineCount": 10}), + json!({"path": "workspace:fixture/big.py", "startLine": 0, "lineCount": 10}), + json!({"path": "workspace:fixture/big.py", "startLine": 1, "lineCount": 2001}), + json!({"path": "workspace:fixture/big.py", "startLine": 1}), + ] { + assert!(!catalog.authorizes_material(&lines, &arguments)); + } + } + #[test] fn write_material_is_bounded_and_requires_a_directory_scope() { let catalog = authorization(&["README.md"], &["src"]); diff --git a/crates/xgen-cli/src/composition.rs b/crates/xgen-cli/src/composition.rs index 4f0d076..39fa87a 100644 --- a/crates/xgen-cli/src/composition.rs +++ b/crates/xgen-cli/src/composition.rs @@ -11,10 +11,12 @@ use xgen_adapter_filesystem::{ APPLY_PATCH_CAPABILITY_ID, APPLY_PATCH_CONTRACT_VERSION, FILESYSTEM_READ_SCOPE, FILESYSTEM_WRITE_SCOPE, LIST_DIRECTORY_CAPABILITY_ID, LIST_DIRECTORY_CONTRACT_VERSION, MAX_APPLY_PATCH_BYTES, MAX_APPLY_PATCH_EDITS, MAX_DIRECTORY_SCAN_ENTRIES, - MAX_LIST_DIRECTORY_ENTRIES, MAX_QUERY_OUTPUT_CANONICAL_BYTES, MAX_SEARCH_FILE_BYTES, - MAX_SEARCH_MATCHES, MAX_SEARCH_PREVIEW_BYTES, MAX_SEARCH_QUERY_BYTES, + MAX_LIST_DIRECTORY_ENTRIES, MAX_QUERY_OUTPUT_CANONICAL_BYTES, + MAX_READ_TEXT_LINES_CONTENT_BYTES, MAX_READ_TEXT_LINES_COUNT, MAX_READ_TEXT_LINES_FILE_BYTES, + MAX_SEARCH_FILE_BYTES, MAX_SEARCH_MATCHES, MAX_SEARCH_PREVIEW_BYTES, MAX_SEARCH_QUERY_BYTES, MAX_SEARCH_QUERY_UNICODE_SCALARS, MAX_SEARCH_TOTAL_BYTES, MAX_SEARCH_VISITED_ENTRIES, - MAX_WRITE_ATOMIC_BYTES, READ_TEXT_CAPABILITY_ID, READ_TEXT_CONTRACT_VERSION, ReadTextLimits, + MAX_WRITE_ATOMIC_BYTES, READ_TEXT_CAPABILITY_ID, READ_TEXT_CONTRACT_VERSION, + READ_TEXT_LINES_CAPABILITY_ID, READ_TEXT_LINES_CONTRACT_VERSION, ReadTextLimits, SEARCH_TEXT_CAPABILITY_ID, SEARCH_TEXT_CONTRACT_VERSION, STAT_CAPABILITY_ID, STAT_CONTRACT_VERSION, WRITE_ATOMIC_CAPABILITY_ID, WRITE_ATOMIC_CONTRACT_VERSION, WorkspaceId, WorkspaceResourceResolver, WorkspaceRoot, @@ -2441,6 +2443,9 @@ struct DiscoveryLimitsDescriptor { search_file_bytes: usize, search_total_bytes: usize, search_preview_bytes: usize, + read_text_lines_count: u64, + read_text_lines_file_bytes: usize, + read_text_lines_content_bytes: usize, query_output_canonical_bytes: usize, } @@ -2616,6 +2621,9 @@ fn workspace_discovery_profile_digest(workspace: &WorkspaceRoot) -> Result 64 * 1024); + fs::write(workspace.join("large.py"), &large).expect("large file should write"); + let server = SequentialServer::spawn_responses(vec![ + plan_response( + "read_tail", + "Read the definition at the end of the large file", + "xgeny.fs/read-text-lines", + &json!({"path": "large.py", "startLine": 3_001, "lineCount": 50}), + ), + completion_response(), + ]); + + let output = bounded_output(xgen(&state_root).args([ + "run", + "--workspace", + path_text(&workspace), + "--base-url", + &server.base_url, + "--model", + MODEL, + "--tokenizer", + TOKENIZER, + "--allow-dir", + ".", + "--allow-read", + "--allow-remote-model-egress", + "What does target return?", + ])) + .expect("xgen run should finish"); + assert_eq!(output.status.code(), Some(0), "{}", stderr(&output)); + + let requests = (0..2) + .map(|_| { + server + .requests + .recv_timeout(TEST_TIMEOUT) + .expect("every model request should arrive") + }) + .collect::>(); + server.handle.join().expect("provider server should finish"); + + let first = planning_context(&requests[0]); + let lines = capability(&first, "xgeny.fs/read-text-lines"); + assert_eq!(lines["effectClass"], "read_only"); + let after_read = planning_context(&requests[1]); + let observed = tool_output(&after_read, "xgeny.fs/read-text-lines"); + assert_eq!(observed["content"], "def target():\n return 42\n"); + assert_eq!(observed["startLine"], 3_001); + assert_eq!(observed["endLine"], 3_002); + assert_eq!(observed["totalLines"], 3_002); + assert_eq!(observed["truncated"], false); +} + #[test] fn failed_write_still_stops_the_run() { let fixture = tempdir().expect("test directory should exist"); @@ -1069,12 +1130,13 @@ fn public_cli_discovers_searches_stats_reads_and_replays_offline() { assert!(first_system_prompt.contains("always set dependsOn to an empty array")); assert!(first_system_prompt.contains("durable plan chronology")); assert!(first_system_prompt.contains("durable receipt-completion chronology")); - assert_eq!(first["capabilities"].as_array().unwrap().len(), 6); + assert_eq!(first["capabilities"].as_array().unwrap().len(), 7); assert_eq!(first["toolOutputs"], json!([])); assert!(capability_ids(&first).contains(&"xgeny.fs/list-directory")); assert!(capability_ids(&first).contains(&"xgeny.fs/search-text")); assert!(capability_ids(&first).contains(&"xgeny.fs/stat")); assert!(capability_ids(&first).contains(&"xgeny.fs/read-text")); + assert!(capability_ids(&first).contains(&"xgeny.fs/read-text-lines")); assert!(capability_ids(&first).contains(&"xgeny.fs/write-atomic")); assert!(capability_ids(&first).contains(&"xgeny.fs/apply-patch")); let path_description = capability(&first, "xgeny.fs/list-directory")["inputSchema"] diff --git a/crates/xgen-protocol/src/assets.rs b/crates/xgen-protocol/src/assets.rs index 16677a6..44a7fbc 100644 --- a/crates/xgen-protocol/src/assets.rs +++ b/crates/xgen-protocol/src/assets.rs @@ -67,6 +67,10 @@ pub(crate) const FIXTURES: &[Asset] = &[ "valid/capability-definition.fs-read-text.json", "../../../protocol/fixtures/v1alpha1/valid/capability-definition.fs-read-text.json" ), + asset!( + "valid/capability-definition.fs-read-text-lines.json", + "../../../protocol/fixtures/v1alpha1/valid/capability-definition.fs-read-text-lines.json" + ), asset!( "valid/capability-definition.fs-search-text.json", "../../../protocol/fixtures/v1alpha1/valid/capability-definition.fs-search-text.json" diff --git a/crates/xgen-protocol/src/lib.rs b/crates/xgen-protocol/src/lib.rs index 09f63f9..60d75fd 100644 --- a/crates/xgen-protocol/src/lib.rs +++ b/crates/xgen-protocol/src/lib.rs @@ -850,8 +850,8 @@ mod tests { fn bundled_protocol_is_conformant() { let report = check_bundled_protocol().expect("bundled protocol should be conformant"); assert_eq!(report.schema_count, 9); - assert_eq!(report.fixture_count, 33); - assert_eq!(report.valid_fixture_count, 23); + assert_eq!(report.fixture_count, 34); + assert_eq!(report.valid_fixture_count, 24); assert_eq!(report.invalid_fixture_count, 10); assert!(report.semantic_check_count >= 10); } diff --git a/docs/adr/0022-capability-confined-filesystem-read-adapter.md b/docs/adr/0022-capability-confined-filesystem-read-adapter.md index 898bdf8..15162cf 100644 --- a/docs/adr/0022-capability-confined-filesystem-read-adapter.md +++ b/docs/adr/0022-capability-confined-filesystem-read-adapter.md @@ -143,7 +143,7 @@ read가 bounded이며 한 byte 초과하면 실패한다. 그 뒤 `String::from_ 머물도록 선택했다. PR3의 MVP composition 회귀는 effective Run budget도 512 KiB로 설정하고 그 동일한 budget과 비교한다. 더 작은 context budget을 선택한 host에서는 유효한 큰 read가 실행된 뒤 다음 planning turn 직전에 `ContextBudgetExceeded`로 pause할 수 있다. 큰 파일은 상한을 높이지 않고 -후속 range/chunk capability로 다룬다. +후속 range/chunk capability로 다룬다(줄 범위 읽기는 [ADR-0053](0053-bounded-line-range-text-read.md)). 성공 output은 기존 public Definition과 정확히 같다. @@ -221,6 +221,7 @@ Linux, macOS와 Windows의 `cargo test --workspace --locked`가 동일 gate를 - hostile same-UID process, kernel/driver와 untrusted in-process plugin 격리 - concurrent in-place write 중 coherent/atomic file snapshot - filesystem timeout, cancellation, streaming, binary, range/chunk read + (UTF-8 줄 범위 읽기는 [ADR-0053](0053-bounded-line-range-text-read.md)가 다룬다) - case/Unicode alias의 stable physical file identity - ReadOnly crash 뒤 자동 retry와 tool failure를 다음 model turn에 전달하는 typed error output (확정된 read-only 실패 뒤 계획을 계속하는 것은 [ADR-0052](0052-recoverable-read-only-step-failures.md)가 다룬다. 실패 원인 분류 전달은 여전히 범위 밖이다) diff --git a/docs/adr/0052-recoverable-read-only-step-failures.md b/docs/adr/0052-recoverable-read-only-step-failures.md index 2dbb066..85dce1b 100644 --- a/docs/adr/0052-recoverable-read-only-step-failures.md +++ b/docs/adr/0052-recoverable-read-only-step-failures.md @@ -60,8 +60,8 @@ reducer 조건이 넓어지기만 하므로 기존 journal은 새 binary에서 (ADR-0015). - 실패 원인 분류(too-large, not-found 등)는 아직 모델에 전달하지 않는다. 지금 adapter는 고정 분류의 digest만 남긴다. typed failure output은 별도 결정이 필요하다. -- 64 KiB를 넘는 파일을 읽는 방법은 여전히 없다. ADR-0022가 예정한 range/chunk capability는 후속 - 작업이다. +- 64 KiB를 넘는 파일을 읽는 방법은 이 ADR에 없다. 줄 범위 읽기는 + [ADR-0053](0053-bounded-line-range-text-read.md)이 추가한다. ## 검증 diff --git a/docs/adr/0053-bounded-line-range-text-read.md b/docs/adr/0053-bounded-line-range-text-read.md new file mode 100644 index 0000000..32d0bc6 --- /dev/null +++ b/docs/adr/0053-bounded-line-range-text-read.md @@ -0,0 +1,85 @@ +# ADR-0053: 큰 UTF-8 파일의 줄 범위 읽기 + +- 상태: Accepted +- 기준일: 2026-10-25 +- 변경 분류: 범용 filesystem capability 추가. 특정 언어·파일 형식·업무 규칙 없음. +- 기존 경계: ADR-0022, ADR-0024, ADR-0052 +- protocol 변경: 새 CapabilityDefinition fixture 하나. 기존 Definition·schema·journal·SQLite schema는 그대로다. +- planner prompt 변경: 없음. 새 capability는 planning context의 capability 목록으로만 전달된다. + +## 문제 + +`xgeny.fs/read-text`는 64 KiB를 넘는 파일을 거절한다(ADR-0022). ADR-0022는 상한을 높이지 않고 후속 +range/chunk capability로 다루기로 했다. 실사용 시나리오에서 75 KB Python 파일 하나가 이 상한에 +걸렸다. ADR-0052 이후 요청은 더 이상 `failed_work`로 끝나지 않지만, 모델은 그 파일을 읽을 방법이 +없어 같은 실패 읽기와 검색을 반복하다 턴 예산을 소진했다. 소스 코드, 로그, 생성된 문서처럼 64 KiB를 +넘는 텍스트 파일은 흔하다. + +## 결정 + +### 1. `xgeny.fs/read-text-lines@1.0.0`를 추가한다 + +입력은 `{path, startLine, lineCount}`다. `startLine`은 1부터 세고, `lineCount`는 1–2,000이다. 추가 +필드는 거절한다. `--allow-dir`·`--allow-file` 범위는 `read-text`와 같은 규칙(exact file 또는 허용 +directory의 하위)으로 검사한다. effect class는 `read_only`이고 `--allow-read` 승인 아래 실행된다. + +출력은 `{content, startLine, endLine, totalLines, truncated, digest}`다. + +- 줄은 `\n` 기준으로 나누고, 각 줄은 원래 종결자(`\n` 또는 `\r\n`)를 그대로 포함한다. 이어지는 범위를 + 붙이면 원래 파일과 바이트 단위로 같다. 마지막 줄 뒤의 종결자는 빈 줄을 만들지 않는다. +- `endLine`은 마지막으로 반환한 줄이다. 줄을 하나도 반환하지 않으면(범위가 파일 끝을 넘는 경우) + `startLine - 1`이다. `totalLines`로 모델이 다음 범위를 고른다. +- `digest`는 다른 workspace 조회(list-directory·stat·search-text)와 같이 digest를 뺀 출력의 RFC 8785 + canonical 바이트에 대한 SHA-256이다. 파일 내용 digest가 아니므로 `apply-patch`의 `expectedDigest`로 + 쓸 수 없다. + +### 2. 크기 상한 + +- 파일은 8 MiB까지 읽는다. 더 크면 확정 실패다. search-text의 전체 스캔 상한과 같은 값이다. +- 파일 전체가 UTF-8이어야 한다. 아니면 확정 실패다. +- 반환하는 `content`는 JSON escaping 뒤 64 KiB 이하다. raw 바이트가 아니라 escape된 크기로 세므로 + control 문자가 많은 텍스트에서도 출력 하나가 ADR-0022가 계산한 PlanningContext 여유 안에 머문다. +- 상한에 닿으면 그 앞의 온전한 줄까지만 반환하고 `truncated: true`다. 요청 범위의 첫 줄 하나만으로 + 상한을 넘으면 그 줄을 문자 경계에서 잘라 반환하고 역시 `truncated: true`다. 이때 그 줄의 나머지는 + 이 capability로 읽을 수 없다. minified 파일처럼 한 줄이 64 KiB를 넘는 경우의 한계로 남긴다. + +### 3. 실행 의미 + +경로는 `read-text`·search-text와 같이 symlink를 따르지 않는다. 실패는 기존 조회 adapter와 같은 고정 +분류 digest(`open`, `read`, `limit`, `decode`, `encode`)만 남긴다. 실패는 ADR-0052에 따라 recoverable +read failure가 되고, 모델에 원인 분류는 전달되지 않는다. verifier는 digest와 함께 줄 번호 관계 +(`endLine + 1 = startLine + 반환 줄 수`, `endLine <= totalLines`)와 content 상한을 다시 검사한다. + +## 대안 + +- **read-text 상한 상향:** 출력 하나가 PlanningContext 상한을 위협한다(ADR-0022). 거절했다. +- **byte offset 범위:** UTF-8 경계와 줄 경계를 모델이 맞춰야 한다. search-text가 줄 번호를 주므로 + 줄 범위가 바로 이어 쓰기 좋다. +- **read-text에 선택 필드 추가:** 기존 Definition은 immutable 계약이고 digest가 저장된 Run에 묶여 있다. + 새 contract로 분리했다. + +## 결과 + +- 64 KiB를 넘고 8 MiB 이하인 UTF-8 파일을 범위로 나눠 읽을 수 있다. +- workspace discovery mode의 capability가 6개에서 7개가 된다. workspace discovery profile digest가 + 바뀌므로 이 변경 이전 binary로 시작해 멈춘 workspace Run은 새 binary에서 `ConfigurationMismatch`로 + 재개가 거절된다. 완료된 Run의 offline replay는 영향이 없다. +- 큰 파일 수정은 여전히 불가능하다. `apply-patch`·`write-atomic`도 64 KiB 상한이다. 별도 결정이 필요하다. +- 같은 파일을 여러 범위로 읽으면 tool output이 planning context에 쌓인다. context budget 소진은 기존 + `ContextBudgetExceeded` 경로를 탄다. + +## 검증 + +- adapter: 범위 연결이 원본과 같음, CRLF 보존, 파일 끝을 넘는 범위, 빈 파일, escape 기준 상한 절단, + 첫 줄 절단, 8 MiB 초과·비 UTF-8·directory 거절, symlink 비추적, 입력 shape 거절, 출력 변조 거절. +- 권한: exact file과 허용 directory 하위만 승인하고 범위 밖 인자를 거절한다. +- e2e: 64 KiB를 넘는 파일의 끝 범위를 public `xgen run`으로 읽고 다음 planning context에서 출력을 본다. +- 실사용(대화형 `xgen`, 같은 planner model, 2026-10-25): + - 설계 사례: 75 KB·2,004줄 `server.py`가 있는 Python 저장소에서 README의 도구 목록 대조. 이전에는 + 턴 예산 소진으로 끝났다. 이번에는 1–1,655줄(content 상한으로 `truncated`)과 1,656–2,004줄을 이어 + 읽고 18 Step·51초에 완료했다. 다만 모델의 README 수정(중복 행 추가)과 완료 요약이 실제 변경과 + 달랐다. capability가 아니라 planner 출력 품질 문제로 따로 다룬다. + - 검증 사례(설계에 쓰지 않은 저장소): 113 KB·3,296줄 HTTP executor의 기본 timeout과 HTTP 오류 처리 + 설명. `read-text`가 두 번 실패한 뒤 search-text·stat으로 위치를 찾고 100–200줄, 360–479줄을 읽어 + 6 Step·11초에 줄 번호까지 정확히 답했다. + - 두 사례 모두 같은 실패 읽기를 반복했다. 실패 원인 분류를 모델에 전달하지 않는 한계(ADR-0052)다. diff --git a/docs/development/public-local-run-resume.md b/docs/development/public-local-run-resume.md index f708102..f05be33 100644 --- a/docs/development/public-local-run-resume.md +++ b/docs/development/public-local-run-resume.md @@ -3,7 +3,7 @@ 이 문서는 ADR-0023과 ADR-0024 구현을 개발·검증하는 최소 운영 절차다. ADR-0025의 mutation 명령과 별도 `--allow-write` 재개 절차는 [Workspace atomic write](workspace-atomic-write.md)를 따른다. 기존 exact-file mode는 OpenAI-compatible planner와 workspace `read-text` 하나를 유지한다. Source/main의 opt-in -workspace mode는 `list-directory`, `stat`, `search-text`, `read-text`, `write-atomic`, `apply-patch`를 +workspace mode는 `list-directory`, `stat`, `search-text`, `read-text`, `read-text-lines`, `write-atomic`, `apply-patch`를 제공한다. 명시적 executable catalog와 별도 실행 승인은 [Process execute adapter](process-execute-adapter.md)를 따른다. diff --git a/docs/development/rust-workspace.md b/docs/development/rust-workspace.md index 8b7754a..afc3b60 100644 --- a/docs/development/rust-workspace.md +++ b/docs/development/rust-workspace.md @@ -21,7 +21,7 @@ crates/ xgen-cli/ protocol check와 public local run/resume를 제공하는 실행 파일·driver library ``` -`xgen-workgraph`, `xgen-local-store`, `xgen-runtime`의 durable effect, invocation material, Direct Executor, Core verification, VerifiedRunIndex, Persistent dependency frontier, bounded AgentLoop와 model-call lifecycle 계약은 ADR-0008·0010·0011·0012·0013·0014·0015·0016 연구 gate를 위한 내부 실험이다. ADR-0018은 내부 ReadOnly profile, Artifact-bearing Core Receipt v2와 기존 구성요소를 조합하는 bounded CLI library driver를 추가했다. ADR-0019는 event-anchored `ToolOutputRecord`와 SQLite physical schema 7을 추가했고, ADR-0020·0021은 generation-checked planning context와 durable completion을 schema 8에 연결했다. Registry와 Router는 기존 Definition/Instance를 사용한다. `xgen-policy`의 Allow는 provisional이며 runtime Admission이 exact invocation의 one-shot authority를 Run/Step/action/Instance/material에 결합한다. `xgen-adapter-reference`는 비제품 conformance 기준이다. 제품 `xgen-adapter-filesystem`은 exact-file `read-text`와 opt-in directory `list-directory`·`stat`·`search-text`·`write-atomic`·`apply-patch`를 제공한다. `xgen-adapter-process`는 host-catalogued executable을 shell 없이 bounded 실행한다. Public CLI는 기존 `--allow-file` profile을 보존하면서 `--allow-dir` discovery, 별도 `--allow-write` atomic mutation과 별도 `--allow-execute` process 승인을 제공한다. Bare `xgen` REPL은 이 composition을 재사용해 interactive 승인, durable progress와 재개를 제공한다. Network adapter, MCP, Connector와 XGEN 원격 연동은 아직 구현하지 않는다. +`xgen-workgraph`, `xgen-local-store`, `xgen-runtime`의 durable effect, invocation material, Direct Executor, Core verification, VerifiedRunIndex, Persistent dependency frontier, bounded AgentLoop와 model-call lifecycle 계약은 ADR-0008·0010·0011·0012·0013·0014·0015·0016 연구 gate를 위한 내부 실험이다. ADR-0018은 내부 ReadOnly profile, Artifact-bearing Core Receipt v2와 기존 구성요소를 조합하는 bounded CLI library driver를 추가했다. ADR-0019는 event-anchored `ToolOutputRecord`와 SQLite physical schema 7을 추가했고, ADR-0020·0021은 generation-checked planning context와 durable completion을 schema 8에 연결했다. Registry와 Router는 기존 Definition/Instance를 사용한다. `xgen-policy`의 Allow는 provisional이며 runtime Admission이 exact invocation의 one-shot authority를 Run/Step/action/Instance/material에 결합한다. `xgen-adapter-reference`는 비제품 conformance 기준이다. 제품 `xgen-adapter-filesystem`은 exact-file `read-text`와 opt-in directory `list-directory`·`stat`·`search-text`·`read-text-lines`·`write-atomic`·`apply-patch`를 제공한다. `xgen-adapter-process`는 host-catalogued executable을 shell 없이 bounded 실행한다. Public CLI는 기존 `--allow-file` profile을 보존하면서 `--allow-dir` discovery, 별도 `--allow-write` atomic mutation과 별도 `--allow-execute` process 승인을 제공한다. Bare `xgen` REPL은 이 composition을 재사용해 interactive 승인, durable progress와 재개를 제공한다. Network adapter, MCP, Connector와 XGEN 원격 연동은 아직 구현하지 않는다. ## 준비물 diff --git a/docs/development/workspace-filesystem-discovery.md b/docs/development/workspace-filesystem-discovery.md index bfc5d3a..c2daa7f 100644 --- a/docs/development/workspace-filesystem-discovery.md +++ b/docs/development/workspace-filesystem-discovery.md @@ -28,7 +28,7 @@ xgen run \ '프로젝트 구조를 확인하고 관련 구현을 찾아 설명해줘.' ``` -`--allow-dir .`은 선택한 workspace root 전체를 네 read-only Capability로 관찰할 후보 경계를 제공한다. +`--allow-dir .`은 선택한 workspace root 전체를 다섯 read-only Capability로 관찰할 후보 경계를 제공한다. 같은 mode에서 `write-atomic`과 `apply-patch`도 광고되지만 실제 mutation은 별도 `--allow-write`가 있어야 한다. Planner는 immutable input schema에서 `.` 규칙을 받고, 별도 `planningConstraints`에서 caller가 허용한 directory/file 목록을 받는다. Constrained provider prompt는 이를 후보 제한으로 따르되 권한으로 @@ -42,6 +42,7 @@ directory/file 목록을 받는다. Constrained provider prompt는 이를 후보 | `xgeny.fs/stat@1.0.0` | regular file size 또는 directory kind | | `xgeny.fs/search-text@1.0.0` | directory 아래 case-sensitive literal UTF-8 검색 | | `xgeny.fs/read-text@1.0.0` | bounded UTF-8 file content 읽기 | +| `xgeny.fs/read-text-lines@1.0.0` | 8 MiB 이하 UTF-8 file의 줄 범위 읽기 ([ADR-0053](../adr/0053-bounded-line-range-text-read.md)) | | `xgeny.fs/write-atomic@1.0.0` | 별도 승인 아래 UTF-8 file create/atomic replace | | `xgeny.fs/apply-patch@1.0.0` | 별도 승인 아래 기존 UTF-8 file의 strict contextual edit | diff --git a/docs/getting-started.md b/docs/getting-started.md index 228dc72..348a3d2 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -327,7 +327,7 @@ xgen run \ ``` 이 mode는 `list-directory`, `stat`, case-sensitive literal `search-text`, bounded UTF-8 -`read-text`, `write-atomic`, `apply-patch`를 제공한다. `--allow-write`는 read/model egress와 독립된 동의다. 더 좁게 +`read-text`, 큰 파일용 줄 범위 `read-text-lines`, `write-atomic`, `apply-patch`를 제공한다. `--allow-write`는 read/model egress와 독립된 동의다. 더 좁게 허용하려면 `--allow-dir src --allow-dir tests`처럼 반복하고, directory 밖의 exact file만 `--allow-file Cargo.toml`로 추가한다. 미완료 재개에는 원래와 동일한 `--allow-dir`/`--allow-file` 집합을 다시 제공해야 한다. diff --git a/protocol/fixtures/v1alpha1/manifest.json b/protocol/fixtures/v1alpha1/manifest.json index 8ff750f..0dc3054 100644 --- a/protocol/fixtures/v1alpha1/manifest.json +++ b/protocol/fixtures/v1alpha1/manifest.json @@ -5,6 +5,7 @@ {"file": "valid/capability-definition.fs-list-directory.json", "schema": "capability-definition.schema.json", "expectedValid": true}, {"file": "valid/capability-definition.fs-stat.json", "schema": "capability-definition.schema.json", "expectedValid": true}, {"file": "valid/capability-definition.fs-search-text.json", "schema": "capability-definition.schema.json", "expectedValid": true}, + {"file": "valid/capability-definition.fs-read-text-lines.json", "schema": "capability-definition.schema.json", "expectedValid": true}, {"file": "valid/capability-definition.fs-apply-patch.json", "schema": "capability-definition.schema.json", "expectedValid": true}, {"file": "valid/capability-definition.fs-write-atomic.json", "schema": "capability-definition.schema.json", "expectedValid": true}, {"file": "valid/capability-definition.non-idempotent-durable-output.json", "schema": "capability-definition.schema.json", "expectedValid": true}, diff --git a/protocol/fixtures/v1alpha1/valid/capability-definition.fs-read-text-lines.json b/protocol/fixtures/v1alpha1/valid/capability-definition.fs-read-text-lines.json new file mode 100644 index 0000000..cac6aae --- /dev/null +++ b/protocol/fixtures/v1alpha1/valid/capability-definition.fs-read-text-lines.json @@ -0,0 +1,76 @@ +{ + "apiVersion": "xgeny.io/v1alpha1", + "kind": "CapabilityDefinition", + "metadata": { + "id": "xgeny.fs/read-text-lines", + "contractVersion": "1.0.0", + "displayName": "Read text file lines", + "labels": { + "xgeny.io/pack": "core-os" + } + }, + "spec": { + "summary": "Read a range of lines from a UTF-8 text file of up to 8 MiB without following symbolic links. Use it for files larger than read-text accepts (64 KiB) or to read around a search-text match line. Each returned line keeps its line terminator; continue from endLine + 1 while endLine is below totalLines.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["path", "startLine", "lineCount"], + "properties": { + "path": { + "type": "string", + "minLength": 1, + "description": "Slash-separated workspace-relative file path." + }, + "startLine": { + "type": "integer", + "minimum": 1, + "description": "One-based first line to return." + }, + "lineCount": { + "type": "integer", + "minimum": 1, + "maximum": 2000, + "description": "Maximum number of lines to return." + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["content", "startLine", "endLine", "totalLines", "truncated", "digest"], + "properties": { + "content": {"type": "string", "description": "The selected lines, at most 64 KiB once JSON-escaped."}, + "startLine": {"type": "integer", "minimum": 1}, + "endLine": {"type": "integer", "minimum": 0, "description": "Last returned line; startLine - 1 when no line is returned."}, + "totalLines": {"type": "integer", "minimum": 0}, + "truncated": {"type": "boolean", "description": "True when the content limit stopped the range early or cut its only line."}, + "digest": {"type": "string", "pattern": "^sha256:[a-f0-9]{64}$"} + }, + "additionalProperties": false + }, + "effect": { + "class": "read_only", + "resourceSelectors": [ + {"scope": "filesystem.read", "argumentPointer": "/path"} + ], + "criticalActions": [] + }, + "requiredCapabilities": [], + "execution": { + "styles": ["sync"], + "cancellable": false, + "idempotencyKeySupported": false, + "defaultTimeoutMs": 30000, + "maxTimeoutMs": 30000 + }, + "verification": [ + {"strategy": "output_schema", "required": true}, + {"strategy": "postcondition", "required": true, "description": "Digest matches the canonical bounded line-range observation."} + ], + "discovery": { + "keywords": ["file", "large", "lines", "range", "read", "text"], + "examples": ["Read lines 1200 to 1400 of src/server.py."] + } + } +}