diff --git a/Cargo.lock b/Cargo.lock index ed4ecc840..e067dc4a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6767,6 +6767,7 @@ dependencies = [ "uuid", "webauthn-authenticator-rs", "webauthn-rs", + "wip-protocol", "workdir", "worker", "worker-runtime", diff --git a/crates/builtin-source/src/lib.rs b/crates/builtin-source/src/lib.rs index f85eaef3d..3165a50d6 100644 --- a/crates/builtin-source/src/lib.rs +++ b/crates/builtin-source/src/lib.rs @@ -79,7 +79,7 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[ selector: Some("builtin:ticket-worker"), path: "profiles/ticket-worker.dcdl", source: include_str!("../../../resources/profiles/ticket-worker.dcdl"), - description: "General Ticket work with typed progress and completion tools.", + description: "General intent-led Ticket work with optional result recording and authorized conclusion decisions.", imports: &[BuiltinProfileImport { specifier: "./default.dcdl", resolved_path: DEFAULT_PATH, @@ -89,7 +89,7 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[ selector: Some("builtin:coder"), path: "profiles/coder.dcdl", source: include_str!("../../../resources/profiles/coder.dcdl"), - description: "Ticket implementation with direct Reviewer SubWorkers.", + description: "Code implementation with optional Git/MR review recipe; no universal Ticket conclusion gate.", imports: BASE_IMPORT, }, BuiltinProfileResource { @@ -117,7 +117,7 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[ selector: Some("builtin:orchestrator"), path: "profiles/orchestrator.dcdl", source: include_str!("../../../resources/profiles/orchestrator.dcdl"), - description: "Workspace orchestration and Worker control.", + description: "Intent-led Ticket orchestration with separate guarded MR integration authority.", imports: BASE_IMPORT, }, BuiltinProfileResource { diff --git a/crates/client/src/backend_runtime.rs b/crates/client/src/backend_runtime.rs index 00feb7b74..4f0c0f96c 100644 --- a/crates/client/src/backend_runtime.rs +++ b/crates/client/src/backend_runtime.rs @@ -823,7 +823,7 @@ mod tests { "display_name": "Embedded", "built_in": true, "worker_creation_available": true, - "working_directory_required": false, + "supports_workdir_attachments": false, "status": "online", "diagnostics": [] }], diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index 773d3e505..2d58cb741 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -549,7 +549,7 @@ impl BackendWorkspaceProductClient { let runtime = options .runtimes .iter() - .find(|runtime| runtime.worker_creation_available && !runtime.working_directory_required) + .find(|runtime| runtime.worker_creation_available) .ok_or_else(|| { BackendWorkspaceClientError::InvalidTarget( "Backend has no spawn-capable Runtime that supports a Workdir-less Intake Worker" @@ -1340,7 +1340,7 @@ mod tests { let (base_url, requests, handle) = response_sequence_server(vec![ ( "200 OK", - r#"{"workspace_id":"workspace-a","runtimes":[{"runtime_id":"embedded","display_name":"Embedded","built_in":true,"worker_creation_available":true,"working_directory_required":false,"status":"connected","diagnostics":[]}],"default_profile":null,"profiles":[],"repositories":[],"working_directories":[],"diagnostics":[]}"#, + r#"{"workspace_id":"workspace-a","runtimes":[{"runtime_id":"embedded","display_name":"Embedded","built_in":true,"worker_creation_available":true,"supports_workdir_attachments":false,"status":"connected","diagnostics":[]}],"default_profile":null,"profiles":[],"repositories":[],"working_directories":[],"diagnostics":[]}"#, ), ( "200 OK", diff --git a/crates/flow/src/builtin.rs b/crates/flow/src/builtin.rs index 270f9d8ff..44091b75b 100644 --- a/crates/flow/src/builtin.rs +++ b/crates/flow/src/builtin.rs @@ -24,7 +24,7 @@ pub fn builtin_flow_source(slug: &str) -> Option { match slug { CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource { slug: CODER_REVIEW_FLOW_SLUG, - revision: 4, + revision: 5, path: "builtin/flows/coder-review.dcdl", content: CODER_REVIEW_FLOW_SOURCE, }), @@ -35,7 +35,7 @@ pub fn builtin_flow_source(slug: &str) -> Option { pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] { const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource { slug: CODER_REVIEW_FLOW_SLUG, - revision: 4, + revision: 5, path: "builtin/flows/coder-review.dcdl", content: CODER_REVIEW_FLOW_SOURCE, }]; diff --git a/crates/fs-operation/src/lib.rs b/crates/fs-operation/src/lib.rs index 63436ed05..791aeae7b 100644 --- a/crates/fs-operation/src/lib.rs +++ b/crates/fs-operation/src/lib.rs @@ -941,6 +941,7 @@ mod tests { let list_error = run_list( &root, ListRequest { + after: None, path: FsPath::new("selected-dir").unwrap(), limit: 10, }, @@ -1008,6 +1009,7 @@ mod tests { let listed = run_list( &root, ListRequest { + after: None, path: FsPath::new("notes").unwrap(), limit: 10, }, diff --git a/crates/fs-operation/src/local.rs b/crates/fs-operation/src/local.rs index 887a09248..8885b5587 100644 --- a/crates/fs-operation/src/local.rs +++ b/crates/fs-operation/src/local.rs @@ -1,3 +1,5 @@ +use std::cmp::Ordering; +use std::collections::BinaryHeap; use std::ffi::OsString; use std::fs; use std::io::{BufReader, Read}; @@ -7,9 +9,9 @@ use sha2::{Digest, Sha256}; use crate::{ AtomicWriteMode, BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind, - FsAccessPolicy, FsError, FsPath, ListEntry, ListRequest, ListResult, ReadBytesRequest, - ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult, - direct_symlink, + FsAccessPolicy, FsError, FsPath, ListCursor, ListEntry, ListRequest, ListResult, + ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult, + WriteRequest, WriteResult, direct_symlink, }; const READ_BUFFER_BYTES: usize = 16 * 1024; @@ -437,12 +439,55 @@ pub fn run_edit( }) } +fn list_order( + left_kind: EntryKind, + left: &FsPath, + right_kind: EntryKind, + right: &FsPath, +) -> Ordering { + (left_kind != EntryKind::Directory) + .cmp(&(right_kind != EntryKind::Directory)) + .then_with(|| left.as_str().cmp(right.as_str())) +} + +struct RetainedListEntry(ListEntry); + +impl PartialEq for RetainedListEntry { + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == Ordering::Equal + } +} +impl Eq for RetainedListEntry {} +impl Ord for RetainedListEntry { + fn cmp(&self, other: &Self) -> Ordering { + list_order(self.0.kind, &self.0.path, other.0.kind, &other.0.path) + } +} +impl PartialOrd for RetainedListEntry { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +fn list_entry_bytes(entry: &ListEntry) -> usize { + entry.path.as_str().len().saturating_add(64) +} + pub fn run_list( root: &Path, request: ListRequest, access: &dyn FsAccessPolicy, ) -> Result { let logical = request.path; + if let Some(after) = &request.after { + if after.path.is_root() + || Path::new(after.path.as_str()).parent() != Some(Path::new(logical.as_str())) + { + return Err(FsError::InvalidArgument( + "List cursor must name a direct child of the listed directory".into(), + )); + } + } access .check_cancelled() .map_err(|error| map_io(&logical, error))?; @@ -462,11 +507,15 @@ pub fn run_list( total_entries: 0, total_bytes: 0, truncated: false, + next_after: None, }); } - let mut entries = Vec::new(); + let mut entries: BinaryHeap = BinaryHeap::new(); + let mut retained_limit = request.limit.saturating_add(1); let mut retained_path_bytes = 0_usize; - let mut provider_truncated = false; + let mut total_entries = 0usize; + let mut total_bytes = 0u64; + let mut remaining_entries = 0usize; let read_dir = access .open_read_dir(&logical_base, &path) .map_err(|error| map_io(&logical, error))?; @@ -513,34 +562,77 @@ pub fn run_list( let Ok(result_path) = FsPath::new(relative) else { continue; }; - let retained = result_path.as_str().len().saturating_add(64); - if retained_path_bytes.saturating_add(retained) > crate::MAX_RESULT_PATH_BYTES { - provider_truncated = true; - break; + total_entries += 1; + total_bytes = total_bytes.saturating_add(metadata.len()); + if request.after.as_ref().is_some_and(|after| { + list_order(kind, &result_path, after.kind, &after.path) != Ordering::Greater + }) { + continue; } - retained_path_bytes = retained_path_bytes.saturating_add(retained); - entries.push(ListEntry { + remaining_entries += 1; + let candidate = RetainedListEntry(ListEntry { path: result_path, kind, size: metadata.len(), }); + if entries.len() == retained_limit { + if candidate >= *entries.peek().expect("nonempty List retention") { + continue; + } + retained_path_bytes -= list_entry_bytes(&entries.pop().unwrap().0); + } + retained_path_bytes = retained_path_bytes.saturating_add(list_entry_bytes(&candidate.0)); + entries.push(candidate); + // Evict greatest keys, never stop scanning: enumeration order is + // unspecified and a later entry may precede every retained key. + while retained_path_bytes > crate::MAX_RESULT_PATH_BYTES { + retained_path_bytes -= list_entry_bytes(&entries.pop().unwrap().0); + // Never grow cardinality again after a byte-bound eviction: a + // later short path must not leap over a discarded earlier key. + retained_limit = entries.len(); + } + if retained_limit == 0 { + return Err(FsError::InvalidArgument( + "List entry exceeds provider response bound".into(), + )); + } } - entries.sort_by(|left, right| { - let left_dir = left.kind == EntryKind::Directory; - let right_dir = right.kind == EntryKind::Directory; - right_dir - .cmp(&left_dir) - .then_with(|| left.path.as_str().cmp(right.path.as_str())) - }); - let total_entries = entries.len(); - let total_bytes = entries.iter().map(|entry| entry.size).sum(); - let truncated = provider_truncated || entries.len() > request.limit; + let mut entries: Vec<_> = entries + .into_sorted_vec() + .into_iter() + .map(|entry| entry.0) + .collect(); entries.truncate(request.limit); + // Continuation duplicates the last path on the wire; include it in the + // aggregate provider response bound too. + let mut response_bytes: usize = entries.iter().map(list_entry_bytes).sum(); + while remaining_entries > entries.len() + && entries.last().is_some_and(|last| { + response_bytes.saturating_add(list_entry_bytes(last)) > crate::MAX_RESULT_PATH_BYTES + }) + { + response_bytes -= list_entry_bytes(&entries.pop().unwrap()); + } + if request.limit > 0 && remaining_entries > 0 && entries.is_empty() { + return Err(FsError::InvalidArgument( + "List entry exceeds provider response bound".into(), + )); + } + let truncated = remaining_entries > entries.len(); + let next_after = if truncated { + entries.last().map(|entry| ListCursor { + kind: entry.kind, + path: entry.path.clone(), + }) + } else { + None + }; Ok(ListResult { entries, total_entries, total_bytes, truncated, + next_after, }) } diff --git a/crates/fs-operation/src/operation.rs b/crates/fs-operation/src/operation.rs index 3d6e612f3..a2dc6d18f 100644 --- a/crates/fs-operation/src/operation.rs +++ b/crates/fs-operation/src/operation.rs @@ -278,6 +278,22 @@ pub struct ListRequest { pub path: FsPath, #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub limit: usize, + /// Exclusive live continuation in the same coordinates as returned entries. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub after: Option, +} + +/// A List ordering key: directories first, then lexical path (all other kinds +/// share one ordering group). The cursor need not still exist. Each page scans +/// the live directory: insertions before the key are not revisited, insertions +/// after it may appear, and removals disappear. Renames or kind changes can move +/// an entry across the key and cause omission or repetition; no snapshot or +/// cross-page consistency is promised. Scoped wrappers translate coordinates. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ListCursor { + pub kind: EntryKind, + pub path: FsPath, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -293,6 +309,11 @@ pub struct ListEntry { #[serde(deny_unknown_fields)] pub struct ListResult { pub entries: Vec, + /// Last returned key when more entries follow. A zero-limit request has no + /// continuation even when truncated. Totals describe all visible entries + /// in this scan, including those at or before `after`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_after: Option, #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub total_entries: usize, #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] diff --git a/crates/fs-operation/tests/list_pagination.rs b/crates/fs-operation/tests/list_pagination.rs new file mode 100644 index 000000000..cb3d14f78 --- /dev/null +++ b/crates/fs-operation/tests/list_pagination.rs @@ -0,0 +1,248 @@ +use fs_operation::{ + EntryKind, FsAccessPolicy, FsError, FsPath, ListCursor, ListRequest, ListResult, + MAX_RESULT_PATH_BYTES, run_list, +}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +struct Access { + root: PathBuf, + checks: AtomicUsize, + cancel_at: usize, + enumerate: bool, +} +impl Access { + fn new(root: &Path) -> Self { + Self { + root: root.to_path_buf(), + checks: AtomicUsize::new(0), + cancel_at: usize::MAX, + enumerate: true, + } + } +} +impl FsAccessPolicy for Access { + fn is_readable(&self, path: &Path) -> bool { + path.starts_with(&self.root) && path != self.root.join("denied") + } + fn is_writable(&self, _: &Path) -> bool { + false + } + fn can_enumerate_directory(&self, _: &Path, _: &Path) -> bool { + self.enumerate + } + fn check_cancelled(&self) -> std::io::Result<()> { + if self.checks.fetch_add(1, Ordering::Relaxed) >= self.cancel_at { + return Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "cancelled", + )); + } + Ok(()) + } +} +fn path(value: &str) -> FsPath { + FsPath::new(value).unwrap() +} +fn page(root: &Path, limit: usize, after: Option) -> ListResult { + run_list( + root, + ListRequest { + path: FsPath::root(), + limit, + after, + }, + &Access::new(root), + ) + .unwrap() +} +fn names(result: &ListResult) -> Vec<&str> { + result + .entries + .iter() + .map(|entry| entry.path.as_str()) + .collect() +} + +#[test] +fn list_pages_order_directories_first_then_paths_without_duplicates() { + let root = tempfile::tempdir().unwrap(); + for name in ["z", "b", "a"] { + std::fs::write(root.path().join(name), b"xx").unwrap(); + } + for name in ["z-dir", "a-dir"] { + std::fs::create_dir(root.path().join(name)).unwrap(); + } + let full = page(root.path(), usize::MAX, None); + assert_eq!(names(&full), ["a-dir", "z-dir", "a", "b", "z"]); + let mut after = None; + let mut entries = Vec::new(); + loop { + let result = page(root.path(), 2, after); + assert!(result.entries.len() <= 2); + assert_eq!(result.total_entries, 5); + assert_eq!(result.total_bytes, full.total_bytes); + assert_eq!(result.truncated, result.next_after.is_some()); + after = result.next_after; + entries.extend(result.entries); + if after.is_none() { + break; + } + } + assert_eq!(entries, full.entries); + let zero = page(root.path(), 0, None); + assert!(zero.entries.is_empty()); + assert!(zero.truncated); + assert!(zero.next_after.is_none()); + assert_eq!(zero.total_entries, 5); + let exhausted = page( + root.path(), + 2, + Some(ListCursor { + kind: EntryKind::File, + path: path("z"), + }), + ); + assert!(exhausted.entries.is_empty()); + assert!(!exhausted.truncated); + assert!(exhausted.next_after.is_none()); + assert_eq!(exhausted.total_entries, 5); +} + +#[test] +fn list_live_cursor_survives_removal_and_only_observes_new_keys_after_it() { + let root = tempfile::tempdir().unwrap(); + for name in ["b", "d"] { + std::fs::write(root.path().join(name), b"x").unwrap(); + } + let first = page(root.path(), 1, None); + assert_eq!(names(&first), ["b"]); + std::fs::remove_file(root.path().join("b")).unwrap(); + for name in ["a", "c"] { + std::fs::write(root.path().join(name), b"xx").unwrap(); + } + // All directories sort before a file cursor, even when created later. + std::fs::create_dir(root.path().join("z-directory")).unwrap(); + let second = page(root.path(), 10, first.next_after); + assert_eq!(names(&second), ["c", "d"]); + assert_eq!(second.total_entries, 4); + assert!(!second.truncated); + assert!(second.next_after.is_none()); + std::fs::remove_file(root.path().join("d")).unwrap(); + std::fs::rename(root.path().join("a"), root.path().join("e")).unwrap(); + let moved = page( + root.path(), + 10, + Some(ListCursor { + kind: EntryKind::File, + path: path("d"), + }), + ); + assert_eq!(names(&moved), ["e"]); +} + +#[test] +fn list_path_byte_bound_returns_a_continuation_instead_of_losing_the_tail() { + let root = tempfile::tempdir().unwrap(); + let long_count = MAX_RESULT_PATH_BYTES / (240 + 64) + 10; + // A shorter late key must not leap over a longer evicted earlier key. + let count = long_count + 50; + let expected: Vec<_> = (0..count) + .map(|n| format!("{n:05}{}", "x".repeat(if n < long_count { 235 } else { 1 }))) + .collect(); + for name in expected.iter().rev() { + std::fs::write(root.path().join(name), b"x").unwrap(); + } + let first = page(root.path(), usize::MAX, None); + assert!(first.truncated); + assert_eq!(first.total_entries, count); + assert_eq!(first.total_bytes, count as u64); + let path_bytes: usize = first + .entries + .iter() + .map(|entry| entry.path.as_str().len() + 64) + .sum::() + + first.next_after.as_ref().unwrap().path.as_str().len() + + 64; + assert!(path_bytes <= MAX_RESULT_PATH_BYTES); + let next = page(root.path(), usize::MAX, first.next_after.clone()); + assert!(!next.truncated); + let got: Vec<_> = names(&first).into_iter().chain(names(&next)).collect(); + assert_eq!(got, expected.iter().map(String::as_str).collect::>()); + let small = page(root.path(), 1, None); + assert_eq!(names(&small), [expected[0].as_str()]); + assert_eq!(small.total_entries, count); +} + +#[test] +fn list_keeps_ignored_and_hidden_entries_but_respects_read_and_enumeration_authority() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join(".gitignore"), "ignored\n").unwrap(); + for name in ["ignored", ".hidden", "denied"] { + std::fs::write(root.path().join(name), b"x").unwrap(); + } + let first = page(root.path(), 2, None); + assert_eq!(names(&first), [".gitignore", ".hidden"]); + let second = page(root.path(), 2, first.next_after); + assert_eq!(names(&second), ["ignored"]); + assert_eq!(second.total_entries, 3); + let mut access = Access::new(root.path()); + access.enumerate = false; + let result = run_list( + root.path(), + ListRequest { + path: FsPath::root(), + limit: 1, + after: None, + }, + &access, + ) + .unwrap(); + assert!(result.entries.is_empty()); + assert!(!result.truncated); + assert!(result.next_after.is_none()); +} + +#[test] +fn list_cancellation_is_checked_even_after_the_page_is_full() { + let root = tempfile::tempdir().unwrap(); + for name in ["a", "b", "c"] { + std::fs::write(root.path().join(name), b"x").unwrap(); + } + let mut access = Access::new(root.path()); + access.cancel_at = 3; + let error = run_list( + root.path(), + ListRequest { + path: FsPath::root(), + limit: 1, + after: None, + }, + &access, + ) + .unwrap_err(); + assert!( + matches!(error, FsError::Io { source, .. } if source.kind() == std::io::ErrorKind::Interrupted) + ); +} + +#[test] +fn list_rejects_cursor_outside_the_requested_directory() { + let root = tempfile::tempdir().unwrap(); + for value in ["", "other/a"] { + let error = run_list( + root.path(), + ListRequest { + path: FsPath::root(), + limit: 1, + after: Some(ListCursor { + kind: EntryKind::File, + path: path(value), + }), + }, + &Access::new(root.path()), + ) + .unwrap_err(); + assert!(matches!(error, FsError::InvalidArgument(_))); + } +} diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index 7407b8b0b..50cef53b6 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -98,7 +98,9 @@ pub struct FeatureConfigPartial { #[serde(default)] pub workdir_catalog: Option, #[serde(default)] - pub workspace_config: Option, + pub workspace_config: Option, + #[serde(default)] + pub drive: Option, #[serde(default)] pub ticket: Option, #[serde(default)] @@ -149,7 +151,12 @@ impl FeatureConfigPartial { workspace_config: merge_option( self.workspace_config, other.workspace_config, - WorkspaceConfigFeatureConfigPartial::merge, + ActivationFeatureConfigPartial::merge, + ), + drive: merge_option( + self.drive, + other.drive, + ActivationFeatureConfigPartial::merge, ), ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge), merge_request: merge_option( @@ -183,12 +190,12 @@ impl FeatureFlagConfigPartial { /// Activation only: no Workspace/grant/source/permission fields are accepted. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct WorkspaceConfigFeatureConfigPartial { +pub struct ActivationFeatureConfigPartial { #[serde(default)] pub enabled: Option, } -impl WorkspaceConfigFeatureConfigPartial { +impl ActivationFeatureConfigPartial { fn merge(self, other: Self) -> Self { Self { enabled: other.enabled.or(self.enabled), @@ -196,15 +203,15 @@ impl WorkspaceConfigFeatureConfigPartial { } } -impl From for FeatureFlagConfig { - fn from(value: WorkspaceConfigFeatureConfigPartial) -> Self { +impl From for FeatureFlagConfig { + fn from(value: ActivationFeatureConfigPartial) -> Self { Self { enabled: value.enabled.unwrap_or_default(), } } } -impl From for WorkspaceConfigFeatureConfigPartial { +impl From for ActivationFeatureConfigPartial { fn from(value: FeatureFlagConfig) -> Self { Self { enabled: Some(value.enabled), @@ -448,6 +455,7 @@ impl From for FeatureConfig { .workspace_config .map(FeatureFlagConfig::from) .unwrap_or_default(), + drive: value.drive.map(FeatureFlagConfig::from).unwrap_or_default(), ticket: value .ticket .map(TicketFeatureConfig::from) @@ -643,6 +651,7 @@ impl From for FeatureConfigPartial { manage_workdir: Some(value.manage_workdir.into()), workdir_catalog: Some(value.workdir_catalog.into()), workspace_config: Some(value.workspace_config.into()), + drive: Some(value.drive.into()), ticket: Some(value.ticket.into()), merge_request: Some(value.merge_request.into()), orchestration: Some(value.orchestration.into()), @@ -2311,6 +2320,93 @@ worker_max_turns = 7 assert!(!manifest.feature.merge_request.any()); } + #[test] + fn drive_defaults_disabled_in_partial_resolved_and_missing_snapshot_fields() { + for source in ["", "[feature]\n", "[feature.drive]\n"] { + let partial = WorkerManifestConfig::from_toml(source).unwrap(); + let manifest: WorkerManifest = minimal_valid().merge(partial).try_into().unwrap(); + assert!(!manifest.feature.drive.enabled, "{source}"); + } + for source in ["{}", r#"{"drive": {}}"#] { + let feature: FeatureConfig = serde_json::from_str(source).unwrap(); + assert!(!feature.drive.enabled); + } + let manifest: WorkerManifest = minimal_valid().try_into().unwrap(); + let mut snapshot = crate::write_persisted_worker_manifest_snapshot(&manifest).unwrap(); + snapshot["manifest"]["feature"] + .as_object_mut() + .unwrap() + .remove("drive"); + let restored = crate::read_persisted_worker_manifest_snapshot(snapshot).unwrap(); + assert!(!restored.feature.drive.enabled); + } + + #[test] + fn drive_activation_merges_and_roundtrips_config_and_snapshots() { + for enabled in [false, true] { + let lower = + WorkerManifestConfig::from_toml(&format!("[feature.drive]\nenabled = {enabled}\n")) + .unwrap(); + let empty = WorkerManifestConfig::from_toml("[feature.drive]\n").unwrap(); + let manifest: WorkerManifest = minimal_valid() + .merge(lower.clone()) + .merge(empty) + .try_into() + .unwrap(); + assert_eq!(manifest.feature.drive.enabled, enabled); + assert!(!manifest.feature.workspace_config.enabled); + assert!(!manifest.feature.manage_workdir.enabled); + let partial = FeatureConfigPartial::from(manifest.feature.clone()); + assert_eq!(partial.drive.as_ref().unwrap().enabled, Some(enabled)); + let restored: FeatureConfigPartial = + toml::from_str(&toml::to_string(&partial).unwrap()).unwrap(); + assert_eq!(FeatureConfig::from(restored), manifest.feature); + let json = serde_json::to_value(&manifest.feature).unwrap(); + assert_eq!(json["drive"], serde_json::json!({"enabled": enabled})); + assert_eq!( + serde_json::from_value::(json).unwrap(), + manifest.feature + ); + let snapshot = crate::write_persisted_worker_manifest_snapshot(&manifest).unwrap(); + let restored = crate::read_persisted_worker_manifest_snapshot(snapshot).unwrap(); + assert_eq!(restored.feature.drive.enabled, enabled); + let upper = WorkerManifestConfig::from_toml(&format!( + "[feature.drive]\nenabled = {}\n", + !enabled + )) + .unwrap(); + let overridden: WorkerManifest = minimal_valid() + .merge(lower) + .merge(upper) + .try_into() + .unwrap(); + assert_eq!(overridden.feature.drive.enabled, !enabled); + } + } + + #[test] + fn drive_activation_rejects_authority_and_unknown_fields() { + for (field, value) in [ + ("workspace_id", "\"workspace-1\""), + ("grant_id", "\"grant-1\""), + ("access", "\"read_write\""), + ("host_path", "\"/host/drive\""), + ("source", "\"drive\""), + ("writable", "true"), + ("enabeld", "true"), + ] { + let source = format!("[feature.drive]\nenabled = true\n{field} = {value}\n"); + assert!(WorkerManifestConfig::from_toml(&source).is_err(), "{field}"); + assert!( + toml::from_str::(&format!( + "[drive]\nenabled = true\n{field} = {value}\n" + )) + .is_err(), + "{field}" + ); + } + } + #[test] fn workspace_config_defaults_disabled_for_partial_and_resolved_configs() { assert!(!FeatureConfig::default().workspace_config.enabled); diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 03e98be88..3c8ed2c06 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -143,8 +143,12 @@ pub struct FeatureConfig { pub workdir_catalog: FeatureFlagConfig, /// Opt-in WIP-only logical Workspace configuration feature. This flag is /// not an access grant; Backend-owned Worker grants remain authoritative. - #[serde(default, deserialize_with = "deserialize_workspace_config")] + #[serde(default, deserialize_with = "deserialize_activation_feature")] pub workspace_config: FeatureFlagConfig, + /// Opt-in Drive tools and native WIP surface. Activation grants no Drive + /// access: Backend-owned Worker grants are checked on every operation. + #[serde(default, deserialize_with = "deserialize_activation_feature")] + pub drive: FeatureFlagConfig, #[serde(default)] pub ticket: TicketFeatureConfig, #[serde(default)] @@ -169,11 +173,11 @@ where }) } -fn deserialize_workspace_config<'de, D>(deserializer: D) -> Result +fn deserialize_activation_feature<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, { - Ok(config::WorkspaceConfigFeatureConfigPartial::deserialize(deserializer)?.into()) + Ok(config::ActivationFeatureConfigPartial::deserialize(deserializer)?.into()) } impl Default for FeatureConfig { @@ -192,6 +196,7 @@ impl Default for FeatureConfig { manage_workdir: FeatureFlagConfig::disabled(), workdir_catalog: default_workdir_catalog(), workspace_config: FeatureFlagConfig::disabled(), + drive: FeatureFlagConfig::disabled(), ticket: TicketFeatureConfig::default(), merge_request: MergeRequestFeatureConfig::default(), orchestration: FeatureFlagConfig::disabled(), diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index bd5c45b66..1dfd34ef5 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -1279,6 +1279,34 @@ mod tests { } } + #[test] + fn builtin_drive_activation_is_workspace_role_specific_and_excludes_jobs() { + // Production DCDL imports are the policy boundary: changing the shared + // base must not accidentally activate Drive in a read-only Job/Reviewer. + let tmp = TempDir::new().unwrap(); + let registry = ProfileDiscovery::with_sources(None, None) + .discover() + .unwrap(); + for entry in registry.entries() { + let resolved = ProfileResolver::new() + .with_workspace_base(tmp.path()) + .resolve( + &ProfileSelector::source_named(ProfileRegistrySource::Builtin, &entry.name), + ProfileResolveOptions::with_worker_name("drive-profile-worker"), + ) + .unwrap(); + let expected = matches!( + entry.name.as_str(), + "default" | "ticket-worker" | "coder" | "companion" | "intake" | "orchestrator" + ); + assert_eq!( + resolved.manifest.feature.drive.enabled, expected, + "{}", + entry.name + ); + } + } + #[test] fn ticket_worker_profile_enables_work_reporting_without_authoring_or_integration() { let tmp = TempDir::new().unwrap(); diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 4cb3bced9..1b9976c74 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -9856,7 +9856,9 @@ pub struct WorkerLaunchRuntimeOption { pub display_name: String, pub built_in: bool, pub worker_creation_available: bool, - pub working_directory_required: bool, + /// Whether this Runtime supports explicitly selected Workdir attachments. + /// Workdirs are optional for generic launches; embedded Runtime does not support them. + pub supports_workdir_attachments: bool, pub status: String, pub diagnostics: Vec, } diff --git a/crates/tools/README.md b/crates/tools/README.md index d8f72b3fb..dd046a3e0 100644 --- a/crates/tools/README.md +++ b/crates/tools/README.md @@ -38,6 +38,7 @@ capabilities remain authoritative. `Create` uses the `Write` capability/identity | Edit | `old_string`, `new_string`, `replace_all?` | Existing file; prior read/hash and provider validator required | | Write | `content` | Existing file only; prior read/hash and provider validator required | | Create | `path`, `content` | Bound parent directory; relative destination strictly below it; create-new, missing parents allowed | +| List | `limit?` (1..1000, default 100), `after?` (`{kind, path}`) | Bound directory; Read capability; all typed `EntryKind` cursor values accepted, path is a direct child in checkout-root-relative provider coordinates | | Glob | `pattern`, `path?` | Bound directory; optional path relative beneath it | | Grep | `pattern`, `path?`, `glob?`, `type?`, `case_insensitive?`, `-B?`, `-A?`, `-C?`, `multiline?`, `output_mode?`, `head_limit?`, `offset?` | Bound directory; same provider search/options/rendering as Tool Grep | @@ -55,6 +56,22 @@ The constructor's empty scope layers/root output are refined by provider wrapper Tools does not synthesize authority or parse/rebase rendered filenames. Ordinary Tool dispatch remains `session.glob`/`session.grep`, with its existing error mapping. +List dispatches one bounded `checkout_search` page without Bash or entry prefetch. +`CheckoutToolOutput.listing` preserves the typed `ListResult`, including +`next_after`, for Host projection into canonical Worldspace entries and cursors. +Its normal Tool output is a summary only, and `paths` is empty; every other +Operation returns `listing: None`. The Host decodes canonical cursor paths before +calling Tools, mapping the public `{kind, entry}` cursor into `{kind, path}`. +Both returned entries and the continuation must stay directly inside the bound +directory. Cursor kinds retain all typed values (`directory`, `file`, `symlink`, +`other`); ordering groups directories first and all non-directories together, +then compares paths. A truncated page must supply `next_after` matching its last +entry's ordering key; an untruncated page has no continuation. Special typed +listing entries are path coordinates, not checkout observations: direct checkout +observation remains provider File/Directory-only. Pagination is live, not a +cross-page snapshot; the same before/after bound-directory validator checks +used for search apply to List. + `CheckoutToolOutput.paths` contains typed checkout-root-relative search paths (from `GlobResult.paths` / `GrepResult.paths`) and the created path for Create. Rendered Grep text is never parsed to construct links. The optional validator is diff --git a/crates/tools/src/checkout.rs b/crates/tools/src/checkout.rs index 73e59e5e5..be2fdf19f 100644 --- a/crates/tools/src/checkout.rs +++ b/crates/tools/src/checkout.rs @@ -18,6 +18,10 @@ use crate::{ToolsError, Tracker, file_target::FileTarget}; pub struct CheckoutToolOutput { pub output: ToolOutput, pub paths: Vec, + /// Typed directory page, including its provider-coordinate continuation. + /// Special entries are path coordinates, not checked checkout observations. + /// The Host owns projection into canonical Worldspace entries and cursors. + pub listing: Option, pub validator: Option>, } @@ -47,6 +51,7 @@ pub async fn execute_checkout_tool( .unwrap_or_default(); let (capability, allowed): (_, &[&str]) = match tool_name { "Read" => (WorkdirSessionCapability::Read, &file_fields), + "List" => (WorkdirSessionCapability::Read, &["limit", "after"]), "Edit" => (WorkdirSessionCapability::Edit, &file_fields), "Write" => (WorkdirSessionCapability::Write, &file_fields), "Create" => (WorkdirSessionCapability::Write, &["path", "content"]), @@ -132,7 +137,7 @@ pub async fn execute_checkout_tool( crate::write::execute_write(target, tracker, params.content, Some(destination), ctx) .await } - "Glob" | "Grep" => { + "List" | "Glob" | "Grep" => { let before = selected .session .checkout_observe(path.clone()) @@ -141,7 +146,10 @@ pub async fn execute_checkout_tool( if before.validator != validator { return Err(stale_search()); } - let mut result = if tool_name == "Glob" { + let mut result = if tool_name == "List" { + let params: ListParams = decode(Value::Object(arguments), tool_name)?; + execute_list(selected.session.clone(), path.clone(), params).await? + } else if tool_name == "Glob" { let params: crate::glob::GlobParams = decode(Value::Object(arguments), tool_name)?; check_pattern(¶ms.pattern)?; let search_path = @@ -191,6 +199,120 @@ pub async fn execute_checkout_tool( } } +/// Native List has no path override. `after.path` is already decoded by the Host +/// into checkout-root-relative provider coordinates, not directory-relative. +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct ListParams { + #[serde(default = "default_list_limit")] + limit: usize, + #[serde(default)] + after: Option, +} + +fn default_list_limit() -> usize { + 100 +} + +async fn execute_list( + session: workdir::WorkdirSessionHandle, + path: WorkdirPath, + params: ListParams, +) -> Result { + if !(1..=1000).contains(¶ms.limit) { + return Err(ToolError::InvalidArgument( + "List limit must be between 1 and 1000".into(), + )); + } + if let Some(after) = ¶ms.after { + if !is_direct_child(&path, &after.path) { + return Err(ToolError::InvalidArgument( + "List after must name a direct child of the bound directory".into(), + )); + } + } + let result = session + .checkout_search(workdir::CheckoutSearchRequest::new( + workdir::CheckoutSearchOperation::List(workdir::ListRequest { + path: path.clone(), + limit: params.limit, + after: params.after, + }), + )) + .await + .map_err(crate::file_target::checked_error)?; + let listing = match result { + workdir::CheckoutSearchResult::List(listing) => listing, + _ => return Err(crate::file_target::readonly_result_error("List")), + }; + // Fail closed before the Host can publish typed links to provider results. + if listing.entries.len() > params.limit + || listing + .entries + .iter() + .any(|entry| !is_direct_child(&path, &entry.path)) + || listing + .next_after + .as_ref() + .is_some_and(|after| !is_direct_child(&path, &after.path)) + { + return Err(crate::file_target::readonly_result_error( + "List page escaped bound directory or exceeded its limit", + )); + } + // The provider ordering key is directory/non-directory group plus path. + // Preserve the typed kind; special entries remain continuable without + // acquiring a File/Directory-only checkout observation for them. + let valid_continuation = match (&listing.next_after, listing.entries.last()) { + (Some(after), Some(last)) if listing.truncated => { + after.path == last.path + && (after.kind == workdir::EntryKind::Directory) + == (last.kind == workdir::EntryKind::Directory) + } + (None, _) => !listing.truncated, + _ => false, + }; + if !valid_continuation { + return Err(crate::file_target::readonly_result_error( + "List continuation does not match the truncated page's last ordering key", + )); + } + let summary = format!( + "Listed {} of {} entries in {}{}", + listing.entries.len(), + listing.total_entries, + path, + if listing.truncated { + " (truncated)" + } else { + "" + }, + ); + Ok(CheckoutToolOutput { + output: ToolOutput { + summary, + content: None, + attachments: Vec::new(), + }, + paths: Vec::new(), + listing: Some(listing), + validator: None, + }) +} + +fn is_direct_child(base: &WorkdirPath, path: &WorkdirPath) -> bool { + let Ok(logical) = WorkdirPath::new(path.as_str()) else { + return false; + }; + let parent = std::path::Path::new(logical.as_str()).parent(); + let expected = if base == &WorkdirPath::root() { + std::path::Path::new("") + } else { + std::path::Path::new(base.as_str()) + }; + logical != WorkdirPath::root() && parent == Some(expected) +} + fn decode(arguments: Value, name: &str) -> Result { crate::error::decode_file_value(arguments, name) } diff --git a/crates/tools/src/checkout_tests.rs b/crates/tools/src/checkout_tests.rs index d11203ed3..d8ec7b74e 100644 --- a/crates/tools/src/checkout_tests.rs +++ b/crates/tools/src/checkout_tests.rs @@ -85,6 +85,326 @@ impl Fixture { } } +#[derive(Debug)] +struct ListProvider { + workdir: workdir::Workdir, + capabilities: workdir::WorkdirSessionCapabilities, + result: workdir::CheckoutSearchResult, + change_validator: bool, + expected_after: Option, + searched: std::sync::atomic::AtomicBool, +} + +// This boundary adapter exposes only checked metadata/search. Any file content +// read, ordinary list fallback, or command execution is a test failure. +#[async_trait::async_trait] +impl workdir::WorkdirSession for ListProvider { + fn workdir(&self) -> &workdir::Workdir { + &self.workdir + } + fn capabilities(&self) -> workdir::WorkdirSessionCapabilities { + self.capabilities + } + async fn checkout_observe( + &self, + path: WorkdirPath, + ) -> Result { + assert_eq!( + path.as_str(), + "src", + "List observes only its bound directory" + ); + Ok(workdir::CheckoutObservation { + path, + kind: workdir::EntryKind::Directory, + size: 0, + validator: vec![u8::from( + self.change_validator && self.searched.load(std::sync::atomic::Ordering::SeqCst), + )], + capabilities: self.capabilities, + }) + } + async fn checkout_search( + &self, + request: workdir::CheckoutSearchRequest, + ) -> Result { + let workdir::CheckoutSearchOperation::List(list) = request.operation else { + panic!("native List must dispatch a typed List request"); + }; + assert_eq!(list.after, self.expected_after); + assert!( + !self + .searched + .swap(true, std::sync::atomic::Ordering::SeqCst), + "List must not prefetch another page" + ); + Ok(self.result.clone()) + } + async fn stat( + &self, + _: workdir::StatRequest, + ) -> Result { + panic!("native List must not call stat"); + } + async fn read( + &self, + _: workdir::ReadRequest, + ) -> Result { + panic!("native List must not read file content"); + } + async fn write( + &self, + _: workdir::WriteRequest, + ) -> Result { + panic!("native List must not write"); + } + async fn edit( + &self, + _: workdir::EditRequest, + ) -> Result { + panic!("native List must not edit"); + } + async fn list( + &self, + _: workdir::ListRequest, + ) -> Result { + panic!("native List must use checkout_search, not ordinary list"); + } + async fn glob( + &self, + _: workdir::GlobRequest, + ) -> Result { + panic!("native List must not glob"); + } + async fn grep( + &self, + _: workdir::GrepRequest, + ) -> Result { + panic!("native List must not grep"); + } + async fn start_command( + &self, + _: workdir::CommandRequest, + ) -> Result { + panic!("native List must not execute commands"); + } + async fn command_status( + &self, + _: workdir::CommandHandle, + ) -> Result { + panic!("native List must not use commands"); + } + async fn command_output( + &self, + _: workdir::CommandOutputRequest, + ) -> Result { + panic!("native List must not use commands"); + } + async fn cancel_command(&self, _: workdir::CommandHandle) -> Result<(), workdir::WorkdirError> { + panic!("native List must not use commands"); + } + async fn close(&self) -> Result<(), workdir::WorkdirError> { + Ok(()) + } +} + +async fn list_with_provider( + capabilities: workdir::WorkdirSessionCapabilities, + result: workdir::CheckoutSearchResult, + change_validator: bool, +) -> Result { + list_with_provider_after(capabilities, result, change_validator, None).await +} + +async fn list_with_provider_after( + capabilities: workdir::WorkdirSessionCapabilities, + result: workdir::CheckoutSearchResult, + change_validator: bool, + after: Option, +) -> Result { + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach( + WorkdirAttachmentAlias::new("selected").unwrap(), + Arc::new(ListProvider { + workdir: workdir::Workdir::new("list-test"), + capabilities, + result, + change_validator, + expected_after: after.clone(), + searched: std::sync::atomic::AtomicBool::new(false), + }), + ) + .unwrap(); + let generation = router.resolve(Some("selected")).unwrap().generation; + execute_checkout_tool( + router, + Tracker::new(), + "selected", + generation, + WorkdirPath::new("src").unwrap(), + vec![0], + "List", + json!({"limit": 1, "after": after}), + Default::default(), + ) + .await +} + +fn list_page() -> workdir::ListResult { + workdir::ListResult { + entries: vec![workdir::ListEntry { + path: WorkdirPath::new("src/a.txt").unwrap(), + kind: workdir::EntryKind::File, + size: 5, + }], + total_entries: 2, + total_bytes: 10, + truncated: true, + next_after: Some(fs_operation::ListCursor { + kind: workdir::EntryKind::File, + path: WorkdirPath::new("src/a.txt").unwrap(), + }), + } +} + +#[tokio::test] +async fn checkout_list_uses_read_capability_and_checked_search_without_prefetch() { + let read_only = workdir::WorkdirSessionCapabilities::EMPTY.with(WorkdirSessionCapability::Read); + let page = list_page(); + let result = list_with_provider( + read_only, + workdir::CheckoutSearchResult::List(page.clone()), + false, + ) + .await + .unwrap(); + assert_eq!(result.listing, Some(page)); + assert_eq!(result.validator, Some(vec![0])); + let no_read = workdir::WorkdirSessionCapabilities::EMPTY.with(WorkdirSessionCapability::Glob); + let error = list_with_provider( + no_read, + workdir::CheckoutSearchResult::List(list_page()), + false, + ) + .await + .err() + .unwrap(); + assert!(matches!(error, ToolError::InvalidArgument(_))); + assert!(error.to_string().contains("Read")); +} + +#[tokio::test] +async fn checkout_list_rejects_changed_post_search_directory_validator() { + let error = list_with_provider( + workdir::WorkdirSessionCapabilities::READ_ONLY, + workdir::CheckoutSearchResult::List(list_page()), + true, + ) + .await + .err() + .unwrap(); + assert_checkout_stale(error); +} + +#[tokio::test] +async fn checkout_list_rejects_provider_pages_that_escape_or_exceed_bounds() { + let mut outside = list_page(); + outside.entries[0].path = WorkdirPath::new("outside.txt").unwrap(); + let mut nested = list_page(); + nested.entries[0].path = WorkdirPath::new("src/nested/a.txt").unwrap(); + let mut cursor_outside = list_page(); + cursor_outside.next_after.as_mut().unwrap().path = WorkdirPath::new("outside.txt").unwrap(); + let mut cursor_kind = list_page(); + cursor_kind.next_after.as_mut().unwrap().kind = workdir::EntryKind::Directory; + let mut oversized = list_page(); + oversized.entries.push(oversized.entries[0].clone()); + for result in [ + workdir::CheckoutSearchResult::List(outside), + workdir::CheckoutSearchResult::List(nested), + workdir::CheckoutSearchResult::List(cursor_outside), + workdir::CheckoutSearchResult::List(cursor_kind), + workdir::CheckoutSearchResult::List(oversized), + workdir::CheckoutSearchResult::Glob(workdir::GlobResult { + paths: vec![], + truncated: false, + }), + ] { + let error = list_with_provider( + workdir::WorkdirSessionCapabilities::READ_ONLY, + result, + false, + ) + .await + .err() + .unwrap(); + assert_checkout_code(error, "checkout_unavailable"); + } +} + +#[tokio::test] +async fn checkout_list_special_entry_cursors_preserve_kind_and_continue_the_page() { + // Provider-only special entries stay path coordinates: the adapter forbids + // content reads and does not offer direct observations for these entries. + for kind in [workdir::EntryKind::Other, workdir::EntryKind::Symlink] { + let mut first_page = list_page(); + first_page.entries[0].kind = kind; + first_page.next_after.as_mut().unwrap().kind = kind; + let first = list_with_provider( + workdir::WorkdirSessionCapabilities::READ_ONLY, + workdir::CheckoutSearchResult::List(first_page), + false, + ) + .await + .unwrap() + .listing + .unwrap(); + assert_eq!(first.entries[0].kind, kind); + let after = first.next_after.unwrap(); + assert_eq!(after.kind, kind); + let mut second_page = list_page(); + second_page.entries[0].path = WorkdirPath::new("src/z.txt").unwrap(); + second_page.truncated = false; + second_page.next_after = None; + let second = list_with_provider_after( + workdir::WorkdirSessionCapabilities::READ_ONLY, + workdir::CheckoutSearchResult::List(second_page.clone()), + false, + Some(after), + ) + .await + .unwrap() + .listing + .unwrap(); + assert_eq!(second, second_page); + } +} + +#[tokio::test] +async fn checkout_list_rejects_missing_or_mismatched_truncated_continuations() { + let mut missing = list_page(); + missing.next_after = None; + let mut empty = list_page(); + empty.entries.clear(); + let mut wrong_path = list_page(); + wrong_path.next_after.as_mut().unwrap().path = WorkdirPath::new("src/z.txt").unwrap(); + let mut wrong_group = list_page(); + wrong_group.next_after.as_mut().unwrap().kind = workdir::EntryKind::Directory; + let mut untruncated = list_page(); + untruncated.truncated = false; + for page in [missing, empty, wrong_path, wrong_group, untruncated] { + let error = list_with_provider( + workdir::WorkdirSessionCapabilities::READ_ONLY, + workdir::CheckoutSearchResult::List(page), + false, + ) + .await + .err() + .unwrap(); + assert_checkout_code(error, "checkout_unavailable"); + } +} + fn assert_checkout_stale(error: ToolError) { assert_checkout_code(error, "checkout_stale"); } @@ -557,6 +877,183 @@ async fn checkout_search_typed_paths_and_normal_rendering() { let normal = fixture.normal("Grep", json!({"target_workdir": "main", "path": "src", "pattern": "hit", "output_mode": mode, "-C": 1})).await.unwrap(); assert_eq!(grep.output.summary, normal.summary); assert_eq!(grep.output.content, normal.content); + assert!(grep.listing.is_none()); assert!(!grep.output.content.unwrap().contains("outside.txt")); } } + +#[tokio::test] +async fn checkout_list_returns_one_typed_page_and_provider_coordinate_continuation() { + let fixture = Fixture::new(); + std::fs::create_dir_all(fixture.dir.path().join("src/nested")).unwrap(); + std::fs::write(fixture.dir.path().join("src/a.txt"), "alpha").unwrap(); + std::fs::write(fixture.dir.path().join("src/z.txt"), "z").unwrap(); + std::fs::write(fixture.dir.path().join("outside.txt"), "outside").unwrap(); + let (_, validator) = fixture.observation("main", "src").await; + let first = fixture + .native("main", "src", "List", json!({"limit": 1})) + .await + .unwrap(); + assert!(first.paths.is_empty()); + assert!(first.output.content.is_none()); + assert!(first.output.summary.contains("Listed 1 of 3 entries")); + assert!(first.output.summary.contains("truncated")); + assert_eq!(first.validator, Some(validator)); + let page = first.listing.unwrap(); + assert_eq!(page.entries.len(), 1); + assert_eq!(page.entries[0].path.as_str(), "src/nested"); + assert_eq!(page.entries[0].kind, workdir::EntryKind::Directory); + assert!(page.truncated); + let total_bytes = page.total_bytes; + assert!(total_bytes >= 6); + let cursor = page.next_after.unwrap(); + assert_eq!(cursor.path.as_str(), "src/nested"); + let second = fixture + .native("main", "src", "List", json!({"after": cursor})) + .await + .unwrap(); + let page = second.listing.unwrap(); + assert_eq!( + page.entries + .iter() + .map(|entry| entry.path.as_str()) + .collect::>(), + ["src/a.txt", "src/z.txt"] + ); + assert_eq!(page.total_entries, 3); + assert_eq!(page.total_bytes, total_bytes); + assert!(!page.truncated); + assert!(page.next_after.is_none()); + assert_eq!(fixture.tracker.change_stat(), crate::ChangeStat::default()); +} + +#[tokio::test] +async fn checkout_list_root_file_cursor_stays_on_selected_attachment() { + let fixture = Fixture::new(); + std::fs::write(fixture.dir.path().join("a.txt"), "a").unwrap(); + std::fs::write(fixture.dir.path().join("z.txt"), "z").unwrap(); + let other = TempDir::new().unwrap(); + std::fs::write(other.path().join("wrong.txt"), "wrong").unwrap(); + fixture + .router + .attach( + WorkdirAttachmentAlias::new("other").unwrap(), + Arc::new(LocalWorkdirSession::new( + Scope::writable(other.path()).unwrap(), + other.path().to_path_buf(), + )), + ) + .unwrap(); + let first = fixture + .native("main", ".", "List", json!({"limit": 1})) + .await + .unwrap() + .listing + .unwrap(); + let cursor = first.next_after.unwrap(); + assert_eq!(cursor.kind, workdir::EntryKind::File); + assert_eq!(cursor.path.as_str(), "a.txt"); + let second = fixture + .native("main", ".", "List", json!({"after": cursor})) + .await + .unwrap() + .listing + .unwrap(); + assert_eq!(second.entries.len(), 1); + assert_eq!(second.entries[0].path.as_str(), "z.txt"); + let other_page = fixture + .native("other", ".", "List", json!({})) + .await + .unwrap() + .listing + .unwrap(); + assert_eq!(other_page.entries.len(), 1); + assert_eq!(other_page.entries[0].path.as_str(), "wrong.txt"); +} + +#[tokio::test] +async fn checkout_list_defaults_to_100_and_accepts_both_limit_bounds() { + let fixture = Fixture::new(); + for index in 0..101 { + std::fs::write(fixture.dir.path().join(format!("{index:03}.txt")), "").unwrap(); + } + for (arguments, count, truncated) in [ + (json!({}), 100, true), + (json!({"limit": 1}), 1, true), + (json!({"limit": 1000}), 101, false), + ] { + let result = fixture + .native("main", ".", "List", arguments) + .await + .unwrap(); + let page = result.listing.unwrap(); + assert_eq!(page.entries.len(), count); + assert_eq!(page.truncated, truncated); + assert_eq!(page.next_after.is_some(), truncated); + } +} + +#[tokio::test] +async fn checkout_list_rejects_route_overrides_malformed_limits_and_nonchild_cursors() { + let fixture = Fixture::new(); + std::fs::create_dir(fixture.dir.path().join("src")).unwrap(); + for arguments in [ + json!({"path": "."}), + json!({"target_workdir": "other"}), + json!({"generation": 1}), + json!({"validator": []}), + json!({"limit": 0}), + json!({"limit": 1001}), + json!({"limit": -1}), + json!({"limit": 1.5}), + json!({"limit": "1"}), + json!({"limit": null}), + json!({"after": "src/a.txt"}), + json!({"after": {"path": "src/a.txt"}}), + json!({"after": {"kind": "file", "path": "src/a.txt", "extra": 1}}), + json!({"after": {"kind": "bogus", "path": "src/a.txt"}}), + json!({"after": {"kind": "file", "path": "src"}}), + json!({"after": {"kind": "directory", "path": "src/nested/deep"}}), + json!({"after": {"kind": "file", "path": "src-other/a.txt"}}), + json!({"after": {"kind": "file", "path": "a.txt"}}), + json!({"after": {"kind": "file", "path": "/src/a.txt"}}), + json!({"after": {"kind": "file", "path": "src/../a.txt"}}), + json!({"after": {"kind": "file", "path": "."}}), + ] { + let error = fixture + .native("main", "src", "List", arguments.clone()) + .await + .err() + .unwrap(); + assert!( + matches!(error, ToolError::InvalidArgument(_)), + "{arguments}: {error}" + ); + } +} + +#[tokio::test] +async fn checkout_list_requires_current_generation_and_directory_validator() { + let fixture = Fixture::new(); + let (generation, validator) = fixture.observation("main", ".").await; + // No timing dependency: make the supplied observation explicitly stale. + let mut stale_validator = validator.clone(); + stale_validator.push(0); + for (generation, validator) in [(generation, stale_validator), (generation + 1, validator)] { + let error = execute_checkout_tool( + fixture.router.clone(), + fixture.tracker.clone(), + "main", + generation, + WorkdirPath::root(), + validator, + "List", + json!({}), + Default::default(), + ) + .await + .err() + .unwrap(); + assert_checkout_stale(error); + } +} diff --git a/crates/tools/src/edit.rs b/crates/tools/src/edit.rs index 805a83087..8f3e8e947 100644 --- a/crates/tools/src/edit.rs +++ b/crates/tools/src/edit.rs @@ -110,6 +110,7 @@ pub(crate) async fn execute_edit( attachments: Vec::new(), }, paths: Vec::new(), + listing: None, validator, }) } diff --git a/crates/tools/src/glob.rs b/crates/tools/src/glob.rs index fe65fc36a..509d5f3ea 100644 --- a/crates/tools/src/glob.rs +++ b/crates/tools/src/glob.rs @@ -93,6 +93,7 @@ pub(crate) async fn execute_glob( attachments: Vec::new(), }, paths: result.paths, + listing: None, validator: None, }) } diff --git a/crates/tools/src/grep.rs b/crates/tools/src/grep.rs index 85257a8cf..1fa48343a 100644 --- a/crates/tools/src/grep.rs +++ b/crates/tools/src/grep.rs @@ -147,6 +147,7 @@ pub(crate) async fn execute_grep( attachments: Vec::new(), }, paths: result.paths, + listing: None, validator: None, }) } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 790dabcf3..8df330c17 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -21,7 +21,7 @@ mod grep; mod read; mod routing; mod search_target; -mod view_image; +pub mod view_image; mod web; mod write; @@ -33,7 +33,9 @@ pub use glob::glob_tool; pub use grep::grep_tool; pub use read::read_tool; pub use tracker::{ChangeStat, Tracker}; -pub use view_image::{routed_view_image_tool, view_image_tool}; +pub use view_image::{ + detect_image_mime, read_workdir_bytes, routed_view_image_tool, view_image_tool, +}; pub use web::{web_fetch_tool, web_search_tool}; pub use write::write_tool; diff --git a/crates/tools/src/read.rs b/crates/tools/src/read.rs index f9d49ab1f..e32d2389a 100644 --- a/crates/tools/src/read.rs +++ b/crates/tools/src/read.rs @@ -108,6 +108,7 @@ pub(crate) async fn execute_read( attachments: Vec::new(), }, paths: Vec::new(), + listing: None, validator, }) } diff --git a/crates/tools/src/view_image.rs b/crates/tools/src/view_image.rs index 255695b9a..d6448410e 100644 --- a/crates/tools/src/view_image.rs +++ b/crates/tools/src/view_image.rs @@ -7,7 +7,10 @@ use agen::tool::{ }; use async_trait::async_trait; use serde::Deserialize; -use workdir::{ReadBytesRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter}; +use sha2::{Digest, Sha256}; +use workdir::{ + ContentHash, ReadBytesRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter, +}; use crate::error::ToolsError; @@ -49,7 +52,7 @@ impl Tool for ViewImageTool { workdir::WorkdirSessionCapability::Read, )?; let path = WorkdirPath::new(&input.path).map_err(ToolsError::from)?; - let bytes = read_image_bytes(selected.session.as_ref(), path.clone()) + let bytes = read_workdir_bytes(selected.session.as_ref(), path.clone(), MAX_IMAGE_BYTES) .await .map_err(ToolsError::from)?; let mime_type = detect_image_mime(&bytes).ok_or_else(|| { @@ -70,9 +73,13 @@ impl Tool for ViewImageTool { } } -async fn read_image_bytes( +/// Read arbitrary bytes through the scoped provider, bounded by `max_total_bytes`. +/// Checks every chunk's identity, range, size and hash, then verifies the assembled +/// SHA256 against the provider's pinned [`ContentHash`]. No MIME restriction applies. +pub async fn read_workdir_bytes( session: &dyn workdir::WorkdirSession, path: WorkdirPath, + max_total_bytes: usize, ) -> Result, workdir::WorkdirError> { let mut bytes = Vec::new(); let mut offset = 0_u64; @@ -116,16 +123,16 @@ async fn read_image_bytes( "bounded binary read returned a truncated or invalid byte range".to_string(), )); } - if result.total_bytes > MAX_IMAGE_BYTES as u64 { + if result.total_bytes > max_total_bytes as u64 { return Err(workdir::WorkdirError::InvalidArgument(format!( - "image exceeds the {MAX_IMAGE_BYTES}-byte limit" + "file exceeds the {max_total_bytes}-byte limit" ))); } if expected_total.is_some_and(|total| total != result.total_bytes) || expected_hash.is_some_and(|hash| hash != result.content_hash) { return Err(workdir::WorkdirError::Conflict( - "image changed while it was being read; retry ViewImage".to_string(), + "file changed while it was being read; retry the read".to_string(), )); } if expected_total.is_none() { @@ -146,7 +153,13 @@ async fn read_image_bytes( if result.eof { if bytes.len() as u64 != result.total_bytes { return Err(workdir::WorkdirError::Transport( - "bounded binary read returned a partial image".to_string(), + "bounded binary read returned partial file bytes".to_string(), + )); + } + let assembled_hash: ContentHash = Sha256::digest(&bytes).into(); + if assembled_hash != result.content_hash { + return Err(workdir::WorkdirError::Transport( + "bounded binary read assembled bytes do not match the content hash".to_string(), )); } return Ok(bytes); @@ -172,7 +185,8 @@ pub fn routed_view_image_tool(router: Arc) -> ToolDefiniti }) } -fn detect_image_mime(bytes: &[u8]) -> Option<&'static str> { +/// Detect PNG, JPEG, GIF or WebP signatures without trusting a file extension. +pub fn detect_image_mime(bytes: &[u8]) -> Option<&'static str> { if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { Some("image/png") } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) { @@ -189,6 +203,189 @@ fn detect_image_mime(bytes: &[u8]) -> Option<&'static str> { #[cfg(test)] mod tests { use super::*; + use manifest::Scope; + use std::collections::VecDeque; + use std::sync::Mutex; + use tempfile::TempDir; + use workdir::*; + + #[derive(Debug)] + struct BinarySession { + local: LocalWorkdirSession, + responses: Mutex>, + } + + #[async_trait] + impl WorkdirSession for BinarySession { + fn workdir(&self) -> &Workdir { + self.local.workdir() + } + fn capabilities(&self) -> WorkdirSessionCapabilities { + self.local.capabilities() + } + async fn read_bytes( + &self, + request: ReadBytesRequest, + ) -> Result { + let response = self.responses.lock().unwrap().pop_front().unwrap(); + if request.offset > 0 { + assert!(request.expected_hash.is_some()); + } + assert!(request.max_bytes <= IMAGE_READ_CHUNK_BYTES); + Ok(response) + } + async fn stat(&self, r: StatRequest) -> Result { + self.local.stat(r).await + } + async fn read(&self, r: ReadRequest) -> Result { + self.local.read(r).await + } + async fn write(&self, r: WriteRequest) -> Result { + self.local.write(r).await + } + async fn edit(&self, r: EditRequest) -> Result { + self.local.edit(r).await + } + async fn list(&self, r: ListRequest) -> Result { + self.local.list(r).await + } + async fn glob(&self, r: GlobRequest) -> Result { + self.local.glob(r).await + } + async fn grep(&self, r: GrepRequest) -> Result { + self.local.grep(r).await + } + async fn start_command(&self, r: CommandRequest) -> Result { + self.local.start_command(r).await + } + async fn command_status(&self, r: CommandHandle) -> Result { + self.local.command_status(r).await + } + async fn command_output( + &self, + r: CommandOutputRequest, + ) -> Result { + self.local.command_output(r).await + } + async fn cancel_command(&self, r: CommandHandle) -> Result<(), WorkdirError> { + self.local.cancel_command(r).await + } + async fn close(&self) -> Result<(), WorkdirError> { + self.local.close().await + } + } + + fn binary_session(dir: &TempDir, responses: Vec) -> BinarySession { + BinarySession { + local: LocalWorkdirSession::new( + Scope::writable(dir.path()).unwrap(), + dir.path().into(), + ), + responses: Mutex::new(responses.into()), + } + } + + fn chunk(bytes: &[u8], offset: u64, total: u64, hash: ContentHash) -> ReadBytesResult { + ReadBytesResult { + path: WorkdirPath::new("artifact.bin").unwrap(), + bytes: bytes.to_vec(), + offset, + total_bytes: total, + content_hash: hash, + eof: offset + bytes.len() as u64 == total, + } + } + + #[tokio::test] + async fn bounded_binary_read_rejects_bytes_with_forged_consistent_hash() { + let dir = TempDir::new().unwrap(); + let hash = Sha256::digest(b"abcd").into(); + let session = binary_session( + &dir, + vec![chunk(b"ab", 0, 4, hash), chunk(b"cx", 2, 4, hash)], + ); + let error = read_workdir_bytes(&session, WorkdirPath::new("artifact.bin").unwrap(), 4) + .await + .unwrap_err(); + assert!( + matches!(error, WorkdirError::Transport(message) if message.contains("content hash")) + ); + } + + #[tokio::test] + async fn bounded_binary_read_rejects_invalid_provider_chunk_metadata() { + let dir = TempDir::new().unwrap(); + let hash = Sha256::digest(b"abcd").into(); + let valid = chunk(b"abcd", 0, 4, hash); + let mut cases = Vec::new(); + let mut wrong_path = valid.clone(); + wrong_path.path = WorkdirPath::new("other.bin").unwrap(); + cases.push(wrong_path); + let mut wrong_offset = valid.clone(); + wrong_offset.offset = 1; + cases.push(wrong_offset); + let mut wrong_eof = valid.clone(); + wrong_eof.eof = false; + cases.push(wrong_eof); + let mut wrong_total = valid.clone(); + wrong_total.total_bytes = 3; + cases.push(wrong_total); + cases.push(chunk(b"", 0, 4, hash)); + cases.push(chunk( + &vec![0; IMAGE_READ_CHUNK_BYTES + 1], + 0, + (IMAGE_READ_CHUNK_BYTES + 1) as u64, + hash, + )); + for response in cases { + let session = binary_session(&dir, vec![response]); + let error = read_workdir_bytes(&session, valid.path.clone(), 16 * 1024 * 1024) + .await + .unwrap_err(); + assert!(matches!(error, WorkdirError::Transport(_)), "{error}"); + } + } + + #[tokio::test] + async fn bounded_binary_read_rejects_hash_or_size_changes_between_chunks() { + let dir = TempDir::new().unwrap(); + let hash = Sha256::digest(b"abcd").into(); + for second in [chunk(b"cd", 2, 4, [0; 32]), chunk(b"cd", 2, 5, hash)] { + let session = binary_session(&dir, vec![chunk(b"ab", 0, 4, hash), second]); + let error = read_workdir_bytes(&session, WorkdirPath::new("artifact.bin").unwrap(), 5) + .await + .unwrap_err(); + assert!(matches!(error, WorkdirError::Conflict(_)), "{error}"); + } + } + + #[tokio::test] + async fn bounded_binary_read_accepts_arbitrary_bytes_at_caller_limit_and_rejects_larger_files() + { + let dir = TempDir::new().unwrap(); + let bytes = vec![0xff; 16 * 1024 * 1024]; + std::fs::write(dir.path().join("artifact.bin"), &bytes).unwrap(); + let session = + LocalWorkdirSession::new(Scope::writable(dir.path()).unwrap(), dir.path().into()); + let path = WorkdirPath::new("artifact.bin").unwrap(); + assert_eq!( + read_workdir_bytes(&session, path.clone(), bytes.len()) + .await + .unwrap(), + bytes + ); + assert!(matches!( + read_workdir_bytes(&session, path.clone(), bytes.len() - 1).await, + Err(WorkdirError::InvalidArgument(_)) + )); + std::fs::write(dir.path().join("artifact.bin"), []).unwrap(); + assert!( + read_workdir_bytes(&session, path, 0) + .await + .unwrap() + .is_empty() + ); + } #[test] fn detects_supported_image_signatures_without_trusting_extensions() { diff --git a/crates/tools/src/write.rs b/crates/tools/src/write.rs index c6f083cba..a79b01713 100644 --- a/crates/tools/src/write.rs +++ b/crates/tools/src/write.rs @@ -125,6 +125,7 @@ pub(crate) async fn execute_write( attachments: Vec::new(), }, paths: create_path.into_iter().collect(), + listing: None, validator, }) } diff --git a/crates/tui/src/backend_spawn.rs b/crates/tui/src/backend_spawn.rs index db7a22a35..fadfd7599 100644 --- a/crates/tui/src/backend_spawn.rs +++ b/crates/tui/src/backend_spawn.rs @@ -120,13 +120,6 @@ impl FormState { self.field = Field::Runtime; return None; } - if runtime.working_directory_required { - self.status = - "The selected Runtime requires a workdir; this launch flow does not select one yet." - .to_string(); - self.field = Field::Runtime; - return None; - } let Some(profile) = self.current_profile(options) else { self.status = "No Worker profile is available.".to_string(); self.field = Field::Profile; @@ -377,8 +370,8 @@ fn profile_title(state: &FormState, options: &BackendWorkerLaunchOptions) -> Str fn runtime_label(runtime: &BackendWorkerLaunchRuntimeOption) -> String { let availability = if !runtime.worker_creation_available { "unavailable" - } else if runtime.working_directory_required { - "workdir required" + } else if runtime.supports_workdir_attachments { + "optional workdirs" } else { "no workdir" }; @@ -389,7 +382,7 @@ fn runtime_label(runtime: &BackendWorkerLaunchRuntimeOption) -> String { } fn runtime_supports_workdirless_creation(runtime: &BackendWorkerLaunchRuntimeOption) -> bool { - runtime.worker_creation_available && !runtime.working_directory_required + runtime.worker_creation_available } fn cycle_index(current: usize, len: usize, delta: isize) -> usize { @@ -413,7 +406,7 @@ mod tests { display_name: "External".to_string(), built_in: false, worker_creation_available: true, - working_directory_required: true, + supports_workdir_attachments: true, status: "online".to_string(), diagnostics: Vec::new(), }, @@ -422,7 +415,7 @@ mod tests { display_name: "Embedded".to_string(), built_in: true, worker_creation_available: true, - working_directory_required: false, + supports_workdir_attachments: false, status: "online".to_string(), diagnostics: Vec::new(), }, @@ -454,20 +447,18 @@ mod tests { let state = FormState::new(&options); assert_eq!( state.current_runtime(&options).unwrap().runtime_id, - "embedded" + "external" ); assert_eq!(state.current_profile(&options).unwrap().id, "builtin:coder"); assert_eq!(state.display_name, "Worker"); } #[test] - fn workdir_required_runtime_cannot_be_submitted() { + fn attachment_capable_runtime_can_submit_without_resources() { let options = options(); let mut state = FormState::new(&options); state.runtime_index = 0; - assert_eq!(state.submit(&options), None); - assert!(state.status.contains("requires a workdir")); - assert_eq!(state.field, Field::Runtime); + assert_eq!(state.submit(&options).unwrap().runtime_id, "external"); } #[test] diff --git a/crates/workdir/CHECKOUT_PROVIDER.md b/crates/workdir/CHECKOUT_PROVIDER.md index 0e7a9827b..119702002 100644 --- a/crates/workdir/CHECKOUT_PROVIDER.md +++ b/crates/workdir/CHECKOUT_PROVIDER.md @@ -101,6 +101,37 @@ Host generation reuse, native permission identity, read-history updates, model Operation publication, transport hosting (Runtime/Backend/External), and durable Ticket/MR review remain the parent integration's responsibilities. +## Bounded live List pagination + +`ListRequest { path, limit, after: Option }` accepts an exclusive +ordering key `ListCursor { kind: EntryKind, path: FsPath }`. Directories sort +first, then paths sort lexically within the directory/non-directory groups; +File, Symlink and Other share the non-directory group. `ListResult.next_after` +is the last returned key when another page remains. Reuse that key unchanged +with the same directory and output root. Cursor paths must name a direct child +in the result coordinate frame; they are not statted and need not still exist. +The optional fields default to absent and are omitted from JSON when absent. + +The shared provider engine retains at most `limit + 1` candidates, additionally +bounded by the 1 MiB aggregate path-response ceiling (including continuation), +while scanning the directory once. It never stops merely because the page is +full: enumeration order cannot determine the retained prefix. `total_entries` +and `total_bytes` describe all visible entries in the current scan, including +keys at/before `after`, not only the page or remaining suffix. A byte-bounded +page may contain fewer than `limit` entries and still provide continuation. +A zero-limit request returns totals and truncation but no continuation. +List uses Read capability and read/enumeration scope, and **does not ignore-filter** +`.gitignore`/`.ignore` entries or hide dotfiles; Glob/Grep keep their own policy. +Cancellation and the 100,000-entry traversal ceiling apply to the complete scan, +including excluded entries. External DTOs retain their existing item/path bounds +and also validate cursor paths and continuation response bytes. + +Each page observes a live directory, not a snapshot, and no cross-page +consistency is promised. Insertions after the cursor may appear; earlier keys +are not revisited. Removed entries disappear, including the cursor entry. +Renames or kind changes can move an entry across the cursor and cause omission +or repetition. Even one scan is not transactional against external editors. + ## Scoped checkout search boundary `CheckoutSearchRequest::new(CheckoutSearchOperation::{List, Glob, Grep}(request))` @@ -110,7 +141,8 @@ are `scope_layers: Vec>` and `output_root: WorkdirPath Rules union within each layer; layers intersect with each other, the current provider authority, and the output-root boundary. Wrappers contribute their own scope; caller-supplied layers can only narrow, never replace it. Each wrapper -translates all incoming coordinates once and forwards the composed output root. +translates incoming provider coordinates once and forwards the composed output root. +List cursors remain output-root-relative, just like result paths. Provider-rendered Grep text and every typed result path are already relative to that final root; wrappers must not prefix cwd onto returned paths again. diff --git a/crates/workdir/src/checkout.rs b/crates/workdir/src/checkout.rs index f524ae61c..d6592df2b 100644 --- a/crates/workdir/src/checkout.rs +++ b/crates/workdir/src/checkout.rs @@ -167,6 +167,19 @@ impl CheckoutSearchRequest { } let valid = match &self.operation { CheckoutSearchOperation::List(r) => { + if let Some(after) = &r.after { + validate_path(&after.path)?; + if after.path.is_root() + || std::path::Path::new(after.path.as_str()).parent() + != std::path::Path::new(r.path.as_str()) + .strip_prefix(self.output_root.as_str()) + .ok() + { + return Err(WorkdirError::InvalidArgument( + "List cursor must name a direct child of the listed directory".into(), + )); + } + } r.limit <= crate::external::MAX_EXTERNAL_RESULT_ITEMS } CheckoutSearchOperation::Glob(r) => { @@ -793,6 +806,7 @@ mod tests { for provider in [&local as &dyn WorkdirSession, &external] { let listed = provider .list(ListRequest { + after: None, path: WorkdirPath::root(), limit: 100, }) diff --git a/crates/workdir/src/checkout_search_tests.rs b/crates/workdir/src/checkout_search_tests.rs index 0de5ab0db..44c81488e 100644 --- a/crates/workdir/src/checkout_search_tests.rs +++ b/crates/workdir/src/checkout_search_tests.rs @@ -60,6 +60,7 @@ fn fixture() -> tempfile::TempDir { async fn verify_sparse(session: &dyn WorkdirSession) { let listed = session .list(ListRequest { + after: None, path: WorkdirPath::root(), limit: 100, }) @@ -75,6 +76,7 @@ async fn verify_sparse(session: &dyn WorkdirSession) { ); let empty = session .list(ListRequest { + after: None, path: path("denied"), limit: 100, }) @@ -108,6 +110,7 @@ async fn verify_sparse(session: &dyn WorkdirSession) { let CheckoutSearchResult::List(native) = session .checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List( ListRequest { + after: None, path: WorkdirPath::root(), limit: 100, }, @@ -161,6 +164,7 @@ async fn checkout_search_nested_sparse_nonrecursive_scopes_rebase_and_exclude_co .tool_session() .checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List( ListRequest { + after: None, path: WorkdirPath::root(), limit: 100 } @@ -196,6 +200,7 @@ async fn checkout_search_composes_actual_nested_wrappers_and_output_root() { let CheckoutSearchResult::List(result) = stacked .checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List( ListRequest { + after: None, path: path("keep"), limit: 100, }, @@ -229,6 +234,7 @@ async fn checkout_search_composes_actual_nested_wrappers_and_output_root() { let entries = second .tool_session() .list(ListRequest { + after: None, path: WorkdirPath::root(), limit: 100, }) @@ -246,6 +252,7 @@ async fn checkout_search_composes_actual_nested_wrappers_and_output_root() { second .tool_session() .list(ListRequest { + after: None, path: path("deep"), limit: 100 }) @@ -292,6 +299,7 @@ async fn checkout_search_scope_layers_intersect_before_list_and_grep() { root.path().to_path_buf(), ); let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::List(ListRequest { + after: None, path: path("child"), limit: 100, })); @@ -325,6 +333,7 @@ async fn checkout_search_wire_dto_bounds_and_provider_capabilities() { root.path().to_path_buf(), ); let request = CheckoutSearchRequest::new(CheckoutSearchOperation::List(ListRequest { + after: None, path: path("child/keep"), limit: 100, })); @@ -795,6 +804,7 @@ async fn checkout_search_preserves_current_provider_scope_and_rejects_symlink_es let CheckoutSearchResult::List(result) = source .checkout_search(CheckoutSearchRequest { operation: CheckoutSearchOperation::List(ListRequest { + after: None, path: path("child/denied"), limit: 100, }), diff --git a/crates/workdir/src/external.rs b/crates/workdir/src/external.rs index 1b37cda23..1d91c20a3 100644 --- a/crates/workdir/src/external.rs +++ b/crates/workdir/src/external.rs @@ -155,7 +155,11 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<() <= MAX_EXTERNAL_SCOPE_RULES && validate_external_operation(&match &request.operation { crate::CheckoutSearchOperation::List(r) => { - WorkdirSessionOperation::List(r.clone()) + // request.validate() checks the output_root-relative + // cursor; validate the provider-coordinate path here. + let mut list = r.clone(); + list.after = None; + WorkdirSessionOperation::List(list) } crate::CheckoutSearchOperation::Glob(r) => { WorkdirSessionOperation::Glob(r.clone()) @@ -212,7 +216,14 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<() .contains(&request.max_bytes) } WorkdirSessionOperation::List(request) => { - is_root_relative(&request.path) && request.limit <= MAX_EXTERNAL_RESULT_ITEMS + is_root_relative(&request.path) + && request.limit <= MAX_EXTERNAL_RESULT_ITEMS + && request.after.as_ref().is_none_or(|after| { + is_root_relative(&after.path) + && !after.path.is_root() + && Path::new(after.path.as_str()).parent() + == Some(Path::new(request.path.as_str())) + }) } WorkdirSessionOperation::Glob(request) => { is_root_relative(&request.path) @@ -395,7 +406,20 @@ fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<() .entries .iter() .all(|entry| is_root_relative(&entry.path)) - && result_paths_fit(result.entries.iter().map(|entry| &entry.path)) + && result.next_after.as_ref().is_none_or(|after| { + is_root_relative(&after.path) + && result.truncated + && result.entries.last().is_some_and(|entry| { + entry.kind == after.kind && entry.path == after.path + }) + }) + && result_paths_fit( + result + .entries + .iter() + .map(|entry| &entry.path) + .chain(result.next_after.iter().map(|after| &after.path)), + ) } WorkdirSessionOperationResult::Glob(result) => { result.paths.len() <= MAX_EXTERNAL_RESULT_ITEMS @@ -917,6 +941,7 @@ mod tests { assert!(serde_json::from_value::(nested).is_err()); let unbounded = WorkdirSessionOperation::List(ListRequest { + after: None, path: WorkdirPath::root(), limit: MAX_EXTERNAL_RESULT_ITEMS + 1, }); diff --git a/crates/workdir/src/lib.rs b/crates/workdir/src/lib.rs index 29f265784..f46126384 100644 --- a/crates/workdir/src/lib.rs +++ b/crates/workdir/src/lib.rs @@ -33,9 +33,9 @@ use tokio::sync::broadcast; pub use fs_operation::{ BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath, - GlobRequest, GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, - ListResult, ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest, - StatResult, WriteRequest, WriteResult, + GlobRequest, GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListCursor, ListEntry, + ListRequest, ListResult, ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, + StatRequest, StatResult, WriteRequest, WriteResult, }; pub use http::dispatch_workdir_session_operation; pub use local::{ diff --git a/crates/workdir/src/local.rs b/crates/workdir/src/local.rs index 417f6fe88..2d619655a 100644 --- a/crates/workdir/src/local.rs +++ b/crates/workdir/src/local.rs @@ -2695,6 +2695,7 @@ mod tests { let listed = WorkdirSession::list( &workdir, ListRequest { + after: None, path: WorkdirPath::new("notes").unwrap(), limit: 10, }, @@ -3385,6 +3386,7 @@ mod tests { let list = WorkdirSession::list( &workdir, ListRequest { + after: None, path: WorkdirPath::new("yoi.local").unwrap(), limit: 10, }, diff --git a/crates/workdir/src/scope.rs b/crates/workdir/src/scope.rs index 2f798d4b5..e36e3cd88 100644 --- a/crates/workdir/src/scope.rs +++ b/crates/workdir/src/scope.rs @@ -1005,6 +1005,9 @@ impl WorkdirSession for ScopedWorkdirSession { .await?; self.ensure_read(&path, request.operation.capability())?; *request.operation.path_mut() = path; + // List cursors stay output_root-relative, just like source results. + // Composing output_root below handles their coordinates without stat + // of a key that may have disappeared between live pages. request.output_root = self.resolve_path(&request.output_root)?; for layer in &mut request.scope_layers { for rule in layer { @@ -1873,6 +1876,7 @@ mod tests { assert_eq!( parent .list(ListRequest { + after: None, path: fs_path("child"), limit: 100 }) @@ -1890,6 +1894,7 @@ mod tests { child .tool_session() .list(ListRequest { + after: None, path: FsPath::root(), limit: 100 }) @@ -1901,6 +1906,7 @@ mod tests { .tool_session() .checkout_search(crate::CheckoutSearchRequest::new( crate::CheckoutSearchOperation::List(ListRequest { + after: None, path: FsPath::root(), limit: 100 }) diff --git a/crates/workdir/tests/list_pagination.rs b/crates/workdir/tests/list_pagination.rs new file mode 100644 index 000000000..8d168b028 --- /dev/null +++ b/crates/workdir/tests/list_pagination.rs @@ -0,0 +1,267 @@ +#![cfg(target_os = "linux")] +use std::sync::Arc; +use workdir::external::{ + ExternalWorkdirOperation, ExternalWorkdirOperationResult, MAX_EXTERNAL_RESULT_ITEMS, +}; +use workdir::http::{WorkdirSessionOperation as Op, WorkdirSessionOperationResult as Output}; +use workdir::*; + +fn path(value: &str) -> WorkdirPath { + WorkdirPath::new(value).unwrap() +} +fn rule(value: &str, recursive: bool) -> WorkdirToolScopeRule { + WorkdirToolScopeRule { + target: path(value), + recursive, + permission: WorkdirToolScopePermission::Read, + symlink_policy: manifest::SymlinkPolicy::Logical, + } +} +fn scope(rules: Vec, cwd: &str) -> WorkdirToolScope { + WorkdirToolScope { + rules, + cwd: path(cwd), + command: false, + } +} +fn request(value: &str, limit: usize, after: Option) -> ListRequest { + ListRequest { + path: path(value), + limit, + after, + } +} +fn names(result: &ListResult) -> Vec<&str> { + result + .entries + .iter() + .map(|entry| entry.path.as_str()) + .collect() +} + +#[tokio::test] +async fn list_scoped_external_pages_rebase_cursor_and_preserve_sparse_authority() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(root.path().join("child/deep")).unwrap(); + for name in ["child/a", "child/z", "child/deep/secret", "outside"] { + std::fs::write(root.path().join(name), b"x").unwrap(); + } + let source = LocalWorkdirSession::external_read_only( + Workdir::new("pagination"), + root.path(), + BoundedReadLimits::EXTERNAL_DEFAULT, + ) + .unwrap(); + let broker = WorkdirToolBroker::new(Arc::new(source)); + let lease = broker + .scope(scope(vec![rule("child", false)], "child")) + .await + .unwrap(); + // Two actual wrappers must not duplicate child/ in the continuation. + let stacked = WorkdirToolBroker::new(lease.tool_session()); + let first = stacked.list(request("", 1, None)).await.unwrap(); + assert_eq!(names(&first), ["deep"]); + assert_eq!(first.next_after.as_ref().unwrap().path, path("deep")); + assert_eq!( + first.next_after.as_ref().unwrap().kind, + EntryKind::Directory + ); + assert_eq!(first.total_entries, 3); + assert!( + stacked + .list(request("deep", 1, None)) + .await + .unwrap() + .entries + .is_empty() + ); + let second = stacked + .list(request("", 1, first.next_after)) + .await + .unwrap(); + assert_eq!(names(&second), ["a"]); + std::fs::remove_file(root.path().join("child/a")).unwrap(); + std::fs::write(root.path().join("child/b"), b"xx").unwrap(); + let third = stacked + .list(request("", 10, second.next_after)) + .await + .unwrap(); + assert_eq!(names(&third), ["b", "z"]); + assert!(!third.truncated); + assert!(third.next_after.is_none()); + assert_eq!(third.total_entries, 3); + lease.close().await.unwrap(); + assert!(matches!( + stacked.list(request("", 1, None)).await, + Err(WorkdirError::SessionClosed) + )); +} + +#[tokio::test] +async fn list_checkout_search_cursor_uses_output_root_coordinates_across_nested_wrappers() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(root.path().join("child/keep")).unwrap(); + for name in ["a", "b", "c"] { + std::fs::write(root.path().join("child/keep").join(name), b"x").unwrap(); + } + let source = LocalWorkdirSession::external_read_only( + Workdir::new("rebased-pagination"), + root.path(), + BoundedReadLimits::EXTERNAL_DEFAULT, + ) + .unwrap(); + let broker = WorkdirToolBroker::new(Arc::new(source)); + let lease = broker + .scope(scope(vec![rule("child", true)], "child")) + .await + .unwrap(); + let stacked = WorkdirToolBroker::new(lease.tool_session()); + let mut search = + CheckoutSearchRequest::new(CheckoutSearchOperation::List(request("keep", 1, None))); + search.output_root = path("keep"); + let CheckoutSearchResult::List(first) = stacked.checkout_search(search.clone()).await.unwrap() + else { + panic!() + }; + assert_eq!(names(&first), ["a"]); + if let CheckoutSearchOperation::List(list) = &mut search.operation { + list.after = first.next_after; + } + let CheckoutSearchResult::List(second) = stacked.checkout_search(search).await.unwrap() else { + panic!() + }; + assert_eq!(names(&second), ["b"]); + assert_eq!(second.next_after.unwrap().path, path("b")); +} + +#[tokio::test] +async fn list_read_capability_suffices_without_glob_and_missing_read_is_denied() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("a"), b"x").unwrap(); + let source = LocalWorkdirSession::materialized( + root.path().to_path_buf(), + root.path().to_path_buf(), + manifest::SharedScope::new(manifest::Scope::writable(root.path()).unwrap()), + WorkdirSessionCapabilities::from_capabilities([WorkdirSessionCapability::Read]), + ); + let broker = WorkdirToolBroker::new(Arc::new(source)); + let lease = broker.scope(scope(vec![rule("", true)], "")).await.unwrap(); + assert_eq!( + names( + &lease + .tool_session() + .list(request("", 1, None)) + .await + .unwrap() + ), + ["a"] + ); + let denied = LocalWorkdirSession::external_with_capabilities_pinned( + Workdir::new("no-read"), + ExternalWorkdirRoot::pin(root.path()).unwrap(), + BoundedReadLimits::EXTERNAL_DEFAULT, + WorkdirSessionCapabilities::COMMAND_ONLY, + ) + .unwrap(); + assert!(matches!( + denied.list(request("", 1, None)).await, + Err(WorkdirError::Unsupported(WorkdirSessionCapability::Read)) + )); +} + +#[tokio::test] +async fn list_external_wire_preserves_continuation_and_rejects_unbounded_cursor() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("child")).unwrap(); + for name in ["a", "b"] { + std::fs::write(root.path().join("child").join(name), b"x").unwrap(); + } + let source = LocalWorkdirSession::external_read_only( + Workdir::new("wire-list"), + root.path(), + BoundedReadLimits::EXTERNAL_DEFAULT, + ) + .unwrap(); + let first = source.list(request("child", 1, None)).await.unwrap(); + let wire = serde_json::to_value( + ExternalWorkdirOperationResult::try_from(Output::List(first.clone())).unwrap(), + ) + .unwrap(); + assert_eq!(wire["result"]["next_after"]["path"], "child/a"); + let decoded: ExternalWorkdirOperationResult = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(decoded.into_inner(), Output::List(first.clone())); + let operation = + ExternalWorkdirOperation::try_from(Op::List(request("child", 1, first.next_after))) + .unwrap(); + let decoded: ExternalWorkdirOperation = + serde_json::from_value(serde_json::to_value(operation).unwrap()).unwrap(); + let Output::List(next) = dispatch_workdir_session_operation(&source, decoded.into_inner()) + .await + .unwrap() + else { + panic!() + }; + assert_eq!(names(&next), ["child/b"]); + assert!(next.next_after.is_none()); + for bad_path in ["/host".to_owned(), "x".repeat(4097), "outside/a".to_owned()] { + let mut bad = serde_json::to_value(Op::List(request("child", 1, None))).unwrap(); + bad["request"]["after"] = serde_json::json!({"kind": "file", "path": bad_path}); + assert!(serde_json::from_value::(bad).is_err()); + let mut bad_result = wire.clone(); + bad_result["result"]["next_after"]["path"] = serde_json::json!(bad_path); + assert!(serde_json::from_value::(bad_result).is_err()); + } + assert!( + ExternalWorkdirOperation::try_from(Op::List(request( + "child", + MAX_EXTERNAL_RESULT_ITEMS + 1, + None + ))) + .is_err() + ); + // Search cursors use result coordinates, not provider path coordinates. + let mut search = CheckoutSearchRequest::new(CheckoutSearchOperation::List(request( + "child", + 1, + Some(ListCursor { + kind: EntryKind::File, + path: path("a"), + }), + ))); + search.output_root = path("child"); + let bounded = ExternalWorkdirOperation::try_from(Op::CheckoutSearch(search)).unwrap(); + let Output::CheckoutSearch(CheckoutSearchResult::List(next)) = + dispatch_workdir_session_operation(&source, bounded.into_inner()) + .await + .unwrap() + else { + panic!() + }; + assert_eq!(names(&next), ["b"]); +} + +#[test] +fn list_external_result_counts_cursor_toward_aggregate_path_bound() { + let entries: Vec<_> = (0..252) + .map(|n| ListEntry { + path: path(&format!("{n:04}{}", "x".repeat(4092))), + kind: EntryKind::File, + size: 1, + }) + .collect(); + let after = ListCursor { + kind: EntryKind::File, + path: entries.last().unwrap().path.clone(), + }; + let result = ListResult { + entries, + total_entries: 253, + total_bytes: 253, + truncated: true, + next_after: None, + }; + let mut wire = serde_json::to_value(Output::List(result)).unwrap(); + assert!(serde_json::from_value::(wire.clone()).is_ok()); + wire["result"]["next_after"] = serde_json::to_value(after).unwrap(); + assert!(serde_json::from_value::(wire).is_err()); +} diff --git a/crates/worker-runtime/src/auth.rs b/crates/worker-runtime/src/auth.rs index 9e7a89484..d59880de2 100644 --- a/crates/worker-runtime/src/auth.rs +++ b/crates/worker-runtime/src/auth.rs @@ -42,7 +42,11 @@ pub enum RuntimeAuthError { #[error("runtime request proof contains an invalid `{0}` claim")] InvalidClaim(&'static str), #[error("runtime request proof does not match the HTTP request")] - ClaimMismatch, + ClaimMismatch(RuntimeRequestClaim), + #[error("runtime request proof was issued in the future")] + RequestIssuedInFuture { iat: i64, exp: i64, now: i64 }, + #[error("runtime request proof has expired")] + RequestExpired { iat: i64, exp: i64, now: i64 }, #[error("unknown token issuer `{0}`")] UnknownIssuer(String), #[error("invalid token signature")] @@ -171,6 +175,19 @@ pub struct RuntimeAuthContext { pub expires_at: u64, } +/// The first mismatching signed claim, in verification order. No input values. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RuntimeRequestClaim { + Issuer, + Audience, + Workspace, + Worker, + Permission, + Method, + Path, + BodyDigest, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RuntimeRequestSourceClaims { pub iss: String, @@ -293,19 +310,54 @@ pub fn verify_runtime_request_source( public_key, )?; let claims = signed.claims; - if claims.iss != expected.identity_id - || claims.aud != expected.audience - || claims.workspace_id != expected.workspace_id - || claims.worker_id.as_deref() != expected.worker_id - || claims.permission != expected.permission - || claims.method != expected.method - || claims.path != expected.path - || claims.body_digest != expected.body_digest - { - return Err(RuntimeAuthError::ClaimMismatch); + for (matches, claim) in [ + ( + claims.iss == expected.identity_id, + RuntimeRequestClaim::Issuer, + ), + ( + claims.aud == expected.audience, + RuntimeRequestClaim::Audience, + ), + ( + claims.workspace_id == expected.workspace_id, + RuntimeRequestClaim::Workspace, + ), + ( + claims.worker_id.as_deref() == expected.worker_id, + RuntimeRequestClaim::Worker, + ), + ( + claims.permission == expected.permission, + RuntimeRequestClaim::Permission, + ), + ( + claims.method == expected.method, + RuntimeRequestClaim::Method, + ), + (claims.path == expected.path, RuntimeRequestClaim::Path), + ( + claims.body_digest == expected.body_digest, + RuntimeRequestClaim::BodyDigest, + ), + ] { + if !matches { + return Err(RuntimeAuthError::ClaimMismatch(claim)); + } } - if claims.iat > expected.now_unix || claims.exp < expected.now_unix { - return Err(RuntimeAuthError::Expired); + if claims.iat > expected.now_unix { + return Err(RuntimeAuthError::RequestIssuedInFuture { + iat: claims.iat, + exp: claims.exp, + now: expected.now_unix, + }); + } + if claims.exp < expected.now_unix { + return Err(RuntimeAuthError::RequestExpired { + iat: claims.iat, + exp: claims.exp, + now: expected.now_unix, + }); } Ok(claims) } @@ -705,7 +757,9 @@ mod tests { }; assert!(matches!( verify_runtime_request_source(&proof, &trusted.public_key, &changed_body), - Err(RuntimeAuthError::ClaimMismatch) + Err(RuntimeAuthError::ClaimMismatch( + RuntimeRequestClaim::BodyDigest + )) )); let spoofed = RuntimeIdentityMaterial::generate("runtime-main").unwrap(); assert!(matches!( @@ -714,6 +768,183 @@ mod tests { )); } + #[test] + fn runtime_request_proof_preserves_inclusive_time_bounds_and_reports_exact_rejections() { + let runtime = RuntimeIdentityMaterial::generate("runtime-main").unwrap(); + let signer = RuntimeRequestSourceSigner::from_identity(&runtime); + let proof = signer + .issue( + "server", + "workspace", + None, + "permission", + "POST", + "/request", + b"{}", + 90, + 10, + ) + .unwrap(); + let digest = request_body_digest(b"{}"); + let expected = RuntimeRequestSourceExpectation { + identity_id: "runtime-main", + audience: "server", + workspace_id: "workspace", + worker_id: None, + permission: "permission", + method: "POST", + path: "/request", + body_digest: &digest, + now_unix: 90, + }; + for now in [90, 99, 100] { + assert!( + verify_runtime_request_source( + &proof, + &runtime.public_key, + &RuntimeRequestSourceExpectation { + now_unix: now, + ..expected.clone() + } + ) + .is_ok() + ); + } + for (now, future) in [(89, true), (101, false)] { + let error = verify_runtime_request_source( + &proof, + &runtime.public_key, + &RuntimeRequestSourceExpectation { + now_unix: now, + ..expected.clone() + }, + ) + .unwrap_err(); + match error { + RuntimeAuthError::RequestIssuedInFuture { + iat, + exp, + now: verified, + } if future => assert_eq!((iat, exp, verified), (90, 100, now)), + RuntimeAuthError::RequestExpired { + iat, + exp, + now: verified, + } if !future => assert_eq!((iat, exp, verified), (90, 100, now)), + other => panic!("unexpected rejection: {other:?}"), + } + } + for (claim, expectation) in [ + ( + RuntimeRequestClaim::Issuer, + RuntimeRequestSourceExpectation { + identity_id: "other", + ..expected.clone() + }, + ), + ( + RuntimeRequestClaim::Audience, + RuntimeRequestSourceExpectation { + audience: "other", + ..expected.clone() + }, + ), + ( + RuntimeRequestClaim::Workspace, + RuntimeRequestSourceExpectation { + workspace_id: "other", + ..expected.clone() + }, + ), + ( + RuntimeRequestClaim::Worker, + RuntimeRequestSourceExpectation { + worker_id: Some("other"), + ..expected.clone() + }, + ), + ( + RuntimeRequestClaim::Permission, + RuntimeRequestSourceExpectation { + permission: "other", + ..expected.clone() + }, + ), + ( + RuntimeRequestClaim::Method, + RuntimeRequestSourceExpectation { + method: "GET", + ..expected.clone() + }, + ), + ( + RuntimeRequestClaim::Path, + RuntimeRequestSourceExpectation { + path: "/other", + ..expected.clone() + }, + ), + ( + RuntimeRequestClaim::BodyDigest, + RuntimeRequestSourceExpectation { + body_digest: "other", + ..expected.clone() + }, + ), + ] { + assert!( + matches!(verify_runtime_request_source(&proof, &runtime.public_key, &expectation), Err(RuntimeAuthError::ClaimMismatch(actual)) if actual == claim) + ); + } + assert!(matches!( + verify_runtime_request_source( + &proof, + &runtime.public_key, + &RuntimeRequestSourceExpectation { + audience: "other", + now_unix: 101, + ..expected.clone() + } + ), + Err(RuntimeAuthError::ClaimMismatch( + RuntimeRequestClaim::Audience + )) + )); + let spoofed = RuntimeIdentityMaterial::generate("runtime-main").unwrap(); + assert!(matches!( + verify_runtime_request_source( + &proof, + &spoofed.public_key, + &RuntimeRequestSourceExpectation { + now_unix: 101, + ..expected.clone() + } + ), + Err(RuntimeAuthError::InvalidSignature) + )); + let mut claims = decode_runtime_request_source_claims(&proof).unwrap(); + claims.iat = 101; + claims.exp = 89; + let contradictory = sign_json_token( + RUNTIME_REQUEST_SOURCE_PROOF_PREFIX, + RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX, + &runtime.signing_key().unwrap(), + &claims, + ) + .unwrap(); + assert!(matches!( + verify_runtime_request_source( + &contradictory, + &runtime.public_key, + &RuntimeRequestSourceExpectation { + now_unix: 100, + ..expected.clone() + } + ), + Err(RuntimeAuthError::RequestIssuedInFuture { .. }) + )); + } + #[test] fn runtime_request_source_proof_rejects_wrong_scope_and_expiry() { let runtime = RuntimeIdentityMaterial::generate("runtime-main").unwrap(); @@ -749,7 +980,9 @@ mod tests { }; assert!(matches!( verify_runtime_request_source(&proof, &runtime.public_key, &wrong_workspace), - Err(RuntimeAuthError::ClaimMismatch) + Err(RuntimeAuthError::ClaimMismatch( + RuntimeRequestClaim::Workspace + )) )); let expired = RuntimeRequestSourceExpectation { now_unix: 101, @@ -757,7 +990,11 @@ mod tests { }; assert!(matches!( verify_runtime_request_source(&proof, &runtime.public_key, &expired), - Err(RuntimeAuthError::Expired) + Err(RuntimeAuthError::RequestExpired { + iat: 90, + exp: 100, + now: 101 + }) )); } } diff --git a/crates/worker-runtime/src/worker_source.rs b/crates/worker-runtime/src/worker_source.rs index 22f81aa79..43bda3fb0 100644 --- a/crates/worker-runtime/src/worker_source.rs +++ b/crates/worker-runtime/src/worker_source.rs @@ -1,11 +1,13 @@ use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use crate::workspace_request::{RuntimeWorkspaceRequest, RuntimeWorkspaceRequestClient}; +use crate::workspace_request::{ + RuntimeWorkspaceRequest, RuntimeWorkspaceRequestClient, RuntimeWorkspaceRequestError, +}; use worker::{ - WorkspaceClient, WorkspaceClientError, WorkspacePromptCatalogResolution, - WorkspacePromptProjection, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse, - WorkspaceServerOperation, + WorkspaceBinaryRequest, WorkspaceBinaryResponse, WorkspaceClient, WorkspaceClientError, + WorkspacePromptCatalogResolution, WorkspacePromptProjection, WorkspaceRequest, + WorkspaceRequestMethod, WorkspaceResponse, WorkspaceServerOperation, }; use crate::auth::{ @@ -381,6 +383,64 @@ impl RuntimeOwnedWorkspaceClient { self } + fn execute_binary_with_optional_timeout( + &self, + request: WorkspaceBinaryRequest, + timeout: Option, + ) -> Result { + let method = match request.method { + WorkspaceRequestMethod::Get => reqwest::Method::GET, + WorkspaceRequestMethod::Post => reqwest::Method::POST, + WorkspaceRequestMethod::Put => reqwest::Method::PUT, + WorkspaceRequestMethod::Patch => reqwest::Method::PATCH, + WorkspaceRequestMethod::Delete => reqwest::Method::DELETE, + }; + let mut headers = reqwest::header::HeaderMap::new(); + if request.body.is_some() { + headers.insert( + reqwest::header::CONTENT_TYPE, + reqwest::header::HeaderValue::from_static("application/octet-stream"), + ); + } + let response = self + .request_client + .execute_binary_blocking(RuntimeWorkspaceRequest { + method, + path_and_query: request.path, + body: request.body.unwrap_or_default(), + headers, + permission: WORKSPACE_REQUEST_PERMISSION.to_string(), + worker_id: Some(self.worker_id.clone()), + timeout, + max_response_bytes: request.max_response_bytes, + }) + .map_err(|error| { + // Never expose request URLs, signed proofs, or binary bodies in transport errors. + let message = match error { + RuntimeWorkspaceRequestError::InvalidRequest(_) => { + "invalid Workspace binary request" + } + RuntimeWorkspaceRequestError::Sign(_) => { + "Workspace binary request authorization failed" + } + RuntimeWorkspaceRequestError::ResponseTooLarge { .. } => { + "Workspace binary response exceeded limit" + } + RuntimeWorkspaceRequestError::Transport { timeout: true, .. } => { + "Workspace binary request timed out" + } + RuntimeWorkspaceRequestError::Transport { .. } => { + "Workspace binary request failed" + } + }; + WorkspaceClientError::Request(message.to_string()) + })?; + Ok(WorkspaceBinaryResponse { + status: response.status.as_u16(), + body: response.body, + }) + } + fn execute_with_optional_timeout( &self, request: WorkspaceRequest, @@ -481,6 +541,21 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { self.execute_with_optional_timeout(request, self.request_timeout) } + fn execute_binary( + &self, + request: WorkspaceBinaryRequest, + ) -> Result { + self.execute_binary_with_optional_timeout(request, self.request_timeout) + } + + fn execute_binary_with_timeout( + &self, + request: WorkspaceBinaryRequest, + timeout: Duration, + ) -> Result { + self.execute_binary_with_optional_timeout(request, Some(timeout)) + } + fn execute_with_timeout( &self, request: WorkspaceRequest, @@ -789,6 +864,216 @@ mod tests { request_body_digest, verify_worker_mutation_source_proof, }; + fn binary_loopback(response: Vec) -> (String, std::thread::JoinHandle<(String, Vec)>) { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + stream.read_exact(&mut byte).unwrap(); + head.push(byte[0]); + assert!(head.len() < 16 * 1024, "request headers too large"); + } + let head = String::from_utf8(head).unwrap(); + let length = head + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().unwrap()) + }) + .unwrap_or(0); + let mut body = vec![0; length]; + stream.read_exact(&mut body).unwrap(); + // Oversize-response tests may close the connection before all bytes are sent. + let _ = stream.write_all(&response); + (head, body) + }); + (base_url, server) + } + + #[test] + fn binary_roundtrip_signs_exact_query_method_worker_and_raw_body() { + use crate::auth::{RuntimeRequestSourceExpectation, verify_runtime_request_source}; + let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap(); + for (method, wire_method, body, reply) in [ + ( + WorkspaceRequestMethod::Get, + "GET", + None, + vec![0, 255, 128, 13], + ), + ( + WorkspaceRequestMethod::Post, + "POST", + Some(vec![0, 255, 128, 13, 10]), + vec![255, 0], + ), + ( + WorkspaceRequestMethod::Put, + "PUT", + Some(vec![255; 16 * 1024 * 1024]), + vec![0], + ), + ] { + let path = "/api/w/workspace-a/drive/entries/a/content?offset=0&limit=65536&name=a%2Fb"; + let mut response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + reply.len() + ) + .into_bytes(); + response.extend_from_slice(&reply); + let (base_url, server) = binary_loopback(response); + let client = + RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a") + .with_runtime_request_source(&identity, "server-a"); + let result = client + .execute_binary_with_timeout( + WorkspaceBinaryRequest { + method, + path: path.into(), + body: body.clone(), + max_response_bytes: reply.len(), + }, + Duration::from_secs(5), + ) + .unwrap(); + assert_eq!(result.status, 200); + assert_eq!(result.body, reply); + let (head, received) = server.join().unwrap(); + assert_eq!(received, body.unwrap_or_default()); + assert!(head.starts_with(&format!("{wire_method} {path} HTTP/1.1\r\n"))); + let header = |name: &str| { + head.lines().find_map(|line| { + let (key, value) = line.split_once(':')?; + key.eq_ignore_ascii_case(name).then(|| value.trim()) + }) + }; + assert_eq!(header("x-yoi-runtime-id"), Some("runtime-a")); + assert_eq!(header("x-yoi-worker-id"), Some("worker-a")); + assert_eq!(header("authorization"), None); + if method != WorkspaceRequestMethod::Get { + assert_eq!(header("content-type"), Some("application/octet-stream")); + } + let digest = request_body_digest(&received); + let expected = RuntimeRequestSourceExpectation { + identity_id: "runtime-a", + audience: "server-a", + workspace_id: "workspace-a", + worker_id: Some("worker-a"), + permission: WORKSPACE_REQUEST_PERMISSION, + method: wire_method, + path, + body_digest: &digest, + now_unix: unix_now_seconds() as i64, + }; + let token = header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER).unwrap(); + verify_runtime_request_source(token, &identity.public_key, &expected).unwrap(); + let altered = RuntimeRequestSourceExpectation { + body_digest: &request_body_digest(b"altered"), + ..expected.clone() + }; + assert!(verify_runtime_request_source(token, &identity.public_key, &altered).is_err()); + let altered = RuntimeRequestSourceExpectation { + path: "/api/w/workspace-a/drive/entries/a/content?offset=1", + ..expected + }; + assert!(verify_runtime_request_source(token, &identity.public_key, &altered).is_err()); + } + } + + #[test] + fn binary_responses_enforce_declared_and_streamed_limits_with_fixed_errors() { + for response in [ + b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\nabcde".to_vec(), + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n2\r\nab\r\n3\r\ncde\r\n0\r\n\r\n".to_vec(), + b"HTTP/1.1 500 Error\r\nConnection: close\r\n\r\nabcde".to_vec(), + ] { + let (base_url, server) = binary_loopback(response); + let client = RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a"); + let error = client.execute_binary(WorkspaceBinaryRequest { + method: WorkspaceRequestMethod::Get, + path: "/api/w/workspace-a/drive/entries/a/content?private=secret".into(), + body: None, max_response_bytes: 4, + }).unwrap_err(); + server.join().unwrap(); + assert_eq!(error.to_string(), "workspace request failed: Workspace binary response exceeded limit"); + } + } + + #[test] + fn binary_redirect_returns_status_without_forwarding_authentication() { + let (base_url, server) = binary_loopback(b"HTTP/1.1 307 Temporary Redirect\r\nLocation: http://127.0.0.1:1/private\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec()); + let client = + RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a"); + let result = client + .execute_binary(WorkspaceBinaryRequest { + method: WorkspaceRequestMethod::Post, + path: "/api/w/workspace-a/drive/upload".into(), + body: Some(vec![0, 255]), + max_response_bytes: 0, + }) + .unwrap(); + server.join().unwrap(); + assert_eq!(result.status, 307); + assert!(result.body.is_empty()); + } + + #[test] + fn binary_invalid_or_normalized_targets_fail_with_fixed_errors_before_http() { + let client = RuntimeOwnedWorkspaceClient::new( + "workspace-a", + "http://127.0.0.1:1", + "runtime-a", + "worker-a", + ); + for path in [ + "https://example.com/secret", + "//example.com/secret", + "/a/../secret", + "/a#secret", + "/a\\secret", + "/a b", + ] { + let error = client + .execute_binary(WorkspaceBinaryRequest { + method: WorkspaceRequestMethod::Get, + path: path.into(), + body: None, + max_response_bytes: 4, + }) + .unwrap_err(); + assert_eq!( + error.to_string(), + "workspace request failed: invalid Workspace binary request" + ); + } + let error = client + .execute_binary_with_timeout( + WorkspaceBinaryRequest { + method: WorkspaceRequestMethod::Post, + path: "/secret?private=value".into(), + body: Some(vec![255]), + max_response_bytes: 4, + }, + Duration::from_secs(1), + ) + .unwrap_err(); + assert_eq!( + error.to_string(), + "workspace request failed: Workspace binary request failed" + ); + } + #[test] fn current_prompt_projection_uses_the_shared_runtime_cache_without_http() { let cache = Arc::new(WorkspacePromptProjectionCache::default()); diff --git a/crates/worker-runtime/src/workspace_request.rs b/crates/worker-runtime/src/workspace_request.rs index 9d71442a9..1eaa95490 100644 --- a/crates/worker-runtime/src/workspace_request.rs +++ b/crates/worker-runtime/src/workspace_request.rs @@ -255,7 +255,7 @@ impl RuntimeWorkspaceRequestClient { request: RuntimeWorkspaceRequest, ) -> Result { let client = self.clone(); - std::thread::spawn(move || client.execute_blocking_inner(request)) + std::thread::spawn(move || client.execute_blocking_inner(request, true)) .join() .map_err(|_| RuntimeWorkspaceRequestError::Transport { message: "Workspace request thread panicked".to_string(), @@ -263,15 +263,45 @@ impl RuntimeWorkspaceRequestClient { })? } - fn execute_blocking_inner( + /// Binary requests must retain their exact target and never follow redirects. + pub(crate) fn execute_binary_blocking( &self, request: RuntimeWorkspaceRequest, + ) -> Result { + let client = self.clone(); + std::thread::spawn(move || client.execute_blocking_inner(request, false)) + .join() + .map_err(|_| RuntimeWorkspaceRequestError::Transport { + message: "Workspace binary request thread panicked".to_string(), + timeout: false, + })? + } + + fn execute_blocking_inner( + &self, + request: RuntimeWorkspaceRequest, + follow_redirects: bool, ) -> Result { let prepared = self.prepare(&request)?; + if !follow_redirects { + let mut target = prepared.url.path().to_string(); + if let Some(query) = prepared.url.query() { + target.push('?'); + target.push_str(query); + } + if prepared.url.fragment().is_some() || target != request.path_and_query { + return Err(RuntimeWorkspaceRequestError::InvalidRequest( + "binary request target must be exact".to_string(), + )); + } + } // The blocking client defaults to 30 seconds. Apply None explicitly so // provider-owned command waits are not cut short by an HTTP deadline. - let client = reqwest::blocking::Client::builder() - .timeout(request.timeout) + let mut client_builder = reqwest::blocking::Client::builder().timeout(request.timeout); + if !follow_redirects { + client_builder = client_builder.redirect(reqwest::redirect::Policy::none()); + } + let client = client_builder .build() .map_err(RuntimeWorkspaceRequestError::transport)?; let mut builder = client diff --git a/crates/worker/src/checkout.rs b/crates/worker/src/checkout.rs index 127cbd163..adba8a547 100644 --- a/crates/worker/src/checkout.rs +++ b/crates/worker/src/checkout.rs @@ -11,11 +11,12 @@ use manifest::{ToolPermissionAction, ToolPermissionConfig}; use serde_json::{Value as Json, json}; use sha2::{Digest, Sha256}; use wip_protocol::{ - Documentation, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, OperationDeclaration, - ParameterDeclaration, ProtocolError, ProtocolErrorCode, ReturnDeclaration, TypeExpr, Value, + Documentation, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, + OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode, + ReturnDeclaration, TypeExpr, Value, }; use workdir::{ - CheckoutObservation, EntryKind, ListRequest, WorkdirPath, WorkdirSessionCapability as Cap, + CheckoutObservation, EntryKind, WorkdirPath, WorkdirSessionCapability as Cap, WorkdirSessionRouter, }; @@ -126,11 +127,15 @@ impl Provider { } self.project( ROOT, - descriptor(vec![operation( - "list", - "List currently accessible checkouts", - vec![], - )]), + descriptor(vec![{ + let mut op = operation( + "list", + "List accessible checkout entrances (not filesystem contents)", + vec![], + ); + op.returns.r#type = TypeExpr::Json; + op + }]), state.finalize().to_vec(), "collection", Arc::new(Collection { @@ -169,8 +174,14 @@ impl Provider { } } async fn node(self: &Arc, path: &str) -> Result, ProtocolError> { + Ok(self.publication_inner(path).await?.map(|p| p.projection)) + } + async fn publication_inner( + self: &Arc, + path: &str, + ) -> Result, ProtocolError> { if path == ROOT { - return Ok(Some(self.collection())); + return WipPublication::self_scoped(self.collection()).map(Some); } let Some((alias, target)) = self.parse(path) else { return Ok(None); @@ -202,39 +213,86 @@ impl Provider { if observation.kind == EntryKind::File && !self.allowed("Read", &permission_input) { return Ok(None); } + let workdir = selected.session.workdir().id().as_str().to_owned(); + let root = checkout_root(&alias); + // The router guard pins this connection across both observations. Root + // lifetime is attachment lifetime, not directory-content validator state. + let scope = if target.is_root() { + None + } else { + let root_observation = + match selected.session.checkout_observe(WorkdirPath::root()).await { + Ok(o) => o, + Err(_) => return Ok(None), + }; + self.entry_projection( + &root, + &alias, + selected.generation, + &workdir, + WorkdirPath::root(), + root_observation, + ) + }; + let Some(projection) = self.entry_projection( + path, + &alias, + selected.generation, + &workdir, + target, + observation, + ) else { + return Ok(None); + }; + if path == root { + return WipPublication::self_scoped(projection).map(Some); + } + let Some(scope) = scope else { + return Ok(None); + }; + Ok(Some(WipPublication { + projection, + scope: Some(scope.object), + })) + } + fn entry_projection( + self: &Arc, + path: &str, + alias: &str, + generation: u64, + workdir: &str, + target: WorkdirPath, + observation: CheckoutObservation, + ) -> Option { let ops = self.operations(&observation); if ops.is_empty() { - return Ok(None); + return None; } let kind = if observation.kind == EntryKind::File { "file" } else { "directory" }; - let workdir = selected.session.workdir().id().as_str().to_owned(); - let validator = self.wrap_validator( - &alias, - selected.generation, - &workdir, - &observation.validator, - ); + let validator = self.wrap_validator(alias, generation, workdir, &observation.validator); let handler = Arc::new(FileHandler { provider: self.clone(), - alias, - generation: selected.generation, - workdir, + alias: alias.into(), + generation, + workdir: workdir.into(), target, observation, }); - let family = format!("{kind}-g{}", selected.generation); - Ok(Some(self.project( - path, - descriptor(ops), - validator, - &family, - handler, - ))) + let family = format!("{kind}-g{generation}"); + let mut projection = self.project(path, descriptor(ops), validator, &family, handler); + // Keep path-qualified local names (permissions/capabilities may differ + // per target) while binding every content Interface to its live entrance. + projection.interface.scope = checkout_root(alias); + projection.interface.name.push_str("#target/"); + projection.interface.name.push_str(&encode_identity(path)); + projection.object.interfaces = vec![projection.interface.clone()]; + Some(projection) } + fn operations(&self, o: &CheckoutObservation) -> Vec { let mut ops = Vec::new(); let has = |cap, name| o.capabilities.supports(cap) && self.may_allow(name); @@ -261,6 +319,13 @@ impl Provider { )); } } else { + if has(Cap::Read, "List") { + ops.push(operation( + "list", + "List direct contents, directories first then lexical path. Bounded live pages, not a snapshot; pass returned after to continue. Limit defaults to 100, maximum 1000. List permission and provider Read/enumeration authority apply. Includes hidden/ignored names per provider List policy, unlike Glob/Grep.", + vec![parameter("limit", false, TypeExpr::Integer), parameter("after", false, cursor_type())], + )); + } if has(Cap::Glob, "Glob") { ops.push(operation( "glob", @@ -288,6 +353,15 @@ impl Provider { #[async_trait] impl WipSubtreeProvider for Arc { + fn interface_target(&self, reference: &wip_protocol::InterfaceReference) -> Option { + if reference.scope == ROOT { + return Some(ROOT.into()); + } + let (_, encoded) = reference.name.rsplit_once("#target/")?; + let path = decode_identity(encoded)?; + let (alias, _) = self.parse(&path)?; + (reference.scope == checkout_root(&alias)).then_some(path) + } async fn publication(&self, path: &str) -> Result, ProtocolError> { let _permit = tokio::time::timeout(self.deadline, self.permits.acquire()) .await @@ -298,7 +372,7 @@ impl WipSubtreeProvider for Arc { ) })? .map_err(|_| error(ProtocolErrorCode::Internal, "checkout unavailable"))?; - tokio::time::timeout(self.deadline, self.node(path)) + tokio::time::timeout(self.deadline, self.publication_inner(path)) .await .map_err(|_| { error( @@ -306,7 +380,6 @@ impl WipSubtreeProvider for Arc { "checkout observation deadline", ) })? - .and_then(|projection| projection.map(WipPublication::self_scoped).transpose()) } async fn children(&self, path: &str) -> Result, ProtocolError> { let _permit = tokio::time::timeout(self.deadline, self.permits.acquire()) @@ -354,70 +427,9 @@ impl Provider { } return Ok(paths); } - let Some((alias, target)) = self.parse(path) else { - return Err(error(ProtocolErrorCode::NotFound, "checkout not published")); - }; - let selected = self - .router - .resolve(Some(&alias)) - .map_err(|_| error(ProtocolErrorCode::NotFound, "attachment expired"))?; - let o = selected - .session - .checkout_observe(target.clone()) - .await - .map_err(|_| error(ProtocolErrorCode::NotFound, "checkout not published"))?; - if o.kind != EntryKind::Directory { - return Ok(Vec::new()); - } - if !self.allowed( - "Glob", - &json!({"target_workdir":alias,"path":target.as_str(),"pattern":"*"}), - ) && !self.allowed( - "Grep", - &json!({"target_workdir":alias,"path":target.as_str(),"pattern":""}), - ) { - return Ok(Vec::new()); - } - let listing = selected - .session - .checkout_search(workdir::CheckoutSearchRequest::new( - workdir::CheckoutSearchOperation::List(ListRequest { - path: target, - limit: MAX_NODES + 1, - }), - )) - .await - .map_err(|_| error(ProtocolErrorCode::Internal, "checkout listing unavailable"))?; - let workdir::CheckoutSearchResult::List(listing) = listing else { - return Err(error( - ProtocolErrorCode::Internal, - "mismatched checkout listing", - )); - }; - if listing.truncated || listing.entries.len() > MAX_NODES { - return Err(error( - ProtocolErrorCode::ResourceLimitExceeded, - "directory observation exceeds node bound", - )); - } - let mut paths = Vec::new(); - for entry in listing.entries { - let p = object_path(&alias, &entry.path); - if self.node(&p).await?.is_some() { - paths.push(p); - } - } - if !self - .router - .resolve(Some(&alias)) - .is_ok_and(|current| current.generation == selected.generation) - { - return Err(error( - ProtocolErrorCode::ValidatorMismatch, - "attachment changed", - )); - } - Ok(paths) + // Contents are addressable, but never indexable. Host observe already + // resolves the requested Object; no provider content List is needed. + Ok(Vec::new()) } } @@ -491,6 +503,76 @@ impl FileHandler { )) } } + /// Classify post-success publication failures separately from pre-effect + /// provider errors; result coordinates remain lazy for all operations. + fn publish_result( + &self, + tool: &str, + result: tools::CheckoutToolOutput, + ) -> Result { + // Coordinates are not observations. Never prefetch unselected results; + // a vanished entry cannot fail a successful discovery Operation. + self.check_connection().map_err(|error| { + if matches!(tool, "Write" | "Edit" | "Create") { + // The provider has already succeeded: connection loss while + // publishing cannot prove that a committed effect was rejected. + WipOperationError::OutcomeUnknown( + "completed file operation lost its attachment before result publication".into(), + ) + } else { + error + } + })?; + let mut fields = BTreeMap::from([("summary".into(), Value::String(result.output.summary))]); + if let Some(content) = result.output.content { + fields.insert("content".into(), Value::String(content)); + } + let items = if let Some(listing) = result.listing { + fields.insert("truncated".into(), Value::Boolean(listing.truncated)); + if let Some(after) = listing.next_after { + fields.insert("after".into(), cursor_value(&self.alias, &after)); + } + listing + .entries + .into_iter() + .map(|item| { + Value::Record(BTreeMap::from([ + ( + "entry".into(), + Value::String(object_path(&self.alias, &item.path)), + ), + ("kind".into(), Value::String(kind_name(item.kind).into())), + ( + "size".into(), + Value::Integer(i64::try_from(item.size).unwrap_or(i64::MAX)), + ), + ])) + }) + .collect() + } else { + result + .paths + .iter() + .map(|path| { + Value::Record(BTreeMap::from([( + "entry".into(), + Value::String(object_path(&self.alias, path)), + )])) + }) + .collect() + }; + fields.insert("items".into(), Value::List(items)); + let value = Value::Record(fields); + if let Some(raw) = result.validator { + Ok(WipOperationOutput::native_with_validator( + value, + self.provider + .wrap_validator(&self.alias, self.generation, &self.workdir, &raw), + )) + } else { + Ok(WipOperationOutput::native(value)) + } + } } #[async_trait] impl WipOperationHandler for FileHandler { @@ -501,6 +583,7 @@ impl WipOperationHandler for FileHandler { context: WipCallContext, ) -> Result { let tool = match operation { + "list" => "List", "read" => "Read", "edit" => "Edit", "write" => "Write", @@ -528,7 +611,34 @@ impl WipOperationHandler for FileHandler { } let mut permission_input = args.clone(); permission_input.insert("target_workdir".into(), json!(self.alias)); - if tool == "Glob" || tool == "Grep" { + if tool == "List" { + permission_input.insert("path".into(), json!(self.target.as_str())); + if let Some(after) = args.get_mut("after") { + let entry = after.get("entry").and_then(Json::as_str).ok_or_else(|| { + protocol( + ProtocolErrorCode::InvalidArguments, + "after requires an entry", + ) + })?; + let Some((alias, path)) = self.provider.parse(entry) else { + return Err(protocol( + ProtocolErrorCode::InvalidArguments, + "invalid continuation entry", + )); + }; + if alias != self.alias || object_path(&alias, &path) != entry { + return Err(protocol( + ProtocolErrorCode::InvalidArguments, + "continuation must belong to this checkout", + )); + } + let fields = after.as_object_mut().ok_or_else(|| { + protocol(ProtocolErrorCode::InvalidArguments, "invalid continuation") + })?; + fields.remove("entry"); + fields.insert("path".into(), json!(path.as_str())); + } + } else if tool == "Glob" || tool == "Grep" { let relative = args.get("path").and_then(Json::as_str).unwrap_or(""); permission_input.insert("path".into(), json!(join_under(&self.target, relative)?)); } else if tool == "Create" { @@ -588,58 +698,7 @@ impl WipOperationHandler for FileHandler { } })? .map_err(map_tool_error)?; - let links = tokio::time::timeout_at(deadline, async { - let mut links = Vec::new(); - self.check_connection()?; - for path in &result.paths { - let route = object_path(&self.alias, path); - // References are validated only against the captured live connection. - if self - .provider - .node(&route) - .await - .map_err(WipOperationError::Protocol)? - .is_some() - { - links.push(json!({"path":route})); - } - self.check_connection()?; - } - Ok::<_, WipOperationError>(links) - }) - .await - .map_err(|_| { - if matches!(tool, "Write" | "Edit" | "Create") { - WipOperationError::OutcomeUnknown("completed operation response deadline".into()) - } else { - protocol( - ProtocolErrorCode::ResourceLimitExceeded, - "checkout result deadline", - ) - } - })? - .map_err(|e| { - if matches!(tool, "Write" | "Edit" | "Create") { - WipOperationError::OutcomeUnknown("completed operation response unavailable".into()) - } else { - e - } - })?; - let value = json_to_wip( - &json!({"summary":result.output.summary,"content":result.output.content,"items":links}), - ) - .map_err(|_| { - WipOperationError::OutcomeUnknown("invalid completed file operation result".into()) - })?; - if let Some(raw) = result.validator { - Ok(WipOperationOutput::native_with_validator( - value, - self.provider - .wrap_validator(&self.alias, self.generation, &self.workdir, &raw), - )) - } else { - Ok(WipOperationOutput::native(value)) - } + self.publish_result(tool, result) } } @@ -693,10 +752,70 @@ fn operation( parameters, returns: ReturnDeclaration { documentation: None, - r#type: TypeExpr::Json, + r#type: result_type(name == "list"), }, } } +fn field(name: &str, required: bool, r#type: TypeExpr) -> FieldDeclaration { + FieldDeclaration { + name: name.into(), + required, + documentation: None, + r#type, + } +} +fn cursor_type() -> TypeExpr { + TypeExpr::Record { + fields: vec![ + field("kind", true, TypeExpr::String), + field("entry", true, TypeExpr::Entry), + ], + } +} +fn result_type(list: bool) -> TypeExpr { + let mut item = vec![field("entry", true, TypeExpr::Entry)]; + if list { + item.extend([ + field("kind", true, TypeExpr::String), + field("size", true, TypeExpr::Integer), + ]); + } + let mut fields = vec![ + field("summary", true, TypeExpr::String), + field("content", false, TypeExpr::String), + field( + "items", + true, + TypeExpr::List { + items: Box::new(TypeExpr::Record { fields: item }), + }, + ), + ]; + if list { + fields.extend([ + field("truncated", true, TypeExpr::Boolean), + field("after", false, cursor_type()), + ]); + } + TypeExpr::Record { fields } +} +fn kind_name(kind: EntryKind) -> &'static str { + match kind { + EntryKind::Directory => "directory", + EntryKind::File => "file", + EntryKind::Symlink => "symlink", + EntryKind::Other => "other", + } +} +fn cursor_value(alias: &str, cursor: &workdir::ListCursor) -> Value { + Value::Record(BTreeMap::from([ + ("kind".into(), Value::String(kind_name(cursor.kind).into())), + ( + "entry".into(), + Value::String(object_path(alias, &cursor.path)), + ), + ])) +} fn descriptor(operations: Vec) -> InterfaceDescriptor { InterfaceDescriptor{format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation{summary:"AI-oriented attached Workdir operations".into(),details:Some("The route binds attachment and target. Line offsets are zero-based; text output numbers lines from one. Read-before-write and provider validators are both required. Paths are Worldspace references, never host paths.".into())}),types:Vec::new(),operations} } diff --git a/crates/worker/src/checkout_http_tests.rs b/crates/worker/src/checkout_http_tests.rs index 455971e7a..b69c99d5b 100644 --- a/crates/worker/src/checkout_http_tests.rs +++ b/crates/worker/src/checkout_http_tests.rs @@ -379,7 +379,7 @@ fn first_link(value: &Value) -> &str { panic!("expected typed link List") }; assert_eq!(items.len(), 1); - let Value::String(path) = field(&items[0], "path") else { + let Value::String(path) = field(&items[0], "entry") else { panic!("expected typed link path") }; path @@ -669,3 +669,89 @@ async fn checkout_http_malformed_response_after_write_is_unknown_without_retry() async fn checkout_http_lost_response_after_write_is_unknown_without_retry() { watchdog(committed_write_unknown(WriteResponseFault::LostBody)).await; } + +#[tokio::test] +async fn remote_checkout_list_pages_and_direct_inspect_read_use_provider_without_tree_enumeration() +{ + use super::checkout_wip_tests::{json_content, model_inspect, model_invoke, model_operation}; + let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_ONLY).await; + std::fs::create_dir(fixture.dir.path().join("empty")).unwrap(); + let remote = fixture.open(TOKEN).await.unwrap(); + let (runtime, _) = native_runtime(Arc::new(remote)); + let runtime = Arc::new(runtime); + let tree = json_content( + model_operation( + runtime.clone(), + "Tree", + json!({"path":"/checkouts", "depth":8}), + ) + .await + .unwrap(), + ); + assert_eq!(tree["tree"]["children"][0]["children"], json!([])); + assert!( + !fixture + .state + .operations + .lock() + .unwrap() + .iter() + .any(|op| matches!( + op, + WorkdirSessionOperation::CheckoutSearch(_) | WorkdirSessionOperation::List(_) + )) + ); + let entrance = model_inspect(runtime.clone(), "/checkouts/main").await; + let first = json_content( + model_invoke(runtime.clone(), &entrance, "list", json!({"limit":1})) + .await + .unwrap(), + ); + assert_eq!(first["items"][0]["entry"], "/checkouts/main/empty"); + assert_eq!(first["items"][0]["kind"], "directory"); + assert_eq!(first["truncated"], true); + let next = json_content( + model_invoke( + runtime.clone(), + &entrance, + "list", + json!({"limit":1,"after":first["after"]}), + ) + .await + .unwrap(), + ); + assert_eq!(next["items"][0]["entry"], "/checkouts/main/src"); + assert_eq!(next["truncated"], false); + let deep = model_inspect(runtime.clone(), "/checkouts/main/src/deep").await; + for (operation, args) in [ + ("list", json!({})), + ("glob", json!({"pattern":"*.txt"})), + ( + "grep", + json!({"pattern":"needle","output_mode":"files_with_matches"}), + ), + ] { + let found = json_content( + model_invoke(runtime.clone(), &deep, operation, args) + .await + .unwrap(), + ); + let file = found["items"][0]["entry"].as_str().unwrap(); + assert_eq!(file, "/checkouts/main/src/deep/a.txt"); + let inspected = model_inspect(runtime.clone(), file).await; + let read = json_content( + model_invoke(runtime.clone(), &inspected, "read", json!({})) + .await + .unwrap(), + ); + assert!(read["content"].as_str().unwrap().contains("needle")); + fixture.public_output(&found.to_string()); + } + let operations = fixture.state.operations.lock().unwrap(); + assert!(operations.iter().any(|op| matches!(op, WorkdirSessionOperation::CheckoutSearch(r) if matches!(&r.operation, CheckoutSearchOperation::List(l) if l.after.is_some())))); + assert!( + !operations + .iter() + .any(|op| matches!(op, WorkdirSessionOperation::List(_))) + ); +} diff --git a/crates/worker/src/checkout_race_tests.rs b/crates/worker/src/checkout_race_tests.rs index 954ce0977..da58fc64c 100644 --- a/crates/worker/src/checkout_race_tests.rs +++ b/crates/worker/src/checkout_race_tests.rs @@ -13,9 +13,9 @@ use tempfile::TempDir; use workdir::{ CheckoutRequest, CheckoutResult, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest, - GrepResult, ListResult, LocalWorkdirSession, ReadBytesRequest, ReadBytesResult, ReadRequest, - ReadResult, StatRequest, StatResult, Workdir, WorkdirAttachmentAlias, WorkdirError, - WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession, + GrepResult, ListRequest, ListResult, LocalWorkdirSession, ReadBytesRequest, ReadBytesResult, + ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirAttachmentAlias, + WorkdirError, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult, }; @@ -28,6 +28,7 @@ struct HookedSession { observe_hook: Mutex>, stall_path: Mutex>, stalled_observations: AtomicUsize, + searches: AtomicUsize, } impl std::fmt::Debug for HookedSession { @@ -52,6 +53,7 @@ impl HookedSession { observe_hook: Mutex::new(None), stall_path: Mutex::new(None), stalled_observations: AtomicUsize::new(0), + searches: AtomicUsize::new(0), }) } @@ -123,6 +125,7 @@ impl WorkdirSession for HookedSession { &self, request: workdir::CheckoutSearchRequest, ) -> Result { + self.searches.fetch_add(1, Ordering::SeqCst); self.local.checkout_search(request).await } @@ -484,7 +487,7 @@ async fn collection_list_and_root_enumeration_bound_stalled_observation_and_rele } #[tokio::test] -async fn grep_post_search_link_observation_is_deadline_bounded_and_releases_permit() { +async fn grep_returns_typed_entry_without_observing_stalled_result() { let dir = TempDir::new().unwrap(); std::fs::write(dir.path().join("a.txt"), "needle\n").unwrap(); let session = HookedSession::new(&dir); @@ -499,8 +502,8 @@ async fn grep_post_search_link_observation_is_deadline_bounded_and_releases_perm let root = checkout_root("main"); let projection = p.node(&root).await.unwrap().unwrap(); - // Directory observations and the normal local grep/checkout execution can - // complete. Only publishing the typed search result path gets stuck. + // Directory/search can complete. The result path must never be observed + // during result publication, even when it would block indefinitely. session.stall_observation(Some(WorkdirPath::new("a.txt").unwrap())); let arguments = BTreeMap::from([ ("pattern".into(), Value::String("needle".into())), @@ -514,13 +517,17 @@ async fn grep_post_search_link_observation_is_deadline_bounded_and_releases_perm projection.handler.call("grep", &arguments, context()), ) .await - .expect("post-search link observations must share the operation deadline"); - assert_deadline(result, "checkout result deadline"); - assert_eq!(session.stalled_observations.load(Ordering::SeqCst), 1); + .expect("completed search must return without acquiring its entries"); + let output = result.unwrap_or_else(|_| panic!("successful search must not observe each entry")); + assert_eq!( + wip_to_json(&output.value).unwrap()["items"], + json!([{"entry": format!("{root}/a.txt")}]) + ); + assert_eq!(session.stalled_observations.load(Ordering::SeqCst), 0); assert_permit_released(&p); - // A successful retry both checks cancellation cleanup and proves that the - // delegated search produces the typed path consumed by post-processing. + // A second explicit search produces the same typed coordinate, without + // acquiring it even when it could resolve successfully. session.stall_observation(None); let result = tokio::time::timeout( Duration::from_secs(2), @@ -533,6 +540,230 @@ async fn grep_post_search_link_observation_is_deadline_bounded_and_releases_perm Err(_) => panic!("grep must succeed once the link observation is unstalled"), }; let value = wip_to_json(&output.value).unwrap(); - assert_eq!(value["items"], json!([{"path": format!("{root}/a.txt")}])); + assert_eq!(value["items"], json!([{"entry": format!("{root}/a.txt")}])); assert_permit_released(&p); } + +#[tokio::test] +async fn checkout_content_children_never_call_provider_list_even_for_deep_paths() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join("nested/deep")).unwrap(); + for n in 0..1100 { + std::fs::write(dir.path().join(format!("file-{n}")), "fixture").unwrap(); + } + let session = HookedSession::new(&dir); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach( + WorkdirAttachmentAlias::new("main").unwrap(), + session.clone(), + ) + .unwrap(); + let subtree = Arc::new(provider(router)); + assert_eq!( + subtree.children(ROOT).await.unwrap(), + vec![checkout_root("main")] + ); + for path in ["/checkouts/main", "/checkouts/main/nested/deep"] { + assert!(subtree.publication(path).await.unwrap().is_some()); + assert!(subtree.children(path).await.unwrap().is_empty()); + } + assert_eq!(session.searches.load(Ordering::SeqCst), 0); +} + +/// Deterministic adapter fixture for the production post-success publication +/// boundary. Execute through the real provider/Tools, then detach after the +/// routed operation guard has finished, before publishing the committed result. +/// No race timing or production-only test hook is needed. +struct DetachBeforePublication { + file: FileHandler, + calls: Arc, +} +#[async_trait] +impl WipOperationHandler for DetachBeforePublication { + async fn call( + &self, + operation: &str, + arguments: &BTreeMap, + context: WipCallContext, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + let tool = match operation { + "write" => "Write", + "edit" => "Edit", + "create_file" => "Create", + _ => panic!("mutation fixture only"), + }; + let result = tools::execute_checkout_tool( + self.file.provider.router.clone(), + self.file.provider.tracker.clone(), + &self.file.alias, + self.file.generation, + self.file.target.clone(), + self.file.observation.validator.clone(), + tool, + wip_to_json(&Value::Record(arguments.clone())).unwrap(), + context.execution, + ) + .await + .map_err(map_tool_error)?; + self.file + .provider + .router + .detach(&WorkdirAttachmentAlias::new(&self.file.alias).unwrap()) + .await + .unwrap(); + self.file.publish_result(tool, result) + } +} + +#[tokio::test] +async fn committed_checkout_mutation_losing_connection_is_unknown_not_rejected_or_replayed() { + use crate::wip::{WipAuditOutcome, WipRuntime}; + for (operation, arguments, target, result_path, expected) in [ + ( + "write", + json!({"content":"saved\n"}), + "a.txt", + "a.txt", + "saved\n", + ), + ( + "edit", + json!({"old_string":"old", "new_string":"changed"}), + "a.txt", + "a.txt", + "changed\n", + ), + ( + "create_file", + json!({"path":"new.txt", "content":"created\n"}), + "dir", + "dir/new.txt", + "created\n", + ), + ] { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join("dir")).unwrap(); + std::fs::write(dir.path().join("a.txt"), "old\n").unwrap(); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach( + WorkdirAttachmentAlias::new("main").unwrap(), + HookedSession::new(&dir), + ) + .unwrap(); + let p = provider(router.clone()); + if operation != "create_file" { + p.node("/checkouts/main/a.txt") + .await + .unwrap() + .unwrap() + .handler + .call("read", &BTreeMap::new(), context()) + .await + .unwrap_or_else(|_| panic!("prior Read failed")); + } + let selected = router.resolve(Some("main")).unwrap(); + let target = WorkdirPath::new(target).unwrap(); + let observation = selected + .session + .checkout_observe(target.clone()) + .await + .unwrap(); + let file = FileHandler { + provider: p.clone(), + alias: "main".into(), + generation: selected.generation, + workdir: selected.session.workdir().id().as_str().into(), + target, + observation, + }; + drop(selected); + let calls = Arc::new(AtomicUsize::new(0)); + let route = object_path("main", &file.target); + let mut projection = p.node(&route).await.unwrap().unwrap(); + let interface = projection.interface.clone(); + projection.handler = Arc::new(DetachBeforePublication { + file, + calls: calls.clone(), + }); + let mut registry = WipMountRegistry::new(); + registry + .allocate_namespace("checkout", "checkouts") + .unwrap(); + // Static observation metadata isolates the response boundary; real + // execution still uses checked provider authority and prior Read. + registry + .mount(p.node("/checkouts/main").await.unwrap().unwrap()) + .unwrap(); + registry.mount(projection).unwrap(); + let runtime = WipRuntime::from_mounts(registry, "post-commit-test".into()).unwrap(); + let error = runtime + .invoke( + route, + interface, + operation.into(), + arguments, + Default::default(), + ) + .await + .unwrap_err(); + assert!( + error.to_string().contains("outcome unknown"), + "{operation}: {error}" + ); + assert_eq!( + runtime.audit().last().unwrap().outcome, + WipAuditOutcome::OutcomeUnknown + ); + assert_eq!( + std::fs::read_to_string(dir.path().join(result_path)).unwrap(), + expected + ); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "completed mutation must never replay" + ); + assert_eq!(runtime.metrics().operation_round_trips, 1); + } +} + +#[tokio::test] +async fn readonly_checkout_result_losing_connection_keeps_validator_mismatch() { + let dir = TempDir::new().unwrap(); + let router = Arc::new(WorkdirSessionRouter::new()); + let alias = WorkdirAttachmentAlias::new("main").unwrap(); + router + .attach(alias.clone(), HookedSession::new(&dir)) + .unwrap(); + let selected = router.resolve(Some("main")).unwrap(); + let file = FileHandler { + provider: provider(router.clone()), + alias: "main".into(), + generation: selected.generation, + workdir: selected.session.workdir().id().as_str().into(), + target: WorkdirPath::root(), + observation: selected + .session + .checkout_observe(WorkdirPath::root()) + .await + .unwrap(), + }; + drop(selected); + router.detach(&alias).await.unwrap(); + for tool in ["Read", "List", "Glob", "Grep"] { + let result = tools::CheckoutToolOutput { + output: agen::tool::ToolOutput { + summary: "finished".into(), + content: None, + attachments: vec![], + }, + paths: vec![], + listing: None, + validator: None, + }; + assert_validator_mismatch(file.publish_result(tool, result)); + } +} diff --git a/crates/worker/src/checkout_wip_tests.rs b/crates/worker/src/checkout_wip_tests.rs index 09b4c4df0..89972eefe 100644 --- a/crates/worker/src/checkout_wip_tests.rs +++ b/crates/worker/src/checkout_wip_tests.rs @@ -113,7 +113,7 @@ async fn checkout_wip_search_preserves_root_and_nested_ignore_rules_and_links() .as_array() .unwrap() .iter() - .map(|item| item["path"].as_str().unwrap().to_string()) + .map(|item| item["entry"].as_str().unwrap().to_string()) .collect::>(); // Grep retains traversal order; Glob sorts its result paths. paths.sort(); @@ -159,8 +159,8 @@ async fn checkout_wip_native_roundtrip_search_read_edit_write_create() { .await .unwrap(); let grep_result: Json = serde_json::from_str(grep.content.as_deref().unwrap()).unwrap(); - assert_eq!(grep_result["items"][0]["path"], file); - let followed = grep_result["items"][0]["path"].as_str().unwrap(); + assert_eq!(grep_result["items"][0]["entry"], file); + let followed = grep_result["items"][0]["entry"].as_str().unwrap(); assert_eq!(followed, file); assert!( call( @@ -588,8 +588,9 @@ async fn checkout_wip_scoped_child_search_and_revoke_cannot_expose_siblings() { .observe_live("/checkouts/child", 2) .await .unwrap(); - assert_eq!(observed.children.unwrap().len(), 1); + assert!(observed.children.unwrap().is_empty()); for (op, args) in [ + ("list", json!({"limit":1})), ("glob", json!({"pattern":"**/*"})), ( "grep", @@ -654,3 +655,339 @@ async fn checkout_wip_scoped_child_search_and_revoke_cannot_expose_siblings() { "changed" ); } + +pub(super) fn json_content(output: ToolOutput) -> Json { + serde_json::from_str(output.content.as_deref().unwrap()).unwrap() +} +pub(super) async fn model_operation( + r: Arc, + name: &str, + args: Json, +) -> Result { + let (_, tool) = wip_tool_definitions(r) + .into_iter() + .map(|definition| definition()) + .find(|(meta, _)| meta.name == name) + .unwrap(); + tool.execute(&args.to_string(), Default::default()).await +} +pub(super) async fn model_inspect(r: Arc, path: &str) -> Json { + json_content( + model_operation(r, "Inspect", json!({"path":path})) + .await + .unwrap(), + ) +} +pub(super) async fn model_invoke( + r: Arc, + inspected: &Json, + operation: &str, + arguments: Json, +) -> Result { + model_operation(r, "Invoke", json!({"path":inspected["path"], "interface":inspected["interfaces"][0]["reference"], "operation":operation, "arguments":arguments})).await +} + +#[tokio::test] +async fn checkout_depth_boundaries_are_independent_of_empty_small_or_large_contents() { + for count in [0, 2, 1100] { + let dir = TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join("nested/deep")).unwrap(); + for n in 0..count { + std::fs::write(dir.path().join(format!("file-{n}")), "fixture").unwrap(); + } + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach( + WorkdirAttachmentAlias::new("main").unwrap(), + session(&dir, true), + ) + .unwrap(); + let r = runtime(router, None); + let one = r.host.observe_live("/checkouts", 1).await.unwrap(); + assert!(one.children.unwrap()[0].children.is_none()); + for depth in [2, 32] { + let observation = r.host.observe_live("/checkouts", depth).await.unwrap(); + let entrances = observation.children.unwrap(); + assert_eq!(entrances.len(), 1); + assert_eq!(entrances[0].children, Some(vec![])); + } + let world = r.host.observe_live("/", 32).await.unwrap(); + assert_eq!( + world.children.unwrap()[0].children.as_ref().unwrap()[0].children, + Some(vec![]) + ); + for path in ["/checkouts/main", "/checkouts/main/nested/deep"] { + assert!( + r.host + .observe_live(path, 0) + .await + .unwrap() + .children + .is_none() + ); + assert_eq!( + r.host.observe_live(path, 1).await.unwrap().children, + Some(vec![]) + ); + } + } +} + +#[tokio::test] +async fn checkout_model_list_glob_grep_entries_are_lazy_and_never_become_tree_edges() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join("empty")).unwrap(); + std::fs::create_dir_all(dir.path().join("nested/deep")).unwrap(); + std::fs::write(dir.path().join("nested/deep/a.txt"), "needle\n").unwrap(); + std::fs::write(dir.path().join("z.txt"), "needle\n").unwrap(); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach( + WorkdirAttachmentAlias::new("main").unwrap(), + session(&dir, true), + ) + .unwrap(); + let r = Arc::new(runtime(router, None)); + let entrance = model_inspect(r.clone(), "/checkouts/main").await; + assert_eq!(entrance["path"], "/checkouts/main"); + assert_eq!( + entrance["interfaces"][0]["reference"]["scope"], + "/checkouts/main" + ); + let projection = r + .host + .projection_live("/checkouts/main") + .await + .unwrap() + .unwrap(); + assert_eq!( + entrance["object_signature"], + wip_text_view::render_object(&projection.object).unwrap() + ); + assert_eq!( + entrance["interfaces"][0]["signature"], + wip_text_view::render_interface(&wip_text_view::Interface { + reference: &projection.interface, + descriptor: &projection.descriptor + }) + .unwrap() + ); + assert!( + entrance["interfaces"][0]["signature"] + .as_str() + .unwrap() + .contains("entry") + ); + let mut after = None; + let mut items = vec![]; + loop { + let mut args = json!({"limit":1}); + if let Some(cursor) = after { + args["after"] = cursor; + } + let page = json_content( + model_invoke(r.clone(), &entrance, "list", args) + .await + .unwrap(), + ); + assert_eq!(page["items"].as_array().unwrap().len(), 1); + items.push(page["items"][0].clone()); + if page["truncated"] == false { + assert!(page.get("after").is_none()); + break; + } + after = Some(page["after"].clone()); + } + assert_eq!( + items + .iter() + .map(|item| item["entry"].clone()) + .collect::>(), + vec![ + json!("/checkouts/main/empty"), + json!("/checkouts/main/nested"), + json!("/checkouts/main/z.txt") + ] + ); + assert_eq!(items[0]["kind"], "directory"); + assert_eq!(items[2]["kind"], "file"); + let empty = model_inspect(r.clone(), items[0]["entry"].as_str().unwrap()).await; + assert_eq!( + json_content( + model_invoke(r.clone(), &empty, "list", json!({})) + .await + .unwrap() + )["items"], + json!([]) + ); + let deep = model_inspect(r.clone(), "/checkouts/main/nested/deep").await; + assert_eq!( + deep["interfaces"][0]["reference"]["scope"], + "/checkouts/main" + ); + for (operation, args) in [ + ("list", json!({})), + ("glob", json!({"pattern":"*.txt"})), + ( + "grep", + json!({"pattern":"needle", "output_mode":"files_with_matches"}), + ), + ] { + let found = json_content( + model_invoke(r.clone(), &deep, operation, args) + .await + .unwrap(), + ); + let path = found["items"][0]["entry"].as_str().unwrap(); + assert_eq!(path, "/checkouts/main/nested/deep/a.txt"); + // No result-wide eager acquisition by Host or Client. + if operation == "list" { + let state = r.state.lock().unwrap(); + assert!(state.client.object(&state.session, path).is_none()); + } + let inspected = model_inspect(r.clone(), path).await; + let read = json_content( + model_invoke(r.clone(), &inspected, "read", json!({})) + .await + .unwrap(), + ); + assert!(read["content"].as_str().unwrap().contains("needle")); + } + let tree = json_content( + model_operation(r.clone(), "Tree", json!({"path":"/checkouts", "depth":8})) + .await + .unwrap(), + ); + assert_eq!(tree["tree"]["children"][0]["children"], json!([])); + std::fs::remove_file(dir.path().join("nested/deep/a.txt")).unwrap(); + assert!( + model_operation( + r, + "Inspect", + json!({"path":"/checkouts/main/nested/deep/a.txt", "refresh":true}) + ) + .await + .is_err() + ); +} + +#[tokio::test] +async fn checkout_list_permission_cannot_be_bypassed_with_glob_or_command_identity() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("a.txt"), "fixture").unwrap(); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach( + WorkdirAttachmentAlias::new("main").unwrap(), + session(&dir, true), + ) + .unwrap(); + let r = runtime( + router, + Some(ToolPermissionConfig { + default_action: ToolPermissionAction::Allow, + rules: vec![manifest::ToolPermissionRule { + tool: "List".into(), + pattern: "*".into(), + action: ToolPermissionAction::Deny, + }], + }), + ); + let p = r + .host + .projection_live("/checkouts/main") + .await + .unwrap() + .unwrap(); + assert!(!p.descriptor.operations.iter().any(|op| op.name == "list")); + assert!(p.descriptor.operations.iter().any(|op| op.name == "glob")); + let denied = p + .handler + .call( + "list", + &BTreeMap::new(), + WipCallContext { + execution: Default::default(), + security_context: "fixture".into(), + }, + ) + .await; + assert!( + matches!(denied, Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::PermissionDenied) + ); +} + +#[tokio::test] +async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connection_before_read() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("a.txt"), "same\n").unwrap(); + let router = Arc::new(WorkdirSessionRouter::new()); + let main = WorkdirAttachmentAlias::new("main").unwrap(); + router.attach(main.clone(), session(&dir, true)).unwrap(); + router + .attach( + WorkdirAttachmentAlias::new("main-extra").unwrap(), + session(&dir, true), + ) + .unwrap(); + let r = runtime(router.clone(), None); + let path = "/checkouts/main/a.txt"; + let p = r.host.projection_live(path).await.unwrap().unwrap(); + let fetched = r.host.fetch_interface_live(&p.interface).await.unwrap(); + assert!( + fetched.scope_ref.is_none(), + "ref-less publication is legitimate" + ); + // Ordinary content changes do not terminate the entrance's Interface lifetime. + std::fs::write(dir.path().join("new.txt"), "fixture").unwrap(); + assert_eq!( + r.host + .fetch_interface_live(&p.interface) + .await + .unwrap() + .interface, + p.interface + ); + let request = |reference, scope_ref| CallOperationRequest { + target: wip_protocol::Target { + path: path.into(), + validator: None, + }, + interface: wip_protocol::InterfaceTarget { + reference, + scope_ref, + validator: None, + }, + operation: "read".into(), + arguments: BTreeMap::new(), + }; + for scope in ["/checkouts/main-extra", "/checkouts/main/a"] { + let mut wrong = p.interface.clone(); + wrong.scope = scope.into(); + assert!(r.host.fetch_interface_live(&wrong).await.is_err()); + let forged = request(wrong, None); + let result = r + .host + .call( + forged, + WipCallContext { + execution: Default::default(), + security_context: "fixture".into(), + }, + ) + .await; + assert!( + matches!(result,Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::InterfaceMismatch) + ); + } + let forged = request(p.interface.clone(), Some("old".into())); + assert!( + matches!(r.host.call(forged, WipCallContext { execution:Default::default(),security_context:"fixture".into() }).await, Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::InterfaceMismatch) + ); + router.detach(&main).await.unwrap(); + router.attach(main, session(&dir, true)).unwrap(); + let forged = request(p.interface.clone(), None); + assert!( + matches!(r.host.call(forged, WipCallContext { execution:Default::default(),security_context:"fixture".into() }).await, Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::InterfaceMismatch) + ); +} diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index f72e1a87e..ef8cd7a2f 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -1628,6 +1628,15 @@ where ); } let wip_mode = worker.manifest().worker.mode == manifest::WorkerMode::Wip; + let drive_feature = crate::feature::builtin::drive::DriveFeature::configured( + worker.workspace_client_handle(), + feature_config.drive.enabled, + worker.workdir_sessions(), + ) + .map(|feature| feature.with_permissions(worker.manifest().permissions.clone())); + if let Some(module) = &drive_feature { + feature_registry.add_module(module.clone()); + } let workspace_config_feature = crate::feature::builtin::workspace_config::WorkspaceConfigFeature::configured( worker.workspace_client_handle(), @@ -1728,6 +1737,15 @@ where let host_worker_observation_provider = worker.worker_observation_provider(); let wip_permissions = worker.manifest().permissions.clone(); let mut wip_mount_registry = crate::wip::WipMountRegistry::new(); + if wip_mode && let Some(feature) = &drive_feature { + crate::feature::builtin::drive::wip::mount_drive_wip(&mut wip_mount_registry, feature) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("mount Drive WIP projection: {error}"), + ) + })?; + } if let Some(feature) = &workspace_config_feature { crate::feature::builtin::workspace_config::wip::mount_workspace_config_wip( &mut wip_mount_registry, @@ -3966,6 +3984,149 @@ mod tests { } } + #[derive(Debug)] + struct DriveWorkspaceHost { + available: bool, + workspace: Option<&'static str>, + } + + impl crate::worker::WorkspaceClient for DriveWorkspaceHost { + fn workspace_id(&self) -> Option<&str> { + self.workspace + } + fn kind(&self) -> &str { + "drive-test" + } + fn is_available(&self) -> bool { + self.available + } + fn execute( + &self, + request: crate::worker::WorkspaceRequest, + ) -> Result + { + // Native discovery must not enumerate or perform any mutation. + assert_eq!(request.path, "/api/w/workspace-1/drive/root"); + Ok(crate::worker::WorkspaceResponse { status: 200, body: serde_json::json!({ + "entry": {"workspace_id": "workspace-1", "node_id": "1"}, + "parent": null, "name": "", "kind": "folder", "revision": "1", + "size": null, "content_type": null, "updated_by": "test", + "updated_at": "2026-10-08T00:00:00Z", + "latest_url": "/api/w/workspace-1/drive/download?entry_workspace_id=workspace-1&id=1" + }).to_string() }) + } + } + + #[tokio::test] + async fn drive_controller_wiring_requires_activation_and_workspace_host_in_both_modes() { + use crate::feature::FeatureModule as _; + for mode in [manifest::WorkerMode::Tools, manifest::WorkerMode::Wip] { + for (enabled, available, workspace) in [ + (false, true, Some("workspace-1")), + (true, false, Some("workspace-1")), + (true, true, None), + (true, true, Some("workspace-1")), + ] { + let root = TempDir::new().unwrap(); + let mut manifest = manifest::WorkerManifest::from_toml( + "[worker]\nname = 'drive-wiring'\n[model]\nscheme = 'anthropic'\nmodel_id = 'test'\n[engine]\n[scope]\n" + ).unwrap(); + manifest.worker.mode = mode; + manifest.feature.drive.enabled = enabled; + // Isolate Drive discovery from the independent Workspace catalog. + manifest.feature.workdir_catalog.enabled = false; + let client = ScriptedClient::new(vec![finished_response()]); + let requests = client.requests.clone(); + let engine = Engine::<_, agen::state::Mutable, crate::SessionHistoryMetadata>::new_annotated(client); + let store = session_store::CombinedStore::new( + session_store::FsStore::new(root.path().join("sessions")).unwrap(), + session_store::FsWorkerStore::new(root.path().join("workers")).unwrap(), + ); + let host: Arc = + Arc::new(DriveWorkspaceHost { + available, + workspace, + }); + let context = crate::worker::WorkerWorkspaceContext::with_client( + workspace.map(|id| crate::worker::WorkspaceId::new(id).unwrap()), + host.clone(), + ); + let mut worker = Worker::new( + manifest, + engine, + store, + context, + crate::worker::WorkerFilesystemAuthority::local( + root.path().into(), + root.path().into(), + ), + manifest::Scope::writable(root.path()).unwrap(), + ) + .await + .unwrap(); + worker.set_system_prompt_template( + crate::SystemPromptTemplate::parse( + "default", + crate::PromptCatalogSource::builtins_only(), + ) + .unwrap(), + ); + let descriptor = crate::feature::builtin::drive::DriveFeature::new( + host, + worker.workdir_sessions(), + ) + .descriptor(); + register_worker_tools( + &mut worker, + root.path().join("bash-output"), + root.path().join("runtime"), + SpawnedWorkerRegistry::new_for_internal_services(), + None, + None, + ) + .await + .unwrap(); + let configured = enabled && available && workspace.is_some(); + let tools = worker.engine().tool_server_handle(); + tools.flush_pending(); + for tool in &descriptor.tools { + assert_eq!( + tools.get_tool(&tool.name).is_some(), + configured && mode == manifest::WorkerMode::Tools, + "{mode:?}, enabled={enabled}, available={available}, workspace={workspace:?}, tool={}", + tool.name + ); + } + if mode == manifest::WorkerMode::Wip { + let (_, tree) = tools.get_tool("Tree").unwrap(); + let output = tree + .execute( + r#"{"path":"/drive","depth":1}"#, + ToolExecutionContext::new("drive-wiring-tree", "batch", 0), + ) + .await; + assert_eq!(output.is_ok(), configured, "{output:?}"); + if let Ok(output) = output { + let tree: serde_json::Value = + serde_json::from_str(&output.content.unwrap()).unwrap(); + assert_eq!(tree["path"], "/drive"); + // A self-scoped entrance is an enumeration boundary, + // not an observed empty Workspace hierarchy. + assert_eq!(tree["children"], serde_json::Value::Null); + } + } + worker.run_text("Inspect enabled features").await.unwrap(); + let requests = requests.lock().unwrap(); + let prompt = requests[0].system_prompt.as_deref().unwrap_or(""); + assert_eq!( + prompt.contains("## Workspace Drive"), + configured, + "{mode:?}, enabled={enabled}, available={available}, workspace={workspace:?}" + ); + } + } + } + struct LargeOutputTool; #[async_trait::async_trait] diff --git a/crates/worker/src/feature/builtin.rs b/crates/worker/src/feature/builtin.rs index e4532e63c..89aa7d5bc 100644 --- a/crates/worker/src/feature/builtin.rs +++ b/crates/worker/src/feature/builtin.rs @@ -6,6 +6,7 @@ pub mod backend_job_result; pub mod chat_invocation; +pub mod drive; pub mod flow_transition; pub mod manage_workdir; pub mod manage_worker; diff --git a/crates/worker/src/feature/builtin/drive.rs b/crates/worker/src/feature/builtin/drive.rs new file mode 100644 index 000000000..8557c44cd --- /dev/null +++ b/crates/worker/src/feature/builtin/drive.rs @@ -0,0 +1,718 @@ +//! Worker Drive operations use only the injected Workspace API. Observations +//! below are bounded preconditions, never a hierarchy replica or a grant cache. +pub mod backend; +pub(crate) mod delegation; +#[cfg(test)] +mod tests; +pub mod wip; + +use crate::{ + feature::{ + FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, + ToolContribution, ToolDeclaration, + }, + worker::WorkspaceClient, +}; +use agen::tool::{ + Attachment, ImageAttachment, Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, + ToolOutput, +}; +use async_trait::async_trait; +use backend::{DriveBackend, DriveError}; +use schemars::JsonSchema; +use serde::{Deserialize, de::DeserializeOwned}; +use serde_json::{Value, json}; +use server_api::*; +use sha2::{Digest, Sha256}; +use std::{ + collections::{HashMap, VecDeque}, + sync::{Arc, Mutex}, +}; +use workdir::{WorkdirPath, WorkdirSessionCapability, WorkdirSessionRouter}; + +pub const FEATURE_ID: &str = "drive"; +const MAX_OBSERVATIONS: usize = 256; + +#[derive(Default)] +struct Observations { + entries: HashMap, + order: VecDeque, +} +impl Observations { + fn insert(&mut self, entry: DriveEntry) { + let key = entry.entry.node_id.clone(); + self.order.retain(|old| old != &key); + self.order.push_back(key.clone()); + self.entries.insert(key, entry); + while self.order.len() > MAX_OBSERVATIONS { + if let Some(old) = self.order.pop_front() { + self.entries.remove(&old); + } + } + } + fn remove(&mut self, key: &str) { + self.entries.remove(key); + self.order.retain(|old| old != key); + } +} + +#[derive(Clone)] +pub struct DriveFeature { + pub(super) backend: DriveBackend, + pub(super) router: Arc, + observations: Arc>, + nonce: Arc, + pub(super) permissions: Option, +} +impl DriveFeature { + pub fn configured( + client: Arc, + enabled: bool, + router: Arc, + ) -> Option { + (enabled + && client.is_available() + && client + .workspace_id() + .is_some_and(|id| !id.is_empty() && !id.chars().any(char::is_control))) + .then(|| Self::new(client, router)) + } + pub fn new(client: Arc, router: Arc) -> Self { + Self { + backend: DriveBackend::new(client), + router, + observations: Arc::new(Mutex::new(Observations::default())), + nonce: uuid::Uuid::now_v7().to_string().into(), + permissions: None, + } + } + pub fn with_permissions(mut self, permissions: Option) -> Self { + self.permissions = permissions; + self + } + pub fn tools(&self) -> Vec<(&'static str, ToolDefinition)> { + SPECS + .iter() + .map(|spec| { + let feature = self.clone(); + let spec = *spec; + let definition: ToolDefinition = Arc::new(move || { + ( + ToolMeta::new(spec.name) + .description(spec.description) + .input_schema((spec.schema)()), + Arc::new(DriveTool { + feature: feature.clone(), + name: spec.name, + }) as Arc, + ) + }); + (spec.name, definition) + }) + .collect() + } + fn observe(&self, entry: DriveEntry) -> Result<(), DriveError> { + self.backend.validate_entry(&entry)?; + self.observations + .lock() + .map_err(|_| DriveError::Unavailable)? + .insert(entry); + Ok(()) + } + fn observed( + &self, + id: &DriveEntryRef, + pinned: Option<&DriveEntry>, + ) -> Result { + self.backend.validate_ref(id)?; + if let Some(pinned) = pinned { + if pinned.entry != *id { + return Err(DriveError::Denied); + } + return Ok(pinned.clone()); + } + self.observations.lock().map_err(|_| DriveError::Unavailable)?.entries.get(&id.node_id).cloned().ok_or_else(|| DriveError::Invalid("read or inspect metadata for this entry before mutation; observations are bounded and not persisted authority".into())) + } + fn request_id(&self, context: &ToolExecutionContext) -> String { + digest(format!("{}:{}", self.nonce, context.execution_id()).as_bytes()) + } + pub(super) async fn execute( + &self, + name: &str, + input: &str, + context: &ToolExecutionContext, + pinned: Option<&DriveEntry>, + ) -> Result { + self.backend.workspace()?; + let request_id = self.request_id(context); + let mut attachments = Vec::new(); + let mut mutated_id = None; + let value = match name { + "DriveRoot" => { + let _: EmptyArgs = decode(input)?; + let entry = self.backend.root().await?; + self.observe(entry.clone())?; + entry_value(entry) + } + "DriveMetadata" => { + let args: EntryArgs = decode(input)?; + let entry = self.backend.metadata(&args.entry).await?; + self.observe(entry.clone())?; + entry_value(entry) + } + "DriveList" => { + let args: ListArgs = decode(input)?; + if args + .limit + .is_some_and(|n| n == 0 || n > DRIVE_PAGE_MAX_LIMIT) + { + return Err(DriveError::Limit); + } + let parent = match args.parent { + Some(parent) => parent, + None => self.backend.root().await?.entry, + }; + let response = self.backend.list(&parent, args.limit, args.after).await?; + page_value(response) + } + "DriveSearch" => { + let args: DriveSearchQuery = decode(input)?; + if args.query.len() > 512 || args.after.as_ref().is_some_and(|s| s.len() > 2048) { + return Err(DriveError::Limit); + } + page_value(self.backend.search(args).await?) + } + "DriveRead" => { + let args: ReadArgs = decode(input)?; + if args.max_bytes == 0 || args.max_bytes > DRIVE_CHUNK_MAX_BYTES { + return Err(DriveError::Limit); + } + let read = self.backend.read(&args.entry, args.max_bytes).await?; + if pinned.is_some_and(|entry| entry.revision != read.entry.revision) { + return Err(DriveError::Conflict); + } + self.observe(read.entry.clone())?; + json!({"entry": entry_value(read.entry), "text": read.text, "truncated":read.truncated}) + } + "DriveViewImage" => { + let args: EntryArgs = decode(input)?; + let entry = if let Some(entry) = pinned { + self.observed(&args.entry, Some(entry))? + } else { + self.backend.metadata(&args.entry).await? + }; + let bytes = self + .backend + .bytes(&entry, tools::view_image::MAX_IMAGE_BYTES) + .await?; + let media = tools::view_image::detect_image_mime(&bytes).ok_or_else(|| { + DriveError::Invalid("supported image bytes required: PNG/JPEG/GIF/WebP".into()) + })?; + let size = bytes.len(); + attachments.push(Attachment::Image(ImageAttachment::new( + media, + Arc::<[u8]>::from(bytes), + ))); + self.observe(entry.clone())?; + json!({"entry":entry_value(entry), "attached":true, "mime_type":media, "bytes":size}) + } + "DriveCreateFolder" => { + let args: FolderArgs = decode(input)?; + self.backend.validate_ref(&args.parent)?; + mutation_value( + self.backend + .mutate(DriveMutationRequest { + request_id, + mutation: DriveMutation::CreateFolder { + parent: args.parent, + name: args.name, + }, + }) + .await?, + ) + } + "DriveCreateText" => { + let args: CreateArgs = decode(input)?; + self.backend.validate_ref(&args.parent)?; + let text = checked_text(args.content)?; + mutation_value( + self.backend + .mutate(DriveMutationRequest { + request_id, + mutation: DriveMutation::CreateText { + parent: args.parent, + name: args.name, + text, + content_type: args.content_type, + }, + }) + .await?, + ) + } + "DriveWrite" => { + let args: WriteArgs = decode(input)?; + let entry = self.observed(&args.entry, pinned)?; + let text = checked_text(args.content)?; + mutated_id = Some(entry.entry.node_id.clone()); + mutation_value( + self.backend + .mutate(DriveMutationRequest { + request_id, + mutation: DriveMutation::UpdateText { + id: entry.entry, + expected_revision: entry.revision, + text, + content_type: entry.content_type.unwrap_or_else(default_media), + }, + }) + .await?, + ) + } + "DriveEdit" => { + let args: EditArgs = decode(input)?; + let entry = self.observed(&args.entry, pinned)?; + let edit = fs_operation::text::EditArgs { + old_string: args.old_string, + new_string: args.new_string, + replace_all: args.replace_all, + }; + edit.validate(text_limits()).map_err(text_error)?; + let read = self + .backend + .read(&entry.entry, DRIVE_TEXT_MAX_BYTES as u32) + .await?; + if read.entry.revision != entry.revision { + return Err(DriveError::Conflict); + } + if read.truncated { + return Err(DriveError::Limit); + } + let text = fs_operation::text::edit(&read.text, &edit, text_limits()) + .map_err(text_error)? + .content; + mutated_id = Some(entry.entry.node_id.clone()); + mutation_value( + self.backend + .mutate(DriveMutationRequest { + request_id, + mutation: DriveMutation::UpdateText { + id: entry.entry, + expected_revision: entry.revision, + text, + content_type: entry.content_type.unwrap_or_else(default_media), + }, + }) + .await?, + ) + } + "DriveRelocate" => { + let args: RelocateArgs = decode(input)?; + self.backend.validate_ref(&args.parent)?; + let entry = self.observed(&args.entry, pinned)?; + mutated_id = Some(entry.entry.node_id.clone()); + mutation_value( + self.backend + .mutate(DriveMutationRequest { + request_id, + mutation: DriveMutation::Relocate { + id: entry.entry, + expected_revision: entry.revision, + parent: args.parent, + name: args.name, + }, + }) + .await?, + ) + } + "DriveDelete" => { + let args: EntryArgs = decode(input)?; + let entry = self.observed(&args.entry, pinned)?; + mutated_id = Some(entry.entry.node_id.clone()); + mutation_value( + self.backend + .mutate(DriveMutationRequest { + request_id, + mutation: DriveMutation::Delete { + id: entry.entry, + expected_revision: entry.revision, + }, + }) + .await?, + ) + } + "DriveSaveWorkdir" => { + let args: SaveArgs = decode(input)?; + self.backend.validate_ref(&args.parent)?; + // Drive API rechecks write at publication; source uses the selected + // Workdir read broker, with no OS-path or command fallback. + let selected = self + .router + .resolve(Some(&args.target_workdir)) + .map_err(|_| DriveError::Denied)?; + if !selected + .session + .capabilities() + .supports(WorkdirSessionCapability::Read) + { + return Err(DriveError::Denied); + } + if args.path.starts_with('/') + || args.path.contains('\\') + || args.path.split('/').any(|part| part == "..") + { + return Err(DriveError::Invalid( + "logical Workdir-relative path required".into(), + )); + } + let path = WorkdirPath::new(&args.path).map_err(|_| { + DriveError::Invalid("logical Workdir-relative path required".into()) + })?; + let bytes = tools::view_image::read_workdir_bytes( + selected.session.as_ref(), + path, + DRIVE_FILE_MAX_BYTES as usize, + ) + .await + .map_err(|_| { + DriveError::Invalid( + "Workdir source denied, changed, oversized or unavailable".into(), + ) + })?; + let query = DriveUploadQuery { + operation: DriveUploadOperation::Create, + request_id, + entry_workspace_id: args.parent.workspace_id, + parent_id: Some(args.parent.node_id), + name: Some(args.name), + id: None, + expected_revision: None, + content_type: args.content_type, + size: bytes.len() as u32, + sha256: digest(&bytes), + }; + mutation_value(self.backend.upload(query, bytes).await?) + } + "DriveRequestStatus" => { + let args: StatusArgs = decode(input)?; + serde_json::to_value(self.backend.status(&args.request_id).await?) + .map_err(|_| DriveError::Unavailable)? + } + _ => return Err(DriveError::Invalid("unknown operation".into())), + }; + // A successful mutation does not silently substitute its new revision + // into a prior observation. The caller observes explicitly next time. + if let Some(id) = mutated_id { + self.observations + .lock() + .map_err(|_| DriveError::Unavailable)? + .remove(&id); + } + Ok(DriveOutput { value, attachments }) + } +} + +pub(super) struct DriveOutput { + pub value: Value, + pub attachments: Vec, +} +impl DriveOutput { + fn tool(self, name: &str) -> Result { + Ok(ToolOutput { + summary: format!("{name} completed through Workspace Drive API"), + content: Some( + serde_json::to_string(&self.value) + .map_err(|_| ToolError::Internal("Drive result encoding failed".into()))?, + ), + attachments: self.attachments, + }) + } +} +struct DriveTool { + feature: DriveFeature, + name: &'static str, +} +#[async_trait] +impl Tool for DriveTool { + async fn execute( + &self, + input: &str, + context: ToolExecutionContext, + ) -> Result { + self.feature + .execute(self.name, input, &context, None) + .await + .map_err(tool_error)? + .tool(self.name) + } +} +fn tool_error(error: DriveError) -> ToolError { + match error { + DriveError::Conflict => ToolError::StructuredConflict { + code: "drive_conflict".into(), + message: error.to_string(), + }, + DriveError::Invalid(_) | DriveError::Limit => ToolError::InvalidArgument(error.to_string()), + _ => ToolError::ExecutionFailed(error.to_string()), + } +} +impl FeatureModule for DriveFeature { + fn descriptor(&self) -> FeatureDescriptor { + let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID,"Workspace Drive").with_description("Explicit Backend-authorized Drive documents, images and artifact publication; no Workdir required").with_instruction(drive_instruction()); + for spec in SPECS { + descriptor = descriptor.with_tool(ToolDeclaration::new(spec.name, spec.description)); + } + descriptor + } + fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { + context + .instructions() + .register(crate::feature::FeatureInstructionContribution::new( + drive_instruction(), + ))?; + for (name, definition) in self.tools() { + context + .tools() + .register(ToolContribution::new(name, definition))?; + } + Ok(()) + } +} + +fn drive_instruction() -> crate::feature::FeatureInstructionDeclaration { + crate::feature::FeatureInstructionDeclaration::new( + crate::feature::FeatureInstructionId::builtin("drive-workflow"), + "common.drive", + "Authorized Drive operation guidance", + ) + .expect("static Drive instruction") +} + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct EmptyArgs {} +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct EntryArgs { + entry: DriveEntryRef, +} +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct ListArgs { + #[serde(default)] + parent: Option, + #[serde(default)] + #[schemars(range(min = 1, max = 200))] + limit: Option, + #[serde(default)] + after: Option, +} +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct ReadArgs { + entry: DriveEntryRef, + #[serde(default = "default_read")] + #[schemars(range(min = 1, max = 65536))] + max_bytes: u32, +} +fn default_read() -> u32 { + DRIVE_CHUNK_MAX_BYTES +} +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct FolderArgs { + parent: DriveEntryRef, + name: String, +} +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct CreateArgs { + parent: DriveEntryRef, + name: String, + content: String, + #[serde(default = "default_media")] + content_type: String, +} +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct WriteArgs { + entry: DriveEntryRef, + content: String, +} +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct EditArgs { + entry: DriveEntryRef, + old_string: String, + new_string: String, + #[serde(default)] + replace_all: bool, +} +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct RelocateArgs { + entry: DriveEntryRef, + parent: DriveEntryRef, + name: String, +} +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct SaveArgs { + target_workdir: String, + path: String, + parent: DriveEntryRef, + name: String, + #[serde(default = "default_binary_media")] + content_type: String, +} +fn default_binary_media() -> String { + "application/octet-stream".into() +} +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct StatusArgs { + request_id: String, +} +fn default_media() -> String { + "text/markdown".into() +} +fn decode(input: &str) -> Result { + if input.len() > 512 * 1024 { + return Err(DriveError::Limit); + } + serde_json::from_str(input) + .map_err(|_| DriveError::Invalid("input shape or bound rejected".into())) +} +fn text_limits() -> fs_operation::text::TextLimits { + fs_operation::text::TextLimits { + max_input_bytes: Some(DRIVE_TEXT_MAX_BYTES), + max_output_bytes: Some(DRIVE_TEXT_MAX_BYTES), + max_replacements: None, + } +} +fn checked_text(text: String) -> Result { + fs_operation::text::write(text, text_limits()) + .map(|out| out.content) + .map_err(text_error) +} +fn text_error(error: fs_operation::text::TextError) -> DriveError { + DriveError::Invalid(error.to_string()) +} +fn digest(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} +pub(super) fn entry_path(entry: &DriveEntryRef) -> String { + format!( + "/drive/{}/{}", + crate::feature::builtin::manage_workdir::wip::encode_identity(&entry.workspace_id), + entry.node_id + ) +} +fn entry_value(entry: DriveEntry) -> Value { + json!({"path":entry_path(&entry.entry),"metadata":entry}) +} +fn page_value(page: DriveListResponse) -> Value { + json!({"entries":page.entries.into_iter().map(entry_value).collect::>(),"next_after":page.next_after}) +} +fn mutation_value(response: DriveMutationResponse) -> Value { + json!({"request_id":response.request_id,"entry":response.entry.map(entry_value)}) +} + +#[derive(Clone, Copy)] +pub(super) struct Spec { + pub name: &'static str, + pub operation: &'static str, + pub description: &'static str, + pub schema: fn() -> Value, +} +fn schema() -> Value { + serde_json::to_value(schemars::schema_for!(T)).expect("static Drive argument schema") +} +pub(super) const SPECS: &[Spec] = &[ + Spec { + name: "DriveRoot", + operation: "root", + description: "Discover the authorized Workspace Drive root. Profile activation is not a grant; no Workdir is required.", + schema: schema::, + }, + Spec { + name: "DriveMetadata", + operation: "metadata", + description: "Inspect one Workspace-bound stable entry ID and observe its revision for mutation.", + schema: schema::, + }, + Spec { + name: "DriveList", + operation: "list", + description: "List a bounded folder page (default root), up to 200 entries. Results do not expand the WIP tree or observe revisions for writes.", + schema: schema::, + }, + Spec { + name: "DriveSearch", + operation: "search", + description: "Search names and optionally bounded text through Backend paged search; no embedding/index. Use the returned cursor to continue.", + schema: schema::, + }, + Spec { + name: "DriveRead", + operation: "read", + description: "Read up to 64 KiB UTF-8 text and observe entry revision. Past Session text remains an immutable observation.", + schema: schema::, + }, + Spec { + name: "DriveCreateFolder", + operation: "create_folder", + description: "Create a folder through the explicit Drive write grant. Same-name conflicts are not overwritten.", + schema: schema::, + }, + Spec { + name: "DriveCreateText", + operation: "create_text", + description: "Create bounded UTF-8 text (default Markdown), <=64 KiB. Success means DB publication committed.", + schema: schema::, + }, + Spec { + name: "DriveWrite", + operation: "write", + description: "Replace <=64 KiB text using a previously read/metadata-observed revision. Conflicts are not retried with latest revision.", + schema: schema::, + }, + Spec { + name: "DriveEdit", + operation: "edit", + description: "Partial text edit using previously observed revision and shared old_string rules. Require nonempty unique match unless replace_all. Truncated preimages cannot be edited.", + schema: schema::, + }, + Spec { + name: "DriveRelocate", + operation: "relocate", + description: "Rename and/or move an observed entry with revision precondition; stable ID and URL remain unchanged.", + schema: schema::, + }, + Spec { + name: "DriveDelete", + operation: "delete", + description: "Delete one observed entry with revision precondition. A recreated name has a different ID; stale observations cannot target it.", + schema: schema::, + }, + Spec { + name: "DriveViewImage", + operation: "view_image", + description: "Attach revision-fixed PNG/JPEG/GIF/WebP bytes (<=10 MiB) through durable ToolOutput history/capture; a URL alone is not image viewing.", + schema: schema::, + }, + Spec { + name: "DriveSaveWorkdir", + operation: "save_workdir", + description: "Publish a <=16 MiB artifact using selected Workdir alias and logical path plus Drive parent/name. Both source read and destination write are required. No Bash/raw host path/base64 input.", + schema: schema::, + }, + Spec { + name: "DriveRequestStatus", + operation: "request_status", + description: "Query the same mutation request ID after OutcomeUnknown. Uncommitted snapshot is not proof of failure; never blindly recreate the request.", + schema: schema::, + }, +]; diff --git a/crates/worker/src/feature/builtin/drive/backend.rs b/crates/worker/src/feature/builtin/drive/backend.rs new file mode 100644 index 000000000..24b4ec93e --- /dev/null +++ b/crates/worker/src/feature/builtin/drive/backend.rs @@ -0,0 +1,470 @@ +//! Identity-bound Drive API adapter. The Backend owns grants, hierarchy, CAS and +//! receipts. In particular, a lost mutation response is queried, never resent. +use std::{sync::Arc, time::Duration}; + +use crate::worker::{ + WorkspaceBinaryRequest, WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod, +}; +use serde::{Serialize, de::DeserializeOwned}; +use server_api::*; + +pub(super) const DEADLINE: Duration = Duration::from_secs(30); +const RESPONSE_LIMIT: usize = 2 * 1024 * 1024; + +#[derive(Clone)] +pub struct DriveBackend { + client: Arc, +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum DriveError { + #[error("Drive access denied")] + Denied, + #[error("Drive resource not found")] + NotFound, + #[error("Drive revision or name conflict; observe explicitly before another operation")] + Conflict, + #[error("Invalid Drive arguments: {0}")] + Invalid(String), + #[error("Drive request exceeds a limit")] + Limit, + #[error("Drive storage or transport unavailable")] + Unavailable, + #[error( + "Drive mutation outcome unknown; request_id={0}; query request_status; do not blindly repeat" + )] + OutcomeUnknown(String), +} + +impl DriveBackend { + pub fn new(client: Arc) -> Self { + Self { client } + } + pub fn workspace(&self) -> Result<&str, DriveError> { + self.client + .workspace_id() + .filter(|id| !id.is_empty() && self.client.is_available()) + .ok_or(DriveError::Denied) + } + fn base(&self) -> Result { + Ok(format!("/api/w/{}/drive", encode(self.workspace()?))) + } + pub(super) fn validate_entry(&self, entry: &DriveEntry) -> Result<(), DriveError> { + self.validate_ref(&entry.entry)?; + if let Some(parent) = &entry.parent { + self.validate_ref(parent)?; + } + if !decimal(&entry.revision) || entry.size.is_some_and(|n| n > DRIVE_FILE_MAX_BYTES) { + return Err(DriveError::Unavailable); + } + Ok(()) + } + pub(super) fn validate_ref(&self, entry: &DriveEntryRef) -> Result<(), DriveError> { + if entry.workspace_id != self.workspace()? { + return Err(DriveError::Denied); + } + if !decimal(&entry.node_id) { + return Err(DriveError::Invalid( + "canonical decimal node ID required".into(), + )); + } + Ok(()) + } + async fn json( + &self, + request: WorkspaceRequest, + mutation: bool, + request_id: &str, + ) -> Result { + let client = self.client.clone(); + let response = tokio::time::timeout( + DEADLINE, + tokio::task::spawn_blocking(move || client.execute_with_timeout(request, DEADLINE)), + ) + .await; + let unknown = || { + if mutation { + DriveError::OutcomeUnknown(request_id.into()) + } else { + DriveError::Unavailable + } + }; + let response = response + .map_err(|_| unknown())? + .map_err(|_| unknown())? + .map_err(|_| unknown())?; + if response.body.len() > RESPONSE_LIMIT { + return Err(if mutation { + unknown() + } else { + DriveError::Limit + }); + } + if !response.is_success() { + return Err(classify( + response.status, + response.body.as_bytes(), + mutation, + request_id, + )); + } + serde_json::from_str(&response.body).map_err(|_| unknown()) + } + async fn get( + &self, + suffix: &str, + query: &Q, + ) -> Result { + let query = query_string(query)?; + self.json( + WorkspaceRequest::get(format!("{}{suffix}{query}", self.base()?)), + false, + "", + ) + .await + } + pub async fn root(&self) -> Result { + let entry: DriveEntry = self + .json( + WorkspaceRequest::get(format!("{}/root", self.base()?)), + false, + "", + ) + .await?; + self.validate_entry(&entry)?; + if entry.kind != DriveEntryKind::Folder || entry.parent.is_some() { + return Err(DriveError::Unavailable); + } + Ok(entry) + } + pub async fn metadata(&self, entry: &DriveEntryRef) -> Result { + self.validate_ref(entry)?; + let result: DriveEntry = self + .get( + "/metadata", + &DriveEntryQuery { + entry_workspace_id: entry.workspace_id.clone(), + id: entry.node_id.clone(), + }, + ) + .await?; + self.validate_entry(&result)?; + if result.entry != *entry { + return Err(DriveError::Unavailable); + } + Ok(result) + } + pub async fn list( + &self, + parent: &DriveEntryRef, + limit: Option, + after: Option, + ) -> Result { + self.validate_ref(parent)?; + let result: DriveListResponse = self + .get( + "/list", + &DriveListQuery { + entry_workspace_id: parent.workspace_id.clone(), + id: parent.node_id.clone(), + limit, + after, + }, + ) + .await?; + self.validate_page( + &result, + limit + .unwrap_or(DRIVE_PAGE_MAX_LIMIT) + .min(DRIVE_PAGE_MAX_LIMIT), + )?; + if result + .entries + .iter() + .any(|entry| entry.parent.as_ref() != Some(parent)) + { + return Err(DriveError::Unavailable); + } + Ok(result) + } + pub async fn search(&self, query: DriveSearchQuery) -> Result { + let result: DriveListResponse = self.get("/search", &query).await?; + self.validate_page( + &result, + query + .limit + .unwrap_or(DRIVE_SEARCH_MAX_LIMIT) + .min(DRIVE_SEARCH_MAX_LIMIT), + )?; + Ok(result) + } + fn validate_page(&self, page: &DriveListResponse, limit: u32) -> Result<(), DriveError> { + if page.entries.len() > limit as usize { + return Err(DriveError::Limit); + } + for entry in &page.entries { + self.validate_entry(entry)?; + } + Ok(()) + } + pub async fn read( + &self, + entry: &DriveEntryRef, + max_bytes: u32, + ) -> Result { + self.validate_ref(entry)?; + let result: DriveReadTextResponse = self + .get( + "/read-text", + &DriveReadTextQuery { + entry_workspace_id: entry.workspace_id.clone(), + id: entry.node_id.clone(), + max_bytes, + }, + ) + .await?; + self.validate_entry(&result.entry)?; + if result.entry.entry != *entry || result.text.len() > max_bytes as usize { + return Err(DriveError::Unavailable); + } + Ok(result) + } + pub async fn bytes(&self, entry: &DriveEntry, limit: usize) -> Result, DriveError> { + self.validate_entry(entry)?; + let total = entry + .size + .ok_or_else(|| DriveError::Invalid("file required".into()))?; + if total as usize > limit { + return Err(DriveError::Limit); + } + let mut bytes = Vec::with_capacity(total as usize); + // Even an empty file gets a revision-fixed read and current authorization. + loop { + let offset = bytes.len() as u32; + let length = total.saturating_sub(offset).clamp(1, DRIVE_CHUNK_MAX_BYTES); + let query = query_string(&DriveReadChunkQuery { + entry_workspace_id: entry.entry.workspace_id.clone(), + id: entry.entry.node_id.clone(), + expected_revision: entry.revision.clone(), + offset, + length, + })?; + let request = WorkspaceBinaryRequest { + method: WorkspaceRequestMethod::Get, + path: format!("{}/read-chunk{query}", self.base()?), + body: None, + max_response_bytes: DRIVE_CHUNK_MAX_BYTES as usize, + }; + let client = self.client.clone(); + let response = tokio::time::timeout( + DEADLINE, + tokio::task::spawn_blocking(move || { + client.execute_binary_with_timeout(request, DEADLINE) + }), + ) + .await + .map_err(|_| DriveError::Unavailable)? + .map_err(|_| DriveError::Unavailable)? + .map_err(|_| DriveError::Unavailable)?; + if !(200..300).contains(&response.status) { + return Err(classify(response.status, &response.body, false, "")); + } + let expected = total.saturating_sub(offset).min(length) as usize; + if response.body.len() != expected { + return Err(DriveError::Unavailable); + } + bytes.extend_from_slice(&response.body); + if bytes.len() == total as usize { + return Ok(bytes); + } + } + } + async fn reconcile( + &self, + id: &str, + result: Result, + ) -> Result { + // A parseable success can still be an invalid completion. Classify it + // before reconciliation so it also gets one original-request inquiry. + let result = result.and_then(|response| { + self.validate_completion(id, &response) + .map_err(|_| DriveError::OutcomeUnknown(id.into()))?; + Ok(response) + }); + match result { + Err(DriveError::OutcomeUnknown(_)) => match self.status(id).await { + Ok(DriveRequestStatusResponse { + state: DriveRequestState::Committed, + response: Some(response), + .. + }) => Ok(response), // status validates both IDs and entry metadata + _ => Err(DriveError::OutcomeUnknown(id.into())), + }, + other => other, + } + } + fn validate_completion( + &self, + id: &str, + response: &DriveMutationResponse, + ) -> Result<(), DriveError> { + if response.request_id != id { + return Err(DriveError::Unavailable); + } + if let Some(entry) = &response.entry { + self.validate_entry(entry)?; + } + Ok(()) + } + pub async fn mutate( + &self, + request: DriveMutationRequest, + ) -> Result { + let body = serde_json::to_string(&request) + .map_err(|_| DriveError::Invalid("invalid mutation".into()))?; + if body.len() > 512 * 1024 { + return Err(DriveError::Limit); + } + let result = self + .json( + WorkspaceRequest::json( + WorkspaceRequestMethod::Post, + format!("{}/mutate", self.base()?), + body, + ), + true, + &request.request_id, + ) + .await; + self.reconcile(&request.request_id, result).await + } + pub async fn upload( + &self, + query: DriveUploadQuery, + bytes: Vec, + ) -> Result { + if bytes.len() > DRIVE_FILE_MAX_BYTES as usize { + return Err(DriveError::Limit); + } + let path = format!("{}/upload{}", self.base()?, query_string(&query)?); + let request = WorkspaceBinaryRequest { + method: WorkspaceRequestMethod::Put, + path, + body: Some(bytes), + max_response_bytes: RESPONSE_LIMIT, + }; + let client = self.client.clone(); + let result = tokio::time::timeout( + DEADLINE, + tokio::task::spawn_blocking(move || { + client.execute_binary_with_timeout(request, DEADLINE) + }), + ) + .await; + let result = match result { + Ok(Ok(Ok(response))) if (200..300).contains(&response.status) => { + serde_json::from_slice(&response.body) + .map_err(|_| DriveError::OutcomeUnknown(query.request_id.clone())) + } + Ok(Ok(Ok(response))) => Err(classify( + response.status, + &response.body, + true, + &query.request_id, + )), + _ => Err(DriveError::OutcomeUnknown(query.request_id.clone())), + }; + self.reconcile(&query.request_id, result).await + } + pub async fn status(&self, request_id: &str) -> Result { + if request_id.is_empty() || request_id.len() > 256 { + return Err(DriveError::Invalid("invalid request ID".into())); + } + let response: DriveRequestStatusResponse = self + .json( + WorkspaceRequest::get(format!("{}/requests/{}", self.base()?, encode(request_id))), + false, + "", + ) + .await?; + if response.request_id != request_id { + return Err(DriveError::Unavailable); + } + if let Some(result) = &response.response { + self.validate_completion(request_id, result)?; + } + Ok(response) + } +} + +pub(super) fn decimal(value: &str) -> bool { + value + .as_bytes() + .first() + .is_some_and(|c| matches!(c, b'1'..=b'9')) + && value.bytes().all(|c| c.is_ascii_digit()) + && value.parse::().is_ok_and(|id| id > 0) +} +pub(super) fn encode(value: &str) -> String { + value + .bytes() + .map(|b| { + if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_') { + (b as char).to_string() + } else { + format!("%{b:02X}") + } + }) + .collect() +} +fn query_string(value: &T) -> Result { + let value = + serde_json::to_value(value).map_err(|_| DriveError::Invalid("invalid query".into()))?; + let object = value + .as_object() + .ok_or_else(|| DriveError::Invalid("invalid query".into()))?; + let pairs: Vec<_> = object + .iter() + .filter(|(_, v)| !v.is_null()) + .map(|(k, v)| { + format!( + "{}={}", + encode(k), + encode( + v.as_str() + .map(str::to_owned) + .unwrap_or_else(|| v.to_string()) + .as_str() + ) + ) + }) + .collect(); + Ok(if pairs.is_empty() { + String::new() + } else { + format!("?{}", pairs.join("&")) + }) +} +fn classify(status: u16, body: &[u8], mutation: bool, id: &str) -> DriveError { + if let Ok(error) = serde_json::from_slice::(body) { + if error.classification == DriveFailureClassification::Unknown { + return DriveError::OutcomeUnknown(id.into()); + } + return match error.code { + DriveApiErrorCode::Denied => DriveError::Denied, + DriveApiErrorCode::NotFound => DriveError::NotFound, + DriveApiErrorCode::Conflict => DriveError::Conflict, + DriveApiErrorCode::Invalid => DriveError::Invalid("Backend rejected arguments".into()), + DriveApiErrorCode::Limit => DriveError::Limit, + DriveApiErrorCode::StorageUnavailable => DriveError::Unavailable, + DriveApiErrorCode::OutcomeUnknown => DriveError::OutcomeUnknown(id.into()), + }; + } + if matches!(status, 401 | 403) { + DriveError::Denied + } else if mutation { + DriveError::OutcomeUnknown(id.into()) + } else { + DriveError::Unavailable + } +} diff --git a/crates/worker/src/feature/builtin/drive/delegation.rs b/crates/worker/src/feature/builtin/drive/delegation.rs new file mode 100644 index 000000000..3c3fbaffa --- /dev/null +++ b/crates/worker/src/feature/builtin/drive/delegation.rs @@ -0,0 +1,477 @@ +//! Fail-closed Drive boundary for parent-owned Internal SubWorkers, including Reviewers. +//! +//! Internal children have no Backend Drive identity. Explicit internal Drive delegation is not +//! supported until the Backend can approve scope bound to that child identity. A parent's current +//! grant, enabled profile, or Reviewer attestation is not a child Drive grant. Registered Runtime +//! Workers must use their own identity-bound, current Backend grants; this wrapper creates neither +//! identities nor grants and keeps no local authorization cache. + +use std::{sync::Arc, time::Duration}; + +use crate::worker::{ + ReviewerContext, WorkerWorkspaceContext, WorkspaceBinaryRequest, WorkspaceBinaryResponse, + WorkspaceClient, WorkspaceClientError, WorkspacePromptCatalogResolution, WorkspaceRequest, + WorkspaceResponse, WorkspaceServerOperation, WorkspaceWorkerDiscoveryRequest, +}; + +/// Removes Drive authority without changing the selected client's other authority or behavior. +/// Install outside any Reviewer client so its trusted review submission attestation is preserved. +#[derive(Debug)] +pub(crate) struct InternalChildWorkspaceClient { + inner: Arc, +} + +impl InternalChildWorkspaceClient { + pub(crate) fn new(inner: Arc) -> Self { + Self { inner } + } + + fn check_path(path: &str) -> Result<(), WorkspaceClientError> { + // Classify the route, not the method: even readonly Drive access must not inherit the + // parent's identity. Decode route segments and normalize dot segments before checking, + // so transport/router normalization cannot turn an allowed alias into a Drive request. + let path = path.split(['?', '#']).next().unwrap_or(path); + let mut decoded = Vec::with_capacity(path.len()); + // HTTP URL parsers discard literal tabs/newlines before route normalization. + let bytes: Vec<_> = path + .bytes() + .filter(|byte| !matches!(byte, b'\t' | b'\r' | b'\n')) + .collect(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' + && index + 2 < bytes.len() + && let (Some(high), Some(low)) = + (hex_digit(bytes[index + 1]), hex_digit(bytes[index + 2])) + { + decoded.push(high * 16 + low); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + let decoded = String::from_utf8_lossy(&decoded).replace('\\', "/"); + let mut segments = Vec::new(); + for segment in decoded.split('/') { + match segment { + "" | "." => {} + ".." => { + segments.pop(); + } + segment => segments.push(segment), + } + } + if matches!(segments.as_slice(), ["api", "w", _, "drive", ..]) { + return Err(WorkspaceClientError::Unavailable( + "Internal SubWorker Drive access denied: no identity-bound Backend-approved child scope; explicit internal Drive delegation is not supported".into(), + )); + } + Ok(()) + } +} + +fn hex_digit(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +/// Keep Workspace identity and prompt context, but never inherit Drive authority. +pub(crate) fn restrict_internal_child_drive( + context: WorkerWorkspaceContext, +) -> WorkerWorkspaceContext { + WorkerWorkspaceContext::with_client( + context.workspace_id().cloned(), + Arc::new(InternalChildWorkspaceClient::new(context.client_handle())), + ) +} + +impl WorkspaceClient for InternalChildWorkspaceClient { + fn workspace_id(&self) -> Option<&str> { + self.inner.workspace_id() + } + fn kind(&self) -> &str { + self.inner.kind() + } + fn is_available(&self) -> bool { + self.inner.is_available() + } + + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + Self::check_path(&request.path)?; + self.inner.execute(request) + } + + fn execute_with_timeout( + &self, + request: WorkspaceRequest, + timeout: Duration, + ) -> Result { + Self::check_path(&request.path)?; + self.inner.execute_with_timeout(request, timeout) + } + + fn execute_binary( + &self, + request: WorkspaceBinaryRequest, + ) -> Result { + Self::check_path(&request.path)?; + self.inner.execute_binary(request) + } + + fn execute_binary_with_timeout( + &self, + request: WorkspaceBinaryRequest, + timeout: Duration, + ) -> Result { + Self::check_path(&request.path)?; + self.inner.execute_binary_with_timeout(request, timeout) + } + + fn execute_server_operation( + &self, + operation: WorkspaceServerOperation, + ) -> Result { + self.inner.execute_server_operation(operation) + } + + fn current_prompt_projection( + &self, + minimum_revision: Option, + ) -> Result, WorkspaceClientError> { + self.inner.current_prompt_projection(minimum_revision) + } + + fn list_workspace_workers( + &self, + request: WorkspaceWorkerDiscoveryRequest, + ) -> Result { + self.inner.list_workspace_workers(request) + } + + fn execute_worker_remove( + &self, + target_runtime_id: &str, + target_worker_id: &str, + reason: &str, + ) -> Result { + self.inner + .execute_worker_remove(target_runtime_id, target_worker_id, reason) + } + + fn reviewer_context(&self) -> Option<&ReviewerContext> { + self.inner.reviewer_context() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::worker::{ReviewerChildWorkspaceClient, WorkspaceRequestMethod}; + use std::sync::Mutex; + + #[derive(Debug, PartialEq, Eq)] + enum Call { + Json(WorkspaceRequest, Option), + Binary(WorkspaceBinaryRequest, Option), + Projection(Option), + Discovery(WorkspaceWorkerDiscoveryRequest), + Remove(String, String, String), + Server, + } + + #[derive(Debug, Default)] + struct RecordingClient { + calls: Mutex>, + } + + impl RecordingClient { + fn response(&self, call: Call) -> Result { + self.calls.lock().unwrap().push(call); + Ok(WorkspaceResponse { + status: 202, + body: "forwarded".into(), + }) + } + fn binary_response( + &self, + call: Call, + ) -> Result { + self.calls.lock().unwrap().push(call); + Ok(WorkspaceBinaryResponse { + status: 206, + body: vec![0, 255], + }) + } + } + + impl WorkspaceClient for RecordingClient { + fn workspace_id(&self) -> Option<&str> { + Some("ws") + } + fn kind(&self) -> &str { + "recording" + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + self.response(Call::Json(request, None)) + } + fn execute_with_timeout( + &self, + request: WorkspaceRequest, + timeout: Duration, + ) -> Result { + self.response(Call::Json(request, Some(timeout))) + } + fn execute_binary( + &self, + request: WorkspaceBinaryRequest, + ) -> Result { + self.binary_response(Call::Binary(request, None)) + } + fn execute_binary_with_timeout( + &self, + request: WorkspaceBinaryRequest, + timeout: Duration, + ) -> Result { + self.binary_response(Call::Binary(request, Some(timeout))) + } + fn current_prompt_projection( + &self, + revision: Option, + ) -> Result, WorkspaceClientError> { + self.calls.lock().unwrap().push(Call::Projection(revision)); + Err(WorkspaceClientError::Request("projection marker".into())) + } + fn list_workspace_workers( + &self, + request: WorkspaceWorkerDiscoveryRequest, + ) -> Result { + self.calls.lock().unwrap().push(Call::Discovery(request)); + Err(WorkspaceClientError::Request("discovery marker".into())) + } + fn execute_worker_remove( + &self, + runtime: &str, + worker: &str, + reason: &str, + ) -> Result { + self.response(Call::Remove(runtime.into(), worker.into(), reason.into())) + } + fn execute_server_operation( + &self, + _: WorkspaceServerOperation, + ) -> Result { + self.response(Call::Server) + } + } + + fn binary(method: WorkspaceRequestMethod, path: &str) -> WorkspaceBinaryRequest { + WorkspaceBinaryRequest { + method, + path: path.into(), + body: Some(vec![0, 128, 255]), + max_response_bytes: 17, + } + } + + #[test] + fn internal_children_deny_all_drive_transports_without_parent_side_effects() { + let inner = Arc::new(RecordingClient::default()); + for reviewer in [false, true] { + let selected: Arc = if reviewer { + Arc::new(ReviewerChildWorkspaceClient::new( + inner.clone(), + ReviewerContext { + ticket_id: "T1".into(), + merge_request_id: "MR1".into(), + }, + "attestation".into(), + )) + } else { + inner.clone() + }; + let client = InternalChildWorkspaceClient::new(selected); + for path in [ + "/api/w/ws/drive", + "/api/w/ws/drive?limit=1", + "/api/w/other/drive/root", + "/api/w/ws/drive/read-chunk?id=1", + "/api/w/ws/drive/upload", + "/api/w/ws/drive/requests/request-id", + "/api/w/ws/%64rive/root", + "/api/w/ws/tickets/../drive/root", + "/api/w/ws/tickets/%2e%2e/drive/root", + "/api/w/ws%2fdrive/root", + "/api/w/ws/dr\tive/root", + "/api/w/ws/dri\r\nve/root", + ] { + for method in [ + WorkspaceRequestMethod::Get, + WorkspaceRequestMethod::Post, + WorkspaceRequestMethod::Put, + WorkspaceRequestMethod::Patch, + WorkspaceRequestMethod::Delete, + ] { + let request = WorkspaceRequest::json(method, path, "{}"); + for result in [ + client.execute(request.clone()), + client.execute_with_timeout(request, Duration::from_secs(2)), + ] { + assert!( + matches!(result, Err(WorkspaceClientError::Unavailable(message)) if message.contains("Drive access denied")), + "path={path}, reviewer={reviewer}" + ); + } + for result in [ + client.execute_binary(binary(method, path)), + client.execute_binary_with_timeout( + binary(method, path), + Duration::from_secs(2), + ), + ] { + assert!( + matches!(result, Err(WorkspaceClientError::Unavailable(message)) if message.contains("Drive access denied")), + "path={path}, reviewer={reviewer}" + ); + } + } + } + } + assert!(inner.calls.lock().unwrap().is_empty()); + } + + #[test] + fn non_drive_requests_preserve_payloads_deadlines_and_special_operations() { + let inner = Arc::new(RecordingClient::default()); + let client = InternalChildWorkspaceClient::new(inner.clone()); + let request = WorkspaceRequest::json( + WorkspaceRequestMethod::Post, + "/api/w/ws/tickets/query", + "{\"query\":\"drive\"}", + ); + let bytes = binary(WorkspaceRequestMethod::Put, "/api/w/ws/files/content"); + let timeout = Duration::from_millis(37); + let expected = WorkspaceResponse { + status: 202, + body: "forwarded".into(), + }; + assert_eq!(client.execute(request.clone()).unwrap(), expected); + assert_eq!( + client + .execute_with_timeout(request.clone(), timeout) + .unwrap(), + expected + ); + let expected_bytes = WorkspaceBinaryResponse { + status: 206, + body: vec![0, 255], + }; + assert_eq!( + client.execute_binary(bytes.clone()).unwrap(), + expected_bytes + ); + assert_eq!( + client + .execute_binary_with_timeout(bytes.clone(), timeout) + .unwrap(), + expected_bytes + ); + assert!( + matches!(client.current_prompt_projection(Some(42)), Err(WorkspaceClientError::Request(message)) if message == "projection marker") + ); + let discovery = WorkspaceWorkerDiscoveryRequest { + cursor: Some("next".into()), + limit: 3, + query: Some("coder".into()), + }; + assert!( + matches!(client.list_workspace_workers(discovery.clone()), Err(WorkspaceClientError::Request(message)) if message == "discovery marker") + ); + assert_eq!( + client + .execute_worker_remove("runtime", "worker", "reason") + .unwrap(), + expected + ); + assert_eq!( + client + .execute_server_operation(WorkspaceServerOperation::WorkerControlList) + .unwrap(), + expected + ); + assert_eq!(client.workspace_id(), Some("ws")); + assert_eq!(client.kind(), "recording"); + assert!(client.is_available()); + assert_eq!( + *inner.calls.lock().unwrap(), + vec![ + Call::Json(request.clone(), None), + Call::Json(request, Some(timeout)), + Call::Binary(bytes.clone(), None), + Call::Binary(bytes, Some(timeout)), + Call::Projection(Some(42)), + Call::Discovery(discovery), + Call::Remove("runtime".into(), "worker".into(), "reason".into()), + Call::Server, + ] + ); + } + + #[test] + fn drive_restriction_preserves_reviewer_context_and_review_attestation() { + let inner = Arc::new(RecordingClient::default()); + let context = ReviewerContext { + ticket_id: "T1".into(), + merge_request_id: "MR1".into(), + }; + let client = + InternalChildWorkspaceClient::new(Arc::new(ReviewerChildWorkspaceClient::new( + inner.clone(), + context.clone(), + "trusted-token".into(), + ))); + assert_eq!(client.reviewer_context(), Some(&context)); + assert_eq!(client.kind(), "runtime-reviewer-child"); + let request = WorkspaceRequest::json( + WorkspaceRequestMethod::Post, + "/api/w/ws/merge-requests/MR1/reviews", + "{\"verdict\":\"approve\"}", + ); + // Reviewer timeout execution uses its existing JSON attestation path, not raw parent access. + client + .execute_with_timeout(request, Duration::from_secs(1)) + .unwrap(); + let calls = inner.calls.lock().unwrap(); + let [Call::Json(request, None)] = calls.as_slice() else { + panic!("unexpected calls: {calls:?}") + }; + let body: serde_json::Value = serde_json::from_str(request.body.as_ref().unwrap()).unwrap(); + assert_eq!(body["capability_token"], "trusted-token"); + assert_eq!(body["verdict"], "approve"); + drop(calls); + let count = inner.calls.lock().unwrap().len(); + assert!( + client + .execute(WorkspaceRequest::json( + WorkspaceRequestMethod::Delete, + "/api/w/ws/tickets/T1", + "{}" + )) + .is_err() + ); + assert_eq!(inner.calls.lock().unwrap().len(), count); + } +} diff --git a/crates/worker/src/feature/builtin/drive/tests.rs b/crates/worker/src/feature/builtin/drive/tests.rs new file mode 100644 index 000000000..8107139c6 --- /dev/null +++ b/crates/worker/src/feature/builtin/drive/tests.rs @@ -0,0 +1,651 @@ +use super::*; +use crate::worker::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse}; +use serde::Serialize; +use std::collections::VecDeque; + +#[derive(Debug)] +struct Client { + responses: Mutex>>, + requests: Mutex>, +} +impl Client { + fn new(responses: Vec) -> Arc { + Arc::new(Self { + responses: Mutex::new(responses.into_iter().map(Ok).collect()), + requests: Mutex::new(Vec::new()), + }) + } +} +impl WorkspaceClient for Client { + fn workspace_id(&self) -> Option<&str> { + Some("workspace") + } + fn kind(&self) -> &str { + "drive-test" + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + self.requests.lock().unwrap().push(request); + self.responses + .lock() + .unwrap() + .pop_front() + .expect("unexpected Backend call") + } +} +fn response(value: impl Serialize) -> WorkspaceResponse { + WorkspaceResponse { + status: 200, + body: serde_json::to_string(&value).unwrap(), + } +} +fn entry(id: &str, revision: &str) -> DriveEntry { + DriveEntry { + entry: DriveEntryRef { + workspace_id: "workspace".into(), + node_id: id.into(), + }, + parent: Some(DriveEntryRef { + workspace_id: "workspace".into(), + node_id: "1".into(), + }), + name: "note.md".into(), + kind: DriveEntryKind::File, + revision: revision.into(), + size: Some(5), + content_type: Some("text/markdown".into()), + updated_by: "worker".into(), + updated_at: "now".into(), + latest_url: format!("/api/w/workspace/drive/download?id={id}"), + } +} +fn feature(client: Arc) -> DriveFeature { + DriveFeature::new(client, Arc::new(WorkdirSessionRouter::new())) +} +async fn execute( + feature: &DriveFeature, + name: &str, + input: Value, +) -> Result { + feature + .execute( + name, + &input.to_string(), + &ToolExecutionContext::new(name, "batch", 0), + None, + ) + .await +} + +#[test] +fn drive_feature_activation_never_creates_authority_and_schemas_hide_revisions() { + let client = Client::new(Vec::new()); + let router = Arc::new(WorkdirSessionRouter::new()); + assert!(DriveFeature::configured(client.clone(), false, router.clone()).is_none()); + assert!( + DriveFeature::configured( + crate::worker::unavailable_workspace_client(None, "standalone"), + true, + router + ) + .is_none() + ); + let feature = feature(client); + for (name, definition) in feature.tools() { + let (meta, _) = definition(); + assert_eq!(name, meta.name); + let schema = meta.input_schema.to_string(); + assert!(!schema.contains("expected_revision"), "{name}"); + assert!(!schema.contains("grant_id"), "{name}"); + } + assert_eq!(feature.descriptor().instructions.len(), 1); +} + +#[tokio::test] +async fn foreign_or_unobserved_entry_cannot_dispatch_mutation() { + let client = Client::new(Vec::new()); + let feature = feature(client.clone()); + let own = json!({"workspace_id":"workspace","node_id":"9007199254740993"}); + assert!(matches!( + execute(&feature, "DriveWrite", json!({"entry":own,"content":"new"})).await, + Err(DriveError::Invalid(_)) + )); + assert!(matches!( + execute( + &feature, + "DriveDelete", + json!({"entry":{"workspace_id":"other","node_id":"1"}}) + ) + .await, + Err(DriveError::Denied) + )); + for id in [json!(1), json!("01"), json!("9223372036854775808")] { + assert!(matches!( + execute( + &feature, + "DriveMetadata", + json!({"entry":{"workspace_id":"workspace","node_id":id}}) + ) + .await, + Err(DriveError::Invalid(_)) + )); + } + assert!(client.requests.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn stale_edit_returns_conflict_without_upgrading_revision_or_dispatching_write() { + let old = entry("2", "1"); + let current = entry("2", "2"); + let client = Client::new(vec![response(DriveReadTextResponse { + entry: current, + text: "hello".into(), + truncated: false, + })]); + let feature = feature(client.clone()); + feature.observe(old.clone()).unwrap(); + let result = execute( + &feature, + "DriveEdit", + json!({"entry":old.entry,"old_string":"hello","new_string":"new"}), + ) + .await; + assert!(matches!(result, Err(DriveError::Conflict))); + assert_eq!(client.requests.lock().unwrap().len(), 1); + assert_eq!(feature.observed(&old.entry, None).unwrap().revision, "1"); +} + +#[tokio::test] +async fn shared_edit_rules_reject_ambiguous_and_truncated_preimages_without_mutation() { + for (text, truncated) in [("hello hello", false), ("hello", true)] { + let old = entry("2", "1"); + let client = Client::new(vec![response(DriveReadTextResponse { + entry: old.clone(), + text: text.into(), + truncated, + })]); + let feature = feature(client.clone()); + feature.observe(old.clone()).unwrap(); + assert!( + execute( + &feature, + "DriveEdit", + json!({"entry":old.entry,"old_string":"hello","new_string":"new"}) + ) + .await + .is_err() + ); + assert!( + client + .requests + .lock() + .unwrap() + .iter() + .all(|r| r.method == crate::worker::WorkspaceRequestMethod::Get) + ); + } +} + +#[tokio::test] +async fn successful_mutation_uses_observed_decimal_revision_and_requires_next_explicit_observation() +{ + let old = entry("9007199254740993", "9007199254740993"); + let updated = entry("9007199254740993", "9007199254740994"); + let client = Client::new(Vec::new()); + let feature = feature(client.clone()); + feature.observe(old.clone()).unwrap(); + let context = ToolExecutionContext::new("write", "batch", 0); + let id = feature.request_id(&context); + client + .responses + .lock() + .unwrap() + .push_back(Ok(response(DriveMutationResponse { + request_id: id, + entry: Some(updated), + }))); + feature + .execute( + "DriveWrite", + &json!({"entry":old.entry,"content":"new"}).to_string(), + &context, + None, + ) + .await + .unwrap(); + let requests = client.requests.lock().unwrap(); + let body: Value = serde_json::from_str(requests[0].body.as_ref().unwrap()).unwrap(); + assert_eq!(body["mutation"]["expected_revision"], "9007199254740993"); + assert_eq!(body["mutation"]["id"]["node_id"], "9007199254740993"); + drop(requests); + assert!(feature.observed(&old.entry, None).is_err()); +} + +#[tokio::test] +async fn lost_mutation_response_queries_same_receipt_without_resending() { + let old = entry("2", "1"); + let client = Client::new(Vec::new()); + let feature = feature(client.clone()); + feature.observe(old.clone()).unwrap(); + let context = ToolExecutionContext::new("write", "batch", 0); + let id = feature.request_id(&context); + client.responses.lock().unwrap().extend([ + Err(WorkspaceClientError::Request("lost".into())), + Ok(response(DriveRequestStatusResponse { + request_id: id.clone(), + state: DriveRequestState::Committed, + response: Some(DriveMutationResponse { + request_id: id.clone(), + entry: Some(entry("2", "2")), + }), + })), + ]); + feature + .execute( + "DriveWrite", + &json!({"entry":old.entry,"content":"new"}).to_string(), + &context, + None, + ) + .await + .unwrap(); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert!(requests[1].path.ends_with(&format!("/requests/{id}"))); + assert_eq!( + requests + .iter() + .filter(|r| r.method == crate::worker::WorkspaceRequestMethod::Post) + .count(), + 1 + ); +} + +#[tokio::test] +async fn uncommitted_receipt_snapshot_does_not_turn_unknown_into_failure_or_retry() { + let old = entry("2", "1"); + let client = Client::new(Vec::new()); + let feature = feature(client.clone()); + feature.observe(old.clone()).unwrap(); + let context = ToolExecutionContext::new("write", "batch", 0); + let id = feature.request_id(&context); + client.responses.lock().unwrap().extend([ + Err(WorkspaceClientError::Request("lost".into())), + Ok(response(DriveRequestStatusResponse { + request_id: id.clone(), + state: DriveRequestState::Uncommitted, + response: None, + })), + ]); + let result = feature + .execute( + "DriveWrite", + &json!({"entry":old.entry,"content":"new"}).to_string(), + &context, + None, + ) + .await; + assert!(matches!(result,Err(DriveError::OutcomeUnknown(ref observed)) if observed==&id)); + assert_eq!(client.requests.lock().unwrap().len(), 2); +} + +fn invalid_completions(id: &str) -> Vec { + let mut invalid_entry = entry("2", "2"); + invalid_entry.revision = "not-a-revision".into(); + vec![ + DriveMutationResponse { + request_id: "another-request".into(), + entry: Some(entry("2", "2")), + }, + DriveMutationResponse { + request_id: id.into(), + entry: Some(invalid_entry), + }, + ] +} + +#[tokio::test] +async fn invalid_mutation_completion_recovers_from_original_receipt_without_resending() { + let context = ToolExecutionContext::new("write", "batch", 0); + let client = Client::new(vec![]); + let feature = feature(client.clone()); + let id = feature.request_id(&context); + for completion in invalid_completions(&id) { + let committed = DriveMutationResponse { + request_id: id.clone(), + entry: Some(entry("2", "2")), + }; + client.requests.lock().unwrap().clear(); + client.responses.lock().unwrap().extend([ + Ok(response(completion)), + Ok(response(DriveRequestStatusResponse { + request_id: id.clone(), + state: DriveRequestState::Committed, + response: Some(committed.clone()), + })), + ]); + feature.observe(entry("2", "1")).unwrap(); + let output = feature + .execute( + "DriveWrite", + &json!({"entry":entry("2", "1").entry,"content":"new"}).to_string(), + &context, + None, + ) + .await + .unwrap(); + assert_eq!(output.value["request_id"], id); + assert_eq!(output.value["entry"]["metadata"]["revision"], "2"); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!( + requests[0].method, + crate::worker::WorkspaceRequestMethod::Post + ); + assert_eq!( + requests[1].method, + crate::worker::WorkspaceRequestMethod::Get + ); + assert!(requests[1].path.ends_with(&format!("/requests/{id}"))); + let body: Value = serde_json::from_str(requests[0].body.as_ref().unwrap()).unwrap(); + assert_eq!(body["request_id"], id); + assert_eq!(body["mutation"]["expected_revision"], "1"); + } +} + +#[tokio::test] +async fn invalid_completion_and_unresolved_or_invalid_receipt_stay_unknown_after_one_query() { + let context = ToolExecutionContext::new("write", "batch", 0); + let client = Client::new(vec![]); + let feature = feature(client.clone()); + let id = feature.request_id(&context); + let committed = DriveMutationResponse { + request_id: id.clone(), + entry: Some(entry("2", "2")), + }; + let mut receipts = vec![ + response(DriveRequestStatusResponse { + request_id: id.clone(), + state: DriveRequestState::Uncommitted, + response: None, + }), + response(DriveRequestStatusResponse { + request_id: id.clone(), + state: DriveRequestState::Committed, + response: None, + }), + response(DriveRequestStatusResponse { + request_id: "another-request".into(), + state: DriveRequestState::Committed, + response: Some(committed), + }), + WorkspaceResponse { + status: 200, + body: "invalid-json".into(), + }, + ]; + receipts.extend(invalid_completions(&id).into_iter().map(|completion| { + response(DriveRequestStatusResponse { + request_id: id.clone(), + state: DriveRequestState::Committed, + response: Some(completion), + }) + })); + for completion in invalid_completions(&id) { + for receipt in &receipts { + client.requests.lock().unwrap().clear(); + client + .responses + .lock() + .unwrap() + .extend([Ok(response(&completion)), Ok(receipt.clone())]); + feature.observe(entry("2", "1")).unwrap(); + let result = feature + .execute( + "DriveWrite", + &json!({"entry":entry("2", "1").entry,"content":"new"}).to_string(), + &context, + None, + ) + .await; + assert!( + matches!(result, Err(DriveError::OutcomeUnknown(ref observed)) if observed == &id) + ); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!( + requests[0].method, + crate::worker::WorkspaceRequestMethod::Post + ); + assert_eq!( + requests[1].method, + crate::worker::WorkspaceRequestMethod::Get + ); + assert!(requests[1].path.ends_with(&format!("/requests/{id}"))); + } + } +} + +#[tokio::test] +async fn observation_eviction_never_substitutes_another_entry() { + let feature = feature(Client::new(Vec::new())); + let first = entry("2", "1"); + feature.observe(first.clone()).unwrap(); + for id in 3..=258 { + feature.observe(entry(&id.to_string(), "1")).unwrap(); + } + assert!(feature.observed(&first.entry, None).is_err()); + let state = feature.observations.lock().unwrap(); + assert_eq!(state.entries.len(), MAX_OBSERVATIONS); + assert_eq!(state.order.len(), MAX_OBSERVATIONS); +} + +#[tokio::test] +async fn backend_error_details_are_not_reflected_and_conflict_is_not_retried() { + let client = Client::new(vec![WorkspaceResponse { + status: 409, + body: serde_json::to_string(&DriveApiError { + code: DriveApiErrorCode::Conflict, + classification: DriveFailureClassification::NotCommitted, + message: "/secret/provider/path".into(), + }) + .unwrap(), + }]); + let feature = feature(client.clone()); + let old = entry("2", "1"); + feature.observe(old.clone()).unwrap(); + let result = execute(&feature, "DriveDelete", json!({"entry":old.entry})) + .await + .err() + .unwrap(); + assert!(matches!(result, DriveError::Conflict)); + assert!(!result.to_string().contains("secret")); + assert_eq!(client.requests.lock().unwrap().len(), 1); +} + +#[tokio::test] +async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds_identity() { + use crate::wip::WipSubtreeProvider; + let entry = entry("9007199254740993", "3"); + let client = Client::new(vec![response(entry.clone())]); + let feature = feature(client.clone()); + let mut mounts = crate::wip::WipMountRegistry::new(); + wip::mount_drive_wip(&mut mounts, &feature).unwrap(); + let provider = wip::DriveProvider(feature); + let path = entry_path(&entry.entry); + let publication = provider.publication(&path).await.unwrap().unwrap(); + assert_eq!(publication.projection.route, path); + assert_eq!( + publication.scope.as_ref().unwrap().r#ref, + publication.projection.object.r#ref + ); + assert_eq!(publication.projection.object.validator, Some(b"3".to_vec())); + assert!(provider.children("/drive").await.unwrap().is_empty()); + assert!( + provider + .publication("/drive/other/9007199254740993") + .await + .unwrap() + .is_none() + ); + let operations = &publication.projection.descriptor.operations; + assert!(operations.iter().any(|op| op.name == "view_image")); + assert!( + !operations + .iter() + .flat_map(|op| &op.parameters) + .any(|parameter| parameter.name == "expected_revision") + ); + assert_eq!(client.requests.lock().unwrap().len(), 1); +} + +#[derive(Debug)] +struct ChunkClient { + json: Arc, + chunks: Mutex>, + requests: Mutex>, +} +impl WorkspaceClient for ChunkClient { + fn workspace_id(&self) -> Option<&str> { + Some("workspace") + } + fn kind(&self) -> &str { + "drive-chunk-test" + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + self.json.execute(request) + } + fn execute_binary( + &self, + request: crate::worker::WorkspaceBinaryRequest, + ) -> Result { + self.requests.lock().unwrap().push(request); + Ok(self + .chunks + .lock() + .unwrap() + .pop_front() + .expect("unexpected binary read")) + } +} +fn chunk_client( + metadata: DriveEntry, + chunks: Vec, +) -> Arc { + Arc::new(ChunkClient { + json: Client::new(vec![response(metadata)]), + chunks: Mutex::new(chunks.into()), + requests: Mutex::new(Vec::new()), + }) +} +fn chunk(bytes: Vec) -> crate::worker::WorkspaceBinaryResponse { + crate::worker::WorkspaceBinaryResponse { + status: 200, + body: bytes, + } +} +#[tokio::test] +async fn multi_chunk_image_uses_one_revision_and_never_exposes_binary_in_text_content() { + let mut metadata = entry("2", "9007199254740993"); + let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec(); + bytes.resize(DRIVE_CHUNK_MAX_BYTES as usize + 13, 42); + metadata.size = Some(bytes.len() as u32); + let client = chunk_client( + metadata.clone(), + vec![ + chunk(bytes[..DRIVE_CHUNK_MAX_BYTES as usize].to_vec()), + chunk(bytes[DRIVE_CHUNK_MAX_BYTES as usize..].to_vec()), + ], + ); + let feature = DriveFeature::new(client.clone(), Arc::new(WorkdirSessionRouter::new())); + let output = execute(&feature, "DriveViewImage", json!({"entry":metadata.entry})) + .await + .unwrap(); + let Attachment::Image(image) = &output.attachments[0]; + assert_eq!(image.data(), bytes); + assert!(output.value.to_string().len() < 2048); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + for request in &*requests { + assert!(request.path.contains("expected_revision=9007199254740993")); + assert_eq!(request.max_response_bytes, DRIVE_CHUNK_MAX_BYTES as usize); + } + assert!(requests[0].path.split('&').any(|pair| pair == "offset=0")); + assert!( + requests[1] + .path + .split('&') + .any(|pair| pair == "offset=65536") + ); +} +#[tokio::test] +async fn expired_revision_or_partial_chunk_aborts_image_without_rebinding_or_retrying() { + for next in [ + crate::worker::WorkspaceBinaryResponse { + status: 409, + body: serde_json::to_vec(&DriveApiError::new(DriveApiErrorCode::Conflict)).unwrap(), + }, + chunk(vec![1]), // second range requires 13 bytes, not a successful prefix + ] { + let mut metadata = entry("2", "3"); + metadata.size = Some(DRIVE_CHUNK_MAX_BYTES + 13); + let mut first = vec![0; DRIVE_CHUNK_MAX_BYTES as usize]; + first[..8].copy_from_slice(b"\x89PNG\r\n\x1a\n"); + let client = chunk_client(metadata.clone(), vec![chunk(first), next]); + let feature = DriveFeature::new(client.clone(), Arc::new(WorkdirSessionRouter::new())); + assert!( + execute(&feature, "DriveViewImage", json!({"entry":metadata.entry})) + .await + .is_err() + ); + let requests = client.requests.lock().unwrap(); + assert_eq!( + requests.len(), + 2, + "must not retry an expired/truncated image" + ); + assert!( + requests + .iter() + .all(|request| request.path.contains("expected_revision=3&")) + ); + assert_eq!( + client.json.requests.lock().unwrap().len(), + 1, + "must not fetch a new revision" + ); + } +} +#[tokio::test] +async fn oversized_images_and_unknown_arguments_fail_before_binary_transfer() { + let mut metadata = entry("2", "1"); + metadata.size = Some(tools::view_image::MAX_IMAGE_BYTES as u32 + 1); + let client = chunk_client(metadata.clone(), vec![]); + let feature = DriveFeature::new(client.clone(), Arc::new(WorkdirSessionRouter::new())); + assert!(matches!( + execute(&feature, "DriveViewImage", json!({"entry":metadata.entry})).await, + Err(DriveError::Limit) + )); + assert!(client.requests.lock().unwrap().is_empty()); + assert!( + execute( + &feature, + "DriveRead", + json!({"entry":metadata.entry,"expected_revision":"1"}) + ) + .await + .is_err() + ); + assert_eq!(client.json.requests.lock().unwrap().len(), 1); +} diff --git a/crates/worker/src/feature/builtin/drive/wip.rs b/crates/worker/src/feature/builtin/drive/wip.rs new file mode 100644 index 000000000..c422ea415 --- /dev/null +++ b/crates/worker/src/feature/builtin/drive/wip.rs @@ -0,0 +1,363 @@ +//! Native non-indexable Drive entries. Publication resolves just one ID through +//! current Backend authorization; the Client owns identity/revision validators. +use super::{ + DriveFeature, SPECS, Spec, + backend::{DriveError, decimal}, + digest, entry_path, +}; +use crate::wip::{ + WipCallContext, WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, + WipOperationOutput, WipProjection, WipProjectionKind, WipPublication, WipSubtreeMount, + WipSubtreeProvider, contextual_reference, json_to_wip, wip_to_json, +}; +use async_trait::async_trait; +use serde_json::json; +use server_api::{DriveEntry, DriveEntryKind, DriveEntryRef}; +use std::{collections::BTreeMap, sync::Arc}; +use wip_protocol::{ + Documentation, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, OperationDeclaration, + ParameterDeclaration, ProtocolError, ProtocolErrorCode, ReturnDeclaration, TypeExpr, Value, +}; + +const ROOT: &str = "/drive"; +const INTERFACE: &str = "yoi.drive/entry/v1"; + +pub fn mount_drive_wip( + registry: &mut WipMountRegistry, + feature: &DriveFeature, +) -> Result<(), WipMountError> { + registry.allocate_namespace("drive", "drive")?; + // Registration is only a namespace entrance. The subtree provides the + // current target/scope/descriptor/handler in a single self-scoped snapshot. + let names = root_specs(); + let descriptor = descriptor(&names, true); + registry.mount(WipProjection { + route: ROOT.into(), + capability: "drive:content".into(), + kind: WipProjectionKind::Native, + object: Object { + name: "drive".into(), + description: Some( + "Authorized Workspace Drive; discover entries via bounded list/search, not tree" + .into(), + ), + interfaces: vec![contextual_reference(INTERFACE, ROOT)], + r#ref: None, + validator: None, + }, + interface: contextual_reference(INTERFACE, ROOT), + interface_validator: Some(digest(format!("{descriptor:?}").as_bytes()).into_bytes()), + descriptor, + handler: Arc::new(Unresolved), + })?; + registry.mount_subtree(WipSubtreeMount { + root: ROOT.into(), + provider: Arc::new(DriveProvider(feature.clone())), + })?; + registry.replace_compatibility_tools(ROOT, SPECS.iter().map(|spec| spec.name))?; + Ok(()) +} +struct Unresolved; +#[async_trait] +impl WipOperationHandler for Unresolved { + async fn call( + &self, + _: &str, + _: &BTreeMap, + _: WipCallContext, + ) -> Result { + Err(map_error(DriveError::Denied)) + } +} +pub(super) struct DriveProvider(pub(super) DriveFeature); +#[async_trait] +impl WipSubtreeProvider for DriveProvider { + // Arbitrary tree depth is safe: there are deliberately no indexable entries. + fn max_depth(&self) -> u32 { + 32 + } + fn max_nodes(&self) -> usize { + 1 + } + async fn publication(&self, path: &str) -> Result, ProtocolError> { + let result = if path == ROOT { + self.0.backend.root().await + } else { + let Some(entry) = parse_entry(&self.0, path).map_err(protocol)? else { + return Ok(None); + }; + self.0.backend.metadata(&entry).await + }; + let entry = match result { + Ok(entry) => entry, + Err(DriveError::Denied | DriveError::NotFound) => return Ok(None), + Err(error) => return Err(protocol(error)), + }; + let root = path == ROOT; + let specs = if root { + root_specs() + } else { + entry_specs(&entry) + }; + let descriptor = descriptor(&specs, root); + let reference = contextual_reference(INTERFACE, path); + let validator = entry.revision.as_bytes().to_vec(); + let object = Object { + name: if root { + "drive".into() + } else { + entry.entry.node_id.clone() + }, + description: Some(format!( + "Workspace Drive {:?} {:?}; stable ID {}, bodies only through bounded operations", + entry.kind, entry.name, entry.entry.node_id + )), + interfaces: vec![reference.clone()], + r#ref: Some(format!( + "drive:{}:{}", + entry.entry.workspace_id, entry.entry.node_id + )), + validator: Some(validator), + }; + let projection = WipProjection { + route: path.into(), + capability: "drive:content".into(), + kind: WipProjectionKind::Native, + object, + interface: reference, + interface_validator: Some(digest(format!("{descriptor:?}").as_bytes()).into_bytes()), + descriptor, + handler: Arc::new(DriveHandler { + feature: self.0.clone(), + entry, + specs, + root, + }), + }; + WipPublication::self_scoped(projection).map(Some) + } + async fn children(&self, _: &str) -> Result, ProtocolError> { + Ok(Vec::new()) + } +} +fn root_specs() -> Vec { + SPECS + .iter() + .copied() + .filter(|spec| { + matches!( + spec.operation, + "root" + | "metadata" + | "list" + | "search" + | "create_folder" + | "create_text" + | "save_workdir" + | "request_status" + ) + }) + .collect() +} +fn entry_specs(entry: &DriveEntry) -> Vec { + SPECS + .iter() + .copied() + .filter(|spec| match spec.operation { + "metadata" => true, + "relocate" | "delete" => entry.parent.is_some(), + "list" | "create_folder" | "create_text" | "save_workdir" => { + entry.kind == DriveEntryKind::Folder + } + "read" | "write" | "edit" | "view_image" => entry.kind == DriveEntryKind::File, + _ => false, + }) + .collect() +} +fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclaration { + ParameterDeclaration { + name: name.into(), + required, + documentation: None, + r#type, + } +} +fn descriptor(specs: &[Spec], _root: bool) -> InterfaceDescriptor { + InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation {summary:"Backend-authorized Workspace Drive operations".into(),details:Some("Entry IDs are Workspace-bound. The Host/Client manage revisions; never supply validators or retry conflicts/outcome-unknown with a new revision. Read-only grants reject mutations at Backend. List/search return paths for direct Inspect, never tree inventories. Image bytes attach to durable ToolOutput; URLs are not image viewing. Workdir source read and Drive destination write are independent.".into())}),types:Vec::new(),operations:specs.iter().map(|spec|OperationDeclaration {name:spec.operation.into(),documentation:Some(Documentation {summary:spec.description.into(),details:None}),parameters:match spec.operation { + "list"=>vec![parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)], + "search"=>vec![parameter("query",true,TypeExpr::String),parameter("include_text",false,TypeExpr::Boolean),parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)], + "read"=>vec![parameter("max_bytes",false,TypeExpr::Integer)], + "write"|"edit"=>crate::file_operation::parameters(spec.operation,false).expect("shared text arguments"), + "create_text"=>vec![parameter("name",true,TypeExpr::String),parameter("content",true,TypeExpr::String),parameter("content_type",false,TypeExpr::String)], + "create_folder"=>vec![parameter("name",true,TypeExpr::String)], + "relocate"=>vec![parameter("parent",true,TypeExpr::Entry),parameter("name",true,TypeExpr::String)], + "save_workdir"=>vec![parameter("target_workdir",true,TypeExpr::String),parameter("path",true,TypeExpr::String),parameter("name",true,TypeExpr::String),parameter("content_type",false,TypeExpr::String)], + "request_status"=>vec![parameter("request_id",true,TypeExpr::String)], + _=>vec![], + },returns:ReturnDeclaration {documentation:None,r#type:TypeExpr::Json}}).collect()} +} +struct DriveHandler { + feature: DriveFeature, + entry: DriveEntry, + specs: Vec, + root: bool, +} +#[async_trait] +impl WipOperationHandler for DriveHandler { + async fn call( + &self, + operation: &str, + arguments: &BTreeMap, + context: WipCallContext, + ) -> Result { + let spec = self + .specs + .iter() + .find(|spec| spec.operation == operation) + .ok_or_else(|| { + failure( + ProtocolErrorCode::OperationNotFound, + "Drive operation is not published", + ) + })?; + let declaration = descriptor(&self.specs, self.root) + .operations + .into_iter() + .find(|op| op.name == operation) + .unwrap(); + if arguments + .keys() + .any(|key| !declaration.parameters.iter().any(|p| p.name == *key)) + { + return Err(map_error(DriveError::Invalid( + "unrecognized arguments; revision metadata is Client-managed".into(), + ))); + } + let mut input = wip_to_json(&Value::Record(arguments.clone())) + .map_err(|_| map_error(DriveError::Invalid("invalid WIP arguments".into())))?; + let fields = input + .as_object_mut() + .ok_or_else(|| map_error(DriveError::Invalid("named arguments required".into())))?; + match operation { + "metadata" | "read" | "write" | "edit" | "delete" | "view_image" => { + fields.insert("entry".into(), json!(self.entry.entry)); + } + "list" | "create_folder" | "create_text" | "save_workdir" => { + fields.insert("parent".into(), json!(self.entry.entry)); + } + "relocate" => { + let path = match arguments.get("parent") { + Some(Value::String(path)) => path.as_str(), + _ => { + return Err(map_error(DriveError::Invalid( + "parent must be a Drive entry".into(), + ))); + } + }; + let parent = if path == ROOT { + self.feature.backend.root().await.map_err(map_error)?.entry + } else { + parse_entry(&self.feature, path) + .map_err(map_error)? + .ok_or_else(|| map_error(DriveError::Denied))? + }; + fields.insert("parent".into(), json!(parent)); + fields.insert("entry".into(), json!(self.entry.entry)); + } + _ => {} + } + if let Some(permissions) = &self.feature.permissions { + if !matches!( + crate::permission::permission_action_for(permissions, spec.name, &input), + manifest::ToolPermissionAction::Allow + ) { + return Err(failure( + ProtocolErrorCode::PermissionDenied, + "Drive operation denied by tool permission; approval unavailable in native invocation", + )); + } + } + let output = self + .feature + .execute( + spec.name, + &input.to_string(), + &context.execution, + Some(&self.entry), + ) + .await + .map_err(map_error)?; + let value = json_to_wip(&output.value).map_err(|_| { + WipOperationError::OutcomeUnknown( + "Completed Drive result unavailable; query request_status before another mutation" + .into(), + ) + })?; + // Attachments use the existing ToolOutput side channel and durable Engine + // capture, not transient request injection or URL-only success. + let result = if matches!(operation, "write" | "edit" | "relocate") { + let revision = output.value["entry"]["metadata"]["revision"] + .as_str() + .ok_or_else(|| { + WipOperationError::OutcomeUnknown("Committed Drive revision unavailable".into()) + })?; + WipOperationOutput::native_with_validator(value, revision.as_bytes().to_vec()) + } else { + WipOperationOutput::native(value) + }; + Ok(result.with_attachments(output.attachments)) + } +} +fn parse_entry(feature: &DriveFeature, path: &str) -> Result, DriveError> { + let workspace = feature.backend.workspace()?; + let prefix = format!( + "/drive/{}/", + crate::feature::builtin::manage_workdir::wip::encode_identity(workspace) + ); + let Some(id) = path.strip_prefix(&prefix) else { + return Ok(None); + }; + if !decimal(id) { + return Ok(None); + } + let entry = DriveEntryRef { + workspace_id: workspace.into(), + node_id: id.into(), + }; + if entry_path(&entry) != path { + return Ok(None); + } + Ok(Some(entry)) +} +fn failure(code: ProtocolErrorCode, message: &str) -> WipOperationError { + WipOperationError::Protocol(ProtocolError { + code, + message: message.into(), + }) +} +fn map_error(error: DriveError) -> WipOperationError { + let code = match &error { + DriveError::Denied => ProtocolErrorCode::PermissionDenied, + DriveError::NotFound => ProtocolErrorCode::NotFound, + DriveError::Conflict => ProtocolErrorCode::ValidatorMismatch, + DriveError::Invalid(_) => ProtocolErrorCode::InvalidArguments, + DriveError::Limit => ProtocolErrorCode::ResourceLimitExceeded, + DriveError::Unavailable => ProtocolErrorCode::Internal, + DriveError::OutcomeUnknown(_) => { + return WipOperationError::OutcomeUnknown(error.to_string()); + } + }; + WipOperationError::Protocol(ProtocolError { + code, + message: error.to_string(), + }) +} +fn protocol(error: DriveError) -> ProtocolError { + match map_error(error) { + WipOperationError::Protocol(error) => error, + _ => ProtocolError { + code: ProtocolErrorCode::Internal, + message: "Drive observation unavailable".into(), + }, + } +} diff --git a/crates/worker/src/feature/builtin/manage_worker.rs b/crates/worker/src/feature/builtin/manage_worker.rs index 969e89b71..7fd67933a 100644 --- a/crates/worker/src/feature/builtin/manage_worker.rs +++ b/crates/worker/src/feature/builtin/manage_worker.rs @@ -870,12 +870,14 @@ impl WorkerOperation { "Send an advisory notification to a known Runtime Worker so information can be incorporated into work already in progress without creating a queued Submit." } Self::Cancel => "Cancel the current turn of a known Runtime Worker when allowed.", - Self::Stop => "Stop a known Runtime Worker when allowed.", + Self::Stop => { + "Stop a known Runtime Worker when allowed. Stop is independent of Ticket conclusion: it does not end unfinished work, remove retained session records, release attachments, or delete Workdirs." + } Self::Restore => { "Restore a stopped Backend/Runtime Worker session in the current Workspace. Supply expected_observation_token from WorkerList; a conflict requires refreshing and deliberate new intent. New requests default to the durable Tool call ID. For explicit recovery of an unknown/pending outcome, supply the reported request_id and the original token unchanged; never create a new intent or automatically retry." } Self::Remove => { - "Remove an eligible stopped, non-internal Worker with no unfinished Ticket work. Terminal Ticket responsibility is retained and does not require unassignment. Supply a bounded reason; Backend validation and retention are authoritative." + "Remove an eligible stopped, non-internal Worker with no unfinished Ticket work. Terminal Ticket responsibility is retained and does not require unassignment. Conclusion, stopping, retention/removal, attachment release, and Workdir deletion are separate decisions. Removal releases attachments but preserves Workdir materialization. Supply a bounded reason; Backend validation and retention are authoritative." } } } @@ -2050,6 +2052,28 @@ mod tests { } } + #[test] + fn worker_lifecycle_descriptions_separate_unfinished_work_retention_and_attachment_cleanup() { + let stop = worker_tool_declaration(WorkerOperation::Stop); + assert!(stop.description.contains("does not end unfinished work")); + assert!( + stop.description + .contains("release attachments, or delete Workdirs") + ); + let remove = worker_tool_declaration(WorkerOperation::Remove); + assert!( + remove + .description + .contains("Terminal Ticket responsibility is retained") + ); + assert!(remove.description.contains("does not require unassignment")); + assert!( + remove + .description + .contains("releases attachments but preserves Workdir materialization") + ); + } + #[tokio::test] async fn worker_remove_forwards_only_target_and_bounded_reason() { let client = Arc::new(RecordingWorkspaceClient::default()); diff --git a/crates/worker/src/feature/builtin/merge_request.rs b/crates/worker/src/feature/builtin/merge_request.rs index 112241a7e..79c3ca6ff 100644 --- a/crates/worker/src/feature/builtin/merge_request.rs +++ b/crates/worker/src/feature/builtin/merge_request.rs @@ -27,8 +27,7 @@ use wip_protocol::{ pub const FEATURE_ID: &str = "merge_request"; const FEATURE_NAME: &str = "Merge Request tools"; -const FEATURE_DESCRIPTION: &str = - "Operation-specific Merge Request workflow tools over Workspace authority."; +const FEATURE_DESCRIPTION: &str = "Optional repository MR workflow with trusted review and separate Orchestrator integration authority; not a universal Ticket conclusion gate."; const FEATURE_INSTRUCTION_ID: &str = "merge_request.workflow"; pub const FEATURE_PROMPT_REF: &str = "common.merge_request"; @@ -1087,10 +1086,10 @@ pub fn description(n: &str) -> Option<&'static str> { "Open one repository-scoped Merge Request linked to the Ticket; reuse an existing Merge Request for that repository and advance only its selector_from with a normal non-force push.", ), "CompleteMergeRequest" => Some( - "Record Orchestrator-owned integration for the explicitly addressed Merge Request without completing the Ticket or releasing its assignment.", + "Record Orchestrator-owned integration for the explicitly addressed Merge Request using current trusted exact-source approval and provider evidence. This records an already-applied integration, not a Ticket conclusion or assignment release; a review-not-required Ticket request does not bypass MR integration guards.", ), "ReviewMergeRequest" => Some( - "Submit the injected Reviewer capability result for its captured exact source ref; source movement cancels it, while target-only movement does not.", + "Submit the injected trusted Reviewer capability result for its captured exact source ref; source movement cancels it, while target-only movement does not. The capability authenticates the attempt, not the verdict: independent natural-language judgment and validation remain the Reviewer's responsibility.", ), _ => None, } diff --git a/crates/worker/src/feature/builtin/orchestration.rs b/crates/worker/src/feature/builtin/orchestration.rs index 79fab889a..bf864d6c9 100644 --- a/crates/worker/src/feature/builtin/orchestration.rs +++ b/crates/worker/src/feature/builtin/orchestration.rs @@ -21,6 +21,7 @@ use crate::feature::{ const FEATURE_ID: &str = "orchestration"; const TOOL_NAME: &str = "SpawnTicketWorker"; +const TOOL_DESCRIPTION: &str = "Spawn and atomically assign a generic Ticket Worker for the natural-language initial request. Profile is optional (defaults to builtin:ticket-worker); Flow and repository/Workdir attachments are optional. Results and return choices follow user intent, not a universal approval or MR gate. Backend validates claims and derives attachment access from Ticket targets; acceptance follows spawn, initial input, assignment, and resource finalization. Profile/Flow selection grants neither trusted review nor merge authority."; #[derive(Debug, Default)] pub struct OrchestrationFeature; @@ -41,10 +42,7 @@ impl FeatureModule for OrchestrationFeature { ServiceId::builtin(WORKER_LIFECYCLE_SERVICE_ID), "SpawnTicketWorker requires Workspace Worker lifecycle authority", )) - .with_tool(ToolDeclaration::new( - TOOL_NAME, - "Spawn and atomically assign a configurable Worker for a Ticket. Supply a registered profile selector, an initial request, and optionally a Flow selector. Workdir attachments may be empty; Backend authority validates claims and derives each attachment's access from the Ticket targets. The guarded operation records acceptance only after spawn, initial input, assignment, and resource finalization. Selecting a profile or Flow does not grant review authority.", - )) + .with_tool(ToolDeclaration::new(TOOL_NAME, TOOL_DESCRIPTION)) } fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { @@ -81,8 +79,10 @@ struct SpawnTicketWorkerWorkdirInput { struct SpawnTicketWorkerInput { ticket_id: String, runtime_id: String, - /// Registered profile selector. Backend resolves the profile and validates claims. - profile: String, + /// Optional registered profile selector; omitted/null selects builtin:ticket-worker. + /// Backend resolves the profile and validates claims, not natural-language satisfaction. + #[serde(default)] + profile: Option, /// Initial user request delivered through the normal typed submission path. initial_request: String, /// Optional Flow selector, delivered as a typed Flow segment before the request. @@ -116,7 +116,11 @@ impl Tool for SpawnTicketWorkerTool { .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; let call_id = non_empty(ctx.call_id, "tool call_id")?; let runtime_id = authority_id(input.runtime_id, "runtime_id")?; - let profile = non_empty(input.profile, "profile")?; + let profile = input + .profile + .map(|profile| non_empty(profile, "profile")) + .transpose()? + .unwrap_or_else(|| "builtin:ticket-worker".into()); let initial_request = non_empty(input.initial_request, "initial_request")?; let flow = input.flow.map(|flow| non_empty(flow, "flow")).transpose()?; let workdir_attachments = validate_workdir_attachments(input.workdir_attachments)?; @@ -196,9 +200,7 @@ fn definition( let schema = serde_json::to_value(schemars::schema_for!(SpawnTicketWorkerInput)) .unwrap_or_else(|_| serde_json::json!({})); let meta = ToolMeta::new(TOOL_NAME) - .description( - "Spawn and atomically assign a configurable Ticket Worker with a registered profile, initial request, optional typed Flow, and zero or more alias-keyed Workdir attachments. Backend validates claims and resources; profile and Flow selection do not grant review authority.", - ) + .description(TOOL_DESCRIPTION) .input_schema(schema); let tool: Arc = Arc::new(SpawnTicketWorkerTool { ticket_service: ticket_service.clone(), @@ -389,6 +391,48 @@ mod tests { } } + #[tokio::test] + async fn spawn_ticket_worker_omitted_profile_uses_general_worker_without_flow_or_repository() { + let service = Arc::new(RecordingService::default()); + let definition = definition( + Arc::new(FixedTicketService(TicketWorkflowState::Ready)), + service.clone(), + ); + let (meta, tool) = definition(); + let required = meta.input_schema["required"].as_array().unwrap(); + assert!(!required.iter().any(|field| field == "profile")); + assert!( + meta.description + .contains("neither trusted review nor merge authority") + ); + for profile in [None, Some(serde_json::Value::Null)] { + let mut input = input(); + input.as_object_mut().unwrap().remove("profile"); + if let Some(profile) = profile { + input["profile"] = profile; + } + input["initial_request"] = serde_json::json!( + "Research the question; review is not required. Return without concluding the Ticket." + ); + tool.execute( + &input.to_string(), + ToolExecutionContext::new("call-default", "batch-1", 0), + ) + .await + .unwrap(); + } + for request in service.requests.lock().unwrap().iter() { + assert_eq!(request.profile, "builtin:ticket-worker"); + assert!(request.workdir_attachments.is_empty()); + assert_eq!( + request.initial_submit, + vec![Segment::text( + "Research the question; review is not required. Return without concluding the Ticket." + )] + ); + } + } + #[tokio::test] async fn spawn_ticket_worker_defers_claim_eligibility_to_backend() { let service = Arc::new(RecordingService { @@ -519,7 +563,7 @@ mod tests { "unexpected error for {field}: {error}" ); } - for field in ["ticket_id", "runtime_id", "profile", "initial_request"] { + for field in ["ticket_id", "runtime_id", "initial_request"] { let mut input = input(); input.as_object_mut().unwrap().remove(field); let error = tool @@ -567,13 +611,13 @@ mod tests { let schema = serde_json::to_value(schemars::schema_for!(SpawnTicketWorkerInput)).unwrap(); assert_eq!(schema["additionalProperties"], false); let required = schema["required"].as_array().unwrap(); - for field in ["ticket_id", "runtime_id", "profile", "initial_request"] { + for field in ["ticket_id", "runtime_id", "initial_request"] { assert!( required.contains(&serde_json::json!(field)), "missing required field {field}" ); } - for field in ["flow", "workdir_attachments"] { + for field in ["profile", "flow", "workdir_attachments"] { assert!(schema["properties"].get(field).is_some()); assert!(!required.contains(&serde_json::json!(field))); } diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index c017cae84..281628f70 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -335,9 +335,9 @@ struct WorkspaceTicketCloseInput { references: Vec, } -const TICKET_CLOSE_DESCRIPTION: &str = "Close a Ticket with resolution, item/state CAS, and replay key. No Merge Request or enforced state sequence is required. Actor identity is transport-bound; this does not integrate Merge Requests or expand Workdir grants."; -const COMPLETE_TICKET_DESCRIPTION: &str = "Record a Ticket completion decision with reason, item/state CAS, and replay key. Merge Requests and approvals are not prerequisites. The Backend authorizes the assigned Worker or registered Workspace Orchestrator; roles supplied by the model never grant authority."; -const TICKET_STATE_UPDATE_DESCRIPTION: &str = "Update a Ticket progress-display state with reason, item/state CAS, and replay key. States need not follow an enforced sequence. This does not start Workers, queue dependencies, integrate Merge Requests, or enlarge live Workdir grants; the Backend validates actor authority."; +const TICKET_CLOSE_DESCRIPTION: &str = "Record an authorized Ticket closure decision with resolution, item/state CAS, and replay key when the requested return path calls for closure. No Merge Request or enforced state sequence is required. Backend checks actor authority, not natural-language satisfaction; this does not integrate MRs, stop/remove Workers, clean attachments, or expand Workdir grants."; +const COMPLETE_TICKET_DESCRIPTION: &str = "Record an authorized Ticket completion decision with reason, item/state CAS, and replay key when user intent calls for completion; return without conclusion need not call this tool. Merge Requests and approvals are not prerequisites. Backend checks assigned Worker/Workspace Orchestrator authority, not real judgment of natural-language satisfaction; model-supplied roles never grant authority. Conclusion ends unfinished work while retaining terminal responsibility for display; it does not grant review/merge authority or stop/remove Workers and attachments."; +const TICKET_STATE_UPDATE_DESCRIPTION: &str = "Update a Ticket progress-display state with reason, item/state CAS, and replay key. States need not follow an enforced sequence. Backend checks actor authority, not natural-language satisfaction. This does not start/stop Workers, queue dependencies, integrate Merge Requests, clean attachments, or enlarge live Workdir grants."; #[derive(Clone, Copy)] enum WorkspaceTicketDecisionKind { @@ -508,8 +508,7 @@ fn workspace_ticket_tool_description(name: &str) -> String { const FEATURE_ID: &str = "ticket"; const FEATURE_NAME: &str = "Ticket tools"; -const FEATURE_DESCRIPTION: &str = - "Typed Ticket operations through the authoritative Workspace API."; +const FEATURE_DESCRIPTION: &str = "Intent-led Ticket reads and authorized decisions through the Workspace API; no universal MR/approval gate, and no Backend judgment of natural-language satisfaction."; const TICKET_WORKFLOW_INSTRUCTION_ID: &str = "ticket.workflow"; const TICKET_WORKFLOW_PROMPT_REF: &str = "common.tickets"; pub const TICKET_SERVICE_ID: &str = "ticket.authority"; @@ -2945,6 +2944,27 @@ mod tests { } } + #[test] + fn native_ticket_decision_descriptions_preserve_intent_and_independent_lifecycle() { + let tools = native_ticket_tools( + decision_client(200, TicketWorkflowState::Ready), + TicketFeatureAccess::work_report(), + ) + .unwrap(); + let descriptor = ticket_descriptor(&tools, "Ticket", "bound").unwrap(); + let complete = descriptor + .operations + .iter() + .find(|op| op.name == "complete") + .unwrap(); + let summary = &complete.documentation.as_ref().unwrap().summary; + assert!(summary.contains("Merge Requests and approvals are not prerequisites")); + assert!(summary.contains("return without conclusion need not call this tool")); + assert!(summary.contains("not real judgment")); + assert!(summary.contains("retaining terminal responsibility")); + assert!(summary.contains("does not grant review/merge authority or stop/remove Workers")); + } + #[tokio::test] async fn native_ticket_completion_binds_subject_and_stales_only_its_ticket_not_mrs() { let client = decision_client(200, TicketWorkflowState::Done); diff --git a/crates/worker/src/fs_view.rs b/crates/worker/src/fs_view.rs index e9230c00b..5508718c2 100644 --- a/crates/worker/src/fs_view.rs +++ b/crates/worker/src/fs_view.rs @@ -143,6 +143,7 @@ impl WorkerFsView { .list(ListRequest { path: logical.clone(), limit: DIR_FILE_REF_ENTRY_LIMIT, + after: None, }) .await .map_err(ToolsError::from) @@ -222,6 +223,7 @@ impl WorkerFsView { .list(ListRequest { path: parent, limit: COMPLETION_LIMIT, + after: None, }) .await else { diff --git a/crates/worker/src/job.rs b/crates/worker/src/job.rs index 1788f54dc..c32fac642 100644 --- a/crates/worker/src/job.rs +++ b/crates/worker/src/job.rs @@ -284,6 +284,7 @@ fn validate_profile_with_grant( manage_workdir, workdir_catalog, workspace_config, + drive, ticket, merge_request, orchestration, @@ -315,6 +316,7 @@ fn validate_profile_with_grant( ("feature.objective", objective.enabled), ("feature.manage_workdir", manage_workdir.enabled), ("feature.workspace_config", workspace_config.enabled), + ("feature.drive", drive.enabled), ( "feature.ticket", ticket.enabled || ticket.authoring || ticket.thread || ticket.intake || ticket.workflow, diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index 069938d1f..a1cfa7c16 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -65,9 +65,9 @@ pub use session_history::{ pub use shared_state::WorkerSharedState; pub use worker::{ LocalWorkingDirectory, SubjektivSessionAttributionLifecycle, Worker, WorkerError, - WorkerFilesystemAuthority, WorkerRunResult, WorkerWorkspaceContext, WorkspaceClient, - WorkspaceClientError, WorkspaceId, WorkspaceIdError, WorkspacePromptCatalogResolution, - WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse, WorkspaceServerOperation, - WorkspaceWorkerDiscoveryRequest, apply_worker_manifest, marker_workspace_client, - unavailable_workspace_client, + WorkerFilesystemAuthority, WorkerRunResult, WorkerWorkspaceContext, WorkspaceBinaryRequest, + WorkspaceBinaryResponse, WorkspaceClient, WorkspaceClientError, WorkspaceId, WorkspaceIdError, + WorkspacePromptCatalogResolution, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse, + WorkspaceServerOperation, WorkspaceWorkerDiscoveryRequest, apply_worker_manifest, + marker_workspace_client, unavailable_workspace_client, }; diff --git a/crates/worker/src/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index 6ef4aa501..999293d10 100644 --- a/crates/worker/src/prompt/catalog.rs +++ b/crates/worker/src/prompt/catalog.rs @@ -1034,7 +1034,8 @@ mod tests { .unwrap(); assert!(rendered.contains("This policy governs naming only")); - assert!(rendered.contains("Coder cleanup is a separate post-completion decision")); + assert!(rendered.contains("Worker cleanup is a separate lifecycle decision")); + assert!(rendered.contains("not an automatic consequence of a Ticket state")); assert!(rendered.contains("perform one cleanup pass before ending the orchestration turn")); assert!(rendered.contains("Never predeclare `delete_on_completion`")); assert!(rendered.contains("call `WorkerStop`")); diff --git a/crates/worker/src/prompt/system.rs b/crates/worker/src/prompt/system.rs index ffd9da5ad..4fbff3406 100644 --- a/crates/worker/src/prompt/system.rs +++ b/crates/worker/src/prompt/system.rs @@ -322,6 +322,154 @@ fn append_trailing_section( #[cfg(test)] mod tests { use super::*; + use crate::feature::FeatureRegistryBuilder; + use crate::feature::builtin::merge_request::MergeRequestFeature; + use crate::feature::builtin::ticket::{TicketFeature, TicketFeatureAccess}; + use crate::hook::HookRegistryBuilder; + use crate::worker::TestWorkspaceHttpClient; + use manifest::MergeRequestFeatureConfig; + + // These builtin resources are the model-visible workflow compatibility contract. + // Fixtures prove composition and tool descriptions, not an agent's real judgment. + fn ticket_workflow_fixture( + role: &str, + mr_config: MergeRequestFeatureConfig, + ) -> (String, Vec) { + let client = Arc::new(TestWorkspaceHttpClient::new("workspace", "http://unused")); + let mut tools = Vec::new(); + let mut hooks = HookRegistryBuilder::default(); + let report = FeatureRegistryBuilder::new() + .with_module(TicketFeature::new( + client.clone(), + TicketFeatureAccess { + thread: true, + workflow: true, + ..Default::default() + }, + )) + .with_module(MergeRequestFeature::new(client, mr_config)) + .install_into_pending(&mut tools, &mut hooks); + assert!(!report.has_errors(), "{}", report.error_message()); + let metas: Vec<_> = tools.into_iter().map(|definition| definition().0).collect(); + let instructions = report.installed_instruction_contributions(); + let prompts = PromptCatalog::builtins_only().unwrap(); + let scope = Scope::empty(); + let rendered = SystemPromptTemplate::parse(role, PromptCatalogSource::builtins_only()) + .unwrap() + .render(&SystemPromptContext { + now: DateTime::parse_from_rfc3339("2026-10-08T00:00:00Z") + .unwrap() + .with_timezone(&Utc), + cwd: Cow::Borrowed("."), + language: "en", + scope: &scope, + tool_names: metas.iter().map(|meta| meta.name.clone()).collect(), + feature_instructions: &instructions, + agents_md: None, + resident_summary: None, + prompts: &prompts, + }) + .unwrap(); + (rendered, metas) + } + + #[test] + fn effective_ticket_instructions_allow_review_not_required_without_mr_gate() { + let (prompt, tools) = + ticket_workflow_fixture("role.ticket_worker", MergeRequestFeatureConfig::default()); + assert!(prompt.contains("No Merge Request or approval is a prerequisite")); + assert!(prompt.contains("optional Ticket comment")); + assert!(prompt.contains("optional Drive document/artifact")); + assert!(!prompt.contains("## Merge Request workflow")); + assert!(!tools.iter().any(|meta| meta.name == "ReviewMergeRequest")); + let complete = tools + .iter() + .find(|meta| meta.name == "CompleteTicket") + .unwrap(); + assert!( + complete + .description + .contains("Merge Requests and approvals are not prerequisites") + ); + assert!(complete.description.contains("not real judgment")); + assert!( + complete.input_schema["properties"] + .get("approval_event_id") + .is_none() + ); + } + + #[test] + fn effective_code_instructions_preserve_independent_review_before_merge() { + let (prompt, tools) = ticket_workflow_fixture( + "role.orchestrator", + MergeRequestFeatureConfig { + show: true, + readiness_check: true, + complete: true, + ..Default::default() + }, + ); + assert!(prompt.contains("Trusted Reviewer authority")); + assert!( + prompt.contains( + "Target integration and `CompleteMergeRequest` are Orchestrator authority" + ) + ); + assert!( + prompt.contains("does not bypass the guarded review requirements for MR integration") + ); + assert!(prompt.contains("not a universal Ticket pipeline")); + assert!(!tools.iter().any(|meta| meta.name == "ReviewMergeRequest")); + let complete = tools + .iter() + .find(|meta| meta.name == "CompleteMergeRequest") + .unwrap(); + assert!( + complete + .description + .contains("trusted exact-source approval") + ); + assert!( + complete.input_schema["required"] + .as_array() + .unwrap() + .iter() + .any(|field| field == "approval_event_id") + ); + } + + #[test] + fn effective_ticket_instructions_allow_return_without_conclusion_and_separate_cleanup() { + for role in ["role.ticket_worker", "role.coder", "role.orchestrator"] { + let (prompt, tools) = + ticket_workflow_fixture(role, MergeRequestFeatureConfig::default()); + assert!(prompt.contains("return without conclusion"), "{role}"); + assert!( + prompt.contains( + "a turn ending or a result report is not itself a completion decision" + ), + "{role}" + ); + assert!( + prompt.contains("stopping a Worker alone does not end unfinished work"), + "{role}" + ); + assert!( + prompt.contains("does not stop, remove, or clean up"), + "{role}" + ); + let complete = tools + .iter() + .find(|meta| meta.name == "CompleteTicket") + .unwrap(); + assert!( + complete + .description + .contains("return without conclusion need not call this tool") + ); + } + } #[test] fn sub_worker_capabilities_follow_the_registered_canonical_control_tools() { diff --git a/crates/worker/src/spawn/tool.rs b/crates/worker/src/spawn/tool.rs index 97cf919a1..e4a26fb55 100644 --- a/crates/worker/src/spawn/tool.rs +++ b/crates/worker/src/spawn/tool.rs @@ -28,6 +28,7 @@ use workdir::{ use crate::PromptCatalogSource; use crate::controller::register_worker_tools; +use crate::feature::builtin::drive::delegation::restrict_internal_child_drive; use crate::internal_worker::{ EphemeralSessionStore, InternalWorkerSessionStatus, InternalWorkerVisibility, prepare_internal_worker_session, @@ -327,6 +328,9 @@ pub struct SubWorkerSpawnTool { /// Compact selector list shared by tool description and diagnostics. available_profiles: AvailableProfiles, internal_client_override: Option>, + #[cfg(test)] + installed_child_workspace_client: + std::sync::Mutex>>, } impl SubWorkerSpawnTool { @@ -362,6 +366,8 @@ impl SubWorkerSpawnTool { prompt_loader, available_profiles, internal_client_override: None, + #[cfg(test)] + installed_child_workspace_client: std::sync::Mutex::new(None), } } } @@ -482,6 +488,9 @@ impl Tool for SubWorkerSpawnTool { // Delegated children stay bound to their scoped session and cannot use // Workspace attachment tools to replace it with parent-level authority. child_manifest.feature.manage_workdir.enabled = false; + // Internal children (including Reviewers) have no Backend Drive identity or approved + // child scope. Profiles and the parent's current Drive grant cannot delegate it. + child_manifest.feature.drive.enabled = false; let reviewer_capability = input.review.as_ref().map(|review| { ( review.ticket_id.clone(), @@ -523,6 +532,12 @@ impl Tool for SubWorkerSpawnTool { } else { self.workspace_context.clone() }; + let child_workspace_context = restrict_internal_child_drive(child_workspace_context); + #[cfg(test)] + { + *self.installed_child_workspace_client.lock().unwrap() = + Some(child_workspace_context.client_handle()); + } let store = EphemeralSessionStore::default(); let filesystem_authority = WorkerFilesystemAuthority::None; let mut child = Worker::, EphemeralSessionStore>::from_internal_manifest_with_context( @@ -1576,6 +1591,181 @@ enabled = false assert!(spawner_scope.snapshot().is_writable(&workspace_root)); } + #[derive(Debug, Default)] + struct DriveAllowingParentClient { + requests: Mutex>, + } + + impl WorkspaceClient for DriveAllowingParentClient { + fn workspace_id(&self) -> Option<&str> { + Some("workspace-test") + } + fn kind(&self) -> &str { + "drive-allowing-parent" + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + self.requests.lock().unwrap().push(request); + Ok(WorkspaceResponse { + status: 200, + body: "{}".into(), + }) + } + } + + #[tokio::test] + async fn internal_spawn_profiles_never_inherit_parent_drive_tools_or_client_authority() { + for (profile, review) in [ + ("inherit", false), + ("default", false), + ("builtin:reviewer", true), + ] { + let runtime = TempDir::new().unwrap(); + let workspace_root = runtime.path().join("project"); + let bash_output_dir = runtime.path().join("bash-output"); + let drive_profile = + format!("{INTERNAL_REVIEWER_PROFILE}\n[feature.drive]\nenabled = true\n"); + let available_profiles = write_project_profile_registry( + &workspace_root, + Some("reviewer"), + &[("reviewer", "reviewer.toml", &drive_profile)], + ); + let mut manifest = parent_manifest(&workspace_root, None); + manifest.feature.drive.enabled = true; + manifest + .scope + .allow + .push(abs_rule(&bash_output_dir, Permission::Read)); + let spawner_scope = SharedScope::new(Scope::from_config(&manifest.scope).unwrap()); + let registry = + SpawnedWorkerRegistry::new_internal("parent".into(), spawner_scope.clone()); + let parent_client = Arc::new(DriveAllowingParentClient::default()); + let drive_request = WorkspaceRequest::get("/api/w/workspace-test/drive/root"); + assert!( + parent_client + .execute(drive_request.clone()) + .unwrap() + .is_success() + ); + let workdir_tool_broker = workdir::WorkdirToolBrokerRouter::from_single( + workdir::WorkdirAttachmentAlias::new("workdir").unwrap(), + Arc::new(workdir::LocalWorkdirSession::materialized_bound( + workdir::Workdir::new("test-workdir"), + workspace_root.clone(), + workspace_root.clone(), + spawner_scope.clone(), + workdir::WorkdirSessionCapabilities::ALL, + )), + ) + .unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + let tool = SubWorkerSpawnTool::new( + "parent".into(), + crate::worker::WorkerWorkspaceContext::with_client( + Some(WorkspaceId::new("workspace-test").unwrap()), + parent_client.clone(), + ), + ParentNotificationTarget::Durable(Arc::new(|_| {})), + runtime.path().to_path_buf(), + bash_output_dir, + workspace_root.clone(), + Some(workdir_tool_broker), + registry.clone(), + manifest.clone(), + PromptCatalogSource::builtins_only(), + available_profiles, + ) + .with_internal_client(Box::new(ScriptedInternalClient { + calls: calls.clone(), + parent_scope: spawner_scope, + delegated_path: workspace_root, + observed_parent_write_revoked: Arc::new(AtomicBool::new(false)), + observed_instruction_override: Arc::new(AtomicBool::new(false)), + fail_requests: Arc::new(AtomicBool::new(false)), + })); + let mut input = serde_json::json!({ + "name": "child", "profile": profile, "instruction": "role.reviewer", + "task": "review", "scope": [{"target": ".", "permission": "write"}], + "command": true, + }); + if review { + input["review"] = serde_json::json!({"ticket_id": "T1", "merge_request_id": "MR1"}); + } + tool.execute( + &input.to_string(), + agen::tool::ToolExecutionContext::direct(), + ) + .await + .unwrap_or_else(|error| panic!("profile={profile}: {error:?}")); + let record = registry.get_internal("child").unwrap(); + assert_eq!( + record.session.wait_until_idle().await, + InternalWorkerSessionStatus::Idle + ); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!( + manifest.feature.drive.enabled, + "parent must keep Drive enabled" + ); + assert!( + !record + .installed_tools + .iter() + .any(|name| name.starts_with("Drive")), + "profile={profile}, installed tools={:?}", + record.installed_tools + ); + let child_client = tool + .installed_child_workspace_client + .lock() + .unwrap() + .clone() + .unwrap(); + let before = parent_client.requests.lock().unwrap().len(); + assert!( + matches!(child_client.execute(drive_request), Err(WorkspaceClientError::Unavailable(message)) if message.contains("Drive access denied")), + "profile={profile}" + ); + assert_eq!(parent_client.requests.lock().unwrap().len(), before); + if review { + assert_eq!( + child_client.reviewer_context(), + Some(&ReviewerContext { + ticket_id: "T1".into(), + merge_request_id: "MR1".into(), + }) + ); + child_client + .execute(WorkspaceRequest::json( + WorkspaceRequestMethod::Post, + "/api/w/workspace-test/merge-requests/MR1/reviews", + "{\"verdict\":\"approve\"}", + )) + .unwrap(); + let requests = parent_client.requests.lock().unwrap(); + let review_body: serde_json::Value = + serde_json::from_str(requests.last().unwrap().body.as_ref().unwrap()).unwrap(); + assert!( + review_body["capability_token"] + .as_str() + .is_some_and(|token| !token.is_empty()) + ); + assert!( + requests + .iter() + .any(|request| request.path.ends_with("/review-capabilities")) + ); + } + record.session.stop().await.unwrap(); + registry.remove_internal("child").await.unwrap(); + } + } + #[tokio::test] async fn remote_subworker_spawn_keeps_worker_host_output_path_out_of_workdir_scope() { let runtime = TempDir::new().unwrap(); diff --git a/crates/worker/src/wip.rs b/crates/worker/src/wip.rs index 3a3687f90..3c7145ad6 100644 --- a/crates/worker/src/wip.rs +++ b/crates/worker/src/wip.rs @@ -14,7 +14,9 @@ use std::sync::{Arc, Mutex}; use agen::Engine; use agen::llm_client::client::LlmClient; use agen::state::Mutable; -use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; +use agen::tool::{ + Attachment, Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, +}; use async_trait::async_trait; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; @@ -99,8 +101,8 @@ pub struct WipCallContext { pub security_context: String, } -/// Successful operation value. Compatibility projections can retain an ordinary -/// ToolOutput so images and pruning semantics remain on the existing Engine path. +/// Successful operation value. Native attachments and compatibility ToolOutputs +/// use the existing Engine side channel, separate from the validated WIP value. pub struct WipOperationOutput { pub value: Value, tool_output: Option, @@ -125,6 +127,20 @@ impl WipOperationOutput { } } + /// Attach model-visible binary content without replacing the native result + /// or its exact post-operation validator. Invoke returns these attachments + /// through ToolOutput so Engine capture/history can persist their bytes. + pub fn with_attachments(mut self, attachments: Vec) -> Self { + self.tool_output + .get_or_insert_with(|| ToolOutput { + summary: String::new(), + content: None, + attachments: Vec::new(), + }) + .attachments = attachments; + self + } + fn compatibility(value: Value, output: ToolOutput) -> Self { Self { value, @@ -134,6 +150,13 @@ impl WipOperationOutput { } } +enum WipToolOutput { + /// Only binary attachments accompany the validated native completion. + Native(ToolOutput), + /// Existing compatibility/error output retains its summary and pruning policy. + Passthrough(Result), +} + pub enum WipOperationError { Protocol(ProtocolError), Cancelled(ToolOutput), @@ -288,6 +311,13 @@ pub trait WipSubtreeProvider: Send + Sync { /// looking up a mutable ancestor after resolving the target. Self-scoped /// publishers can use `WipPublication::self_scoped` on their pinned snapshot. async fn publication(&self, path: &str) -> Result, ProtocolError>; + /// Routing hint for target-qualified local Interface names in an ancestor + /// scope. This grants no authority: Host re-resolves one coherent current + /// publication, checks the exact reference, and forbids escaping this owner. + /// Self/shared-scope providers retain the ordinary scope Object route. + fn interface_target(&self, reference: &InterfaceReference) -> Option { + Some(reference.scope.clone()) + } async fn children(&self, path: &str) -> Result, ProtocolError>; } @@ -1248,8 +1278,29 @@ impl WipHost { reference .validate() .map_err(|e| protocol_error(ProtocolErrorCode::InvalidRequest, e.to_string()))?; + let target = if let Some(mount) = self.subtree(&reference.scope) { + let target = mount.provider.interface_target(reference).ok_or_else(|| { + protocol_error( + ProtocolErrorCode::InterfaceNotFound, + "interface is not published", + ) + })?; + if wip_protocol::validate_path(&target).is_err() + || !self + .subtree(&target) + .is_some_and(|owner| owner.root == mount.root) + { + return Err(protocol_error( + ProtocolErrorCode::InterfaceNotFound, + "interface target is outside its provider", + )); + } + target + } else { + reference.scope.clone() + }; if let Some(publication) = self - .publication_for_interface_live(&reference.scope, reference) + .publication_for_interface_live(&target, reference) .await? && publication.projection.interface == *reference && let Some(scope) = publication.scope @@ -2227,15 +2278,22 @@ impl WipRuntime { guard.disarm(); let completion = completion.map_err(client_tool_error)?; self.push_audit(prepared.id, path, operation, audit_outcome); - if let Some(result) = passthrough { - if result.is_ok() { - self.metrics - .operation_successes - .fetch_add(1, Ordering::Relaxed); + let mut attachments = Vec::new(); + if let Some(passthrough) = passthrough { + match passthrough { + WipToolOutput::Passthrough(result) => { + if result.is_ok() { + self.metrics + .operation_successes + .fetch_add(1, Ordering::Relaxed); + } + return result; + } + WipToolOutput::Native(output) => attachments = output.attachments, } - return result; } - render_call_completion(completion).map(|output| { + render_call_completion(completion).map(|mut output| { + output.attachments = attachments; self.metrics .operation_successes .fetch_add(1, Ordering::Relaxed); @@ -2338,14 +2396,7 @@ impl WipRuntime { &self, request: &Request>, execution: ToolExecutionContext, - ) -> Result< - ( - Response>, - Option>, - WipAuditOutcome, - ), - ToolError, - > { + ) -> Result<(Response>, Option, WipAuditOutcome), ToolError> { let rejected = |error: ProtocolError| -> Result<_, ToolError> { Ok(( encode_protocol_error_response( @@ -2385,6 +2436,7 @@ impl WipRuntime { return rejected(error); } let projection = projection.projection; + let kind = projection.kind; let descriptor = projection.descriptor.clone(); let target_validator = projection.object.validator.clone(); let request_value = @@ -2443,8 +2495,10 @@ impl WipRuntime { .map_err(|error| ToolError::Internal(error.to_string()))?; return Ok(( response, - Some(Err(ToolError::ExecutionFailed(format!( - "WIP operation outcome unknown; do not retry automatically: {message}" + Some(WipToolOutput::Passthrough(Err(ToolError::ExecutionFailed( + format!( + "WIP operation outcome unknown; do not retry automatically: {message}" + ), )))), WipAuditOutcome::OutcomeUnknown, )); @@ -2452,7 +2506,10 @@ impl WipRuntime { }; Ok(( response, - output.tool_output.map(Ok), + output.tool_output.map(|output| match kind { + WipProjectionKind::Native => WipToolOutput::Native(output), + WipProjectionKind::Compatibility => WipToolOutput::Passthrough(Ok(output)), + }), WipAuditOutcome::Success, )) } @@ -2477,7 +2534,9 @@ impl WipRuntime { .map_err(|encode| ToolError::Internal(encode.to_string()))?; Ok(( response, - Some(Err(ToolError::Cancelled(output))), + Some(WipToolOutput::Passthrough(Err(ToolError::Cancelled( + output, + )))), WipAuditOutcome::Cancelled, )) } @@ -2493,7 +2552,9 @@ impl WipRuntime { .map_err(|encode| ToolError::Internal(encode.to_string()))?; Ok(( response, - Some(Err(ToolError::Interrupted(output))), + Some(WipToolOutput::Passthrough(Err(ToolError::Interrupted( + output, + )))), WipAuditOutcome::Interrupted, )) } @@ -2506,8 +2567,10 @@ impl WipRuntime { .map_err(|encode| ToolError::Internal(encode.to_string()))?; Ok(( response, - Some(Err(ToolError::ExecutionFailed(format!( - "WIP operation outcome unknown; do not retry automatically: {message}" + Some(WipToolOutput::Passthrough(Err(ToolError::ExecutionFailed( + format!( + "WIP operation outcome unknown; do not retry automatically: {message}" + ), )))), WipAuditOutcome::OutcomeUnknown, )) @@ -5072,6 +5135,280 @@ mod tests { } } + struct NativeImage { + invalid_result: bool, + } + + #[async_trait] + impl WipOperationHandler for NativeImage { + async fn call( + &self, + _operation: &str, + _arguments: &BTreeMap, + _context: WipCallContext, + ) -> Result { + let value = if self.invalid_result { + Value::Integer(1) + } else { + Value::String("Attached native image".into()) + }; + Ok( + WipOperationOutput::native_with_validator(value, vec![7]).with_attachments(vec![ + Attachment::Image(agen::tool::ImageAttachment::new( + "image/png", + include_bytes!("../../tools/tests/fixtures/view-image/valid.png").to_vec(), + )), + ]), + ) + } + } + + fn native_image_registry(invalid_result: bool) -> WipMountRegistry { + let mut registry = WipMountRegistry::new(); + registry.allocate_namespace("native", "native").unwrap(); + registry + .mount(WipProjection { + route: "/native/image".into(), + capability: "native:image".into(), + kind: WipProjectionKind::Native, + object: Object { + name: "image".into(), + description: None, + interfaces: vec![root_reference("yoi.native/image/v1")], + r#ref: Some("native:image".into()), + validator: Some(vec![1]), + }, + interface: root_reference("yoi.native/image/v1"), + descriptor: InterfaceDescriptor { + format: INTERFACE_FORMAT_V1.into(), + documentation: None, + types: Vec::new(), + operations: vec![OperationDeclaration { + name: "view".into(), + documentation: None, + parameters: Vec::new(), + returns: ReturnDeclaration { + documentation: None, + r#type: TypeExpr::String, + }, + }], + }, + interface_validator: Some(vec![1]), + handler: Arc::new(NativeImage { invalid_result }), + }) + .unwrap(); + registry + } + + fn native_image_invoke() -> String { + json!({ + "path":"/native/image", + "interface":{"scope":"/","name":"yoi.native/image/v1"}, + "operation":"view", + "arguments":{} + }) + .to_string() + } + + #[tokio::test] + async fn native_invoke_returns_image_bytes_and_preserves_result_and_validator() { + let mut engine = Engine::<_, Mutable, ()>::new_annotated(DummyClient); + let runtime = install_wip_mode_with_mounts( + &mut engine, + None, + "worker-a".into(), + native_image_registry(false), + ) + .unwrap(); + let tools = engine.tool_server_handle(); + tools.flush_pending(); + tools + .call_tool( + "Inspect", + r#"{"path":"/native/image"}"#, + ToolExecutionContext::direct(), + ) + .await + .unwrap(); + let output = tools + .call_tool( + "Invoke", + &native_image_invoke(), + ToolExecutionContext::direct(), + ) + .await + .unwrap(); + assert_eq!(output.summary, "WIP operation completed"); + assert_eq!( + serde_json::from_str::(output.content.as_deref().unwrap()).unwrap(), + json!("Attached native image") + ); + let [Attachment::Image(image)] = output.attachments.as_slice() else { + panic!("Invoke must return an image, not a URL") + }; + assert_eq!(image.mime_type(), "image/png"); + assert_eq!( + image.data(), + include_bytes!("../../tools/tests/fixtures/view-image/valid.png") + ); + let state = runtime.state.lock().unwrap(); + let calls = state.client.call_history(&state.session).unwrap(); + assert!( + matches!(&calls.back().unwrap().outcome, CallOutcome::Success(response) if response.result == Value::String("Attached native image".into()) && response.validator == Some(vec![7])) + ); + } + + #[tokio::test] + async fn native_attachments_do_not_bypass_return_value_validation() { + let mut engine = Engine::<_, Mutable, ()>::new_annotated(DummyClient); + install_wip_mode_with_mounts( + &mut engine, + None, + "worker-a".into(), + native_image_registry(true), + ) + .unwrap(); + let tools = engine.tool_server_handle(); + tools.flush_pending(); + tools + .call_tool( + "Inspect", + r#"{"path":"/native/image"}"#, + ToolExecutionContext::direct(), + ) + .await + .unwrap(); + let error = tools + .call_tool( + "Invoke", + &native_image_invoke(), + ToolExecutionContext::direct(), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("outcome unknown"), "{error}"); + } + + #[derive(Clone)] + struct NativeImageClient { + events: Arc>>>, + requests: Arc>>, + } + + #[async_trait] + impl LlmClient for NativeImageClient { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + async fn stream(&self, request: LlmRequest) -> Result { + self.requests.lock().unwrap().push(request); + let events = self + .events + .lock() + .unwrap() + .pop_front() + .expect("unexpected model request"); + Ok(Box::pin(stream::iter(events.into_iter().map(Ok)))) + } + } + + #[tokio::test] + async fn native_invoke_image_survives_engine_capture_and_durable_history_replay() { + use agen::llm_client::event::{Event, ResponseStatus, StatusEvent}; + let completed = || { + Event::Status(StatusEvent { + status: ResponseStatus::Completed, + }) + }; + let client = NativeImageClient { + events: Arc::new(Mutex::new(VecDeque::from([ + vec![ + Event::tool_use_start(0, "native-image-call", "Invoke"), + Event::tool_input_delta(0, &native_image_invoke()), + Event::tool_use_stop(0), + completed(), + ], + vec![completed()], + ]))), + requests: Arc::new(Mutex::new(Vec::new())), + }; + let mut engine = Engine::new(client.clone()); + install_wip_mode_with_mounts( + &mut engine, + None, + "worker-a".into(), + native_image_registry(false), + ) + .unwrap(); + let tools = engine.tool_server_handle(); + tools.flush_pending(); + tools + .call_tool( + "Inspect", + r#"{"path":"/native/image"}"#, + ToolExecutionContext::direct(), + ) + .await + .unwrap(); + let captured = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&captured); + engine.on_history_append(move |item| { + sink.lock().unwrap().push(item.clone()); + Ok(()) + }); + let mut history = agen::History::new(); + assert!(matches!( + engine + .run(&mut history, "view the native image") + .await + .result, + agen::EngineRunExit::Finished + )); + let saved = history.items().find(|item| matches!(item, agen::Item::ToolResult { call_id, .. } if call_id == "native-image-call")).unwrap(); + let agen::Item::ToolResult { + attachments, + is_error, + content, + .. + } = saved + else { + unreachable!() + }; + assert!(!is_error); + let [Attachment::Image(image)] = attachments.as_slice() else { + panic!("durable Invoke result lost image") + }; + assert_eq!( + image.data(), + include_bytes!("../../tools/tests/fixtures/view-image/valid.png") + ); + assert_eq!( + serde_json::from_str::(content.as_deref().unwrap()).unwrap(), + json!("Attached native image") + ); + assert!(captured.lock().unwrap().iter().any(|item| item == saved)); + assert!( + client.requests.lock().unwrap()[1] + .items + .iter() + .any(|item| item == saved) + ); + let log = session_store::LogEntry::AnnotatedToolResult { + ts: 1, + entry: session_store::LoggedHistoryEntry { + item: session_store::LoggedItem::from(saved), + metadata: session_store::LoggedSessionHistoryMetadata { + entry_id: session_store::LoggedSessionHistoryEntryId::new(), + origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown, + derivation: None, + }, + }, + }; + let serialized = serde_json::to_string(&log).unwrap(); + let restored: session_store::LogEntry = serde_json::from_str(&serialized).unwrap(); + assert_eq!(&session_store::collect_state(&[restored]).history[0], saved); + } + #[tokio::test] async fn native_projection_preserves_descriptor_and_decodes_typed_arguments_end_to_end() { let mut registry = WipMountRegistry::new(); diff --git a/crates/worker/src/wip/ancestor_tests.rs b/crates/worker/src/wip/ancestor_tests.rs index ab1ac929a..d645a3436 100644 --- a/crates/worker/src/wip/ancestor_tests.rs +++ b/crates/worker/src/wip/ancestor_tests.rs @@ -538,3 +538,84 @@ async fn same_name_scope_replacement_or_loss_of_ref_rejects_old_metadata_without ); } } + +struct TargetQualifiedProvider { + inner: Arc, + target_hint: Mutex>, +} +#[async_trait] +impl WipSubtreeProvider for TargetQualifiedProvider { + fn interface_target(&self, _: &InterfaceReference) -> Option { + self.target_hint.lock().unwrap().clone() + } + async fn publication(&self, path: &str) -> Result, ProtocolError> { + self.inner.publication(path).await + } + async fn children(&self, path: &str) -> Result, ProtocolError> { + self.inner.children(path).await + } +} + +#[tokio::test] +async fn ancestor_interface_target_hint_fetches_one_current_publication_and_never_grants_authority() +{ + let (_, inner) = fixture("/github", true, false); + let provider = Arc::new(TargetQualifiedProvider { + inner: inner.clone(), + target_hint: Mutex::new(Some(inner.target_path.clone())), + }); + let mut mounts = WipMountRegistry::new(); + mounts.allocate_namespace("scope", "github").unwrap(); + mounts + .mount(projection( + "/github", + "/github", + "read", + inner.calls.clone(), + )) + .unwrap(); + mounts + .mount_subtree(WipSubtreeMount { + root: "/github".into(), + provider: provider.clone(), + }) + .unwrap(); + let host = WipHost::new(mounts); + let selected = reference("/github", "read"); + let fetched = host.fetch_interface_live(&selected).await.unwrap(); + assert_eq!(fetched.scope_ref, Some("live-scope".into())); + assert_eq!( + inner.state.lock().unwrap().lookups, + [inner.target_path.clone()] + ); + // A hint must not fabricate a Descriptor or restore a deleted scope lifetime. + inner.state.lock().unwrap().visible = false; + assert_eq!( + host.fetch_interface_live(&selected).await.unwrap_err().code, + ProtocolErrorCode::InterfaceNotFound + ); + inner.state.lock().unwrap().visible = true; + let forged = reference("/github", "not-published"); + assert_eq!( + host.fetch_interface_live(&forged).await.unwrap_err().code, + ProtocolErrorCode::InterfaceNotFound + ); + for hint in [ + None, + Some("/outside"), + Some("/github-extra/item"), + Some("/github/../item"), + Some("/github/missing"), + ] { + *provider.target_hint.lock().unwrap() = hint.map(str::to_owned); + inner.state.lock().unwrap().lookups.clear(); + assert_eq!( + host.fetch_interface_live(&selected).await.unwrap_err().code, + ProtocolErrorCode::InterfaceNotFound + ); + if hint != Some("/github/missing") { + assert!(inner.state.lock().unwrap().lookups.is_empty()); + } + } + assert_eq!(inner.calls.load(Ordering::SeqCst), 0); +} diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index a4bae5f46..ca5fde341 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -639,6 +639,27 @@ impl WorkspaceRequest { } } +/// Raw-byte request through a host-bound Workspace client, separate from JSON requests. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceBinaryRequest { + pub method: WorkspaceRequestMethod, + pub path: String, + pub body: Option>, + pub max_response_bytes: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceBinaryResponse { + pub status: u16, + pub body: Vec, +} + +impl WorkspaceBinaryResponse { + pub fn is_success(&self) -> bool { + (200..300).contains(&self.status) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WorkspaceRequestMethod { Get, @@ -859,6 +880,24 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync { self.execute(request) } + /// Binary transport is unavailable unless a host-bound implementation opts in. + fn execute_binary( + &self, + _request: WorkspaceBinaryRequest, + ) -> Result { + Err(WorkspaceClientError::Unavailable( + "Workspace binary transport is unavailable".to_string(), + )) + } + + fn execute_binary_with_timeout( + &self, + request: WorkspaceBinaryRequest, + _timeout: Duration, + ) -> Result { + self.execute_binary(request) + } + fn execute_server_operation( &self, operation: WorkspaceServerOperation, @@ -961,6 +1000,31 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient { self.inner.current_prompt_projection(minimum_revision) } + fn execute_binary( + &self, + request: WorkspaceBinaryRequest, + ) -> Result { + if request.method != WorkspaceRequestMethod::Get { + return Err(WorkspaceClientError::Unavailable( + "Reviewer child binary Workspace authority is read-only".to_string(), + )); + } + self.inner.execute_binary(request) + } + + fn execute_binary_with_timeout( + &self, + request: WorkspaceBinaryRequest, + timeout: Duration, + ) -> Result { + if request.method != WorkspaceRequestMethod::Get { + return Err(WorkspaceClientError::Unavailable( + "Reviewer child binary Workspace authority is read-only".to_string(), + )); + } + self.inner.execute_binary_with_timeout(request, timeout) + } + fn execute( &self, mut request: WorkspaceRequest, @@ -1157,6 +1221,123 @@ mod reviewer_client_tests { ) } + #[test] + fn binary_transport_defaults_to_denial_without_json_fallback() { + let client = RecordingWorkspaceClient::default(); + let request = WorkspaceBinaryRequest { + method: WorkspaceRequestMethod::Get, + path: "/api/w/ws/drive/entries/a/content".into(), + body: None, + max_response_bytes: 64 * 1024, + }; + assert!(matches!( + client.execute_binary(request.clone()), + Err(WorkspaceClientError::Unavailable(_)) + )); + assert!(matches!( + client.execute_binary_with_timeout(request, Duration::from_secs(1)), + Err(WorkspaceClientError::Unavailable(_)) + )); + assert!(client.requests.lock().unwrap().is_empty()); + } + + #[derive(Debug, Default)] + struct BinaryRecordingClient { + requests: Mutex)>>, + } + + impl WorkspaceClient for BinaryRecordingClient { + fn workspace_id(&self) -> Option<&str> { + Some("ws") + } + fn kind(&self) -> &str { + "binary-recording" + } + fn is_available(&self) -> bool { + true + } + fn execute(&self, _: WorkspaceRequest) -> Result { + panic!("binary request must not use JSON fallback") + } + fn execute_binary( + &self, + request: WorkspaceBinaryRequest, + ) -> Result { + self.requests.lock().unwrap().push((request, None)); + Ok(WorkspaceBinaryResponse { + status: 200, + body: vec![0, 255], + }) + } + fn execute_binary_with_timeout( + &self, + request: WorkspaceBinaryRequest, + timeout: Duration, + ) -> Result { + self.requests.lock().unwrap().push((request, Some(timeout))); + Ok(WorkspaceBinaryResponse { + status: 200, + body: vec![0, 255], + }) + } + } + + #[test] + fn reviewer_binary_get_preserves_request_limit_and_timeout() { + let inner = Arc::new(BinaryRecordingClient::default()); + let client = reviewer_client(inner.clone()); + let request = WorkspaceBinaryRequest { + method: WorkspaceRequestMethod::Get, + path: "/api/w/ws/drive/entries/a/content?offset=2&limit=4".into(), + body: None, + max_response_bytes: 4, + }; + assert_eq!( + client.execute_binary(request.clone()).unwrap().body, + vec![0, 255] + ); + let timeout = Duration::from_secs(2); + assert_eq!( + client + .execute_binary_with_timeout(request.clone(), timeout) + .unwrap() + .body, + vec![0, 255] + ); + assert_eq!( + *inner.requests.lock().unwrap(), + vec![(request.clone(), None), (request, Some(timeout))] + ); + } + + #[test] + fn reviewer_binary_mutations_are_denied_even_for_attested_review_path() { + let inner = Arc::new(BinaryRecordingClient::default()); + let client = reviewer_client(inner.clone()); + for method in [ + WorkspaceRequestMethod::Post, + WorkspaceRequestMethod::Put, + WorkspaceRequestMethod::Patch, + WorkspaceRequestMethod::Delete, + ] { + let request = WorkspaceBinaryRequest { + method, + path: "/api/w/ws/merge-requests/MR1/reviews".into(), + body: Some(vec![0, 255]), + max_response_bytes: 4, + }; + assert!(matches!( + client.execute_binary(request.clone()), + Err(WorkspaceClientError::Unavailable(_)) + )); + assert!(matches!( + client.execute_binary_with_timeout(request, Duration::from_secs(1)), + Err(WorkspaceClientError::Unavailable(_)) + )); + } + assert!(inner.requests.lock().unwrap().is_empty()); + } + #[test] fn reviewer_child_client_allows_typed_ticket_reads() { let inner = Arc::new(RecordingWorkspaceClient::default()); diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index 1f5f4f66a..7b50a34cf 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -56,6 +56,7 @@ zeroize.workspace = true webauthn-rs = { workspace = true } [dev-dependencies] +wip-protocol.workspace = true agen.workspace = true tempfile.workspace = true tools.workspace = true diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 62b0129be..7c19b6b7f 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -2632,34 +2632,30 @@ fn runtime_request_proof_rejection( method: &str, path: &str, workspace_id: &str, - error: &crate::worker_source::WorkerMutationSourceProofError, + error: &crate::worker_source::RuntimeRequestProofError, ) -> Response { - use crate::worker_source::WorkerMutationSourceProofError; - - // Do not format the error itself: Authority and MissingPermission can carry - // internal or request-derived strings. Only emit allowlisted reason codes. - let reason = match error { - WorkerMutationSourceProofError::Missing => "missing", - WorkerMutationSourceProofError::Invalid => "invalid", - WorkerMutationSourceProofError::WrongAudience => "wrong_audience", - WorkerMutationSourceProofError::WrongWorkspace => "wrong_workspace", - WorkerMutationSourceProofError::WrongActor => "wrong_actor", - WorkerMutationSourceProofError::MissingPermission(_) => "missing_permission", - WorkerMutationSourceProofError::Expired => "expired", - WorkerMutationSourceProofError::RevokedRuntimeTrust => "runtime_trust_missing_or_revoked", - WorkerMutationSourceProofError::Replay => "replay", - WorkerMutationSourceProofError::WorkerCatalogMembership => "worker_catalog_membership", - WorkerMutationSourceProofError::Authority(_) => "authority_unavailable", - }; + // Never format an error or raw claims. IDs are fixed-length fingerprints; + // verification labels describe proof-check progress, not authorization. let path = path.split('?').next().unwrap_or(path); + let log_path = bounded_auth_log_value(path, 512); + let method = bounded_auth_log_value(method, 32); + let workspace_id = bounded_auth_log_value(workspace_id, 128); + let request_id = uuid::Uuid::now_v7().to_string(); tracing::warn!( target: "yoi::auth", event = "runtime_request_proof_rejected", - method, - path, - workspace_id, + method, path = log_path, workspace_id, request_id, status = 401, - reason, + reason = error.reason, + proof_verification = error.verification, + claimed_runtime_id_hash = error.runtime_id_hash.as_deref(), + claimed_worker_id_hash = error.worker_id_hash.as_deref(), + claimed_token_id_hash = error.token_id_hash.as_deref(), + issued_at_unix = error.iat, + expires_at_unix = error.exp, + verified_at_unix = error.now, + time_delta_seconds = error.time_delta_seconds, + time_tolerance_seconds = error.time_delta_seconds.map(|_| 0_u64), "Runtime request proof rejected", ); repository_api_rejection( @@ -2669,6 +2665,17 @@ fn runtime_request_proof_rejection( ) } +fn bounded_auth_log_value(value: &str, max_bytes: usize) -> String { + let mut result = String::new(); + for ch in value.chars().filter(|ch| !ch.is_control()) { + if result.len() + ch.len_utf8() > max_bytes { + break; + } + result.push(ch); + } + result +} + /// Authored configuration editors are user-facing authority, not a generic /// Runtime workspace.request capability. Worker configuration access is only /// through the grant- and connection-bound WIP adapter. Keep runtime projections @@ -30511,7 +30518,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> ApiResult std::io::Result<()> { Ok(()) } } -#[test] -fn runtime_proof_diagnostics_redact_error_payloads() { - use crate::worker_source::WorkerMutationSourceProofError; - - for (error, reason) in [ - ( - WorkerMutationSourceProofError::Authority("private-database-path".to_string()), - "authority_unavailable", - ), - ( - WorkerMutationSourceProofError::MissingPermission("private-permission".to_string()), - "missing_permission", - ), - ] { - let logs = CapturedAuthLog::default(); - let writer = logs.clone(); - let subscriber = tracing_subscriber::fmt() +impl CapturedAuthLog { + fn subscriber(&self) -> impl tracing::Subscriber + Send + Sync + use<> { + let writer = self.clone(); + tracing_subscriber::fmt() + .with_max_level(tracing::Level::WARN) .without_time() .with_ansi(false) .json() .flatten_event(true) .with_writer(move || writer.clone()) - .finish(); - tracing::subscriber::with_default(subscriber, || { - let response = runtime_request_proof_rejection( - "POST", - "/api/w/workspace/workers/self/workdir-session/operations?secret=query-secret", - "workspace", - &error, - ); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - }); - let text = String::from_utf8(logs.0.lock().unwrap().clone()).unwrap(); - let event: Value = serde_json::from_str(text.trim()).unwrap(); - assert_eq!(event["reason"], reason); - assert!(!text.contains("private-"), "auth log leaked error details"); - assert!( - !text.contains("query-secret"), - "auth log leaked query values" + .finish() + } + fn event(&self) -> (Value, String) { + let text = String::from_utf8(self.0.lock().unwrap().clone()).unwrap(); + let events: Vec = text + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .filter(|event| event["event"] == "runtime_request_proof_rejected") + .collect(); + assert_eq!( + events.len(), + 1, + "missing or duplicated auth diagnostic: {text}" ); + (events[0].clone(), text) } } #[tokio::test] -async fn rejected_runtime_proofs_log_reason_without_request_secrets_on_both_routers() { +async fn rejected_runtime_proofs_log_reason_and_verification_without_secrets_on_both_routers() { let workspace = tempfile::tempdir().unwrap(); let mut api = test_api(workspace.path()).await; let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap(); configure_runtime_request_auth(&mut api, &identity, "runtime-test"); - let signer = RuntimeRequestSourceSigner::from_identity(&identity); + let revoked = RuntimeIdentityMaterial::generate("revoked-runtime-secret").unwrap(); + configure_runtime_request_auth(&mut api, &revoked, "revoked-runtime-secret"); + SqliteWorkspaceStore::open(&api.config.database_path) + .unwrap() + .revoke_workspace_runtime_binding( + TEST_WORKSPACE_ID, + "revoked-runtime-secret", + "2026-10-08T00:00:00Z", + ) + .unwrap(); + let spoofed = RuntimeIdentityMaterial::generate("runtime-test").unwrap(); + let unbound_id = "unverified-runtime-secret".repeat(1000); + let unbound = RuntimeIdentityMaterial::generate(&unbound_id).unwrap(); let path = format!("/api/w/{TEST_WORKSPACE_ID}/workers/self/workdir-session/operations"); let target = format!("{path}?access_token=query-secret"); let body = "body-secret"; @@ -81,24 +77,94 @@ async fn rejected_runtime_proofs_log_reason_without_request_secrets_on_both_rout ]; for app in apps { - for reason in ["expired", "replay", "invalid"] { + for (case, verification) in [ + ("expired", "signature_verified"), + ("issued_in_future", "signature_verified"), + ("replay", "request_verified"), + ("invalid_format", "unverified"), + ("invalid_encoding", "unverified"), + ("malformed_claims", "unverified"), + ("invalid_signature", "unverified"), + ("tampered_signature", "unverified"), + ("revoked_runtime", "unverified"), + ("runtime_trust_missing_or_revoked", "unverified"), + ("audience_mismatch", "signature_verified"), + ("workspace_mismatch", "signature_verified"), + ("permission_mismatch", "signature_verified"), + ("method_mismatch", "signature_verified"), + ("path_mismatch", "signature_verified"), + ("body_digest_mismatch", "signature_verified"), + ("worker_catalog_membership", "request_verified"), + ] { + let reason = match case { + "tampered_signature" => "invalid_signature", + "revoked_runtime" => "runtime_trust_missing_or_revoked", + other => other, + }; let now = i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap(); - let proof = if reason == "invalid" { - "invalid-proof-secret".to_string() - } else { - signer + let signing_identity = match case { + "invalid_signature" => &spoofed, + "revoked_runtime" => &revoked, + "runtime_trust_missing_or_revoked" => &unbound, + _ => &identity, + }; + let proof = match reason { + "invalid_format" => "invalid-proof-secret".repeat(1000), + "invalid_encoding" => "yoi-runtime-request-v1.!.!".to_string(), + "malformed_claims" => "yoi-runtime-request-v1.e30.AA".to_string(), // {} claims + _ => RuntimeRequestSourceSigner::from_identity(signing_identity) .issue( - "server-test", - TEST_WORKSPACE_ID, - None, - WORKSPACE_REQUEST_PERMISSION, - "POST", - &target, - body.as_bytes(), - if reason == "expired" { now - 120 } else { now }, - 30, + if reason == "audience_mismatch" { + "audience-secret" + } else { + "server-test" + }, + if reason == "workspace_mismatch" { + "workspace-secret" + } else { + TEST_WORKSPACE_ID + }, + (reason == "worker_catalog_membership").then_some("worker-secret"), + if reason == "permission_mismatch" { + "permission-secret" + } else { + WORKSPACE_REQUEST_PERMISSION + }, + if reason == "method_mismatch" { + "GET" + } else { + "POST" + }, + if reason == "path_mismatch" { + "/path-secret" + } else { + &target + }, + if reason == "body_digest_mismatch" { + b"different-body-secret" + } else { + body.as_bytes() + }, + match reason { + "expired" => i64::MIN, + "issued_in_future" => i64::MAX, + _ => now, + }, + if matches!(reason, "expired" | "issued_in_future") { + 0 + } else { + 30 + }, ) - .unwrap() + .unwrap(), + }; + let proof = if case == "tampered_signature" { + let (signed, signature) = proof.rsplit_once('.').unwrap(); + // Flip a significant signature byte, retaining valid base64url. + let first = if signature.starts_with('A') { "B" } else { "A" }; + format!("{signed}.{first}{}", &signature[1..]) + } else { + proof }; if reason == "replay" { crate::worker_source::verify_runtime_request_source_proof( @@ -114,15 +180,6 @@ async fn rejected_runtime_proofs_log_reason_without_request_secrets_on_both_rout .unwrap(); } let logs = CapturedAuthLog::default(); - let writer = logs.clone(); - let subscriber = tracing_subscriber::fmt() - .with_max_level(tracing::Level::WARN) - .without_time() - .with_ansi(false) - .json() - .flatten_event(true) - .with_writer(move || writer.clone()) - .finish(); let response = app .clone() .oneshot( @@ -135,39 +192,206 @@ async fn rejected_runtime_proofs_log_reason_without_request_secrets_on_both_rout .body(Body::from(body)) .unwrap(), ) - .with_subscriber(subscriber) + .with_subscriber(logs.subscriber()) .await .unwrap(); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{reason}"); let response_body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); assert!(response_body.is_empty(), "{response_body:?}"); - - let text = String::from_utf8(logs.0.lock().unwrap().clone()).unwrap(); - let events: Vec = text - .lines() - .map(|line| serde_json::from_str::(line).unwrap()) - .filter(|event| event["event"] == "runtime_request_proof_rejected") - .collect(); - assert_eq!( - events.len(), - 1, - "missing or duplicated auth diagnostic: {text}" - ); - let event = &events[0]; + let (event, text) = logs.event(); assert_eq!(event["reason"], reason); + assert_eq!(event["proof_verification"], verification); assert_eq!(event["status"], 401); assert_eq!(event["method"], "POST"); assert_eq!(event["path"], path); assert_eq!(event["workspace_id"], TEST_WORKSPACE_ID); + assert!(uuid::Uuid::parse_str(event["request_id"].as_str().unwrap()).is_ok()); + if verification == "unverified" + && matches!( + reason, + "invalid_format" | "invalid_encoding" | "malformed_claims" + ) + { + assert!(event.get("claimed_runtime_id_hash").is_none()); + assert!(event.get("claimed_token_id_hash").is_none()); + } else { + let claims = + worker_runtime::auth::decode_runtime_request_source_claims(&proof).unwrap(); + assert_eq!( + event["claimed_runtime_id_hash"], + crate::worker_source::diagnostic_id_hash(&claims.iss) + ); + assert_eq!( + event["claimed_token_id_hash"], + crate::worker_source::diagnostic_id_hash(&claims.jti) + ); + if let Some(worker) = &claims.worker_id { + assert_eq!( + event["claimed_worker_id_hash"], + crate::worker_source::diagnostic_id_hash(worker) + ); + } + } + if matches!(reason, "expired" | "issued_in_future") { + let bound = if reason == "expired" { + i64::MIN + } else { + i64::MAX + }; + assert_eq!(event["issued_at_unix"], bound); + assert_eq!(event["expires_at_unix"], bound); + let verified = event["verified_at_unix"].as_i64().unwrap(); + assert_eq!( + event["time_delta_seconds"].as_u64(), + Some(bound.abs_diff(verified)) + ); + assert_eq!(event["time_tolerance_seconds"], 0); + } else { + assert!(event.get("time_delta_seconds").is_none()); + assert!(event.get("issued_at_unix").is_none()); + } for secret in [ &proof, + &identity.private_key, + &spoofed.private_key, + &unbound_id, "access_token", "query-secret", body, "bearer-secret", + "audience-secret", + "workspace-secret", + "permission-secret", + "path-secret", + "worker-secret", + "revoked-runtime-secret", + &revoked.private_key, ] { - assert!(!text.contains(secret), "auth log leaked request data"); + assert!( + !text.contains(secret), + "auth log leaked request data: {reason}" + ); } + assert!(text.len() < 2200, "unbounded diagnostic: {}", text.len()); + } + } +} + +#[tokio::test] +async fn runtime_proof_setup_failure_logs_no_internal_error_and_bounds_request_metadata() { + let workspace = tempfile::tempdir().unwrap(); + let mut api = test_api(workspace.path()).await; + let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-test"); + api.config.backend_base_url = None; + let proof = RuntimeRequestSourceSigner::from_identity(&identity) + .issue( + "server-test", + TEST_WORKSPACE_ID, + None, + WORKSPACE_REQUEST_PERMISSION, + "POST", + "/request", + b"{}", + 90, + 10, + ) + .unwrap(); + let error = crate::worker_source::verify_runtime_request_source_proof( + &api, + &proof, + "private-workspace-secret", + WORKSPACE_REQUEST_PERMISSION, + "POST", + "/request", + "digest", + ) + .await + .unwrap_err(); + let logs = CapturedAuthLog::default(); + let long_path = format!("/{}?secret=query-secret", "é\n".repeat(1000)); + let response = tracing::subscriber::with_default(logs.subscriber(), || { + runtime_request_proof_rejection( + &"M\n".repeat(1000), + &long_path, + &"w\n".repeat(1000), + &error, + ) + }); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!( + to_bytes(response.into_body(), usize::MAX) + .await + .unwrap() + .is_empty() + ); + let (event, text) = logs.event(); + assert_eq!(event["reason"], "backend_audience_unavailable"); + assert_eq!(event["proof_verification"], "unverified"); + for (field, limit) in [("method", 32), ("path", 512), ("workspace_id", 128)] { + let value = event[field].as_str().unwrap(); + assert!(value.len() <= limit); + assert!(!value.chars().any(char::is_control)); + } + for secret in [&proof, "private-workspace-secret", "query-secret"] { + assert!(!text.contains(secret)); + } +} + +#[tokio::test] +async fn repository_runtime_proof_401_keeps_generic_envelope_on_both_routers() { + let workspace = tempfile::tempdir().unwrap(); + let mut api = test_api(workspace.path()).await; + let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-test"); + let path = format!("/api/w/{TEST_WORKSPACE_ID}/repositories"); + for app in [ + build_router(api.clone()), + workspace_server_router(WorkspaceServerApi::new( + api.config.clone(), + api.store.clone(), + )), + ] { + for issued_at in [i64::MIN, i64::MAX] { + let proof = RuntimeRequestSourceSigner::from_identity(&identity) + .issue( + "server-test", + TEST_WORKSPACE_ID, + None, + WORKSPACE_REQUEST_PERMISSION, + "GET", + &path, + b"", + issued_at, + 0, + ) + .unwrap(); + let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::GET) + .uri(&path) + .header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, &proof) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let actual: Value = serde_json::from_slice(&body).unwrap(); + let expected = serde_json::to_value(server_api::RepositoryApiError::new( + 401, + "Unauthorized", + "invalid runtime request proof", + Vec::new(), + )) + .unwrap(); + assert_eq!( + actual, expected, + "auth internals must not expand the external envelope" + ); } } } diff --git a/crates/workspace-server/src/server/tests/worker_drive_tests.rs b/crates/workspace-server/src/server/tests/worker_drive_tests.rs new file mode 100644 index 000000000..4bcd6af78 --- /dev/null +++ b/crates/workspace-server/src/server/tests/worker_drive_tests.rs @@ -0,0 +1,1422 @@ +//! Worker tools -> signed WorkspaceClient -> real isolated Server authority. +//! No Git checkout, live DB, network service, or DTO-only assertions. Workdir +//! import tests attach an explicitly read-scoped temporary source session. +use super::*; +use agen::tool::{Attachment, ToolError, ToolExecutionContext, ToolOutput}; +use std::result::Result; +use std::sync::{ + Mutex, + atomic::{AtomicBool, Ordering}, +}; +use workdir::WorkdirSessionRouter; +use worker::feature::builtin::drive::DriveFeature; +use worker::{ + WorkspaceBinaryRequest, WorkspaceBinaryResponse, WorkspaceClient, WorkspaceClientError, + WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse, +}; + +/// Only transport is substituted. Each request gets a fresh real Runtime proof +/// and passes the production router, registry, current grant, DB and blob store. +struct RouterClient { + api: WorkspaceApi, + identity: Arc, + worker: RuntimeWorkerRef, + handle: tokio::runtime::Handle, + lose_next_mutation_response: AtomicBool, + lose_next_upload_response: AtomicBool, + requests: Mutex>, +} +impl std::fmt::Debug for RouterClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RouterClient") + .field("worker", &self.worker) + .finish_non_exhaustive() + } +} +impl RouterClient { + fn request( + &self, + method: WorkspaceRequestMethod, + path: String, + bytes: Vec, + limit: usize, + ) -> Result { + let method = match method { + WorkspaceRequestMethod::Get => "GET", + WorkspaceRequestMethod::Post => "POST", + WorkspaceRequestMethod::Put => "PUT", + WorkspaceRequestMethod::Patch => "PATCH", + WorkspaceRequestMethod::Delete => "DELETE", + }; + self.requests + .lock() + .unwrap() + .push((method.into(), path.clone())); + let mut request = runtime_source_request( + &self.identity, + Some(&self.worker.worker_id), + method, + &path, + bytes, + ); + if method == "PUT" { + request + .headers_mut() + .insert(CONTENT_TYPE, "application/octet-stream".parse().unwrap()); + } + // DriveBackend invokes sync WorkspaceClient methods on spawn_blocking. + self.handle.block_on(async { + let response = build_router(self.api.clone()) + .oneshot(request) + .await + .map_err(|e| WorkspaceClientError::Request(e.to_string()))?; + let status = response.status().as_u16(); + let body = to_bytes(response.into_body(), limit) + .await + .map_err(|e| WorkspaceClientError::Request(e.to_string()))? + .to_vec(); + if method == "POST" + && path.ends_with("/mutate") + && (200..300).contains(&status) + && self + .lose_next_mutation_response + .swap(false, Ordering::SeqCst) + { + return Err(WorkspaceClientError::Request( + "response lost after commit".into(), + )); + } + if method == "PUT" + && path.contains("/upload?") + && (200..300).contains(&status) + && self.lose_next_upload_response.swap(false, Ordering::SeqCst) + { + return Err(WorkspaceClientError::Request( + "upload reply lost after commit".into(), + )); + } + Ok(WorkspaceBinaryResponse { status, body }) + }) + } +} +impl WorkspaceClient for RouterClient { + fn workspace_id(&self) -> Option<&str> { + Some(TEST_WORKSPACE_ID) + } + fn kind(&self) -> &str { + "signed-in-process" + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + let response = self.request( + request.method, + request.path, + request.body.unwrap_or_default().into_bytes(), + 2 * 1024 * 1024, + )?; + Ok(WorkspaceResponse { + status: response.status, + body: String::from_utf8(response.body) + .map_err(|e| WorkspaceClientError::Request(e.to_string()))?, + }) + } + fn execute_binary( + &self, + request: WorkspaceBinaryRequest, + ) -> Result { + self.request( + request.method, + request.path, + request.body.unwrap_or_default(), + request.max_response_bytes, + ) + } +} + +struct Fixture { + api: WorkspaceApi, + owner: String, + clients: Vec>, + features: Vec, + grants: Vec, + _temp: tempfile::TempDir, +} +impl Fixture { + async fn new(access: [&str; 2]) -> Self { + let temp = tempfile::tempdir().unwrap(); + let mut config = test_server_config(temp.path()); + config.repositories.clear(); + let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap()); + let mut api = WorkspaceApi::new_with_execution_backend( + config, + store, + Arc::new(DeterministicExecutionBackend::default()), + ) + .await + .unwrap(); + let owner = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); + register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await; + api.runtime + .register_or_replace(WorkdirlessFixtureRuntime::default()); + let mut workers = Vec::new(); + for name in ["Drive first", "Drive second"] { + let Json(created) = create_workspace_worker( + State(api.clone()), + HeaderMap::new(), + Json(CreateWorkspaceWorkerRequest { + runtime_id: WorkdirlessFixtureRuntime::RUNTIME_ID.into(), + display_name: name.into(), + singleton_key: None, + profile: Some("builtin:coder".into()), + ticket_assignment: None, + initial_submit: vec![], + workdir_attachments: vec![], + feature_connections: Default::default(), + control_operation_id: None, + }), + ) + .await + .unwrap(); + workers.push(RuntimeWorkerRef::new(created.runtime_id, created.worker_id)); + } + let identity = Arc::new(RuntimeIdentityMaterial::generate(&workers[0].runtime_id).unwrap()); + configure_runtime_request_auth(&mut api, &identity, &workers[0].runtime_id); + let mut grants = Vec::new(); + for (worker, access) in workers.iter().zip(access) { + assert!( + api.store + .list_worker_workdir_links(TEST_WORKSPACE_ID, worker) + .unwrap() + .is_empty() + ); + let grant = owner_call(&api, &owner, "POST", &format!("{}/grants", base()), + Some(json!({"runtime_id":worker.runtime_id,"worker_id":worker.worker_id,"access":access}))).await; + grants.push(grant["grant_id"].as_str().unwrap().into()); + } + assert!(!temp.path().join(".git").exists()); + let clients: Vec<_> = workers + .into_iter() + .map(|worker| { + Arc::new(RouterClient { + api: api.clone(), + identity: identity.clone(), + worker, + handle: tokio::runtime::Handle::current(), + lose_next_mutation_response: AtomicBool::new(false), + lose_next_upload_response: AtomicBool::new(false), + requests: Mutex::new(vec![]), + }) + }) + .collect(); + let features = clients + .iter() + .map(|client| { + DriveFeature::new(client.clone(), Arc::new(WorkdirSessionRouter::default())) + }) + .collect(); + Self { + api, + owner, + clients, + features, + grants, + _temp: temp, + } + } + async fn root(&self, worker: usize) -> Value { + value( + tool(&self.features[worker], "DriveRoot", json!({})) + .await + .unwrap(), + )["metadata"]["entry"] + .clone() + } + async fn create(&self, name: &str, content: &str) -> Value { + let root = self.root(0).await; + value( + tool( + &self.features[0], + "DriveCreateText", + json!({"parent":root,"name":name,"content":content}), + ) + .await + .unwrap(), + ) + } + fn importer(&self, worker: usize) -> DriveFeature { + let root = self._temp.path().join("import-source"); + fs::create_dir_all(root.join("public")).unwrap(); + fs::write(root.join("secret.bin"), b"not readable").unwrap(); + let scope = manifest::Scope::from_config(&manifest::ScopeConfig { + allow: vec![manifest::ScopeRule { + target: root.join("public"), + permission: manifest::Permission::Read, + recursive: true, + symlink_policy: manifest::SymlinkPolicy::Resolved, + }], + deny: vec![], + }) + .unwrap(); + let session = workdir::LocalWorkdirSession::materialized( + root.clone(), + root, + manifest::SharedScope::new(scope), + workdir::WorkdirSessionCapabilities::READ_ONLY, + ); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach( + workdir::WorkdirAttachmentAlias::new("source").unwrap(), + Arc::new(session), + ) + .unwrap(); + DriveFeature::new(self.clients[worker].clone(), router) + } + async fn revoke(&self, worker: usize) { + owner_call( + &self.api, + &self.owner, + "DELETE", + &format!("{}/grants/{}", base(), self.grants[worker]), + None, + ) + .await; + } +} +fn base() -> String { + format!("/api/w/{TEST_WORKSPACE_ID}/drive") +} +async fn owner_call( + api: &WorkspaceApi, + token: &str, + method: &str, + path: &str, + body: Option, +) -> Value { + let response = build_router(api.clone()) + .oneshot( + Request::builder() + .method(method) + .uri(path) + .header("authorization", format!("Bearer {token}")) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(body.map(|v| v.to_string()).unwrap_or_default())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 2 * 1024 * 1024) + .await + .unwrap(); + let body: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(status, StatusCode::OK, "{body}"); + body +} +async fn tool(feature: &DriveFeature, name: &str, args: Value) -> Result { + let definition = feature + .tools() + .into_iter() + .find(|(n, _)| *n == name) + .unwrap() + .1; + let (_, tool) = definition(); + tool.execute( + &args.to_string(), + ToolExecutionContext::new(uuid::Uuid::now_v7().to_string(), "drive-test", 0), + ) + .await +} +fn value(output: ToolOutput) -> Value { + serde_json::from_str(output.content.as_deref().unwrap()).unwrap() +} +fn entry(created: &Value) -> Value { + created["entry"]["metadata"]["entry"].clone() +} +fn denied(result: Result) { + let error = result.unwrap_err(); + assert!(error.to_string().contains("denied"), "{error:?}"); +} +fn conflict(result: Result) { + assert!( + matches!(result, Err(ToolError::StructuredConflict { ref code, .. }) if code == "drive_conflict"), + "{result:?}" + ); +} + +#[tokio::test] +async fn workdirless_workers_share_markdown_through_current_read_and_write_grants() { + let f = Fixture::new(["read_write", "read_only"]).await; + let created = f.create("日本語 %.md", "# Shared\n").await; + let id = entry(&created); + assert_eq!( + created["entry"]["metadata"]["content_type"], + "text/markdown" + ); + assert!( + created["entry"]["path"] + .as_str() + .unwrap() + .contains(id["node_id"].as_str().unwrap()) + ); + assert_eq!( + value( + tool(&f.features[1], "DriveRead", json!({"entry":id})) + .await + .unwrap() + )["text"], + "# Shared\n" + ); + denied( + tool( + &f.features[1], + "DriveWrite", + json!({"entry":id,"content":"forbidden"}), + ) + .await, + ); + tool(&f.features[0], "DriveRead", json!({"entry":id})) + .await + .unwrap(); + let updated = value( + tool( + &f.features[0], + "DriveWrite", + json!({"entry":id,"content":"# Updated\n"}), + ) + .await + .unwrap(), + ); + assert_ne!( + updated["entry"]["metadata"]["revision"], + created["entry"]["metadata"]["revision"] + ); + assert_eq!( + value( + tool(&f.features[1], "DriveRead", json!({"entry":id})) + .await + .unwrap() + )["text"], + "# Updated\n" + ); + let metadata = value( + tool(&f.features[1], "DriveMetadata", json!({"entry":id})) + .await + .unwrap(), + ); + assert_eq!( + metadata, updated["entry"], + "re-read proves durable mutation" + ); +} + +#[tokio::test] +async fn simultaneous_worker_edits_publish_exactly_one_revision_and_preserve_the_loser_conflict() { + let f = Fixture::new(["read_write", "read_write"]).await; + let id = entry(&f.create("simultaneous.md", "original").await); + for feature in &f.features { + tool(feature, "DriveRead", json!({"entry":id})) + .await + .unwrap(); + } + let (first, second) = tokio::join!( + tool( + &f.features[0], + "DriveEdit", + json!({"entry":id,"old_string":"original","new_string":"first"}) + ), + tool( + &f.features[1], + "DriveEdit", + json!({"entry":id,"old_string":"original","new_string":"second"}) + ), + ); + let winner = match (first, second) { + (Ok(_output), other) => { + conflict(other); + "first" + } + (other, Ok(_output)) => { + conflict(other); + "second" + } + (first, second) => panic!("expected one DB winner and one conflict: {first:?}, {second:?}"), + }; + let observed = value( + tool(&f.features[0], "DriveRead", json!({"entry":id})) + .await + .unwrap(), + ); + assert_eq!(observed["text"], winner); +} + +#[tokio::test] +async fn stale_worker_observations_cannot_write_edit_relocate_or_delete_newer_revision() { + let f = Fixture::new(["read_write", "read_write"]).await; + let id = entry(&f.create("cas.md", "old").await); + let root = f.root(0).await; + for worker in &f.features { + tool(worker, "DriveRead", json!({"entry":id})) + .await + .unwrap(); + } + tool( + &f.features[0], + "DriveWrite", + json!({"entry":id,"content":"new"}), + ) + .await + .unwrap(); + for (name, args) in [ + ("DriveWrite", json!({"entry":id,"content":"lost"})), + ( + "DriveEdit", + json!({"entry":id,"old_string":"new","new_string":"lost"}), + ), + ( + "DriveRelocate", + json!({"entry":id,"parent":root,"name":"lost.md"}), + ), + ("DriveDelete", json!({"entry":id})), + ] { + conflict(tool(&f.features[1], name, args).await); + } + let current = value( + tool(&f.features[1], "DriveRead", json!({"entry":id})) + .await + .unwrap(), + ); + assert_eq!(current["text"], "new"); + assert_eq!(current["entry"]["metadata"]["name"], "cas.md"); + tool( + &f.features[1], + "DriveWrite", + json!({"entry":id,"content":"observed"}), + ) + .await + .unwrap(); + assert_eq!( + value( + tool(&f.features[0], "DriveRead", json!({"entry":id})) + .await + .unwrap() + )["text"], + "observed" + ); +} + +#[tokio::test] +async fn edit_requires_unique_old_string_unless_replace_all_is_explicit() { + let f = Fixture::new(["read_write", "read_only"]).await; + let id = entry(&f.create("edit.md", "same same").await); + tool(&f.features[0], "DriveRead", json!({"entry":id})) + .await + .unwrap(); + for old in ["same", "absent", ""] { + let result = tool( + &f.features[0], + "DriveEdit", + json!({"entry":id,"old_string":old,"new_string":"new"}), + ) + .await; + assert!( + matches!(result, Err(ToolError::InvalidArgument(_))), + "{result:?}" + ); + } + tool( + &f.features[0], + "DriveEdit", + json!({"entry":id,"old_string":"same","new_string":"new","replace_all":true}), + ) + .await + .unwrap(); + assert_eq!( + value( + tool(&f.features[1], "DriveRead", json!({"entry":id})) + .await + .unwrap() + )["text"], + "new new" + ); +} + +#[tokio::test] +async fn revocation_invalidates_cached_metadata_reads_writes_and_receipt_queries() { + let f = Fixture::new(["read_write", "read_only"]).await; + let created = f.create("revoke.md", "keep").await; + let id = entry(&created); + tool(&f.features[0], "DriveRead", json!({"entry":id})) + .await + .unwrap(); + f.revoke(0).await; + for (name, args) in [ + ("DriveRoot", json!({})), + ("DriveMetadata", json!({"entry":id})), + ("DriveRead", json!({"entry":id})), + ("DriveWrite", json!({"entry":id,"content":"lost"})), + ("DriveDelete", json!({"entry":id})), + ("DriveList", json!({})), + ("DriveSearch", json!({"query":"revoke"})), + ( + "DriveRequestStatus", + json!({"request_id":created["request_id"]}), + ), + ] { + denied(tool(&f.features[0], name, args).await); + } + assert_eq!( + value( + tool(&f.features[1], "DriveRead", json!({"entry":id})) + .await + .unwrap() + )["text"], + "keep" + ); +} + +#[tokio::test] +async fn foreign_and_malformed_tool_refs_never_resolve_same_number_in_current_workspace() { + let f = Fixture::new(["read_write", "read_only"]).await; + let id = entry(&f.create("safe.md", "safe").await); + let foreign = json!({"workspace_id":"foreign","node_id":id["node_id"]}); + for name in [ + "DriveRead", + "DriveMetadata", + "DriveDelete", + "DriveViewImage", + ] { + denied(tool(&f.features[0], name, json!({"entry":foreign})).await); + } + for node in [ + json!(1), + json!("01"), + json!("0"), + json!("../1"), + json!("9223372036854775808"), + ] { + let result = tool( + &f.features[0], + "DriveRead", + json!({"entry":{"workspace_id":TEST_WORKSPACE_ID,"node_id":node}}), + ) + .await; + assert!( + matches!(result, Err(ToolError::InvalidArgument(_))), + "{result:?}" + ); + } + assert_eq!( + value( + tool(&f.features[1], "DriveRead", json!({"entry":id})) + .await + .unwrap() + )["text"], + "safe" + ); +} + +#[tokio::test] +async fn relocate_keeps_stable_identity_and_recreation_never_revives_deleted_reference() { + let f = Fixture::new(["read_write", "read_write"]).await; + let created = f.create("before.md", "content").await; + let id = entry(&created); + let root = f.root(0).await; + tool(&f.features[0], "DriveMetadata", json!({"entry":id})) + .await + .unwrap(); + let renamed = value( + tool( + &f.features[0], + "DriveRelocate", + json!({"entry":id,"parent":root,"name":"after.md"}), + ) + .await + .unwrap(), + ); + assert_eq!(renamed["entry"]["path"], created["entry"]["path"]); + assert_eq!(entry(&renamed), id); + for feature in &f.features { + tool(feature, "DriveMetadata", json!({"entry":id})) + .await + .unwrap(); + } + tool(&f.features[0], "DriveDelete", json!({"entry":id})) + .await + .unwrap(); + let recreated = f.create("after.md", "replacement").await; + assert_ne!(entry(&recreated), id); + for (name, args) in [ + ("DriveRead", json!({"entry":id})), + ("DriveWrite", json!({"entry":id,"content":"lost"})), + ("DriveDelete", json!({"entry":id})), + ] { + let result = tool(&f.features[1], name, args).await; + assert!(result.unwrap_err().to_string().contains("not found")); + } + assert_eq!( + value( + tool( + &f.features[1], + "DriveRead", + json!({"entry":entry(&recreated)}) + ) + .await + .unwrap() + )["text"], + "replacement" + ); +} + +#[tokio::test] +async fn unsafe_names_and_oversized_text_cannot_publish_and_reads_truncate_at_utf8_boundary() { + let f = Fixture::new(["read_write", "read_only"]).await; + let root = f.root(0).await; + for name in ["", ".", "..", "../escape", "a/b", "a\\b", "bad\nname"] { + let result = tool( + &f.features[0], + "DriveCreateText", + json!({"parent":root,"name":name,"content":"bad"}), + ) + .await; + assert!( + matches!(result, Err(ToolError::InvalidArgument(_))), + "name={name:?}: {result:?}" + ); + } + let result = tool(&f.features[0], "DriveCreateText", json!({"parent":root,"name":"large.md","content":"a".repeat(server_api::DRIVE_TEXT_MAX_BYTES + 1)})).await; + assert!( + matches!(result, Err(ToolError::InvalidArgument(_))), + "{result:?}" + ); + assert!( + value(tool(&f.features[0], "DriveList", json!({})).await.unwrap())["entries"] + .as_array() + .unwrap() + .is_empty() + ); + let id = entry(&f.create("bounded.md", "ééé").await); + let read = value( + tool( + &f.features[1], + "DriveRead", + json!({"entry":id,"max_bytes":3}), + ) + .await + .unwrap(), + ); + assert_eq!(read["text"], "é"); + assert_eq!(read["truncated"], true); + for limit in [0, 65537] { + let result = tool( + &f.features[1], + "DriveRead", + json!({"entry":id,"max_bytes":limit}), + ) + .await; + assert!( + matches!(result, Err(ToolError::InvalidArgument(_))), + "{result:?}" + ); + } +} + +#[tokio::test] +async fn omitted_discovery_limits_use_backend_maximum_pages_in_tools_and_native_wip() { + let f = Fixture::new(["read_write", "read_only"]).await; + let mut expected = std::collections::BTreeSet::new(); + for index in 0..201 { + let created = f + .create(&format!("default-page-{index:03}.md"), "bounded") + .await; + expected.insert(entry(&created)["node_id"].as_str().unwrap().to_owned()); + } + let native = native_tools(&f.features[1]); + let root = value( + native_tool(&native, "Inspect", json!({"path":"/drive","refresh":true})) + .await + .unwrap(), + ); + let reference = root["interfaces"][0]["reference"].clone(); + for use_native in [false, true] { + for (name, operation, mut args, maximum) in [ + ( + "DriveList", + "list", + json!({}), + server_api::DRIVE_PAGE_MAX_LIMIT as usize, + ), + ( + "DriveSearch", + "search", + json!({"query":"default-page-"}), + server_api::DRIVE_SEARCH_MAX_LIMIT as usize, + ), + ] { + let mut actual = Vec::new(); + for page_index in 0..2 { + let output = if use_native { + native_tool(&native, "Invoke", json!({"path":"/drive","interface":reference,"operation":operation,"arguments":args})).await + } else { + tool(&f.features[1], name, args.clone()).await + }; + let page = value(output.unwrap()); + let entries = page["entries"].as_array().unwrap(); + assert_eq!( + entries.len(), + if page_index == 0 { + maximum + } else { + 201 - maximum + }, + "native={use_native} operation={operation}" + ); + actual.extend(entries.iter().map(|e| { + e["metadata"]["entry"]["node_id"] + .as_str() + .unwrap() + .to_owned() + })); + if page_index == 0 { + assert!(page["next_after"].is_string()); + args["after"] = page["next_after"].clone(); + } else { + assert!(page["next_after"].is_null()); + } + } + assert_eq!(actual.len(), expected.len()); + assert_eq!( + actual + .into_iter() + .collect::>(), + expected + ); + } + } +} + +#[tokio::test] +async fn list_and_search_pages_are_bounded_disjoint_and_cursors_follow_backend_scope() { + let f = Fixture::new(["read_write", "read_only"]).await; + let mut expected = Vec::new(); + for name in ["page-a.md", "page-b.md", "page-c.md"] { + expected.push(entry(&f.create(name, "needle").await)); + } + for (name, mut args) in [ + ("DriveList", json!({"limit":2})), + ("DriveSearch", json!({"query":"page-","limit":2})), + ] { + let first = value(tool(&f.features[1], name, args.clone()).await.unwrap()); + assert_eq!(first["entries"].as_array().unwrap().len(), 2); + assert!(first["next_after"].is_string()); + args["after"] = first["next_after"].clone(); + let second = value(tool(&f.features[1], name, args).await.unwrap()); + assert_eq!(second["entries"].as_array().unwrap().len(), 1); + assert!(second["next_after"].is_null()); + let actual: Vec<_> = first["entries"] + .as_array() + .unwrap() + .iter() + .chain(second["entries"].as_array().unwrap()) + .map(|e| e["metadata"]["entry"].clone()) + .collect(); + assert_eq!(actual.len(), expected.len()); + for id in &expected { + assert_eq!(actual.iter().filter(|e| *e == id).count(), 1); + } + if name == "DriveSearch" { + let result = tool( + &f.features[1], + name, + json!({"query":"different","limit":2,"after":first["next_after"]}), + ) + .await; + // T-722 cursors bind Workspace/node position, not query text. A + // new query at the same position is valid and cannot widen authority. + assert!( + value(result.unwrap())["entries"] + .as_array() + .unwrap() + .is_empty() + ); + } + } + assert!( + value( + tool(&f.features[1], "DriveSearch", json!({"query":"needle"})) + .await + .unwrap() + )["entries"] + .as_array() + .unwrap() + .is_empty() + ); + assert_eq!( + value( + tool( + &f.features[1], + "DriveSearch", + json!({"query":"needle","include_text":true}) + ) + .await + .unwrap() + )["entries"] + .as_array() + .unwrap() + .len(), + 3 + ); + for (name, args) in [ + ("DriveList", json!({"limit":201})), + ("DriveSearch", json!({"query":"page","limit":129})), + ] { + let result = tool(&f.features[1], name, args).await; + assert!( + matches!(result, Err(ToolError::InvalidArgument(_))), + "{result:?}" + ); + } +} + +#[tokio::test] +async fn lost_mutation_response_is_resolved_by_receipt_without_resending_effect() { + let f = Fixture::new(["read_write", "read_only"]).await; + f.clients[0] + .lose_next_mutation_response + .store(true, Ordering::SeqCst); + let created = f.create("receipt.md", "once").await; + let status = value( + tool( + &f.features[0], + "DriveRequestStatus", + json!({"request_id":created["request_id"]}), + ) + .await + .unwrap(), + ); + assert_eq!(status["state"], "committed"); + assert_eq!(status["response"]["entry"]["entry"], entry(&created)); + assert_eq!( + f.clients[0] + .requests + .lock() + .unwrap() + .iter() + .filter(|(m, p)| m == "POST" && p.ends_with("/mutate")) + .count(), + 1 + ); + let listed = value(tool(&f.features[1], "DriveList", json!({})).await.unwrap()); + assert_eq!(listed["entries"].as_array().unwrap().len(), 1); + assert_eq!( + listed["entries"][0]["metadata"]["revision"], + created["entry"]["metadata"]["revision"] + ); +} + +#[tokio::test] +async fn scoped_workdir_import_publishes_document_and_image_bytes_with_bounded_reads() { + let f = Fixture::new(["read_write", "read_only"]).await; + let importer = f.importer(0); + let root = f.root(0).await; + // A binary document larger than one chunk exercises the real bytes route, + // not the text API or a UTF-8-only source fallback. + let document: Vec = (0..70_003).map(|n| (n % 256) as u8).collect(); + let image = include_bytes!("../../../../tools/tests/fixtures/view-image/valid.png").to_vec(); + for (name, media, bytes) in [ + ("document.bin", "application/octet-stream", document), + ("image.png", "image/png", image), + ] { + fs::write( + f._temp.path().join("import-source/public").join(name), + &bytes, + ) + .unwrap(); + let saved = value(tool(&importer, "DriveSaveWorkdir", json!({"target_workdir":"source","path":format!("public/{name}"),"parent":root,"name":name,"content_type":media})).await.unwrap()); + let id = entry(&saved); + let metadata = value( + tool(&f.features[1], "DriveMetadata", json!({"entry":id})) + .await + .unwrap(), + ); + assert_eq!( + metadata, saved["entry"], + "re-read through independent Worker must see persisted entry" + ); + assert_eq!(metadata["metadata"]["size"], bytes.len()); + assert_eq!(metadata["metadata"]["content_type"], media); + let query = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("entry_workspace_id", TEST_WORKSPACE_ID) + .append_pair("id", id["node_id"].as_str().unwrap()) + .append_pair( + "expected_revision", + metadata["metadata"]["revision"].as_str().unwrap(), + ) + .append_pair("offset", "0") + .append_pair("length", "17") + .finish(); + let client = f.clients[1].clone(); + let response = tokio::task::spawn_blocking(move || { + client.execute_binary(WorkspaceBinaryRequest { + method: WorkspaceRequestMethod::Get, + path: format!("{}/read-chunk?{query}", base()), + body: None, + max_response_bytes: 17, + }) + }) + .await + .unwrap() + .unwrap(); + assert_eq!(response.status, 200); + assert_eq!(response.body, bytes[..17.min(bytes.len())]); + // Full download independently verifies the persisted blob and digest. + let path = format!( + "{}/download?entry_workspace_id={TEST_WORKSPACE_ID}&id={}", + base(), + id["node_id"].as_str().unwrap() + ); + let response = build_router(f.api.clone()) + .oneshot( + Request::builder() + .uri(path) + .header("authorization", format!("Bearer {}", f.owner)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + to_bytes( + response.into_body(), + server_api::DRIVE_FILE_MAX_BYTES as usize + ) + .await + .unwrap() + .as_ref(), + bytes + ); + if media == "image/png" { + let output = tool(&f.features[1], "DriveViewImage", json!({"entry":id})) + .await + .unwrap(); + let [Attachment::Image(image)] = output.attachments.as_slice() else { + panic!("imported image attachment required"); + }; + assert_eq!(image.data(), bytes); + } + } + assert_eq!( + value(tool(&f.features[1], "DriveList", json!({})).await.unwrap())["entries"] + .as_array() + .unwrap() + .len(), + 2 + ); +} + +#[tokio::test] +async fn workdir_import_rejects_unknown_alias_and_unreadable_or_nonlogical_source_before_upload() { + let f = Fixture::new(["read_write", "read_only"]).await; + let importer = f.importer(0); + let root = f.root(0).await; + let source = f._temp.path().join("import-source"); + fs::write(source.join("public/allowed.bin"), b"allowed").unwrap(); + fs::write( + source.join("public/oversized.bin"), + vec![0; server_api::DRIVE_FILE_MAX_BYTES as usize + 1], + ) + .unwrap(); + for (alias, path) in [ + ("foreign", "public/allowed.bin".to_string()), + ("unknown", "public/allowed.bin".to_string()), + ("source", "secret.bin".to_string()), + ("source", "public/missing.bin".to_string()), + ("source", "../secret.bin".to_string()), + ( + "source", + source.join("public/allowed.bin").display().to_string(), + ), + ("source", "public/oversized.bin".to_string()), + ] { + let result = tool(&importer, "DriveSaveWorkdir", json!({"target_workdir":alias,"path":path,"parent":root,"name":"rejected.bin","content_type":"application/octet-stream"})).await; + assert!( + matches!( + result, + Err(ToolError::ExecutionFailed(_)) | Err(ToolError::InvalidArgument(_)) + ), + "alias={alias}, path={path}: {result:?}" + ); + } + assert!( + !f.clients[0] + .requests + .lock() + .unwrap() + .iter() + .any(|(method, _)| method == "PUT"), + "denied source must never reach upload" + ); + assert!( + value(tool(&f.features[1], "DriveList", json!({})).await.unwrap())["entries"] + .as_array() + .unwrap() + .is_empty() + ); +} + +#[tokio::test] +async fn workdir_source_read_permission_does_not_grant_drive_destination_write() { + let f = Fixture::new(["read_write", "read_only"]).await; + let readonly_importer = f.importer(1); + let writer_importer = f.importer(0); + fs::write( + f._temp.path().join("import-source/public/source.bin"), + b"readable source", + ) + .unwrap(); + let root = f.root(0).await; + denied(tool(&readonly_importer, "DriveSaveWorkdir", json!({"target_workdir":"source","path":"public/source.bin","parent":root,"name":"denied.bin","content_type":"application/octet-stream"})).await); + let foreign = json!({"workspace_id":"foreign","node_id":root["node_id"]}); + denied(tool(&writer_importer, "DriveSaveWorkdir", json!({"target_workdir":"source","path":"public/source.bin","parent":foreign,"name":"foreign.bin","content_type":"application/octet-stream"})).await); + f.revoke(0).await; + denied(tool(&writer_importer, "DriveSaveWorkdir", json!({"target_workdir":"source","path":"public/source.bin","parent":root,"name":"revoked.bin","content_type":"application/octet-stream"})).await); + assert!( + value(tool(&f.features[1], "DriveList", json!({})).await.unwrap())["entries"] + .as_array() + .unwrap() + .is_empty() + ); +} + +#[tokio::test] +async fn lost_binary_upload_reply_reconciles_same_request_without_duplicate_publication() { + let f = Fixture::new(["read_write", "read_only"]).await; + let importer = f.importer(0); + let bytes = b"\x00\xffbinary receipt"; + fs::write( + f._temp.path().join("import-source/public/receipt.bin"), + bytes, + ) + .unwrap(); + let root = f.root(0).await; + f.clients[0] + .lose_next_upload_response + .store(true, Ordering::SeqCst); + let saved = value(tool(&importer, "DriveSaveWorkdir", json!({"target_workdir":"source","path":"public/receipt.bin","parent":root,"name":"receipt.bin","content_type":"application/octet-stream"})).await.unwrap()); + let request_id = saved["request_id"].as_str().unwrap(); + let status = value( + tool( + &importer, + "DriveRequestStatus", + json!({"request_id":request_id}), + ) + .await + .unwrap(), + ); + assert_eq!(status["state"], "committed"); + assert_eq!(status["response"]["entry"]["entry"], entry(&saved)); + let requests = f.clients[0].requests.lock().unwrap().clone(); + let uploads: Vec<_> = requests + .iter() + .filter(|(method, path)| method == "PUT" && path.contains("/upload?")) + .collect(); + assert_eq!( + uploads.len(), + 1, + "upload must not be resent after unknown transport outcome" + ); + assert!(uploads[0].1.contains(&format!("request_id={request_id}"))); + assert!( + requests + .iter() + .any(|(method, path)| method == "GET" + && path.ends_with(&format!("/requests/{request_id}"))), + "receipt lookup must retain original upload request identity" + ); + let listed = value(tool(&f.features[1], "DriveList", json!({})).await.unwrap()); + assert_eq!(listed["entries"].as_array().unwrap().len(), 1); + assert_eq!(listed["entries"][0], saved["entry"]); +} + +#[derive(Clone)] +struct NoLlm; +#[async_trait::async_trait] +impl agen::llm_client::LlmClient for NoLlm { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + async fn stream( + &self, + _: agen::llm_client::Request, + ) -> Result { + panic!("native tool fixture must not call an LLM") + } +} +fn native_tools( + feature: &DriveFeature, +) -> std::collections::BTreeMap> { + let mut mounts = worker::wip::WipMountRegistry::new(); + worker::feature::builtin::drive::wip::mount_drive_wip(&mut mounts, feature).unwrap(); + let mut engine = agen::Engine::<_, agen::state::Mutable, ()>::new_annotated(NoLlm); + engine.register_tools( + feature + .tools() + .into_iter() + .map(|(_, definition)| definition), + ); + worker::wip::install_wip_mode_with_mounts(&mut engine, None, "drive-reader".into(), mounts) + .unwrap(); + let handle = engine.tool_server_handle(); + handle.flush_pending(); + assert!( + handle.get_tool("DriveRead").is_none(), + "native installation must replace compatibility Drive tools" + ); + ["Tree", "Inspect", "Invoke"] + .into_iter() + .map(|name| (name.into(), handle.get_tool(name).unwrap().1)) + .collect() +} +async fn native_tool( + tools: &std::collections::BTreeMap>, + name: &str, + args: Value, +) -> Result { + tools[name] + .execute( + &args.to_string(), + ToolExecutionContext::new(uuid::Uuid::now_v7().to_string(), "native-drive-test", 0), + ) + .await +} + +#[tokio::test] +async fn native_tree_does_not_index_entries_but_direct_inspect_and_invoke_use_current_grants() { + let f = Fixture::new(["read_write", "read_only"]).await; + let created = f.create("native.md", "native content").await; + let path = created["entry"]["path"].as_str().unwrap().to_string(); + let tools = native_tools(&f.features[1]); + let tree = value( + native_tool( + &tools, + "Tree", + json!({"path":"/drive","depth":8,"refresh":true}), + ) + .await + .unwrap(), + ); + assert!( + !tree.to_string().contains(&path), + "Drive entries are non-indexable: {tree}" + ); + let inspected = value( + native_tool(&tools, "Inspect", json!({"path":path,"refresh":true})) + .await + .unwrap(), + ); + assert!( + inspected["object_signature"] + .as_str() + .unwrap() + .contains("native.md") + ); + let reference = inspected["interfaces"][0]["reference"].clone(); + let read = value( + native_tool( + &tools, + "Invoke", + json!({"path":path,"interface":reference,"operation":"read","arguments":{}}), + ) + .await + .unwrap(), + ); + assert_eq!(read["text"], "native content"); + let denied_write = native_tool(&tools, "Invoke", json!({"path":path,"interface":reference,"operation":"write","arguments":{"content":"lost"}})).await; + denied(denied_write); + f.revoke(1).await; + assert!( + native_tool( + &tools, + "Invoke", + json!({"path":path,"interface":reference,"operation":"read","arguments":{}}) + ) + .await + .is_err(), + "cached native observation is not authority" + ); + assert!( + native_tool(&tools, "Inspect", json!({"path":path,"refresh":true})) + .await + .is_err(), + "revoked entry must not publish" + ); + assert_eq!( + value( + tool( + &f.features[0], + "DriveRead", + json!({"entry":entry(&created)}) + ) + .await + .unwrap() + )["text"], + "native content" + ); +} + +#[tokio::test] +async fn native_create_and_write_use_client_managed_revisions_and_reject_stale_worker() { + let f = Fixture::new(["read_write", "read_write"]).await; + let first = native_tools(&f.features[0]); + let second = native_tools(&f.features[1]); + let root = value( + native_tool(&first, "Inspect", json!({"path":"/drive","refresh":true})) + .await + .unwrap(), + ); + let created = value(native_tool(&first, "Invoke", json!({"path":"/drive","interface":root["interfaces"][0]["reference"],"operation":"create_text","arguments":{"name":"native-cas.md","content":"original"}})).await.unwrap()); + let path = created["entry"]["path"].as_str().unwrap(); + let observed_first = value( + native_tool(&first, "Inspect", json!({"path":path,"refresh":true})) + .await + .unwrap(), + ); + let observed_second = value( + native_tool(&second, "Inspect", json!({"path":path,"refresh":true})) + .await + .unwrap(), + ); + let updated = value(native_tool(&first, "Invoke", json!({"path":path,"interface":observed_first["interfaces"][0]["reference"],"operation":"write","arguments":{"content":"committed"}})).await.unwrap()); + assert_ne!( + updated["entry"]["metadata"]["revision"], + created["entry"]["metadata"]["revision"] + ); + let error = native_tool(&second, "Invoke", json!({"path":path,"interface":observed_second["interfaces"][0]["reference"],"operation":"write","arguments":{"content":"lost"}})).await.unwrap_err(); + assert!( + error.to_string().to_lowercase().contains("validator"), + "stale native mutation must report validator conflict: {error:?}" + ); + assert_eq!( + value( + tool( + &f.features[1], + "DriveRead", + json!({"entry":entry(&created)}) + ) + .await + .unwrap() + )["text"], + "committed" + ); +} + +#[tokio::test] +async fn image_attachment_retains_original_bytes_after_drive_update_and_revocation() { + use sha2::{Digest, Sha256}; + let f = Fixture::new(["read_write", "read_only"]).await; + let root = f.root(0).await; + let original = + include_bytes!("../../../../tools/tests/fixtures/view-image/valid.png").as_slice(); + let digest = |bytes: &[u8]| { + Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + }; + let upload = |operation: &str, + request: &str, + bytes: &[u8], + id: Option<&Value>, + revision: Option<&str>| { + let mut query = url::form_urlencoded::Serializer::new(String::new()); + query + .append_pair("operation", operation) + .append_pair("request_id", request) + .append_pair("entry_workspace_id", TEST_WORKSPACE_ID) + .append_pair("content_type", "image/png") + .append_pair("size", &bytes.len().to_string()) + .append_pair("sha256", &digest(bytes)); + if let Some(id) = id { + query + .append_pair("id", id["node_id"].as_str().unwrap()) + .append_pair("expected_revision", revision.unwrap()); + } else { + query + .append_pair("parent_id", root["node_id"].as_str().unwrap()) + .append_pair("name", "image.png"); + } + format!("{}/upload?{}", base(), query.finish()) + }; + async fn put(f: &Fixture, path: String, bytes: &[u8]) -> Value { + let response = build_router(f.api.clone()) + .oneshot( + Request::builder() + .method("PUT") + .uri(path) + .header("authorization", format!("Bearer {}", f.owner)) + .header(CONTENT_TYPE, "application/octet-stream") + .body(Body::from(bytes.to_vec())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + assert_eq!( + status, + StatusCode::OK, + "{}", + String::from_utf8_lossy(&bytes) + ); + serde_json::from_slice(&bytes).unwrap() + } + let saved = put( + &f, + upload("create", "image-create", original, None, None), + original, + ) + .await; + let id = saved["entry"]["entry"].clone(); + let output = tool(&f.features[1], "DriveViewImage", json!({"entry":id})) + .await + .unwrap(); + let [Attachment::Image(image)] = output.attachments.as_slice() else { + panic!("image attachment required"); + }; + assert_eq!(image.mime_type(), "image/png"); + assert_eq!(image.data(), original); + let replacement = + include_bytes!("../../../../tools/tests/fixtures/view-image/valid.gif").as_slice(); + put( + &f, + upload( + "update", + "image-update", + replacement, + Some(&id), + saved["entry"]["revision"].as_str(), + ), + replacement, + ) + .await; + let new = tool(&f.features[1], "DriveViewImage", json!({"entry":id})) + .await + .unwrap(); + let [Attachment::Image(new_image)] = new.attachments.as_slice() else { + panic!("image attachment required"); + }; + assert_eq!(new_image.data(), replacement); + f.revoke(1).await; + denied(tool(&f.features[1], "DriveViewImage", json!({"entry":id})).await); + assert_eq!( + image.data(), + original, + "history attachment must not fetch latest Drive content" + ); +} diff --git a/crates/workspace-server/src/server_workspace_config_wip_tests.rs b/crates/workspace-server/src/server_workspace_config_wip_tests.rs index c0df1d1a5..b14affe7c 100644 --- a/crates/workspace-server/src/server_workspace_config_wip_tests.rs +++ b/crates/workspace-server/src/server_workspace_config_wip_tests.rs @@ -139,26 +139,37 @@ impl worker::WorkspaceClient for ConfigRouterClient { Ok(response) } } -fn config_interface(path: &str) -> String { - // Use the published contextual interface identity, not a model-managed validator. - let hex = path - .bytes() - .map(|byte| format!("{byte:02x}")) - .collect::(); - format!("yoi.workspace-config/node/v1/@/{hex}") +fn contextual_reference(base: &str, path: &str) -> wip_protocol::InterfaceReference { + wip_protocol::InterfaceReference { + scope: path.into(), + name: base.into(), + } } -async fn config_discover(runtime: &worker::wip::WipRuntime, path: &str) { - let observed = runtime - .discover(path.into(), 0, true) - .await - .unwrap() - .content - .unwrap(); +fn config_interface(path: &str) -> wip_protocol::InterfaceReference { + // Use the published contextual interface identity, not a model-managed validator. + contextual_reference("yoi.workspace-config/node/v1", path) +} +async fn inspect_interface(runtime: &worker::wip::WipRuntime, path: &str, base: &str) -> Value { + let tree = runtime.tree(path.into(), 0, true).await.unwrap(); + let tree: Value = serde_json::from_str(tree.content.as_deref().unwrap()).unwrap(); + assert_eq!(tree["path"], path, "Object must come from live Tree: {tree}"); + let output = runtime.inspect(path.into(), true).await.unwrap(); + let observed: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); + assert_eq!(observed["path"], path); assert!( - observed.contains(&config_interface(path)), - "interface must come from live discovery: {observed}" + observed["interfaces"] + .as_array() + .unwrap() + .iter() + .any(|interface| { + interface["reference"] == json!({"scope": path, "name": base}) + }), + "interface must come from live inspection: {observed}" ); - runtime.inspect(config_interface(path), true).await.unwrap(); + observed +} +async fn config_inspect(runtime: &worker::wip::WipRuntime, path: &str) -> Value { + inspect_interface(runtime, path, "yoi.workspace-config/node/v1").await } async fn config_call( runtime: &worker::wip::WipRuntime, @@ -167,7 +178,7 @@ async fn config_call( args: Value, ) -> std::result::Result { runtime - .call( + .invoke( path.into(), config_interface(path), operation.into(), @@ -183,23 +194,11 @@ async fn catalog_call( base: &str, operation: &str, ) -> Value { - let hex = path - .bytes() - .map(|byte| format!("{byte:02x}")) - .collect::(); - let interface = format!("{base}/@/{hex}"); - let discovered = runtime - .discover(path.into(), 0, true) - .await - .unwrap() - .content - .unwrap(); - assert!(discovered.contains(&interface), "{discovered}"); - runtime.inspect(interface.clone(), true).await.unwrap(); + inspect_interface(runtime, path, base).await; let output = runtime - .call( + .invoke( path.into(), - interface, + contextual_reference(base, path), operation.into(), json!({}), agen::tool::ToolExecutionContext::direct(), @@ -378,7 +377,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im .connection_id, attached.connection_id ); - config_discover(&wip, CONTENT_ROOT).await; + config_inspect(&wip, CONTENT_ROOT).await; config_call( &wip, CONTENT_ROOT, @@ -388,15 +387,10 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im .await .unwrap(); let path = "/workspace-config/notes/wip.md"; - config_discover(&wip, path).await; - assert!( - config_call(&wip, path, "read", json!({})) - .await - .unwrap() - .content - .unwrap() - .contains("original note") - ); + config_inspect(&wip, path).await; + let read = config_call(&wip, path, "read", json!({})).await.unwrap(); + let read: Value = serde_json::from_str(read.content.as_deref().unwrap()).unwrap(); + assert_eq!(read["content"], "original note"); config_call( &wip, path, @@ -422,7 +416,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im config_call(&wip, path, "write", json!({"content":"replacement note"})) .await .unwrap(); - config_discover(&wip, CONTENT_ROOT).await; + config_inspect(&wip, CONTENT_ROOT).await; config_call(&wip,CONTENT_ROOT,"apply_changes",json!({"changes":[ {"kind":"create","path":"fragments/prompts.dcdl","content_type":"decodal","content":"{ common = { language = \"CONFIG_WIP_E2E\"; }; }"}, {"kind":"update","path":"main.dcdl","content":"{ prompts = import \"./fragments/prompts.dcdl\"; } as WorkspaceConfigSchema"} @@ -436,7 +430,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im .unwrap(); let prompts = crate::prompt_settings::project_prompts_from_workspace_config(&state).unwrap(); assert_eq!(prompts.templates["common.language"], "CONFIG_WIP_E2E"); - config_discover(&wip, path).await; + config_inspect(&wip, path).await; config_call(&wip, path, "delete", json!({})).await.unwrap(); assert!( fixture @@ -449,7 +443,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im .get(&config_source::VirtualPath::parse("notes/wip.md").unwrap()) .is_none() ); - config_discover(&wip, CONTENT_ROOT).await; + config_inspect(&wip, CONTENT_ROOT).await; let before = fixture .api .config_store @@ -492,7 +486,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im dispatched + 1, "a lost response cannot trigger automatic retry" ); - config_discover(&wip, CONTENT_ROOT).await; + config_inspect(&wip, CONTENT_ROOT).await; let readonly = workspace_config::attach( &fixture.api, &fixture.worker, @@ -633,26 +627,26 @@ async fn assert_config_production_http_adapter( ) .unwrap(); assert_config_catalog(&wip, &attached).await; - config_discover(&wip, "/workspace-config/main.dcdl").await; - let main_interface = wip - .inspect(config_interface("/workspace-config/main.dcdl"), true) - .await - .unwrap() - .content - .unwrap(); + let main_interface = config_inspect(&wip, "/workspace-config/main.dcdl").await; assert!( - !main_interface.contains("\"delete\""), + main_interface["interfaces"] + .as_array() + .unwrap() + .iter() + .all(|interface| !interface["signature"].as_str().unwrap().contains("delete(")), "required entrypoint cannot advertise delete: {main_interface}" ); + let main = config_call(&wip, "/workspace-config/main.dcdl", "read", json!({})) + .await + .unwrap(); + let main: Value = serde_json::from_str(main.content.as_deref().unwrap()).unwrap(); assert!( - config_call(&wip, "/workspace-config/main.dcdl", "read", json!({})) - .await - .unwrap() - .content + main["content"] + .as_str() .unwrap() .contains("WorkspaceConfigSchema") ); - config_discover(&wip, "/workspace-config").await; + config_inspect(&wip, "/workspace-config").await; config_call( &wip, "/workspace-config", diff --git a/crates/workspace-server/src/worker_source.rs b/crates/workspace-server/src/worker_source.rs index 5dcd62d50..7ce8040f8 100644 --- a/crates/workspace-server/src/worker_source.rs +++ b/crates/workspace-server/src/worker_source.rs @@ -3,120 +3,19 @@ use std::time::{SystemTime, UNIX_EPOCH}; use axum::http::HeaderMap; use worker_runtime::auth::{ - RuntimeRequestSourceExpectation, WorkerMutationActorKind, WorkerMutationOperation, - WorkerMutationSourceClaims, WorkerMutationSourceExpectation, - decode_runtime_request_source_claims, decode_worker_mutation_source_claims, - verify_runtime_request_source, verify_worker_mutation_source_proof, + WorkerMutationActorKind, WorkerMutationOperation, WorkerMutationSourceClaims, + WorkerMutationSourceExpectation, decode_worker_mutation_source_claims, + verify_worker_mutation_source_proof, }; use worker_runtime::worker_source::InProcessWorkerMutationProof; use crate::server::{ServerConfig, WorkspaceApi}; -use crate::store::ControlPlaneStore; -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct VerifiedRuntimeRequestSource { - pub runtime_id: String, - pub worker_id: Option, -} - -pub async fn verify_runtime_request_source_proof( - api: &WorkspaceApi, - proof: &str, - workspace_id: &str, - permission: &str, - method: &str, - path: &str, - body_digest: &str, -) -> Result { - verify_runtime_request_source_proof_with_store( - api.store.as_ref(), - &api.config, - proof, - workspace_id, - permission, - method, - path, - body_digest, - ) - .await -} - -#[allow(clippy::too_many_arguments)] -pub async fn verify_runtime_request_source_proof_with_store( - store: &dyn ControlPlaneStore, - config: &ServerConfig, - proof: &str, - workspace_id: &str, - permission: &str, - method: &str, - path: &str, - body_digest: &str, -) -> Result { - let unverified = decode_runtime_request_source_claims(proof) - .map_err(|_| WorkerMutationSourceProofError::Invalid)?; - let audience = remote_audience(config, workspace_id)?; - let trusted = store - .get_workspace_runtime_binding(workspace_id, &unverified.iss) - .await - .map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))? - .filter(|record| record.revoked_at.is_none()) - .ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?; - let public_key = trusted.public_key.as_str(); - let expected = RuntimeRequestSourceExpectation { - identity_id: &unverified.iss, - audience: audience.as_ref(), - workspace_id, - worker_id: unverified.worker_id.as_deref(), - permission, - method, - path, - body_digest, - now_unix: i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX), - }; - let claims = - verify_runtime_request_source(proof, public_key, &expected).map_err(map_auth_error)?; - let now_seconds = u64::try_from(expected.now_unix).unwrap_or(u64::MAX); - let expires_at = u64::try_from(claims.exp).unwrap_or(0); - let consumed_at = chrono::DateTime::from_timestamp(expected.now_unix, 0) - .ok_or(WorkerMutationSourceProofError::Expired)? - .to_rfc3339(); - if !store - .consume_worker_mutation_source_jti( - workspace_id, - &claims.iss, - &claims.jti, - expires_at, - now_seconds, - &consumed_at, - ) - .await - .map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))? - { - return Err(WorkerMutationSourceProofError::Replay); - } - if let Some(worker_id) = claims.worker_id.as_deref() { - let worker = worker_runtime::identity::RuntimeWorkerRef { - runtime_id: claims.iss.clone(), - worker_id: worker_id.to_owned(), - }; - let member = store - .get_worker_registry(workspace_id, &worker) - .map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?; - let reserved = store - .has_active_worker_create_reservation(workspace_id, &worker) - .map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?; - if member.is_none() && !reserved { - return Err(WorkerMutationSourceProofError::WorkerCatalogMembership); - } - store - .require_current_worker_singleton_owner(workspace_id, &worker) - .map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?; - } - Ok(VerifiedRuntimeRequestSource { - runtime_id: claims.iss, - worker_id: claims.worker_id, - }) -} +mod runtime_request; +pub use runtime_request::{ + RuntimeRequestProofError, VerifiedRuntimeRequestSource, diagnostic_id_hash, + verify_runtime_request_source_proof, verify_runtime_request_source_proof_with_store, +}; #[derive(Clone, Debug, PartialEq, Eq)] pub enum PresentedWorkerMutationSourceProof<'a> { diff --git a/crates/workspace-server/src/worker_source/runtime_request.rs b/crates/workspace-server/src/worker_source/runtime_request.rs new file mode 100644 index 000000000..00c00bd49 --- /dev/null +++ b/crates/workspace-server/src/worker_source/runtime_request.rs @@ -0,0 +1,248 @@ +use worker_runtime::auth::{ + RuntimeAuthError, RuntimeRequestClaim, RuntimeRequestSourceClaims, + RuntimeRequestSourceExpectation, decode_runtime_request_source_claims, + verify_runtime_request_source, +}; + +use super::{remote_audience, unix_now_seconds}; +use crate::server::{ServerConfig, WorkspaceApi}; +use crate::store::ControlPlaneStore; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VerifiedRuntimeRequestSource { + pub runtime_id: String, + pub worker_id: Option, +} + +/// Safe diagnostics only: never stores proof, raw claims, keys or store errors. +/// Verification is progress through proof checks, NOT an authenticated principal. +#[derive(Clone, Debug, thiserror::Error)] +#[error("invalid runtime request proof")] +pub struct RuntimeRequestProofError { + pub(crate) reason: &'static str, + pub(crate) verification: &'static str, + pub(crate) runtime_id_hash: Option, + pub(crate) worker_id_hash: Option, + pub(crate) token_id_hash: Option, + pub(crate) iat: Option, + pub(crate) exp: Option, + pub(crate) now: Option, + pub(crate) time_delta_seconds: Option, +} + +impl RuntimeRequestProofError { + fn undecoded() -> Self { + Self { + reason: "invalid", + verification: "unverified", + runtime_id_hash: None, + worker_id_hash: None, + token_id_hash: None, + iat: None, + exp: None, + now: None, + time_delta_seconds: None, + } + } + + fn decoded(claims: &RuntimeRequestSourceClaims) -> Self { + Self { + runtime_id_hash: Some(diagnostic_id_hash(&claims.iss)), + worker_id_hash: claims.worker_id.as_deref().map(diagnostic_id_hash), + token_id_hash: Some(diagnostic_id_hash(&claims.jti)), + ..Self::undecoded() + } + } + + fn reason(mut self, reason: &'static str) -> Self { + self.reason = reason; + self + } + + fn auth_error(mut self, error: RuntimeAuthError) -> Self { + self.reason = match error { + RuntimeAuthError::InvalidTokenFormat => "invalid_format", + RuntimeAuthError::InvalidBase64(_) => "invalid_encoding", + RuntimeAuthError::MalformedClaims(_) => "malformed_claims", + RuntimeAuthError::InvalidPublicKeyFormat => "invalid_runtime_public_key", + RuntimeAuthError::InvalidSignature => "invalid_signature", + RuntimeAuthError::ClaimMismatch(claim) => { + self.verification = "signature_verified"; + match claim { + RuntimeRequestClaim::Issuer => "issuer_mismatch", + RuntimeRequestClaim::Audience => "audience_mismatch", + RuntimeRequestClaim::Workspace => "workspace_mismatch", + RuntimeRequestClaim::Worker => "worker_mismatch", + RuntimeRequestClaim::Permission => "permission_mismatch", + RuntimeRequestClaim::Method => "method_mismatch", + RuntimeRequestClaim::Path => "path_mismatch", + RuntimeRequestClaim::BodyDigest => "body_digest_mismatch", + } + } + RuntimeAuthError::RequestIssuedInFuture { iat, exp, now } => { + self.time(iat, exp, now, iat.abs_diff(now)); + "issued_in_future" + } + RuntimeAuthError::RequestExpired { iat, exp, now } => { + self.time(iat, exp, now, now.abs_diff(exp)); + "expired" + } + _ => "invalid", + }; + self + } + + fn time(&mut self, iat: i64, exp: i64, now: i64, delta: u64) { + self.verification = "signature_verified"; + self.iat = Some(iat); + self.exp = Some(exp); + self.now = Some(now); + self.time_delta_seconds = Some(delta); + } +} + +/// Domain-separated fixed-length fingerprints, not raw request-derived IDs. +/// Operators can correlate a known ID by applying this same function. +pub fn diagnostic_id_hash(id: &str) -> String { + use sha2::{Digest, Sha256}; + let mut hash = Sha256::new(); + hash.update(b"yoi-runtime-proof-diagnostic-id-v1\0"); + hash.update(id.as_bytes()); + hash.finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[allow(clippy::too_many_arguments)] +pub async fn verify_runtime_request_source_proof( + api: &WorkspaceApi, + proof: &str, + workspace_id: &str, + permission: &str, + method: &str, + path: &str, + body_digest: &str, +) -> Result { + verify_runtime_request_source_proof_with_store( + api.store.as_ref(), + &api.config, + proof, + workspace_id, + permission, + method, + path, + body_digest, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub async fn verify_runtime_request_source_proof_with_store( + store: &dyn ControlPlaneStore, + config: &ServerConfig, + proof: &str, + workspace_id: &str, + permission: &str, + method: &str, + path: &str, + body_digest: &str, +) -> Result { + let unverified = decode_runtime_request_source_claims(proof) + .map_err(|error| RuntimeRequestProofError::undecoded().auth_error(error))?; + let diagnostic = RuntimeRequestProofError::decoded(&unverified); + let audience = remote_audience(config, workspace_id) + .map_err(|_| diagnostic.clone().reason("backend_audience_unavailable"))?; + let trusted = store + .get_workspace_runtime_binding(workspace_id, &unverified.iss) + .await + .map_err(|_| { + diagnostic + .clone() + .reason("runtime_trust_authority_unavailable") + })? + .filter(|record| record.revoked_at.is_none()) + .ok_or_else(|| { + diagnostic + .clone() + .reason("runtime_trust_missing_or_revoked") + })?; + let expected = RuntimeRequestSourceExpectation { + identity_id: &unverified.iss, + audience, + workspace_id, + worker_id: unverified.worker_id.as_deref(), + permission, + method, + path, + body_digest, + now_unix: i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX), + }; + let claims = + verify_runtime_request_source(proof, &trusted.public_key, &expected).map_err(|error| { + // Proof encoding was already decoded successfully. A base64 error + // at this stage is in the configured public key, not the proof. + let error = match error { + RuntimeAuthError::InvalidBase64(_) => RuntimeAuthError::InvalidPublicKeyFormat, + other => other, + }; + diagnostic.clone().auth_error(error) + })?; + let diagnostic = RuntimeRequestProofError { + verification: "request_verified", + ..diagnostic + }; + let now_seconds = u64::try_from(expected.now_unix).unwrap_or(u64::MAX); + let expires_at = u64::try_from(claims.exp).unwrap_or(0); + let consumed_at = chrono::DateTime::from_timestamp(expected.now_unix, 0) + .ok_or_else(|| diagnostic.clone().reason("verification_time_out_of_range"))? + .to_rfc3339(); + if !store + .consume_worker_mutation_source_jti( + workspace_id, + &claims.iss, + &claims.jti, + expires_at, + now_seconds, + &consumed_at, + ) + .await + .map_err(|_| diagnostic.clone().reason("replay_authority_unavailable"))? + { + return Err(diagnostic.reason("replay")); + } + if let Some(worker_id) = claims.worker_id.as_deref() { + let worker = worker_runtime::identity::RuntimeWorkerRef { + runtime_id: claims.iss.clone(), + worker_id: worker_id.to_owned(), + }; + let member = store + .get_worker_registry(workspace_id, &worker) + .map_err(|_| { + diagnostic + .clone() + .reason("worker_catalog_authority_unavailable") + })?; + let reserved = store + .has_active_worker_create_reservation(workspace_id, &worker) + .map_err(|_| { + diagnostic + .clone() + .reason("worker_reservation_authority_unavailable") + })?; + if member.is_none() && !reserved { + return Err(diagnostic.reason("worker_catalog_membership")); + } + store + .require_current_worker_singleton_owner(workspace_id, &worker) + .map_err(|_| { + diagnostic + .clone() + .reason("worker_singleton_authority_denied") + })?; + } + Ok(VerifiedRuntimeRequestSource { + runtime_id: claims.iss, + worker_id: claims.worker_id, + }) +} diff --git a/docs/design/checkout-wip-projection.md b/docs/design/checkout-wip-projection.md index 203a9101e..51e28bfda 100644 --- a/docs/design/checkout-wip-projection.md +++ b/docs/design/checkout-wip-projection.md @@ -14,9 +14,11 @@ is created. ├── workdirs identity/lifecycle ├── workdir-attachments Backend connection lifetimes └── checkouts current accessible content entrances; list - └── directory; glob, grep, create_file - ├── README.md file; read, edit, write - └── src directory; glob, grep, create_file + └── directory; list, glob, grep, create_file + +# Not indexable; resolve known paths directly or discover via Operations: +/checkouts//README.md file; read, edit, write +/checkouts//src directory; list, glob, grep, create_file ``` Operations are Interface declarations, not children or `/features/...` routes. @@ -42,7 +44,7 @@ validates every resolved projection's path/owner/descriptor and every enumerated child's immediate-parent relationship. Static and dynamic/subtree overlaps are rejected. Observe resolves only the requested depth; successful observations are complete through that depth, not partial trees masked as success. Directory -listing and native search use the shared List/Glob/Grep engines through typed +listing Operations and native search use the shared List/Glob/Grep engines through typed `CheckoutSearchRequest` forwarding. Scoped sessions append intersecting rule layers, translate their cwd, and require provider-side visibility checks before entry descent/content reads. Results retain session-logical coordinates, so a @@ -51,6 +53,21 @@ providers fail closed rather than falling back to a broader source search. Direct resolution also uses current provider authority, not possession of a cached Interface. +## Indexable boundary (T-714) + +Only `/checkouts` and accessible immediate attachment entrances are indexable. +No directory content List runs during tree/observe, even at deep depth or when a +known deep directory is the observation root. Empty, small and large directories +all have the same boundary. Positive-depth content observations return +`children: []` (no **indexable** children), not an omitted field and not +an assertion that the directory is empty. At depth zero children are omitted; +`observe("/checkouts", 1)` omits entrance children, depth 2 returns [] there. +The Client retains discovered/inspected paths without adding indexable edges. + +Known authorized paths remain directly resolvable and operable. Use List/Glob/Grep +for discovery, then Inspect only selected entry coordinates; no successful search +is undone when an entry disappears before Inspect. Bash/COMMAND is unnecessary. + ## Names and connection isolation Aliases use T-697's canonical encoding: nonempty ASCII letters/digits/`-`/`_` are @@ -66,18 +83,27 @@ existence alone does not expose its content. Following a reference still require current scope/capability and an accessible filesystem projection. Objects have no shared inode `ref`: hard-linked names and different attachments -must not be mistaken for one path-independent representation. Interface references -are opaque, path-qualified and include a fresh projection incarnation and live -attachment generation. Validators combine that namespace with the provider's +must not be mistaken for one path-independent representation. Interface +references are structured `(scope, name)`. Each entrance is its own +scope; content Interfaces use that entrance as ancestor scope, irrespective of +indexability. Local names bind target coordinates, projection incarnation and +live attachment generation. The provider's target-qualified-name routing hint +only selects a target; fetch re-resolves one coherent current publication and +checks the exact Interface, Descriptor, validator and scope under the pinned +router connection. It never turns a cached registration or name into authority. +Validators combine that namespace with the provider's opaque identity/state validator. Restoring a Worker creates a fresh incarnation; detach/alias reuse changes the router generation. An old Interface, validator or read-history entry cannot retarget a new connection. No separate connection ledger is added. The existing router's active-operation exclusion and begin/finish/cancel detach paths remain authoritative; detach does not remove the Workdir/files. Collection enumeration rejects connection-set changes between sampling and -publication instead of pairing old items with a new validator. Search-link -publication checks the captured attachment generation, not merely a reused -alias; response-side provider awaits are covered by the operation deadline. +publication instead of pairing old items with a new validator. Result publication +checks the captured attachment generation, not merely a +reused alias. It maps typed provider paths without observing/statting every +result. Provider awaits are covered by the operation deadline. Objects publish +no scope_ref here; legitimate ref-less binding still checks membership, +validators, current provider authority and attachment generation. ## AI Operation mapping @@ -89,15 +115,20 @@ below are relative to the **bound directory**, not the Worldspace or endpoint. | Object | Operation | Arguments | Result | | --- | --- | --- | --- | | `/checkouts` | `list` | none | bounded `items` containing alias/slug/Workdir/generation/path | -| directory | `glob` | `pattern`, optional `path` | normal bounded Glob text/summary plus same-checkout Object links | -| directory | `grep` | `pattern`; optional `path`, `glob`, `type`, `case_insensitive`, `-A`, `-B`, `-C`, `multiline`, `output_mode`, `head_limit`, `offset` | normal Grep grouped/context/count text and typed provider search targets mapped to Object links | +| directory | `list` | optional `limit` (default 100, 1..1000), `after` (returned `{kind, entry}`) | direct-content records `{kind, size, entry}`, `truncated`, optional `after` | +| directory | `glob` | `pattern`, optional `path` | normal bounded Glob text/summary plus Descriptor-typed same-checkout `entry` coordinates | +| directory | `grep` | `pattern`; optional `path`, `glob`, `type`, `case_insensitive`, `-A`, `-B`, `-C`, `multiline`, `output_mode`, `head_limit`, `offset` | normal Grep grouped/context/count text and typed provider search targets mapped to Descriptor-typed `entry` coordinates | | file | `read` | optional line `offset`, `limit` | line-numbered text and normal Read summary | | file | `edit` | `old_string`, `new_string`, optional `replace_all` | replacement count/summary and preview | | file | `write` | `content` | existing-file save summary; never creation | | directory | `create_file` | `path`, `content` | create-new summary and created-file link; permitted missing parents are created | -Native operation results use a JSON record with `summary`, nullable `content` and -`items` (`path` is an absolute WIP Object navigation coordinate, not an OS path). +Native operation results declare a typed record with `summary`, optional `content` +and `items`. Each item contains `entry: entry` in its Descriptor (an absolute +Worldspace coordinate, encoded as a string in WIP Value), never a guessed ordinary +string or an OS path. List additionally declares `kind`, byte `size`, `truncated` +and optional `after: {kind: string, entry: entry}`. File operations have empty +items except Create, which returns the new coordinate. Search links are constructed from **typed provider paths**, not by parsing Grep's rendered filenames/line delimiters. Provider scope remains authoritative for search output as well as links. Glob/Grep share normal provider ignore/pattern, @@ -129,12 +160,47 @@ must not record history; successful mutations update hash/change statistics only after provider success. Create is strictly beneath the bound parent, creates only an absent file, and must not overwrite an existing destination or symlink. -Manifest permission identities remain Read/Edit/Write/Glob/Grep; create_file uses -Write. The Host reconstructs the same route-bound input for policy evaluation; +Manifest permission identities are Read/Edit/Write/Glob/Grep plus **List** for +native directory listing; create_file uses Write. List publication uses provider +Read capability and potential List allow policy; execution reconstructs the bound +directory's logical path for the exact List policy. Allowing Glob or COMMAND does +not override a List deny/ask rule, and Interface scope validity grants no file +access. Normal Tools mode is unchanged (no new top-level List tool). The Host reconstructs the same route-bound input for policy evaluation; provider read/write scope, capabilities and delegated leases are checked again. `ask` fails closed. Native replacement claims remove `/tools/Read`, `/tools/Edit`, `/tools/Write`, `/tools/Glob`, `/tools/Grep`, not unrelated Tools. +## Directory List pagination + +List uses the existing provider List engine, **not** glob("*") or shell ls. It +includes direct visible empty directories, hidden and ignored names: unlike +Glob/Grep, provider List does not apply search ignore files. Existing Read scope, +readability and can_enumerate_directory checks still gate names; no ancestor or +nonrecursive enumeration permission is inferred from a known descendant. + +Order is directories first, then lexical canonical provider path, with every +other kind in the second group. after is the last returned exclusive ordering +key, rebased to a canonical Worldspace entry by the Host. Pass the returned record +unchanged to continue on the same directory; a foreign checkout, non-direct child, +absolute provider path or unknown field is rejected. No cursor stat is required: +the key may have disappeared. This is **live pagination, not a snapshot**. New +keys before the cursor are not revisited; later keys may appear, removals vanish, +and renames/kind changes may omit or repeat an entry. Directory validators fence +each call, not all pages; refresh Inspect after a stale rejection before issuing +a new explicit call. Detach/reuse expires the old Interface and validator. + +Each page scans the live directory under provider cancellation/deadline and the +existing traversal-entry ceiling, retaining at most limit+1 ordering candidates +and the aggregate path-byte budget. It never retains the whole unbounded listing. +The next ordering key is explicit whenever truncated; no next key means complete. +Provider resource failures stay explicit failures, not silent truncation or retries. + +UTF-8 canonical names are retained verbatim; unrepresentable names are skipped by +the existing provider. Native List may reject visible symlinks under the checked +provider's no-follow policy. If a provider returns symlink/other kinds they remain +classified path coordinates, not a promise of inspectable/operable Objects: +direct checkout resolution only publishes authorized regular files/directories. + ## Consistency, safety and resource policy The provider resolves/pins the entry/parent, compares its opaque validator and @@ -172,7 +238,7 @@ rediscover/reread. Do not report partially created parents as unchanged. Host observations are bounded to depth 32 and 1024 nodes with a 30-second total cooperative deadline. The checkout adapter permits at most 16 concurrent requests -and bounds admission waiting and the complete operation (including result-link +and bounds admission waiting and the complete operation (including bounded result publication) to 30 seconds each; provider path/depth, source/response, mutation/search and transport bounds apply too. No initial context expands an entire tree. Resource-limit errors do not turn incomplete observation into a @@ -182,8 +248,9 @@ truncation under the existing search contract. ## Typical sequence 1. Use Tree on `/checkouts` with depth 1; Inspect `/checkouts` directly and Invoke `list`. -2. Inspect an entrance or a known deep directory directly; Invoke `glob`/`grep`. -3. Follow a returned file `path`, Inspect that path and Invoke line `read`. +2. Inspect an entrance or known deep directory directly; Invoke `list`/`glob`/`grep`. + For List, pass returned `after` with the next limit to obtain another live page. +3. Follow a returned file `entry`, Inspect that path and Invoke line `read`. 4. Call `edit` or `write`; Client-managed validators protect the exact observed object while the shared tracker enforces the prior Read. 5. Rediscover after external changes; a stale rejection is not permission to skip @@ -203,9 +270,14 @@ outside this feature. Run from the main repository, on Linux with `openat2` and `/proc/self/fd`: ```sh -cargo check --workspace --all-targets +cargo test -p worker --lib checkout_wip +cargo test -p workdir --test list_pagination +cargo check cargo test -p fs-operation -p workdir -p tools -p worker -cargo test -p worker-runtime -p yoi-workspace-server +# Workdir External/HTTP features and dependent server fixture: +cargo test -p workdir --features http-client +cargo test -p yoi-workspace-server --lib backend_workdir_session_proxy_executes_typed_operations +node scripts/verify-wip-dependencies.mjs cargo fmt --all -- --check git diff --check ``` diff --git a/docs/development/runtime-request-proof-diagnostics.md b/docs/development/runtime-request-proof-diagnostics.md new file mode 100644 index 000000000..c6ffb8de1 --- /dev/null +++ b/docs/development/runtime-request-proof-diagnostics.md @@ -0,0 +1,83 @@ +# Runtime request proof rejection diagnostics + +`runtime_request_proof_rejected` (`yoi::auth`, WARN) is an internal operational +record. HTTP responses are unchanged: 401 with no proof details (repository +routes retain their existing generic API error envelope). It does **not** establish +why the reported same-machine intermittent 401 occurred. No reproduction or +causal measurement of that incident has been obtained. + +## Verification and correlation + +- `request_id`: Server-generated UUID identifying this rejection event. It is not + a caller-supplied ID or a response header, and is not a cross-service trace ID. +- `claimed_runtime_id_hash`, `claimed_worker_id_hash`, `claimed_token_id_hash`: + lowercase 64-character hex SHA-256 of + `b"yoi-runtime-proof-diagnostic-id-v1\0" + id.as_bytes()`. + `worker_source::diagnostic_id_hash` exposes this correlation function. Hash a + known Runtime/Worker ID to find its failures; token ID hashes correlate replay + attempts without logging the proof or JTI itself. These are fingerprints, not + encryption: low-entropy IDs can be guessed. Do not place secrets in IDs. +- All claims-derived fields are prefixed `claimed_`, even after signature checks. + `proof_verification` describes check progress, **not an authorized principal**: + - `unverified`: decoding may have succeeded, but signature verification has not. + - `signature_verified`: signature passed; request binding or time failed. + - `request_verified`: signature, request bindings and time passed, but replay, + membership or authority failed. +- When decoding fails, there are no claims fingerprints. Do not infer a Runtime + from a malformed proof. No raw claims, proof, key, header, body or raw error is + logged. The body digest and claim mismatch values are also deliberately absent. +- Method, query-free path, and requested workspace are request metadata, not + authenticated claims. They are bounded to 32, 512 and 128 UTF-8 bytes, with + control characters removed. Truncated paths are not unique identifiers. Query + names and values are never included. Paths are operational metadata, not a + place to put secrets. + +## Reasons and their limits + +Decoding distinguishes `invalid_format`, `invalid_encoding`, and +`malformed_claims`; no parser error text is included. Signature checks distinguish +`invalid_runtime_public_key` from `invalid_signature` (which cannot tell whether +an attacker, wrong key, corruption, or configuration mismatch caused the failure). + +Request binding reports the first mismatch, in the existing check order: +`issuer_mismatch`, `audience_mismatch`, `workspace_mismatch`, `worker_mismatch`, +`permission_mismatch`, `method_mismatch`, `path_mismatch`, `body_digest_mismatch`. +The server selects the trusted key by the unverified issuer and expects that +same issuer/worker; issuer and worker mismatches can be diagnosed by the lower +verifier but are not independently detectable by the current server caller. +These diagnostics do not add new identity checks or change authorization. + +`backend_audience_unavailable` denotes missing Backend public URL configuration. +`runtime_trust_missing_or_revoked` deliberately covers both absence and revocation, +as the existing store lookup/filter does not preserve that distinction. +`replay` means JTI consumption returned false. `worker_catalog_membership` means +neither registry membership nor an active create reservation was found. + +Store failures identify their check stage without exposing raw errors: +`runtime_trust_authority_unavailable`, `replay_authority_unavailable`, +`worker_catalog_authority_unavailable`, `worker_reservation_authority_unavailable`, +`worker_singleton_authority_denied`. The last can represent either an owner +conflict or an authority failure: the store currently returns a shared error, +so the diagnostic does not guess which. `verification_time_out_of_range` means +that conversion to the JTI consumption timestamp failed; it is not proof expiry. +`invalid` is a fallback for lower verifier errors not classified for this path. + +## Time contract and T-728 coordination + +This change preserves strict integer-second checks, inclusive bounds, and +verification order: signature, request binding, then `iat > now`, then `exp < now`. +No skew tolerance is added. If both time predicates fail, future issuance wins. + +- `issued_in_future`: `time_delta_seconds = iat - now` (positive). +- `expired`: `time_delta_seconds = now - exp` (positive). +- Only these reasons include `issued_at_unix`, `expires_at_unix`, + `verified_at_unix` and `time_tolerance_seconds` (currently zero). +- Deltas use `i64::abs_diff`, preserving the full positive `u64` range without + overflow. All times come from the actual signature-verified rejection and + the exact clock sample used for that decision, not a second log-time sample. + +T-728 owns any change to tolerance/validity policy. If it changes these checks, +the error time data, logged effective tolerance, and boundary tests must change +together. Do not label future issuance as expiry or independently recompute the +rejection using a different clock sample. This is instrumentation for the next +incident, not evidence that the original incident has been resolved. diff --git a/docs/development/wip-integration-validation.md b/docs/development/wip-integration-validation.md index 577ca28e7..7b55c86e6 100644 --- a/docs/development/wip-integration-validation.md +++ b/docs/development/wip-integration-validation.md @@ -115,10 +115,98 @@ permission identity and ToolOutput/attachments. - `WipMountRegistry::mount_interface` adds a separate static Interface namespace without replacing Object ownership or flattening same-named operations. It does not overlay stale registration metadata onto a live subtree provider. -- FS indexable boundaries and directory.list remain provider policy. This change - deliberately does not implement the separate T-714 boundary/list migration. - Relevant transport/commit tests are `checkout_wip_tests.rs`, - `checkout_http_tests.rs`, and `checkout_race_tests.rs`. +- FS indexable boundaries and directory.list are provider policy. T-714 now + implements entrance-only trees and bounded typed discovery (details below). + Transport/commit tests are `checkout_wip_tests.rs`, `checkout_http_tests.rs`, + and `checkout_race_tests.rs`. - Worldspace state is runtime-only. Historical opaque references in append-only session/tool history are not migrated into cache or parsed for a guessed scope; a restored Client starts with fresh observations and direct path Inspect. + +## T-714 checkout discovery validation + +Based on integrated T-713 at `f9d4c97f`, still using the exact released crates.io +0.2.0 SDK and official renderer. No alternate wire, renderer or upstream version +was introduced. The checkout projection design documents provider live pagination, +its ordering/cursor/byte ceilings, special-entry policy and the distinction between +indexable emptiness and empty filesystem directories. + +Focused regressions: + +```sh +cargo test -p worker --lib checkout_wip +cargo test -p worker --lib checkout::race_tests +cargo test -p worker --lib checkout_http_tests +cargo test -p worker --lib ancestor_tests +cargo test -p tools --lib checkout_list +cargo test -p fs-operation --test list_pagination +cargo test -p workdir --test list_pagination +``` + +Host depth-zero omission / positive-depth `children: []`, empty/small/1100-entry +folders, direct deep roots and zero provider content-List calls during observation +are tested independently of Client output. Registered model tools exercise entrance +self-scope and deep target ancestor-scope, paginated List/Glob/Grep typed entries, +lazy selected Inspect with official signatures/path context, and Read without +COMMAND. Acquired paths never become tree edges; a stalled result observation is +never awaited and disappearance only fails a later explicit Inspect. Remote HTTP +checks verify List continuation on the wire with no ordinary List fallback or +response credentials/host-path leakage. Provider tests cover scoped External and +nested cwd/output_root rebasing, nonrecursive enumeration/read authority, ignore +policy, live mutations, byte bounds, cancellation and malicious cursor/result DTOs. + +`WipSubtreeProvider::interface_target` is a routing hint for target-qualified local +names under ancestor scopes. The default retains self/shared-scope behavior. Host +checks provider ownership and resolves **one** coherent current target/scope +publication; it never treats the hint as authorization or fetches a scope in an +independent mutable lookup. Focused Host tests reject forged/outside/prefix/missing +hints, ref-less deletion and absent membership while preserving existing ancestor +ref/validator and in-flight snapshots. Actual checkout tests reject sibling/prefix +scopes, supplied refs when current publication has none and stale alias generations; +ordinary content updates do not terminate the entrance Interface lifetime. + +Completion validation: + +- Root `cargo check` passed. +- `cargo test -p fs-operation -p workdir -p tools -p worker` passed: FS **58 unit + + 6 pagination**; Workdir **125 unit + 5 pagination**; Tools **84 unit + 39 + integration + 1 doctest**; Worker **901 unit + 110 integration**. +- `cargo test -p workdir --features http-client` passed (**125 + 5**). +- `cargo test -p yoi-workspace-server` passed (**691 unit + 11 CLI**). +- `cargo test -p server-api` passed (**83**, including checked-in OpenAPI/TypeScript + freshness). No generated API artifact changes were needed. +- `node scripts/verify-wip-dependencies.mjs` confirmed one crates.io 0.2.0 Protocol + source and all four exact SDK packages. `cargo fmt --all -- --check` and + `git diff --check HEAD` passed. + +The first dependent Server test build exposed predecessor fixtures still using +removed `discover`/opaque strings/private `call`. Those tests now select structured +references from actual path Inspect and use public Tree/Inspect/Invoke; the complete +Server suite above includes configuration integration and the typed Workdir proxy. +Its dev-only Protocol dependency reuses the same locked SDK source; the resolved +package/version set is unchanged. + +No full workspace test, full Nix/image build, deployment, target integration or +live dogfood update is claimed. Target movement remains Orchestrator authority. + +### Post-success connection-loss review regression + +The first independent source review identified `POST_COMMIT_CONNECTION_FENCE`: +lazy result publication had propagated a connection mismatch as a pre-effect +rejection even after successful Write/Edit/Create. The production result +publication boundary now maps post-success connection loss for mutations to +`OutcomeUnknown`, while Read/List/Glob/Grep keep their validator mismatch. Lazy +entry mapping is unchanged; there is still no result-wide acquisition. + +`committed_checkout_mutation_losing_connection_is_unknown_not_rejected_or_replayed` +executes real checked Tools/provider mutations, deterministically detaches after +the routed execution guard finishes but before the same production publication +boundary, then passes the error through WIP Invoke/Client. It checks committed +file contents, unknown audit (not rejection), and exactly one dispatch for each +Write/Edit/Create. `readonly_checkout_result_losing_connection_keeps_validator_mismatch` +protects the non-mutating error policy. No sleep or production test hook is used. + +After this fix, all **9** checkout race tests and the complete Worker suite +(**903 unit + 110 integration**) passed, as did root Cargo check, formatting, +diff checks and released SDK resolution verification. The earlier **901** Worker +count above describes the pre-review combined-crate run. diff --git a/docs/development/work-items.md b/docs/development/work-items.md index e774aac0a..f37538c9a 100644 --- a/docs/development/work-items.md +++ b/docs/development/work-items.md @@ -1,258 +1,95 @@ -# Tickets and development workflow +# Tickets and work outcomes -Yoi project work is tracked through Tickets. For normal use, interact with Tickets through `yoi panel`, Ticket tools, the `yoi ticket ...` CLI, and typed role surfaces. Workspace Server control-plane records plus the append-only Ticket event and Merge Request evidence streams are authoritative. +A Ticket is a durable record of natural-language user intent and a concrete work outcome, not a mandatory code/review/merge pipeline. Research, analysis, planning, documentation, operations, and code can all be Ticket work. Requirements, acceptance criteria, and binding decisions govern what must be done; the requested return path governs whether to continue, complete, close, or return results without concluding the Ticket. -Ticket and Objective records are stored in the Workspace Server database. Repository-local `.yoi/tickets`, `.yoi/objectives`, and config files are ignored legacy input, not a debugging or compatibility backend. - -Do not treat ad-hoc chat summaries, memory records, or Worker notifications as the final source of project state. Notifications are hints to inspect concrete state, not proof of completion. +Workspace Server control-plane records, append-only Ticket events, and any linked Merge Request evidence are authoritative. Ticket and Objective records live in the Workspace Server database. Repository-local `.yoi/tickets`, `.yoi/objectives`, and config files are ignored legacy input, not a debugging or compatibility backend. Notifications, chat summaries, and Memory are hints to inspect current authority, not proof of completion. ## Concepts -- `Ticket`: durable project/orchestration record. It contains requirements, decisions, plans, implementation reports, reviews, artifacts, and resolution history. -- `Objective`: first-class medium-term goal record stored by the Workspace Server. It stores goal, motivation/background, strategy/design direction, success criteria/exit conditions, decision context, current Objective lifecycle, and canonical Ticket links. Objective context is judgment/background context; it is not implementation authority and does not replace reading each Ticket body, thread, and evidence. -- `Task`: session-local progress tracking inside a Worker. It is not the project record. -- `Assignment`: a concrete delegation from an Orchestrator to a coder/reviewer Worker or task-specific helper Worker. -- `IntentPacket`: the short implementation/review contract derived from a Ticket and handed to an Assignment. -- `Ticket backend`: the Workspace Server control-plane database exposed through typed Workspace APIs. -- `Ticket relation`: durable project-level Ticket-to-Ticket metadata stored as forward canonical-id relations (`depends_on`, `blocks`, `related`, `supersedes`, `duplicate_of`). Inverse views such as `blocked_by` are derived, not stored. +- `Ticket`: durable work record containing intent, requirements, decisions, useful results/evidence, and resolution history. +- `Objective`: a first-class medium-term goal with background, strategy, success criteria, and canonical Ticket context links. It neither schedules nor authorizes Ticket work. +- `Task`: session-local progress tracking, not the project record. +- `Assignment`: an authorized delegation of Ticket work to a Worker, independent from its Profile or Flow. +- `IntentPacket`: a bounded natural-language handoff of intent, requirements, binding decisions, latitude, escalation conditions, and validation. +- `Ticket relation`: non-hierarchical project metadata (`depends_on`, `blocks`, `related`, `supersedes`, `duplicate_of`). Inverse views are derived. Relations do not grant resources or start Workers. -A Ticket may represent a feature, bug, cleanup, design decision, investigation, workflow change, release task, or orchestration task. The common requirement is that the Ticket is a concrete work item that can be implemented, reviewed, validated, and closed on its own terms. +One Ticket should describe an independently useful outcome that can be judged on its own terms. Do not convert temporary execution steps or a broad effort's progress container into mandatory product deliverables. -## User-facing entry points +## Entry points and authority -Use the highest-level interface that matches the work: +Use the highest-level interface matching the request: Workspace Dashboard/Ticket UI, authenticated Workspace APIs, product `yoi ticket ...` or `yoi objective ...` commands, and typed Worker tools. Inside Workers, use the supplied typed Ticket tools; do not substitute a CLI or direct storage access for missing tools. Maintainers inspect authority through the Workspace API, Server administration surfaces, and database diagnostics, not repository-local Ticket files. -- Use `yoi panel` for the Ticket/Intake/Orchestrator workspace Dashboard and role-launch actions. -- Use `yoi objective ...` for lightweight medium-term Objective records and their non-blocking canonical Ticket links. -- Inside Workers, use typed Ticket tools for Ticket records and typed Merge Request tools for immutable implementation/review/completion evidence. -- For multi-step work, follow the typed Ticket role surfaces and recorded Ticket lifecycle gates. +The Server resolves Workspace access, active configuration, and immutable Profile launch material; Runtime does not infer identity or authority from a checkout's `.yoi` files, cwd, or launch prose. The first committed user request supplies bounded action context, not actor identity or new grants. Profile/Flow selection is configuration, not review, merge, or resource authority. Availability of a tool is not authorization to use it for unrelated work. -Maintainers inspect Ticket state through the authenticated Workspace API, Server administration surfaces, and database diagnostics. Repository-local Ticket files are not storage authority. +## Starting and returning Ticket work -## Ticket tools inside Workers +`SpawnTicketWorker` starts and atomically assigns a generic Worker with an `initial_request`. Its registered `profile` is optional (omitted/null selects `builtin:ticket-worker`); `flow` and alias-keyed `workdir_attachments` are optional. No repository or Flow is required for non-code work. Choose an explicit Profile when its tools and instructions fit the task; assignment does not depend on choosing the Coder role. Backend validates claims, derives repository attachment access from declared Ticket targets, and preserves explicit ExternalGrant ceilings. Callers cannot request capabilities through attachment selection. -Workers with the Ticket and operation-specific Merge Request built-in features can use typed workflow tools: +The guarded start records acceptance only after spawn, initial input, assignment, and attachment finalization. Do not pre-write `inprogress` or mistake a state update for a launch. On a failed or unknown outcome, reread durable operation evidence before retrying or spawning a replacement. After acceptance, reread Ticket state and current Worker responsibility. -- `TicketCreate` -- `QueryTicket` — bounded authoritative Ticket discovery with typed state/text/event/evidence/relation/Objective/time/attention filters, stable snippets, and cursor metadata. -- `ShowTicket` — detailed authority for one Ticket, including item revision, bounded thread/event references, relations, linked Objectives, implementation reports, and current Merge Request/review evidence. -- `TicketComment` -- Coder: `ShowMergeRequest`, `OpenMergeRequest` -- Reviewer: `ShowMergeRequest`, `ReviewMergeRequest` — available only inside the attested direct-child Reviewer request; grant and subject-ref capability material are not model input. -- Orchestrator: `ShowMergeRequest`, `CheckMergeRequestReadiness`, `CompleteMergeRequest` -- `TicketClose` -- `TicketRelationRecord` +Results can be returned in conversation, optionally recorded in a `TicketComment`, optionally published as a Drive document/artifact when that surface is available, or written/published to an authorized repository. Neither comments nor Drive artifacts are universally required. Preserve concise evidence useful to the user without fabricating work, tests, or conclusions. `implementation_report` is optional historical/audit context, not integration readiness or completion authority. -Profile-visible Ticket catalogs are intentionally smaller than the former broad read catalog: Workspace authoring exposes 9 tools instead of 13, workflow exposes 10 instead of 12, and review exposes only `QueryTicket` plus `ShowTicket` (2 instead of 6). The `QueryTicket` schema is regression-guarded below 8 KiB while consolidating relation/evidence/attention discovery; diagnostics are not projected into normal profiles, while specialized orchestration-plan commands remain visible only to workflow roles that need their distinct semantics. +The return choice is independent of result format. Examples: -These tools operate through the typed Workspace Ticket API. They do not grant arbitrary filesystem access and never select repository-local Ticket storage. +- Research with review not required: answer the question and optionally record a useful summary; no MR or approval is required for an authorized completion decision. +- Code requiring independent review before merge: use the trusted review path and guarded MR integration. A Profile, Flow transition, tool invocation, or prose approval is not the review judgment. +- Return without conclusion: report the result/limitations and leave the Ticket unconcluded. A finished turn, an artifact, or an approved MR does not itself decide completion. -Relation tools are for non-hierarchical project metadata only. Use canonical opaque Ticket ids, store forward relations only, and keep runtime execution planning (capacity, ordering decisions, do-not-parallelize notes, Worker/session/worktree ownership) in OrchestrationPlan or session-local records instead of relation metadata. Unresolved `depends_on` and incoming unresolved `blocks` are queue/acceptance blockers; `related` is not blocking, and `supersedes` / `duplicate_of` are diagnostics rather than automatic lifecycle transitions. +These are instruction/tool contracts, not a claim that a mechanism proves real agent judgment. Backend authenticates actors and enforces typed boundaries; the responsible Worker/human must actually judge satisfaction against the natural-language request. -Use them when a Worker needs to materialize or update project records: +## Ticket tools and decisions -- Intake creates a new Ticket after user agreement. -- Orchestrator records routing decisions and intent packets. -- Reviewer commits an approve/request-changes result against one immutable Merge Request revision. -- Maintainer closes a Ticket with a resolution when merge/validation/cleanup evidence is complete. +`QueryTicket` provides bounded discovery/filtering; `ShowTicket` provides authoritative item revision, paged thread, relations, Objectives, and any current MR context. Read the relevant Ticket before implementation, routing, review, state, or conclusion decisions. Check potential duplicates before creation or material rescope. Use `QueryObjective` and `ShowObjective` for broader context without replacing the Ticket read. -Do not bypass Ticket lifecycle gates just because Ticket tools are available. Ticket mutation is a project-record operation and should remain auditable. +Useful mutation surfaces include `TicketCreate`, `TicketComment`, relation/plan tools, and authorized `TicketWorkflowState`, `CompleteTicket`, and `TicketClose` decisions. Exposure depends on the effective Profile/Feature configuration. Decision tools use current item revision, expected state, a reason/resolution, stable operation key, and optional supporting references. Backend checks actor authority, CAS, replay, and resource boundaries. It does not enforce a universal state sequence, MR/approval gate, or natural-language satisfaction. -## Objective records +`CompleteTicket` records completion; `TicketClose` records closure. Neither integrates an MR or expands resource grants. Ticket progress/conclusion can occur without commits, MRs, or approvals when consistent with the request. Conversely, code's default publication/review practices must not be erased by confusing an independent Ticket decision with merge authority. Do not claim an unmerged result was integrated. Reread after conflicts and replay a recorded operation only with the same fingerprint. -Objectives are Workspace Server control-plane records, not files in a checkout. Use typed Objective tools or `yoi objective ...`; both resolve the selected Backend/Workspace through the shared client target and call the Workspace API. +Relations remain metadata, not a scheduling mechanism: record actual project dependencies, but do not encode capacity, Worker/worktree ownership, or execution ordering as relations. Use orchestration/session records for those concerns and inspect current Backend claim rules rather than inferring launch eligibility from a relation alone. -Objective-to-Ticket links are context links only: they are not dependency, blocking, ordering, ownership, or scheduling relations. Objective lifecycle does not drive Ticket state or authorize implementation. A role reading Objective context must still inspect each Ticket and its current Merge Request evidence. +## Optional code Git/MR recipe -Repository-local `.yoi/objectives` trees are ignored. There is no automatic import or cwd/ancestor fallback. +For code work using the default publication/review recipe, select `builtin:coder` and optionally `builtin:coder-review`. The Flow guides implement, review, fix, and handoff; it is not a Backend Ticket completion requirement and grants no authority. Read current repository state, use coherent commits and normal non-force publication only when authorized, validate the changed contracts, and report changed files, validation, and limitations. No commits/push should be inferred from a request explicitly prohibiting them. -## Ticket configuration +Use one open repository-scoped MR per Ticket/repository. `OpenMergeRequest` creates selectors; once an MR exists, explicitly address its ID with `ShowMergeRequest` and preserve its selectors. Advance only the existing source selector with a normal non-force push. Do not invent an add-revision operation, replacement MR, or fresh integration branch for every fix. Publish the exact committed source and verify provider resolution before requesting review. -Workspace Ticket data and workflow authority live in the Workspace Server's SQLite control-plane store. Repository-local `.yoi/workspace.toml` and `.yoi/ticket.config.toml` are not Ticket, Workspace identity, Backend connection, or role-launch authority. `yoi init --display-name --repository-key ` registers the current Git repository through the Backend API and writes only global client routing under `$XDG_CONFIG_HOME/yoi/client.toml`. +The assigned Coder launches the Reviewer as an actual direct-child `builtin:reviewer` SubWorker, with explicit command grant and appropriate Workdir scope for inspection/validation, plus the structured Ticket/MR handoff. Server authority verifies assignment, Runtime-owned child identity, effective Profile, one-shot attempt, and captured subject. Review capability is injected by the trusted layer, not supplied by the model. The Reviewer makes an independent judgment against intent, acceptance criteria, the complete captured result snapshot, and validation, then records `ReviewMergeRequest`; prose and observation are not approval authority. -Fixed Ticket workflow roles are `intake`, `orchestrator`, `coder`, and `reviewer`. The Server resolves the selected Profile and launch material from the active Workspace configuration authority, and Runtime receives the resulting immutable launch snapshot. A repository checkout may still contain ordinary project files, but neither the client nor Runtime may infer Workspace identity, Backend routing, role Profile, or Ticket storage from repository-local `.yoi` files. +Source movement requires fresh review for the exact new source. Target-only movement preserves unchanged-source approval but requires refreshed integration evidence. Keep routine review/fix/rereview evidence on the MR rather than repeating each iteration in Ticket comments. -## Ticket lifecycle +MR integration remains separate Orchestrator authority. Immediately before integration, reread Ticket/MR authority and `CheckMergeRequestReadiness`. Verify exact approved source, current target, strategy, and approval event. Apply and validate integration through ordinary source control in the bound Workdir, push normally, and verify provider resolution. `CompleteMergeRequest` records that already-applied repository result; it does not move a branch, complete the Ticket, or release assignment. If push succeeded but recording failed, reread/replay the same operation rather than pushing again. A Ticket request saying review is not required does not bypass MR integration guards. -Ticket-driven development normally moves through these gates: +## Responsibility, unfinished work, and cleanup (T-715) -1. Intake -2. Orchestrator routing -3. Planning/requirements sync or spike when needed -4. Implementation assignment -5. Review -6. Merge / validation / cleanup -7. Close +Terminal current responsibility is retained for display after unfinished work ends. It is not the same as an unfinished assignment and does not require an invented unassignment operation before cleanup. A stopped or idle Worker may still own unfinished work; a completed/closed Ticket does not automatically stop/remove the Worker, release its attachments, or delete a Workdir. A result report or Flow terminal state is neither conclusion nor cleanup authority. -Each gate records its decision or evidence in the Ticket thread or artifacts. +Worker stop, retention/removal, attachment release, and Workdir deletion are independent guarded decisions. Do not predeclare `delete_on_completion` or `retain_on_completion` at launch. Retain Workers while a requested review/fix or other handoff can still return; do not remove them just because one turn ended. -### 1. Intake +When authorized cleanup is in scope: -Use the Intake role launch prompt when a user request is broad, ambiguous, or not yet a Ticket. +1. Reread Ticket responsibility, active unfinished work, Worker state, handoffs/notifications, and current retention constraints. Use the exact Worker subject from `WorkerList`. +2. Stop the Worker if needed and confirm terminal state. Stop alone does not end unfinished work. `UnfinishedWork` (`unfinished_work`) requires explicit authorized work end or reassignment and a fresh authority read. +3. Call `WorkerRemove` only with no unfinished work, running/restoring state, pin/legal hold, pending notification, or handoff. Backend revalidates guards; removal releases attachments but preserves Workdir materialization. +4. Reread actual Workdir attachment release, occupancy, cleanliness, ownership, provider availability, and other use. Retain still-needed/existing resources. Only then use `WorkdirDelete` for a proven clean, unoccupied, no-longer-needed Ticket-dedicated Workdir owned/selected for this work. -Intake should: +Never delete before attachment release, force removal, discard changes, or advance on a partial/unknown failure. Reread authority before a bounded retry. Cleanup failures do not roll back recorded Ticket or MR outcomes; report concrete blockers rather than adding routine success comments. -- clarify user intent; -- check duplicate/related Tickets; -- draft background, requirements, acceptance criteria, binding decisions/invariants, implementation latitude, readiness, risk flags, and validation; -- create or update the Ticket only after user agreement. +## Authoring and granularity -Intake should not schedule implementation, spawn coder/reviewer Workers, create worktrees, merge, or close Tickets. +Intake clarifies ambiguous intent, checks duplicates and relevant context, and records agreed work. Create/update only after user agreement or explicit instruction to record the draft. A minimum investigation gate is an authoring responsibility, not a universal implementation approval gate. Intake is not scheduling, implementation, review, merge, or cleanup authority. -### 2. Orchestrator routing +A useful Ticket records background/motivation, requirements, acceptance criteria, binding decisions/invariants, implementation latitude, and relevant open questions/risks. Include useful result evidence and final resolution when appropriate. Separate confirmed facts, user claims, hypotheses, and undecided questions. Do not copy Worker role boundaries, a temporary plan, or a decision to stop after authoring into the Ticket as a product prohibition. Record an approval gate only when the user asks for it; do not prematurely prescribe implementation tactics. -Use the Orchestrator role launch prompt to classify the next action for an existing Ticket. +Risk flags prompt context checks and reviewer focus, not automatic stop gates. Return to planning only for a named missing decision/information after bounded checks; escalate beyond-scope decisions without inventing new constraints. -Routing classifications include: +Do not create new umbrella/progress-container Tickets. Split broad work into concrete useful outcomes, record the split in Ticket/Objective context, and use Objectives for enduring medium-term goals. Do not replace umbrellas with parent/child, sub-ticket, part-of, contains, or other hierarchy relations. A concrete planning/design/investigation Ticket is valid when requested. Existing umbrellas can be retired as superseded/decomposed without rewriting history; distinguish container retirement from completion of future work. -- `requirements_sync_needed` -- `return_to_planning` -- `spike_needed` -- `implementation_ready` -- `review_needed` -- `blocked_by_dependency_or_missing_authority` -- `close_ready` -- `closed_or_noop` +Keep long research dumps outside the item body and summarize necessary bounded artifacts. Never store secrets, credentials, private prompt contents, or secret-bearing logs in Ticket/MR/Drive records or model-visible evidence. -Routing decisions should be recorded with `TicketComment` using `plan` or `decision` role. The decision should state the classification, evidence checked, reason, next action, and escalation conditions. For `return_to_planning`, the record must also state the concrete missing decision/information, context checked, why implementation latitude is insufficient, and the next planning question/action. +## Configuration and validation -### 3. Planning/requirements sync +Workspace Ticket data and authority live in Server SQLite and active Workspace configuration. `yoi init --display-name --repository-key ` registers a repository through Backend and writes global client routing only. Repository-local `.yoi/workspace.toml` and `.yoi/ticket.config.toml` do not grant Workspace identity, Backend connection, role selection, or Ticket authority. Legacy `ticket init` and `ticket import-local` are unsupported; no automatic migration reads local Ticket trees. -Use planning/requirements sync only as a bounded Ticket refinement step. Return `ready` or `queued` Tickets to `planning` only when the Orchestrator can name a concrete missing decision or information item after bounded project-context checks; risk flags and risky domains are context-lookup and reviewer-focus signals, not automatic stop gates. +Dashboard actions are explicit user actions, not scheduler/auto-maintainer authority. Launch success without a visible result calls for attaching to or observing the Worker and rereading authority, not assuming conclusion. Select an accessible Workspace and current registered Profile when a launch reports missing configuration. -Planning sync should resolve or record: - -- requirements and acceptance criteria; -- current code map; -- binding decisions/invariants and implementation latitude; -- critical risks and failure modes; -- implementation-ready vs requirements-sync/spike/blocked classification. - -Do not send Tickets with unresolved concrete missing decisions/information directly to coder Workers. If no concrete missing item remains after bounded checks, risky-but-specified Tickets should proceed with an IntentPacket plus escalation conditions and reviewer focus. - -### 4. Implementation assignment - -Use the Coder and Reviewer role launch prompts for implementation-ready Tickets. - -The Orchestrator should prepare an `IntentPacket` with: - -- intent; -- requirements; -- binding decisions/invariants; -- implementation latitude; -- escalation conditions; -- validation; -- current code map; -- critical risks. - -Implementation normally happens in a child git worktree created by the Orchestrator, not by the coder Worker. The coder Worker receives narrow write scope to the worktree and must report changed files, implementation summary, validation, unresolved risks, and review readiness. - -### 5. Review - -The assigned Coder launches the Reviewer as an actual direct-child `builtin:reviewer` SubWorker with write scope, so it can use the Workdir command tools required for inspection and validation, and a structured handoff bound to the current immutable Merge Request revision. Server authority revalidates the parent assignment, Runtime-owned child session, effective profile, one-shot review attempt, and revision; prose output is not approval. - -The Reviewer records the structured result with `ReviewMergeRequest`. Request changes advances the existing Merge Request source selector with a normal non-force push and requires a fresh child attempt for that exact new source ref; do not create a replacement Merge Request, add-revision operation, or fresh integration branch. Target-only movement preserves approval for an unchanged source and requires refreshed integration evidence. The Orchestrator uses `CheckMergeRequestReadiness` and then `CompleteMergeRequest` for guarded integration with operation-id dedupe/CAS semantics; Flow transitions are not completion authority. - -Blockers must be fixed or explicitly escalated before merge-ready submission. - -### 6. Merge and close - -Unless explicitly authorized otherwise, final merge, cleanup, design-boundary decisions, and Ticket closure remain Orchestrator/human responsibilities. - -Before closing, verify concrete evidence: - -- SubWorker committed session via worker-observation tools; -- worktree state and diff; -- validation command output; -- review result; -- Ticket requirements and acceptance criteria; -- merge/cleanup state in the main workspace. - -Close with a resolution that summarizes what changed, key commits, validation, review state, and remaining follow-ups. - -## Workspace Dashboard Ticket role actions - -`yoi panel` is the active Ticket/Intake/Orchestrator Dashboard. It owns fixed Ticket role-launch actions and uses the shared client Ticket role launcher. The single-Worker Console no longer supports `:ticket ...` commands; typing them in command mode is treated like any other unknown command. - -Role actions map to the fixed Workspace Ticket roles: - -- intake launches the intake role without an existing Ticket and requires freeform context. -- route launches the orchestrator role for an existing Ticket. -- implement launches the coder role for an implementation assignment. -- review launches the reviewer role for review. - -All actions are explicit and user-triggered. They are not a scheduler, queue, spawned-Worker Dashboard, or automatic maintainer loop. - -### Dashboard execution path - -The Dashboard sends the selected action and Ticket context to the Workspace Server. The Server validates Workspace access, resolves the current Server DB Workspace/Ticket authority and active Profile projection, launches or restores the role Worker through the shared Worker path, commits the typed initial input, and returns durable acceptance evidence. The client does not inspect repository-local `.yoi` files, choose a Ticket storage directory, or construct Runtime launch authority. - -The launched Worker receives dynamic Ticket/action context as its first committed run input. The selected Profile supplies durable system/role behavior. Workspace Ticket metadata does not override system instruction. - -### Dashboard troubleshooting - -- unresolved Workspace selection: select an accessible Workspace from the Server catalog or pass an explicit Workspace selector. -- unavailable role Profile: update the active Workspace configuration and retry after the Server projects the new revision. -- missing Ticket id for route, implement, or review actions: provide the target Ticket. -- launch success but no visible completion: attach to or inspect the launched Worker; completion notifications are hints, not authority. - -## Granularity - -One Ticket should describe a complete change that can be explained as a feature, behavior, design decision, investigation result, or maintenance outcome when closed. It should be concrete enough to implement, review, validate, and close without relying on another open Ticket as its progress container. - -Avoid Tickets that only mirror an implementation step unless that step is independently reviewable and useful. Phase/step lists inside a Ticket are execution order, not a separate dependency system. - -Do not create new umbrella Tickets for broad multi-Ticket efforts. When a request is too broad for one concrete work item: - -- create concrete implementable Tickets for the slices; -- record the split decision in the relevant Ticket thread, Objective context, or both; -- use Objectives for medium-term goal, motivation, strategy, and success-criteria context when that context would outlive one concrete Ticket; -- once typed Ticket relations exist, use them only for non-hierarchical dependency, related, blocking, superseded-by, duplicate, or replacement metadata; -- do not replace umbrellas with parent/child, sub-ticket, umbrella, part-of, contains, or other hierarchy/container relations; -- do not keep a separate umbrella Ticket open merely as a progress container. - -This policy does not forbid an initial concrete planning, design, or investigation Ticket when the user asks for one. The deprecated pattern is a long-lived umbrella/progress-container Ticket whose main purpose is to keep a broad effort open while other concrete Tickets carry the actual work. - -Existing umbrella Tickets may be retired without rewriting history. Once concrete follow-up Tickets and any needed Objective context exist, close the umbrella as superseded/decomposed. The close resolution should state that the container role is retired, not that every related future concern is complete, and should list completed concrete Tickets plus remaining follow-up Tickets/Objectives. - -## Ticket contents - -A useful Ticket states: - -- background and motivation; -- requirements; -- acceptance criteria; -- relevant binding decisions/invariants, implementation latitude, and escalation conditions; -- readiness, open questions, and risk flags when relevant; -- implementation reports when work is submitted; -- reviews; -- final resolution when closed. - -Keep long research dumps out of the item body. Attach necessary bounded artifacts through the Ticket evidence surface and summarize the conclusion in the thread. - -Do not store secrets, credentials, private prompt contents, or raw logs containing secrets in Ticket bodies, thread entries, artifacts, diagnostics, or model-visible prompts. - -## Backend CLI: `yoi ticket` - -The product CLI resolves an explicit or configured Backend target and performs Ticket operations through the Workspace API. It does not open a repository-local backend. Use command help for the current typed command surface. - -Legacy `ticket init` and `ticket import-local` workflows are unsupported. No automatic migration reads old `.yoi/tickets` trees; export data with an older version before upgrading if it must be retained, then import it through an explicit supported Workspace surface. - -## Validation - -Run at least: - -```sh -yoi ticket doctor -git diff --check -``` - -Implementation Tickets usually also need focused tests and broader checks, for example: - -```sh -cargo fmt --check -cargo check --workspace --all-targets -cargo test -p -``` - -Record validation commands and results in the implementation report or resolution. +Validate the actual result with checks appropriate to its acceptance criteria. Code changes follow `AGENTS.md` and [Rust testing strategy](rust-testing-strategy.md): begin with the smallest contract-proving target/filter, then the required compile/test/format checks. Record only validation actually run. Narrow effective instruction/tool fixtures protect inclusion, schema, and policy wording; they do not prove real Worker judgment or end-to-end behavior. diff --git a/docs/development/worker-drive.md b/docs/development/worker-drive.md new file mode 100644 index 000000000..3099d73c2 --- /dev/null +++ b/docs/development/worker-drive.md @@ -0,0 +1,171 @@ +# Worker Drive tools and WIP + +Drive is Workspace-owned DB hierarchy and local blob storage exposed through the +authorized Server API. It is **not** a Workdir, mount, Git checkout, or a Session +attachment store. Worker code never accesses the Server's physical blob root. + +## Enablement and authority + +Set `feature.drive = { enabled = true; };` in a DCDL Profile (or +`[feature.drive] enabled = true` in TOML). Only activation is accepted; Profile +fields cannot specify Workspace, grant, actor, access, or host paths. Default, +Coder, Companion, Intake, Orchestrator and Ticket Worker builtin Profiles select +the feature. Standalone, Reviewer and Job defaults do not. Host must inject an +available Workspace-bound `WorkspaceClient`; standalone activation is inert. + +An authorized Workspace owner must independently grant the **registered Runtime +Worker** whole-Drive `read_only` or `read_write` access through the T-722 grant API. +Every read, metadata resolution, mutation, chunk and receipt query passes current +Backend authorization. An enabled feature, Subject, Ticket assignment, path, ID, +or observed validator is not permission. A read-only grant is never upgraded. +The current API has no read-only capability descriptor endpoint: an authorized +WIP inspect describes the operations for the entry kind, while Backend rejects +writes without a write grant. Tool permission configuration also applies to +native Invoke, including fail-closed `ask` where approval cannot be obtained. + +Internal SubWorkers (including Reviewer children) have no separate Backend Drive +identity/grant contract today. Spawn suppresses Drive activation **after every +selector resolution**, including `inherit` and explicit builtin Profiles, and +wraps the Workspace client to deny Drive JSON/binary operations. They cannot use +the parent's shared client to inherit Drive read or write. Perform such work in +the authorized parent, or a separately registered Runtime Worker with its own +current Backend grant. Any future internal delegation must introduce an explicit +parent-bounded scope and Backend reauthorization, not remove this fence based on +Profile activation. The wrapper preserves non-Drive Workspace operations and +Reviewer attestation. + +## Normal Tools + +`DriveRoot {}` discovers the authorized root and returns `{path, metadata}`. +Public entry inputs are **Workspace-bound** references: + +```json +{"workspace_id":"workspace-id","node_id":"9007199254740993"} +``` + +Node IDs and revisions remain canonical positive decimal **strings**, including +values above JavaScript's safe-integer range. Renaming/moving preserves IDs and +authenticated latest URLs. Deleting/recreating the same name allocates a new ID. + +| Tool | Input/use | +| --- | --- | +| `DriveMetadata` | `entry`: one live entry and its observed revision | +| `DriveList` | optional `parent` (default root), `limit` 1–200, opaque `after` | +| `DriveSearch` | `query`, optional `include_text`, `limit` 1–128, opaque `after` | +| `DriveRead` | `entry`, optional `max_bytes` 1–65,536; reports `truncated` | +| `DriveCreateFolder` | `parent`, logical `name` | +| `DriveCreateText` | `parent`, `name`, `content` <=64 KiB, optional `content_type` (Markdown by default) | +| `DriveWrite` | observed `entry`, complete replacement `content` <=64 KiB | +| `DriveEdit` | observed `entry`, `old_string`, `new_string`, optional `replace_all` | +| `DriveRelocate` | observed `entry`, destination `parent`, new/current `name` | +| `DriveDelete` | observed `entry`; folder deletion follows Backend rules | +| `DriveViewImage` | `entry`; bounded revision-fixed image bytes, not a URL | +| `DriveSaveWorkdir` | explicit `target_workdir` alias, relative `path`, destination `parent`, `name`, optional `content_type` (octet-stream by default) | +| `DriveRequestStatus` | `request_id` from an uncertain mutation | + +List/search are paged discovery only, not implicit mutation observations. Omitting +`limit` uses the Backend maximum (200 for list, 128 for search) in both Tools and +native WIP; use the returned `next_after` to continue. Read or +metadata-inspect an entry before replacing, editing, moving or deleting it. The +Tools adapter retains at most 256 observations, scoped to its injected Workspace. +Eviction requires a new explicit observation; it never substitutes another entry. +A successful mutation removes the prior Tools observation. This is a bounded +precondition cache, not a grant cache, second hierarchy DB, or durable history of +all Drive versions. Restored workers observe again before mutating. + +`DriveEdit` uses the same pure `fs_operation::text` rules as Workdir and Workspace +config: nonempty `old_string`, different replacement, exactly one nonoverlapping +match unless `replace_all=true`, and bounded resulting text. It fetches a full +<=64 KiB preimage and verifies the **previously observed** revision. It cannot edit +truncated content. The Backend's atomic CAS still decides the first winner. + +## Native WIP 0.2.0 + +Start with `Inspect {"path":"/drive"}`. `/drive` offers root/metadata, list/search, +creation, Workdir save and receipt query. Invoke selects the structured Interface +reference shown by Inspect: + +```json +{ + "path":"/drive", + "interface":{"scope":"/drive","name":"yoi.drive/entry/v1"}, + "operation":"list", + "arguments":{"limit":20} +} +``` + +Direct entry paths returned by discovery are: + +```text +/drive// +``` + +Use the returned path rather than hand-constructing it. A file exposes metadata, +read, write/edit, relocate/delete and view_image; a folder exposes metadata, +list, create_folder/create_text, save_workdir and relocate/delete where valid. +A native target binds its entry or creation parent: no ID or revision argument is +needed. Relocate's `parent` is a WIP typed entry path. Each entry is self-scoped; +Host captures target, scope object, descriptor, validator and handler together. +Client retains and supplies metadata automatically. Do not copy validators or +`scope_ref` through model arguments. + +Drive has **no indexable entry children**, regardless of the size of the Drive or +the Client's already-inspected entries. Tree shows the Drive entrance, not a file +inventory. List/search return bounded metadata and paths; only inspect the +selected path. No Worker index/embedding or eager body injection is added. + +## Conflicts, unknown outcomes and publication + +Mutations use a Host-generated request identity, observed node ID and exact +revision. Conflict never causes an adapter to replace the expected revision with +latest and retry. Observe explicitly, reconsider the edit and submit a deliberate +new operation. Native Client stale handling likewise does not replay dispatch. + +Timeout, lost response, oversized/invalid mutation response or invalid completion +may mean **OutcomeUnknown**, including a completion with a mismatched request ID +or invalid entry metadata. The adapter queries the *same* request receipt once; +only a committed receipt with matching IDs and valid metadata returns confirmed +DB success. Otherwise it preserves unknown and the `request_id`. Query +`DriveRequestStatus`; an `uncommitted` snapshot is not +proof that an in-flight request cannot later commit. Do not retry blindly with a +fresh request ID. Backend grants also protect receipt lookup/replay. Safe typed +Denied/NotFound/Conflict/Invalid/Limit/StorageUnavailable/OutcomeUnknown errors are +classified without reflecting provider paths, SQL, transport URLs or secrets. + +Saving a Workdir artifact reads through the selected alias's scoped session +`read_bytes`, checks path/offset/size/hash on every bounded chunk and verifies the +assembled SHA256. Source read and destination write are independent. Unknown +aliases, outside-scope paths, traversal/absolute host paths, changed sources, +source read denial, oversized bodies and destination denial cannot publish a +success. Upload transfers raw bytes internally with size/SHA256 metadata; the +model never has to use Bash, a raw host path, or generate base64. Success is the +Backend's **DB-publication commit**, not merely blob transfer completion. + +## Images and historical observations + +ViewImage reads <=10 MiB PNG/JPEG/GIF/WebP bytes in <=64 KiB Drive chunks at the +metadata revision. Every chunk is authorized and revision-fixed. An intervening +update expires the read; no mixed-generation image is attached. Media is detected +from bytes using the existing image helper, not the filename or a download URL. + +Tools and native Invoke return `ImageAttachment` through `ToolOutput`. Native +attachments accompany—not replace or bypass validation of—the WIP return value. +Engine history, durable Session capture/log replay and normal ToolResult pruning +own those bytes. Later Drive updates/deletion/revocation do not rewrite the text +or image already recorded in a Session; restore reproduces that observation until +normal pruning/correction removes details append-only. This is not full Drive +version history and does not use unrecorded per-request message insertion. + +## Validation seams + +- `cargo test -p worker --lib feature::builtin::drive` +- `cargo test -p worker --lib native_invoke` +- `cargo test -p worker --lib native_attachments_do_not_bypass` +- `cargo test -p worker-runtime --lib binary` +- `cargo test -p yoi-workspace-server --lib worker_drive_tests` + +Server adapter tests use isolated DB + LocalFileSystem + production typed HTTP +router and fresh Runtime proofs/current Worker grants. Worker tests cover +bounded observations, exact CAS inputs, no blind resend, Tools/WIP schemas, +conditional registration, child denial and native image capture/replay. See +`AGENTS.md` for root check, changed-crate tests, format and diff requirements. diff --git a/docs/report/t-714-ticket-read-auth-blocker.md b/docs/report/t-714-ticket-read-auth-blocker.md new file mode 100644 index 000000000..9c9bc0062 --- /dev/null +++ b/docs/report/t-714-ticket-read-auth-blocker.md @@ -0,0 +1,29 @@ +# T-714: Ticket authority read rejected after implementation validation + +## Observed boundary + +On the continuation turn, `ShowTicket({ id: "T-714", event_limit: 5 })` +returned `Workspace Ticket API request failed with HTTP status 401`. +The same assigned Coder session had successfully read the Ticket and recorded +one shared-seam coordination decision earlier. This report does not diagnose +whether the rejection is authentication expiry, capability invalidation, or +another Backend condition. + +## Consequence + +The local checkout implementation and validation were complete. Git retained the +assigned `work/t-714-checkout-discovery` branch and all changes; coherent local +commits were authorized by the user. Publication/MR creation and review were +paused because a fresh authoritative Ticket/MR read could not be obtained. +No alternate actor, CLI, Backend storage access or replacement control-plane +path was attempted. No target integration or live dogfood update occurred. + +## Improvement / recovery + +Provide an actionable typed error distinction between authentication failure and +capability rejection, and a host-owned way to restore valid transport authority +without changing actor identity or bypassing the rejected capability. After that, +reread the Ticket and any linked MR, normally publish only its original source +selector, verify exact provider HEAD, and request an independently bound review. +The local implementation/validation evidence is in commits and +`docs/development/wip-integration-validation.md`; no approval is claimed here. diff --git a/docs/report/t729-restored-base-test-compile-blocker.md b/docs/report/t729-restored-base-test-compile-blocker.md new file mode 100644 index 000000000..1bdb6bdf0 --- /dev/null +++ b/docs/report/t729-restored-base-test-compile-blocker.md @@ -0,0 +1,33 @@ +# T-729: restored base test-only compile blocker + +The assigned checkout starts at `f9d4c97f` (WIP 0.2 integration). On 2026-10-08, +`cargo test -p yoi-workspace-server --lib auth_logging_tests` could not compile +because `server_workspace_config_wip_tests.rs` still calls `WipRuntime::discover` +(lines 152 and 192), which no longer exists, and external `WipRuntime::call` +(lines 170 and 200), which is now crate-private. At the initial failure those +files were unchanged by the T-729 diagnostic work. + +A narrow test filter still builds all lib test modules, so it cannot isolate the +new authentication boundary tests from this unrelated test API mismatch. This +was escalated on T-729 instead of silently disabling those tests or widening the +Ticket to migrate unrelated configuration tests. Future API migration work should +check affected test-only consumers as well as the normal compilation closure. + +The additional no-default-features check also fails in unchanged Runtime modules: +`resource.rs`, `worker_backend.rs` and `worker_source.rs` import feature-gated +`workspace_request`, `reqwest` and `futures` unconditionally. The no-default lib +test build additionally calls feature-gated filesystem constructors. This is not +an auth diagnostic regression and was not repaired by broadening T-729. Default +Runtime crate tests pass (345 library tests, 17 tests in each of the two binaries); +root `cargo check` passes. After correcting the T-729 SHA formatting, the Server +lib test build reports only the four existing WIP API errors above. + +## Resolution of the Server prerequisite + +Ticket T-729 seq7 explicitly authorized a narrow test-only migration. Commit +`717ff255` replaces the helpers with public Tree, path-centered Inspect and +structured InterfaceReference Invoke. All existing Backend authority, attachment, +mutation, stale observation and unknown-outcome assertions are retained. All 12 +`workspace_config_integration` tests pass, and the authentication logging tests +now run (3 passed). No production API was exposed or legacy API revived. The +no-default-features issue remains separate and unresolved. diff --git a/openapi/server-api.json b/openapi/server-api.json index 6d9e3fcac..4796da2c6 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -13659,10 +13659,11 @@ "status": { "type": "string" }, - "worker_creation_available": { + "supports_workdir_attachments": { + "description": "Whether this Runtime supports explicitly selected Workdir attachments.\nWorkdirs are optional for generic launches; embedded Runtime does not support them.", "type": "boolean" }, - "working_directory_required": { + "worker_creation_available": { "type": "boolean" } }, @@ -13671,7 +13672,7 @@ "display_name", "built_in", "worker_creation_available", - "working_directory_required", + "supports_workdir_attachments", "status", "diagnostics" ], @@ -16428,7 +16429,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:5f767ab90e6507d2e9486cd2efe74f62d613912ffa830ad617a088b2848f20da" + "x-yoi-source-digest": "sha256:6dd3e9e7ffe5e653697addc29675545b5fb28ea856b14bafc580355f0453ec12" }, "openapi": "3.1.0", "paths": { diff --git a/resources/config-schema/profile-authoring.dcdl b/resources/config-schema/profile-authoring.dcdl index d2cb97ce3..d05bd5156 100644 --- a/resources/config-schema/profile-authoring.dcdl +++ b/resources/config-schema/profile-authoring.dcdl @@ -40,7 +40,7 @@ let task = Flag; web = Flag; image = Flag; sub_worker = Flag; flow = Flag; worker = { enabled = Bool; direct_spawn = Bool; }; workspace_worker_discovery = Flag; objective = Flag; manage_workdir = Flag; - workdir_catalog = Flag; workspace_config = Flag; orchestration = Flag; + workdir_catalog = Flag; workspace_config = Flag; drive = Flag; orchestration = Flag; ticket = { enabled = Bool; authoring = Bool; thread = Bool; intake = Bool; workflow = Bool; }; merge_request = { show = Bool; open = Bool; review = Bool; readiness_check = Bool; complete = Bool; }; memory = { diff --git a/resources/flows/coder-review.dcdl b/resources/flows/coder-review.dcdl index de1e7ef6b..b212d11e4 100644 --- a/resources/flows/coder-review.dcdl +++ b/resources/flows/coder-review.dcdl @@ -1,3 +1,7 @@ +# Optional code Git/MR recipe, selected only when it fits the requested work. +# This Flow is not a generic Ticket pipeline or Backend completion requirement. +# Transitions guide implementation/review/handoff; they cannot grant review or merge +# authority, judge natural-language satisfaction, or conclude the Ticket. { schema_version = 1; name = "coder-review"; @@ -5,7 +9,7 @@ states = { implement = { - instructions = "Inspect the assigned Workdir Git state before editing. Reuse a suitable restored `work/-` branch, or create a collision-safe work branch from detached HEAD; never overwrite an existing branch. For this assigned Ticket Workdir, you are explicitly authorized to create or switch the local work branch and to use `git add` and `git commit`. Implement the requested Ticket scope, run the narrow and dependent validation required by the changed contracts, and record concrete evidence in coherent commits. After the implementation is committed, validated, and clean, publish only the current Ticket work branch to the Ticket repository remote with a normal non-force push, then verify that the provider resolves the existing Merge Request `selector_from` to exact local `HEAD`. Do not push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Open one Merge Request with `OpenMergeRequest`, or use `ShowMergeRequest` when one is already open; keep its original selectors and advance the same source ref. Never invent an add-revision operation, replacement Merge Request, or fresh integration branch. Source movement requires fresh review; target-only movement does not invalidate source approval and remains Orchestrator integration authority. Do not request review from a dirty Workdir or unpublished source ref. A Flow transition is never Ticket completion authority."; + instructions = "This is an optional code recipe for requested Git publication and independent review, not a universal Ticket gate. If the user requests no review or return without conclusion, honor that bounded request and do not treat this recipe as authority to conclude the Ticket. Inspect the assigned Workdir Git state before editing. Reuse a suitable restored `work/-` branch, or create a collision-safe work branch from detached HEAD; never overwrite an existing branch. Use local work-branch switching, `git add`, `git commit`, and publication only within the request and existing grants; this recipe does not override an explicit no-commit/no-push instruction or expand resource authority. Implement the requested Ticket scope, run the narrow and dependent validation required by the changed contracts, and record concrete evidence in coherent commits. After the implementation is committed, validated, and clean, publish only the current Ticket work branch to the Ticket repository remote with a normal non-force push, then verify that the provider resolves the existing Merge Request `selector_from` to exact local `HEAD`. Do not push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Open one Merge Request with `OpenMergeRequest`, or use `ShowMergeRequest` when one is already open; keep its original selectors and advance the same source ref. Never invent an add-revision operation, replacement Merge Request, or fresh integration branch. Source movement requires fresh review; target-only movement does not invalidate source approval and remains Orchestrator integration authority. Do not request review from a dirty Workdir or unpublished source ref. A Flow transition is never Ticket completion authority."; transitions = { review = { target = "review"; diff --git a/resources/profiles/backend-job.dcdl b/resources/profiles/backend-job.dcdl index cedd59bf0..e4caa214e 100644 --- a/resources/profiles/backend-job.dcdl +++ b/resources/profiles/backend-job.dcdl @@ -10,6 +10,7 @@ import "./base.dcdl" // { }; feature = { + drive = { enabled = false; }; task = { enabled = false; }; memory = { enabled = false; }; subjektiv = { enabled = false; extraction = { enabled = false; }; }; diff --git a/resources/profiles/base.dcdl b/resources/profiles/base.dcdl index 34a92c155..da70358ca 100644 --- a/resources/profiles/base.dcdl +++ b/resources/profiles/base.dcdl @@ -25,6 +25,8 @@ compaction = { }; feature = { + # Activation only; Backend Drive grants remain independent. + drive = { enabled = false; }; task = { enabled = true; }; memory = { enabled = false; staging_tools = false; }; subjektiv = { diff --git a/resources/profiles/coder.dcdl b/resources/profiles/coder.dcdl index 6df406aaa..d082753c8 100644 --- a/resources/profiles/coder.dcdl +++ b/resources/profiles/coder.dcdl @@ -1,6 +1,6 @@ import "./base.dcdl" // { slug = "coder"; - description = "Ticket implementation coder profile."; + description = "Code implementation with optional Git/MR review recipe; no universal Ticket conclusion gate."; scope = "workspace_write"; model = { ref = "codex-oauth/gpt-6.1-sol"; }; engine = { @@ -9,6 +9,7 @@ import "./base.dcdl" // { }; feature = { + drive = { enabled = true; }; task = { enabled = true; }; memory = { enabled = false; }; subjektiv = { enabled = true; }; diff --git a/resources/profiles/companion.dcdl b/resources/profiles/companion.dcdl index bbce5930e..bf3a002fe 100644 --- a/resources/profiles/companion.dcdl +++ b/resources/profiles/companion.dcdl @@ -5,6 +5,7 @@ import "./base.dcdl" // { model = { ref = "codex-oauth/gpt-6-astra"; }; feature = { + drive = { enabled = true; }; task = { enabled = true; }; memory = { enabled = false; }; subjektiv = { enabled = true; }; diff --git a/resources/profiles/default.dcdl b/resources/profiles/default.dcdl index 4ddfb0a5e..1464e3781 100644 --- a/resources/profiles/default.dcdl +++ b/resources/profiles/default.dcdl @@ -5,6 +5,8 @@ import "./base.dcdl" // { delegation_scope = "workspace_write"; feature = { + # Activation only; Backend Drive grants remain independent. + drive = { enabled = true; }; task = { enabled = true; }; memory = { enabled = false; staging_tools = false; }; subjektiv = { enabled = false; extraction = { enabled = false; }; }; diff --git a/resources/profiles/intake.dcdl b/resources/profiles/intake.dcdl index 649b4e166..e38356424 100644 --- a/resources/profiles/intake.dcdl +++ b/resources/profiles/intake.dcdl @@ -5,6 +5,7 @@ import "./base.dcdl" // { engine = { instruction = "role.intake"; }; feature = { + drive = { enabled = true; }; task = { enabled = true; }; memory = { enabled = false; }; subjektiv = { enabled = true; }; diff --git a/resources/profiles/job.dcdl b/resources/profiles/job.dcdl index 87683ac27..f4ba29891 100644 --- a/resources/profiles/job.dcdl +++ b/resources/profiles/job.dcdl @@ -9,6 +9,7 @@ engine = { }; session = { record_event_trace = false; }; feature = { + drive = { enabled = false; }; task = { enabled = false; }; memory = { enabled = false; staging_tools = false; }; subjektiv = { enabled = false; consolidation_tools = false; extraction = { enabled = false; }; }; diff --git a/resources/profiles/orchestrator.dcdl b/resources/profiles/orchestrator.dcdl index dee53280a..37870dcff 100644 --- a/resources/profiles/orchestrator.dcdl +++ b/resources/profiles/orchestrator.dcdl @@ -1,10 +1,11 @@ import "./base.dcdl" // { slug = "orchestrator"; - description = "Ticket orchestrator profile."; + description = "Intent-led Ticket orchestration with separate guarded MR integration authority."; scope = "workspace_write"; engine = { instruction = "role.orchestrator"; }; feature = { + drive = { enabled = true; }; task = { enabled = true; }; memory = { enabled = false; }; subjektiv = { enabled = true; }; diff --git a/resources/profiles/standalone.dcdl b/resources/profiles/standalone.dcdl index 05990e37b..22a0920fd 100644 --- a/resources/profiles/standalone.dcdl +++ b/resources/profiles/standalone.dcdl @@ -2,6 +2,7 @@ import "./default.dcdl" // { slug = "standalone"; description = "Standalone Yoi coding profile."; model = { ref = "codex-oauth/gpt-6.1-sol"; }; + feature = { drive = { enabled = false; }; }; scope = { intent = "workspace_write"; symlink_policy = "logical"; diff --git a/resources/profiles/ticket-worker.dcdl b/resources/profiles/ticket-worker.dcdl index 4da55b41e..03168dc25 100644 --- a/resources/profiles/ticket-worker.dcdl +++ b/resources/profiles/ticket-worker.dcdl @@ -1,6 +1,6 @@ import "./default.dcdl" // { slug = "ticket-worker"; - description = "General Ticket work with typed progress and completion tools."; + description = "General intent-led Ticket work with optional result recording and authorized conclusion decisions."; engine = { instruction = "role.ticket_worker"; }; diff --git a/resources/prompts/catalog.dcdl b/resources/prompts/catalog.dcdl index a5a903411..a5fd621c5 100644 --- a/resources/prompts/catalog.dcdl +++ b/resources/prompts/catalog.dcdl @@ -6,6 +6,7 @@ let defaultDocument = import "./default.md"; commonLanguage = import "./common/language.md"; commonGit = import "./common/git.md"; +commonDrive = import "./common/drive.md"; commonMergeRequest = import "./common/merge-request.md"; commonTickets = import "./common/tickets.md"; commonToolUsage = import "./common/tool-usage.md"; @@ -44,6 +45,7 @@ in default_prompt = defaultDocument.content; common = { git = commonGit.content; + drive = commonDrive.content; language = commonLanguage.content; merge_request = commonMergeRequest.content; tickets = commonTickets.content; diff --git a/resources/prompts/common/drive.md b/resources/prompts/common/drive.md new file mode 100644 index 000000000..c845f5ca4 --- /dev/null +++ b/resources/prompts/common/drive.md @@ -0,0 +1,7 @@ +## Workspace Drive + +Drive is Backend-owned workspace storage, not a Workdir or a host filesystem path. Feature/Profile activation is not a grant; every operation is checked by the Backend. Use the exposed Drive tools or the native WIP `/drive` entry with bounded listing and reading. Do not enumerate a Drive tree or infer authority from a visible reference. + +Observe an entry before replacing or deleting it; the host/client carries its revision precondition. On conflict, reread and reconcile rather than overwriting blindly. On `OutcomeUnknown`, retain the request ID and query request status before another mutation: the operation may have committed. Save a Workdir file into Drive only through a current scoped attachment using its logical relative source path; saving does not attach a Workdir or grant file access. + +Internal SubWorkers currently have no explicit Backend Drive grant mechanism. They must not inherit Drive access through a parent's shared WorkspaceClient, even with an enabled or inherited Profile. Ask the authorized parent to perform the operation instead. diff --git a/resources/prompts/common/merge-request.md b/resources/prompts/common/merge-request.md index fe01c9ac4..308098938 100644 --- a/resources/prompts/common/merge-request.md +++ b/resources/prompts/common/merge-request.md @@ -1,9 +1,11 @@ ## Merge Request workflow -The Merge Request and its append-only thread are the routine authority for review requests, verdicts, fixes, rereview, readiness, and integration evidence. One Ticket may link multiple repository-scoped Merge Requests. After `OpenMergeRequest` returns an ID, every read, review, readiness, and integration operation must explicitly address that `merge_request_id`; never let a Ticket lookup implicitly choose one. Use the operation-specific tools exposed to your role: `OpenMergeRequest`, `ShowMergeRequest`, `ReviewMergeRequest`, `CheckMergeRequestReadiness`, `CompleteMergeRequest`, and `CompleteTicket`. +This guidance applies when the requested work uses a Merge Request; it is not a universal Ticket pipeline or conclusion gate. The Merge Request and its append-only thread are the routine authority for review requests, verdicts, fixes, rereview, readiness, and integration evidence. One Ticket may link multiple repository-scoped Merge Requests. After `OpenMergeRequest` returns an ID, every read, review, readiness, and integration operation must explicitly address that `merge_request_id`; never let a Ticket lookup implicitly choose one. Use the operation-specific tools exposed to your role: `OpenMergeRequest`, `ShowMergeRequest`, `ReviewMergeRequest`, `CheckMergeRequestReadiness`, and `CompleteMergeRequest`. An open Merge Request keeps one immutable `selector_from` and `selector_to`. Advance only the existing source selector with a normal non-force push; do not open a replacement Merge Request, invent an add-revision operation, or create a fresh integration branch for each fix or target movement. Before opening or requesting review, publish the exact source and verify that the provider resolves `selector_from` to local `HEAD`. -A review verdict is valid only for the exact provider-resolved source ref or immutable merged-result subject, selected Merge Request, complete linked MR/source-result snapshot, and current Ticket item revision captured by `ReviewRequested`. Moving a source ref or substantively rescoping the Ticket requires a fresh review. After integration, a fresh review may explicitly address a merged MR to attest updated Ticket requirements against the immutable merged-result snapshot; it does not revise the MergeResult. Moving only the target ref does not invalidate approval for an unchanged source; it requires refreshed readiness/integration evidence against the current target. Target integration and `CompleteMergeRequest` are Orchestrator authority, not Coder or Reviewer authority. `CompleteMergeRequest` records only that selected repository result; it does not finish the Ticket or release its assignment. After every linked required result is integrated, `CompleteTicket` revalidates the exact linked-MR set, current Ticket revision, explicitly identified Reviewer requirement approval, state, and assignment before completing the Ticket. +A review verdict is valid only for the exact provider-resolved source ref or immutable merged-result subject, selected Merge Request, complete linked MR/source-result snapshot, and current Ticket item revision captured by `ReviewRequested`. Moving a source ref or substantively rescoping the Ticket requires a fresh review. After integration, a fresh review may explicitly address a merged MR to attest updated Ticket requirements against the immutable merged-result snapshot; it does not revise the MergeResult. Moving only the target ref does not invalidate approval for an unchanged source; it requires refreshed readiness/integration evidence against the current target. Trusted Reviewer authority—not prose, Profile/Flow selection, or a tool mechanism alone—records the independent judgment. Target integration and `CompleteMergeRequest` are Orchestrator authority, not Coder or Reviewer authority. An instruction that review is not required for the Ticket does not bypass the guarded review requirements for MR integration. -Current Ticket, Merge Request, provider refs, and thread evidence take precedence over stale Memory, old implementation reports, branch-name assumptions, or previous instructions that describe a revision-based workflow. Reread the Ticket and `ShowMergeRequest` before decisions. If source or target movement races with review or completion, stop and reread current authority rather than reusing stale evidence. +`CompleteMergeRequest` records only that selected repository result; it does not finish the Ticket or release its assignment. `CompleteTicket` is an independent authorized decision with a reason and current item/state evidence, not an MR-set/approval gate. Judge the Ticket's requested result and return path separately: it may be completed or closed without MR integration, or returned without conclusion. Do not claim an unmerged result was integrated. + +Current Ticket, Merge Request, provider refs, and thread evidence take precedence over stale Memory, old implementation reports, branch-name assumptions, or previous instructions that describe a revision-based workflow. Reread the Ticket and `ShowMergeRequest` before decisions. If source or target movement races with review or integration, stop and reread current authority rather than reusing stale evidence. diff --git a/resources/prompts/common/tickets.md b/resources/prompts/common/tickets.md index ff8121408..8e44c005d 100644 --- a/resources/prompts/common/tickets.md +++ b/resources/prompts/common/tickets.md @@ -1,9 +1,13 @@ ## Ticket workflow -Use the available typed Ticket tools as the authority for Ticket reads and mutations. Use `QueryTicket` for bounded discovery and filtering, then `ShowTicket` for the authoritative item revision, thread/evidence, relations, linked Objectives, and current Merge Request context before routing, review, or closure decisions. Current linked-MR evidence is completion authority; `implementation_report` entries are optional historical/audit context and must not be required for integration readiness. Do not invoke a Ticket CLI or edit backend storage directly as an alternative implementation of those tools. +Use the available typed Ticket tools as the authority for Ticket reads and mutations. Use `QueryTicket` for bounded discovery and filtering, then `ShowTicket` for the authoritative item revision, thread/evidence, relations, linked Objectives, and any current Merge Request context before routing, review, or conclusion decisions. Do not invoke a Ticket CLI or edit backend storage directly as an alternative implementation of those tools. -Read the relevant Ticket before making implementation, routing, review, state, or closure decisions. Do not infer the current contract from an id, title, notification, or remembered summary alone. Check related or potentially duplicate Tickets when creating or materially rescoping work. +Read the relevant Ticket before making implementation, routing, review, state, or conclusion decisions. Natural-language user intent, requirements, acceptance criteria, and binding decisions govern the work; a Ticket is not necessarily code or a fixed implementation/review/merge pipeline. Do not infer the current contract from an id, title, notification, or remembered summary alone. Check related or potentially duplicate Tickets when creating or materially rescoping work. -Keep durable Ticket records centered on user intent, confirmed background, requirements, acceptance criteria, binding decisions, and implementation/review evidence. Do not copy your own role boundary, current-turn plan, work report, or decision to stop after Ticket authoring into the Ticket as a product prohibition or execution constraint. Do not add constraints the user did not request; record an approval gate only when the user places that gate on the Ticket itself. These are authoring rules that you apply with the request context, not facts for an independent text-only checker to infer. For implementation and review workflows, keep routine revision, verdict, fix, and rereview evidence on the Merge Request; use Ticket comments only for blockers or decisions requiring orchestration attention and the final approved handoff. Use `QueryObjective` for bounded Objective discovery and `ShowObjective` for authoritative revision and linked-Ticket context when coordinating broader work. Separate confirmed facts from user claims, hypotheses, and open questions. Avoid prematurely turning implementation tactics into requirements. +Keep durable Ticket records centered on user intent, confirmed background, requirements, acceptance criteria, binding decisions, and useful evidence. Do not copy your own role boundary, current-turn plan, work report, or decision to stop after Ticket authoring into the Ticket as a product prohibition or execution constraint. Do not add constraints the user did not request; record an approval gate only when the user places that gate on the Ticket itself. These are authoring rules that you apply with the request context, not facts for an independent text-only checker to infer. Separate confirmed facts from user claims, hypotheses, and open questions. Avoid prematurely turning implementation tactics into requirements. Use `QueryObjective` for bounded Objective discovery and `ShowObjective` for authoritative revision and linked-Ticket context when coordinating broader work. -Treat workflow states and relations as typed domain data rather than filesystem layout or naming conventions. Distinguish implementation completion from review and closure, and perform only lifecycle actions supported by the tools and authority available to the current Worker. +Choose a result and return path suited to the request: conversation, an optional Ticket comment, an optional Drive document/artifact when that surface is available, or a repository result. Results need not be persisted as comments or artifacts when the request does not require it. In an MR workflow, keep routine review requests, verdicts, fixes, and rereview evidence on the explicitly addressed MR rather than duplicating each iteration on the Ticket. Do not require an `implementation_report` or treat linked-MR evidence as universal Ticket completion authority. + +Use authorized `TicketWorkflowState`, `CompleteTicket`, or `TicketClose` decisions with current item/state evidence and a reason when the request calls for them. Backend tools enforce actor authority, CAS, replay, and resource boundaries, not natural-language satisfaction or a universal state sequence. No Merge Request or approval is a prerequisite for a generic Ticket decision. Default code Git/MR practices remain separate from these Backend requirements; they do not grant review or merge authority. A request may require independent review before merge, no review, completion/closure without integration, or return without concluding the Ticket. Respect the selected return path: a turn ending or a result report is not itself a completion decision. Mechanism and recorded evidence are not a substitute for real judgment about whether the requested work is satisfied. + +Treat workflow states and relations as typed domain data rather than filesystem layout or naming conventions. Distinguish a result report, Ticket conclusion, MR review/integration, unfinished assignment, and Worker lifecycle. Terminal current responsibility can remain for display after unfinished work ends; stopping a Worker alone does not end unfinished work, and Ticket conclusion alone does not stop, remove, or clean up its Worker or attachments. Perform only lifecycle actions supported by the tools and authority available to the current Worker. diff --git a/resources/prompts/role/coder.md b/resources/prompts/role/coder.md index 2d78b1f9c..743d20f41 100644 --- a/resources/prompts/role/coder.md +++ b/resources/prompts/role/coder.md @@ -1,8 +1,10 @@ -You are the assigned Coder. Implement the requested scope in the provided Workdir and keep durable evidence on the Ticket and its Merge Request. +You are the assigned Coder. Implement the requested scope in the provided Workdir. Follow natural-language user intent and the requested return path rather than imposing a universal review, merge, or Ticket conclusion gate. The following Git/MR practices are the default code recipe unless the bounded request or return path says otherwise, not Backend prerequisites for every Ticket. The optional `builtin:coder-review` Flow guides that recipe and does not grant authority. A request for no review or return without conclusion must not silently become a review cycle or `CompleteTicket` decision; independent review before merge still requires the trusted Reviewer path, and merge remains Orchestrator authority. + +Return the implementation summary, changed files, validation, and limitations in conversation and optionally a Ticket comment or available Drive result when useful or requested. Ticket conclusion is a separate authorized decision based on actual satisfaction and user intent, not an automatic result of a commit, approval, or terminal Flow state. Use each existing repository-scoped Merge Request as the routine authority for its review requests, verdicts, fixes, and rereview cycles. A Ticket may have multiple Merge Requests, but at most one open Merge Request for a given repository. `OpenMergeRequest` creates that repository's selector-based Merge Request; if one already exists, use its explicit ID with `ShowMergeRequest`, keep its original selectors, and advance only that same source ref with a normal non-force push. Never invent an add-revision operation, replacement Merge Request, or fresh integration branch. Source movement requires fresh review of the exact new ref. Target-only movement does not invalidate source approval and is handled later by Orchestrator integration authority. -Do not add a Ticket comment for each review or fix iteration. Add a Ticket comment only when a blocker or decision requires Orchestrator attention, or once after approval to hand off the final implementation and validation evidence. +Do not add a Ticket comment for each review or fix iteration. An optional Ticket comment can record a blocker or decision requiring Orchestrator attention or concise final implementation and validation evidence when useful or requested; an approved handoff is not mandatory for every task. Treat the first committed user message as the bounded Ticket/action context and do not infer control-plane identity from prose. When multiple Workdirs are attached, route every Workdir tool call by its stable alias. Every declared `read_write` target is an authorized implementation/publish/Merge Request repository, but writable targets are not automatically mandatory deliverables; `read_only` targets are reference material and must never be modified or used as a command target. Treat a Backend capability rejection as authority rather than attempting another path to write. diff --git a/resources/prompts/role/orchestrator.md b/resources/prompts/role/orchestrator.md index bfb556b66..4bcaad8c0 100644 --- a/resources/prompts/role/orchestrator.md +++ b/resources/prompts/role/orchestrator.md @@ -2,9 +2,13 @@ You are the Ticket Orchestrator role. {% include "common.git" %} -Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Use typed Ticket tools and current repository state as authority. Explicitly start bounded Ticket work with `SpawnTicketWorker`, selecting a registered `profile`, an `initial_request`, an optional `flow`, and alias-keyed `workdir_attachments`. Work assignment is independent from the selected Profile: ordinary investigation can use `builtin:default` without a Flow or Workdir; Git implementation can select `builtin:coder` with `builtin:coder-review`. Repository attachments must match declared Ticket targets and Backend derives their capabilities; explicit ExternalGrants retain their own grant ceilings. Never request attachment capabilities or infer review/merge authority from a Profile, Flow, or launch text. Do not first record `inprogress`: the guarded operation accepts initial input, assignment, and attachment finalization before recording start. A state change alone does not launch a Worker. If any Runtime, assignment, or finalization step fails or has an unknown outcome, reread durable operation evidence rather than creating a replacement Worker or reporting acceptance. After start, reread the Ticket and verify `inprogress` and `current_worker` before routing further work. Stop when the Ticket explicitly records a separate approval gate or execution requires a new decision outside its bounded scope. +Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Read the Ticket and follow its natural-language intent and requested return path; do not impose implementation, review, merge, approval, or conclusion on every Ticket. Work may be investigation, analysis, planning, documentation, or code. Results may be returned in conversation, optionally recorded in a Ticket comment or available Drive surface, or published to a repository as requested. Complete, close, continue, or return without conclusion according to the request and actual result, using only authorized typed tools. Tool mechanisms enforce authority, not real judgment of whether requirements are satisfied. -The assigned Coder owns its review/fix loop and launches Reviewer SubWorkers itself. Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers, and do not select a Reviewer profile through the generic WorkerSpawn path. If durable `Review` evidence for the current provider-resolved `selector_from` subject is missing, indeterminate, revoked, cancelled, or requests changes, keep the Ticket in progress and return the requirement to the same assigned Coder; never compensate by creating an independent Reviewer Worker. +Explicitly start bounded Ticket work with `SpawnTicketWorker`, an `initial_request`, and optional registered `profile`, `flow`, and alias-keyed `workdir_attachments`. Omitting the profile selects the general `builtin:ticket-worker`; no Flow or repository attachment is required. Assignment is independent from Profile: choose another registered Profile when its tools and instructions fit the task. Git implementation can select `builtin:coder` and the optional `builtin:coder-review` recipe. Repository attachments must match declared Ticket targets and Backend derives their capabilities; explicit ExternalGrants retain their own grant ceilings. Never request attachment capabilities or infer review/merge authority from a Profile, Flow, or launch text. Recording `inprogress` is not a launch prerequisite: the guarded operation accepts initial input, assignment, and attachment finalization before recording start. A state change alone does not launch a Worker. If any Runtime, assignment, or finalization step fails or has an unknown outcome, reread durable operation evidence rather than creating a replacement Worker or reporting acceptance. After start, reread the Ticket and verify `inprogress` and `current_worker` before routing further work. Stop when the Ticket explicitly records a separate approval gate or execution requires a new decision outside its bounded scope. + +The following Git/MR practices apply to code work using that recipe, not to generic Ticket completion. A review-not-required request does not by itself authorize bypassing guarded MR integration. + +The assigned Coder owns its review/fix loop and launches Reviewer SubWorkers itself. Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers, and do not select a Reviewer profile through the generic WorkerSpawn path. If durable `Review` evidence for the current provider-resolved `selector_from` subject is missing, indeterminate, revoked, cancelled, or requests changes, do not integrate that MR; return the review requirement to the same assigned Coder when that work is requested. Never compensate by creating an independent Reviewer Worker or treating review status alone as a Ticket-state requirement. Treat every linked Merge Request as an explicitly addressed implementation result; never invent a primary MR. For each open MR, a current provider-resolved source ref, repository evidence, an effective approval for that exact subject and current Ticket item revision, and no unresolved request-changes are required before integration. Do not require an `implementation_report`. Reviewer authority—not Backend parsing—judges whether the natural-language Ticket requirements and validation evidence are satisfied. Recheck `ShowTicket`, each `ShowMergeRequest`, and each `CheckMergeRequestReadiness` immediately before guarded integration. Source movement requires a fresh Reviewer attempt. Target-only movement preserves source approval and requires refreshed integration evidence against the current target. @@ -14,16 +18,16 @@ Perform integration through normal source-control operations in the bound Orches Push the resulting `merge_to` branch through its configured normal push path. Preserve repository consistency guards: never rewrite history, bypass branch or Worktree safety, update an unrelated ref, or integrate a source revision different from the reviewed subject. Treat an integration blocker as authoritative only when supported by a concrete source-control or provider failure; before proposing a new control-plane capability, verify that the required operation cannot be expressed through the existing bound Workdir and repository provider. -After each repository push, verify the provider resolves `merge_to` exactly to `target_ref_after`, then call `CompleteMergeRequest` with that explicit MR ID, before/after evidence, authoritative approval event, and merge strategy. `CompleteMergeRequest` records and verifies only that already-applied repository integration; it does not update the branch itself, and it does not complete the Ticket. Partial integration is valid: keep the Ticket and assignment in progress while remaining linked MRs are implemented, reviewed, or integrated. +After each repository push, verify the provider resolves `merge_to` exactly to `target_ref_after`, then call `CompleteMergeRequest` with that explicit MR ID, before/after evidence, authoritative approval event, and merge strategy. `CompleteMergeRequest` records and verifies only that already-applied repository integration; it does not update the branch itself, and it does not complete the Ticket. Partial integration is valid: accurately report remaining linked MRs and follow the requested continuation or return path; do not claim they were integrated. -If a repository push succeeds but MR completion recording fails, do not push again or invent a new result. Retry the same `CompleteMergeRequest` operation and evidence. Once every linked required result is recorded, reread the latest Ticket item revision and exact linked MR/source-result set, identify the effective Reviewer approval that attests that exact snapshot and judges the requirements satisfied, then call `CompleteTicket` with the item revision, MR IDs, and that requirement approval event ID. If the Ticket was rescoped after partial or full integration, obtain a fresh Reviewer attestation against the immutable merged-result subjects through an explicitly addressed merged MR; this post-integration review updates requirement judgment without rewriting any MergeResult. `CompleteTicket` alone moves the Ticket to `done` and releases unfinished work atomically while retaining current responsibility for display; MR-set changes, rescope, review revocation, stale assignment/state, or an unmerged result must fail closed. Retry an already-recorded operation only with the same fingerprint. +If a repository push succeeds but MR completion recording fails, do not push again or invent a new result. Retry the same `CompleteMergeRequest` operation and evidence. Separately judge whether the Ticket's requested result has been satisfied and whether the user requested completion, closure, continuation, or return without conclusion. For an authorized completion decision, reread `ShowTicket` and call `CompleteTicket` with current item revision, expected state, a stable operation key, a reason, and optional supporting references. Generic Ticket completion does not require an MR, integration, or Reviewer approval and does not authorize any of them. The Backend validates actor authority and item/state CAS; it does not judge natural-language satisfaction. Retry an already-recorded operation only with the same fingerprint. Terminal Ticket responsibility remains visible after unfinished work ends. Do not create or delegate an implementation worktree/branch until the Ticket records enough agreed intent, requirements, and acceptance criteria to bound the work. Workspace roots, cwd, profile selector, and launch-prompt configuration are control-plane/environment facts rather than user instructions. If the launch input names explicit Git/worktree operation targets, use those paths only for that operation and do not substitute heuristic roots. -Use `WorkerRemove` only for a terminal or authoritatively reassigned non-internal Coder after implementation, review, fix, merge/commit, and report handoffs are complete. Do not remove a Coder merely because one turn completed or it is temporarily idle; retain it while review or request-changes work can still return. The Worker must already be stopped, must not be restoring, must have no unfinished Ticket work, pending notification, Reviewer handoff, legal hold, or pin, and must not be this Orchestrator. Immediately before removal, reread authoritative Ticket state, assignment, thread/review evidence, and the target Worker through `WorkerList`, then call `WorkerRemove` with a concise reason. Backend authority captures the current Worker revision internally and revalidates removal guards; do not guess policy or supply lifecycle authority in model input. After removal, reread the Worker catalog and attachment state. Treat unfinished-work, running/restoring, retention-policy, attachment-close, and attachment-release conflicts as authoritative failures. `WorkerRemove` releases the Worker attachment but deliberately preserves the Workdir materialization. +Use `WorkerRemove` only for an eligible terminal or authoritatively reassigned non-internal Worker after its requested work and handoffs are complete; implementation, review, integration, and reporting matter only when requested for that work. Do not remove a Worker merely because one turn completed or it is temporarily idle; retain it while requested continuation, review, or request-changes work can still return. The Worker must already be stopped, must not be restoring, must have no unfinished Ticket work, pending notification, Reviewer handoff, legal hold, or pin, and must not be this Orchestrator. Immediately before removal, reread authoritative Ticket state, assignment, thread/review evidence, and the target Worker through `WorkerList`, then call `WorkerRemove` with a concise reason. Backend authority captures the current Worker revision internally and revalidates removal guards; do not guess policy or supply lifecycle authority in model input. After removal, reread the Worker catalog and attachment state. Treat unfinished-work, running/restoring, retention-policy, attachment-close, and attachment-release conflicts as authoritative failures. `WorkerRemove` releases the Worker attachment but deliberately preserves the Workdir materialization. -Coder cleanup is a separate post-completion decision owned by this Orchestrator. Never predeclare `delete_on_completion`, `retain_on_completion`, or equivalent retention policy when launching or reserving a Coder. After all `CompleteMergeRequest` operations and guarded `CompleteTicket`, perform one cleanup pass before ending the orchestration turn: reread the current Ticket and `WorkerList`, verify completion is authoritative and the Coder has no unfinished Ticket work (terminal current responsibility may remain), then inspect the Coder Worker, Workdir attachment and occupancy, repository cleanliness, ownership, provider availability, and any other current use of that Workdir. For Worker control, use the exact subject returned by `WorkerList`. If the Coder is active, call `WorkerStop` and reread its terminal status before `WorkerRemove`; idle status, Coder self-report, or review approval alone is not removal authority. Retain an existing or still-needed Workdir. Delete only a Ticket-dedicated Workdir that this Orchestrator created or selected and current authority proves is no longer needed, clean, and unoccupied. +Worker cleanup is a separate lifecycle decision owned by this Orchestrator, not an automatic consequence of a Ticket state, result report, or Flow transition. Never predeclare `delete_on_completion`, `retain_on_completion`, or equivalent retention policy when launching or reserving a Worker. When the requested handoff and any authorized conclusion are complete, perform one cleanup pass before ending the orchestration turn if cleanup is in scope: reread the current Ticket and `WorkerList`, verify the assigned Worker has no unfinished Ticket work (terminal current responsibility may remain), then inspect its Worker state, Workdir attachment and occupancy, repository cleanliness, ownership, provider availability, and any other current use of that Workdir. For Worker control, use the exact subject returned by `WorkerList`. If the Worker is active, call `WorkerStop` and reread its terminal status before `WorkerRemove`; idle status, Worker self-report, or review approval alone is not removal authority. Retain an existing or still-needed Workdir. Delete only a Ticket-dedicated Workdir that this Orchestrator created or selected and current authority proves is no longer needed, clean, and unoccupied. -For Ticket-dedicated cleanup, preserve this guarded order: stop the Coder if needed and confirm it is terminal; verify there is no unfinished Ticket work; call `WorkerRemove`; use `WorkdirList` to reread the actual Workdir and confirm attachment release, clean state, and no occupancy; only then call `WorkdirDelete`. Terminal Ticket responsibility is retained for display and does not require unassignment before cleanup. Never delete a Workdir before Worker removal has released its attachment, and never invent an unassignment operation or bypass when the required authority is unavailable. `UnfinishedWork` (`unfinished_work` on the wire) requires explicit work end or reassignment through available guarded authority, then rereading before retrying `WorkerRemove`; idle or stopped status does not end unfinished work. Running, restoring, pinned, legal hold, occupied, dirty, blocked, provider-unavailable, ownership-unknown, still-needed, or uncertain state means retain the resource and report the concrete bounded blocker. If stop, work end/reassignment, removal, attachment release, or deletion reports a partial failure, do not infer success or advance to the next step; reread current Ticket, active unfinished work, Worker, and Workdir authority before a bounded safe retry. Cleanup failure never rolls back an already completed Merge Request or Ticket. Do not add routine cleanup success comments; report only blockers that require human or Orchestrator judgment. Never force removal, discard changes, or retry from stale assumptions. +For Ticket-dedicated cleanup, preserve this guarded order: stop the Worker if needed and confirm it is terminal; verify there is no unfinished Ticket work; call `WorkerRemove`; use `WorkdirList` to reread the actual Workdir and confirm attachment release, clean state, and no occupancy; only then call `WorkdirDelete`. Terminal Ticket responsibility is retained for display and does not require unassignment before cleanup. Never delete a Workdir before Worker removal has released its attachment, and never invent an unassignment operation or bypass when the required authority is unavailable. `UnfinishedWork` (`unfinished_work` on the wire) requires explicit work end or reassignment through available guarded authority, then rereading before retrying `WorkerRemove`; idle or stopped status does not end unfinished work. Running, restoring, pinned, legal hold, occupied, dirty, blocked, provider-unavailable, ownership-unknown, still-needed, or uncertain state means retain the resource and report the concrete bounded blocker. If stop, work end/reassignment, removal, attachment release, or deletion reports a partial failure, do not infer success or advance to the next step; reread current Ticket, active unfinished work, Worker, and Workdir authority before a bounded safe retry. Cleanup failure never rolls back an already completed Merge Request or Ticket. Do not add routine cleanup success comments; report only blockers that require human or Orchestrator judgment. Never force removal, discard changes, or retry from stale assumptions. diff --git a/resources/prompts/role/reviewer.md b/resources/prompts/role/reviewer.md index b1a25d6b7..8ea4beb7c 100644 --- a/resources/prompts/role/reviewer.md +++ b/resources/prompts/role/reviewer.md @@ -1,5 +1,7 @@ You are the Ticket Reviewer role running as an actual Runtime-owned direct child of the assigned Coder. +This role is the trusted independent review path for an explicitly requested MR review, not a prerequisite for every Ticket conclusion. Review capability authenticates the recorded attempt; it does not supply the verdict or replace real judgment. + Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only, never as a supplied verdict. Review the host-captured explicit Merge Request, `ReviewRequested.subject_ref`, `ticket_item_revision`, and exact linked Merge Request subject snapshot against Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. When a Ticket has multiple MRs, judge the selected MR in the context of that complete linked result set without reusing another MR's approval; an `approve` decision attests that the captured full snapshot satisfies the current Ticket requirements and reported validation, not merely that the selected diff is locally acceptable. Use the available Workdir inspection and command tools for focused validation, but do not intentionally modify implementation files, merge, close, update a repository ref, or take over implementation. Your prose response is not review authority. Before finishing, call `ReviewMergeRequest` exactly once with `approve` or `request_changes`, a bounded evidence summary, and concrete structured findings. Capability authority and subject identity are injected by your child Workspace client and are not model inputs. For an open MR, the Server re-resolves `selector_from`; if it moved, submission records cancellation and fails rather than approving stale work. For an explicitly addressed merged MR, the captured subject is the immutable MergeResult source and the review provides fresh requirement judgment after rescope without changing integration evidence. A verdict applies only to that captured subject; target-only movement does not invalidate approval for an unchanged source, and target integration remains Orchestrator authority. diff --git a/resources/prompts/role/ticket-worker.md b/resources/prompts/role/ticket-worker.md index ad39a3415..a992b6c4b 100644 --- a/resources/prompts/role/ticket-worker.md +++ b/resources/prompts/role/ticket-worker.md @@ -2,6 +2,6 @@ You are the assigned Ticket Worker. Carry out the user's requested work and keep Read the current Ticket with `ShowTicket` before acting. Follow user intent and the Ticket's requirements and acceptance criteria without inventing deliverables or approval gates. The committed user request supplies bounded action context, not control-plane identity or authority. Use only explicitly granted resources and available typed tools. Assignment and Profile selection do not expand resource grants; the typed Backend is authoritative for actor identity, current assignment, Ticket decisions, and resource access. Treat a Backend rejection as authoritative rather than trying a CLI, direct storage access, or another path around it. -Use `TicketComment` for useful natural-language progress, results, blockers, and decisions that need attention. Use `TicketWorkflowState` for authorized progress-display updates with an explanation; it does not launch work, enforce a state sequence, or grant additional resources. When the requested work meets its acceptance criteria, use `CompleteTicket` with a natural-language reason describing the result and supporting evidence, and optional typed references when relevant. Use the current item revision and expected state from `ShowTicket` and a stable operation key for exact replay of each decision; reread after a conflict rather than guessing. +Choose the requested return path rather than always concluding the Ticket. Return results in conversation, optionally use `TicketComment` for useful progress, results, blockers, or decisions, and optionally publish an available Drive document/artifact when useful or requested. No comment or artifact is universally required. Use `TicketWorkflowState` for authorized progress-display updates with an explanation; it does not launch work, enforce a state sequence, or grant additional resources. When the work meets its acceptance criteria and the request calls for completion, use `CompleteTicket` with a natural-language reason describing the result and optional supporting references. Use authorized `TicketClose` for requested closure when available. A request to return without conclusion must not become an automatic completion or closure decision. Use the current item revision and expected state from `ShowTicket` and a stable operation key for exact replay of each decision; reread after a conflict rather than guessing. The Backend checks authority and CAS, not real judgment of natural-language satisfaction. Completion evidence should describe what was done, how it was checked, and any remaining limitations. A commit, Merge Request, approval, or fixed workflow sequence is not a prerequisite for Ticket progress or completion. Do not claim work or validation that did not occur. Ticket completion is separate from Merge Request review or integration and does not confer either authority. If the request needs unavailable resources or authority, report the blocker and ask for the missing grant rather than assuming it. diff --git a/tools/web-ux/browser-tests/memory_document_fixture_server.ts b/tools/web-ux/browser-tests/memory_document_fixture_server.ts index 4fdb49d4a..d4de0cc06 100644 --- a/tools/web-ux/browser-tests/memory_document_fixture_server.ts +++ b/tools/web-ux/browser-tests/memory_document_fixture_server.ts @@ -418,7 +418,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { display_name: "Embedded Runtime", built_in: true, worker_creation_available: true, - working_directory_required: false, + supports_workdir_attachments: false, status: "active", diagnostics: [], }], diff --git a/tools/web-ux/browser-tests/ticket_fixture_server.ts b/tools/web-ux/browser-tests/ticket_fixture_server.ts new file mode 100644 index 000000000..2d7934930 --- /dev/null +++ b/tools/web-ux/browser-tests/ticket_fixture_server.ts @@ -0,0 +1,319 @@ +// Synthetic API fixture serving the production static UI; no Worker/Git execution. +import { extname, join, normalize } from "jsr:@std/path@1.1.4"; +import { + fixtureDetail, + fixturePage, +} from "../../../web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts"; +import type { TicketDetail } from "../../../web/workspace/src/lib/generated/ticket-api.ts"; +import { emptyLaunchOptions } from "../../../web/workspace/src/lib/workspace/sidebar/worker-launch.test-fixtures.ts"; +const port = Number(Deno.args[0]); +const root = Deno.args[1]; +const legacy = Deno.args.includes("--legacy"); +const tickets = new Map(); +const bodies: unknown[] = []; +function ticket(key: string) { + if (!tickets.has(key)) { + const value: TicketDetail = fixtureDetail(key); + value.state = "planning"; + value.title = key === "T-2" + ? "Read-only investigation" + : key === "T-3" + ? "Independent review before merge" + : "Compare options without deciding"; + value.body = key === "T-2" + ? "Investigate using read-only sources; review is not required." + : key === "T-3" + ? "Implement the change, then independent review before merge." + : "Return options to the user. Do not draw a conclusion."; + value.action_eligibility.can_queue = true; + value.action_eligibility.queue_tickets = [key]; + value.action_eligibility.can_start_manual_worker = true; + if (key === "T-2") { + value.targets = [{ repository_key: "main", ref_selector: "develop", access: "read_only" }]; + } + if (key === "T-3") { + value.merge_requests = [{ + merge_request_id: "mr-1", + repository_key: "main", + state: "open", + selector_from: "work/fixture", + selector_to: "develop", + review_status: "none", + source_ref_observation: { status: "unavailable", code: "provider_unavailable" }, + current_subject_ref: null, + review_subject_ref: null, + review_excerpt: null, + integration_evidence_error: null, + updated_at: "2026-01-02T00:00:00Z", + }]; + } + tickets.set(key, value); + } + const value = structuredClone(tickets.get(key)!); + if (legacy) { + const wire = value as unknown as Record; + wire.current_coder = wire.current_worker; + delete wire.current_worker; + const actions = wire.action_eligibility as Record; + actions.can_start_manual_coder = actions.can_start_manual_worker; + delete actions.can_start_manual_worker; + } + return value; +} +Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { + const url = new URL(request.url); + if (url.pathname === "/health") return new Response("ok"); + if (url.pathname === "/fixture-state") return Response.json(bodies); + if (url.pathname === "/api/workspaces") { + return Response.json([{ + workspace_id: "tickets", + display_name: "Shared investigation", + owner_account_id: "owner", + state: "active", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + }]); + } + const match = url.pathname.match(/^\/api\/w\/([^/]+)(\/.*)$/); + if (match) { + const [, id, path] = match; + if (path === "/workspace") { + return Response.json({ + workspace_id: id, + display_name: "Shared investigation", + record_authority: "fixture", + schema_version: 1, + auth: { + Passkey: { + rp_id: "127.0.0.1", + origin: url.origin, + public_base_url: url.origin, + cookie_name: "fixture", + }, + }, + permissions: { + manage_repositories: false, + manage_secrets: false, + manage_runtimes: false, + delete_workspace: false, + }, + extension_points: { + store: "fixture", + event_stream: { status: "ready", note: "fixture", diagnostics: [] }, + host_worker_bridge: { status: "ready", note: "fixture", diagnostics: [] }, + companion_console: { status: "ready", note: "fixture", diagnostics: [] }, + }, + }); + } + if (path === "/repositories") { + return Response.json({ + workspace_id: id, + items: [{ + repository_key: "main", + default_selector: "develop", + kind: "git", + provider: "git", + source: { kind: "local_path", uri: "/fixture/main" }, + source_revision: 1, + source_fingerprint: "fixture", + observed_status: "ready", + record_authority: "fixture", + }], + source: "fixture", + diagnostics: [], + }); + } + if (path === "/orchestrator") { + return Response.json({ + workspace_id: id, + online: false, + disposition: "offline", + worker: null, + diagnostics: [], + }); + } + if (path === "/workers/launch-options") { + const options = emptyLaunchOptions(id); + options.default_profile = "builtin:ticket-worker"; + options.profiles = [{ + id: "builtin:ticket-worker", + label: "Ticket Worker", + description: "Intent-led work", + feature_connections: { subjektiv: false }, + }, { + id: "builtin:coder", + label: "Coder", + description: "Code recipe", + feature_connections: { subjektiv: false }, + }]; + options.working_directories = [{ + working_directory_id: "read-only-sources", + source: { + kind: "external_grant", + grant_id: "fixture-grant", + grant_permissions: { read: true, write: false, command: false }, + }, + materializer_kind: "client_hosted_external", + status: "active", + cleanliness: "unknown", + }]; + if (legacy) { + for (const runtime of options.runtimes) { + const wire = runtime as unknown as Record; + wire.working_directory_required = wire.supports_workdir_attachments; + delete wire.supports_workdir_attachments; + } + } + return Response.json(options); + } + if (path === "/workers") { + return Response.json({ + workspace_id: id, + items: [], + diagnostics: [], + limit: 100, + source: "fixture", + }); + } + if (path === "/working-directories") { + return Response.json({ workspace_id: id, items: [], diagnostics: [] }); + } + if (path === "/protocol/ws" && request.headers.get("upgrade") === "websocket") { + const { socket, response } = Deno.upgradeWebSocket(request); + socket.onmessage = (event) => { + const frame = JSON.parse(String(event.data)); + if (frame?.message?.method === "subscribe_events") { + socket.send(JSON.stringify({ + protocol_version: 2, + frame: "response", + message: { + result: "subscribed", + payload: { + request_id: frame.message.params.request_id, + subscription_id: "fixture-workers", + selector: { topic: "workspace_workers" }, + snapshot: { topic: "workers", data: { workers: [] } }, + }, + }, + })); + } + }; + return response; + } + if (path === "/tickets" && request.method === "POST") { + const body = await request.json(); + bodies.push({ path, body }); + ticket("T-4"); + const created = tickets.get("T-4")!; + created.title = body.title; + created.body = body.body; + created.targets = body.targets ?? []; + return Response.json({ + id: created.id, + resource_key: "T-4", + slug: "fixture", + status: "Open", + }); + } + if (path === "/tickets") { + const items = ["T-1", "T-2", "T-3"].map(ticket).filter((t) => + !url.searchParams.has("states") || + url.searchParams.get("states")!.split(",").includes(t.state) + ); + return Response.json({ + workspace_id: id, + items: items.map((t) => ({ + id: t.id, + resource_key: t.resource_key, + title: t.title, + state: t.state, + priority: t.priority, + updated_at: t.updated_at, + queued_at: t.queued_at, + queued_by: t.queued_by, + record_source: "fixture", + workspace_action_priority: "background", + })), + invalid_records: [], + record_authority: "fixture", + limit: 30, + page: fixturePage(items.length), + }); + } + const tm = path.match(/^\/tickets\/([^/]+)(.*)$/); + if (tm) { + const key = decodeURIComponent(tm[1]).match(/T-\d+/)?.[0] ?? "T-1"; + if (request.method !== "GET") { + const body = await request.json(); + bodies.push({ path, body }); + if (url.searchParams.get("failure")) { + return Response.json({ message: "Outcome unknown; refresh before retry" }, { + status: 503, + }); + } + const stored = tickets.get(key) ?? (ticket(key), tickets.get(key)!); + if (body.expected_item_revision && body.expected_item_revision !== stored.item_revision) { + return Response.json({ message: "Ticket revision conflict; refresh" }, { status: 409 }); + } + if (tm[2] === "/state") { + stored.state = body.state; + stored.events.push({ + sequence: stored.events.length, + kind: "state_changed", + body: body.body, + reason: body.reason, + from: body.expected_state, + to: body.state, + references: [], + attributes: {}, + event_ref: `event-${stored.events.length}`, + }); + } else if (tm[2] === "/events") { + stored.events.push({ + sequence: stored.events.length, + kind: body.role, + body: body.body, + references: [], + attributes: {}, + event_ref: `event-${stored.events.length}`, + }); + } else if (tm[2] === "/close") { + stored.state = "closed"; + stored.resolution = body.resolution; + } else if (tm[2] === "/ready") stored.state = "ready"; + else if (tm[2] === "/queue") { + stored.state = "queued"; + return Response.json({ requested_ticket: key, queued_tickets: [key] }); + } else if (request.method === "PATCH") { + if (body.target) stored.targets = body.target.targets ?? []; + if (body.title) stored.title = body.title; + if (body.body) stored.body = body.body; + } + stored.item_revision += "x"; + stored.event_count = stored.events.length; + } + return Response.json(ticket(key)); + } + return Response.json({ message: "Unavailable fixture resource" }, { status: 404 }); + } + const relative = normalize(url.pathname.replace(/^\/+/, "") || "index.html"); + if (relative.startsWith("..")) return new Response("not found", { status: 404 }); + const mime: Record = { + ".html": "text/html", + ".js": "text/javascript", + ".css": "text/css", + ".woff2": "font/woff2", + ".svg": "image/svg+xml", + }; + let file = join(root, relative); + try { + if ((await Deno.stat(file)).isDirectory) file = join(file, "index.html"); + return new Response(await Deno.readFile(file), { + headers: { "content-type": mime[extname(file)] ?? "application/octet-stream" }, + }); + } catch { + return new Response(await Deno.readFile(join(root, "index.html")), { + headers: { "content-type": "text/html" }, + }); + } +}); diff --git a/tools/web-ux/browser-tests/ticket_shell_test.ts b/tools/web-ux/browser-tests/ticket_shell_test.ts new file mode 100644 index 000000000..b6ef1df4a --- /dev/null +++ b/tools/web-ux/browser-tests/ticket_shell_test.ts @@ -0,0 +1,258 @@ +import { assert, assertEquals } from "jsr:@std/assert@1.0.19"; +import { chromium } from "npm:playwright@1.59.1"; +import { loadScenario } from "../src/scenario.ts"; +import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts"; + +// Browser boundary over the production static build and synthetic loopback API. +// No Backend, Worker, Repository, review or integration operation is executed. +Deno.test("Ticket progress, comment results and reopen work without Git while real MR evidence stays separate", async () => { + const scenarioPath = new URL("../scenarios/tickets.json", import.meta.url).pathname; + const scenario = await loadScenario(scenarioPath); + const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); + const port = (listener.addr as Deno.NetAddr).port; + listener.close(); + scenario.baseUrl = `http://127.0.0.1:${port}`; + scenario.processes![0].args![4] = String(port); + scenario.processes![0].readyUrl = `${scenario.baseUrl}/health`; + const output = new URL("../../../target/web-ux/t720-browser/", import.meta.url).pathname; + const processes = await startOwnedProcesses( + scenario.processes!, + scenarioPath, + `${output}/logs`, + [], + ); + const browser = await chromium.launch({ headless: true }); + try { + for (const colorScheme of ["light", "dark"] as const) { + for (const width of [1440, 768, 390]) { + const context = await browser.newContext({ + viewport: { width, height: 900 }, + colorScheme, + reducedMotion: "reduce", + }); + try { + const page = await context.newPage(); + const unexpected: string[] = []; + page.on("pageerror", (e) => unexpected.push(String(e))); + await page.goto(`${scenario.baseUrl}/w/tickets/tickets`); + const addTicket = page.getByRole("button", { name: "Add Ticket", exact: true }); + await addTicket.waitFor(); + const action = await addTicket.boundingBox(); + assert(action && action.x >= 0 && action.x + action.width <= width && action.y < 900); + assertEquals( + await page.evaluate("document.documentElement.scrollWidth > innerWidth"), + false, + ); + await addTicket.click(); + await page.locator(".ticket-create-form").waitFor(); + assertEquals(await page.locator(".ticket-target-row").count(), 0); + assertEquals( + await page.evaluate("document.documentElement.scrollWidth > innerWidth"), + false, + ); + await page.goto( + `${scenario.baseUrl}/w/tickets/tickets/T-1-compare-options-without-deciding`, + ); + await page.locator(".ticket-detail-header").waitFor(); + // Intent comes first at one-column widths, unlike the former controls-first layout. + if (width < 1024) { + const intent = await page.getByRole("heading", { name: "Intent", exact: true }) + .boundingBox(); + const progress = await page.getByRole("heading", { name: "Progress decision" }) + .boundingBox(); + assert(intent && progress && intent.y < progress.y); + } + const state = page.getByRole("combobox", { name: "State", exact: true }); + await state.waitFor(); + assertEquals(await state.locator("option").allTextContents(), [ + "planning", + "ready", + "queued", + "inprogress", + "done", + "closed", + ]); + await state.selectOption("inprogress"); + await page.getByLabel("Reason", { exact: true }).first().fill( + "Investigating options directly", + ); + await page.getByRole("button", { name: "Apply state", exact: true }).click(); + await page.locator('.ticket-detail-kicker [data-status="inprogress"]').waitFor(); + await page.getByText("Record result or comment", { exact: true }).click(); + await page.getByLabel("Body", { exact: true }).fill( + `Option A and B are available; return to the user without a conclusion (${colorScheme}/${width}).`, + ); + await page.getByRole("button", { name: "Append event" }).click(); + await page.getByText( + `Option A and B are available; return to the user without a conclusion (${colorScheme}/${width}).`, + { exact: true }, + ).waitFor(); + await state.selectOption("done"); + await page.getByLabel("Reason", { exact: true }).first().fill( + "Requested comparison returned", + ); + await page.getByLabel("Result, references, and remaining work (optional)").fill( + "Result: timeline comment. [Reference](https://example.com/options). Remaining: user choice.", + ); + await page.getByRole("button", { name: "Complete Ticket", exact: true }).click(); + await page.locator('.ticket-detail-kicker [data-status="done"]').waitFor(); + assertEquals( + await page.getByRole("heading", { name: "MR requirement evidence" }).count(), + 0, + ); + assert(await page.getByText("No Merge Requests linked.", { exact: false }).isVisible()); + await state.selectOption("planning"); + await page.getByLabel("Reason", { exact: true }).first().fill( + "User requests another comparison", + ); + await page.getByRole("button", { name: "Reopen / apply state" }).click(); + await page.locator('.ticket-detail-kicker [data-status="planning"]').waitFor(); + await page.getByRole("button", { name: "Request Orchestrator (queue)" }).click(); + await page.locator('.ticket-detail-kicker [data-status="queued"]').waitFor(); + await page.locator(".ticket-close-card > summary").click(); + await page.getByLabel("Resolution").fill("Discussion returned; no code or MR required"); + await page.getByRole("button", { name: "Close ticket", exact: true }).click(); + await page.locator('.ticket-detail-kicker [data-status="closed"]').waitFor(); + await page.goto(`${scenario.baseUrl}/w/tickets/tickets/T-2-read-only-investigation`); + await page.locator(".ticket-detail-header").waitFor(); + assertEquals( + await page.getByRole("combobox", { name: "Access", exact: true }).inputValue(), + "read_only", + ); + assert(await page.getByRole("button", { name: "Mark ready", exact: true }).isEnabled()); + await page.getByRole("button", { name: "Mark ready", exact: true }).click(); + await page.locator('.ticket-detail-kicker [data-status="ready"]').waitFor(); + assert(await page.getByRole("combobox", { name: "Repository", exact: true }).isEnabled()); + await page.getByRole("button", { name: "Save targets", exact: true }).click(); + await page.getByRole("button", { name: "Save targets", exact: true }).waitFor({ + state: "visible", + }); + await page.goto( + `${scenario.baseUrl}/w/tickets/tickets/T-3-independent-review-before-merge`, + ); + await page.locator(".ticket-detail-header").waitFor(); + await state.selectOption("done"); + await page.getByLabel("Reason", { exact: true }).first().fill( + "Progress decision is separate from review", + ); + await page.getByRole("button", { name: "Complete Ticket" }).click(); + await page.locator('.ticket-detail-kicker [data-status="done"]').waitFor(); + assert(await page.getByText("main: open", { exact: true }).isVisible()); + assert( + await page.getByText("Ticket completion is not MR approval or integration.", { + exact: true, + }).isVisible(), + ); + assertEquals(await page.getByText("main: merged", { exact: true }).count(), 0); + // Member normal operations do not enable owner configuration controls. + assertEquals(await page.getByRole("link", { name: "Secrets", exact: true }).count(), 0); + assertEquals( + await page.evaluate("document.documentElement.scrollWidth > innerWidth"), + false, + ); + await Deno.mkdir(output, { recursive: true }); + await page.screenshot({ + path: `${output}/${colorScheme}-${width}-mr-done.png`, + fullPage: true, + }); + assertEquals(unexpected, []); + // Reset synthetic records for the next viewport using the fixture's ordinary state API. + for (const key of ["T-1", "T-2", "T-3"]) { + const detail = + await (await context.request.get(`${scenario.baseUrl}/api/w/tickets/tickets/${key}`)) + .json(); + await context.request.post(`${scenario.baseUrl}/api/w/tickets/tickets/${key}/state`, { + data: { + state: "planning", + reason: "Fixture reset", + expected_state: detail.state, + expected_item_revision: detail.item_revision, + }, + }); + } + } finally { + await context.close(); + } + } + } + const context = await browser.newContext(); + try { + const page = await context.newPage(); + await page.goto(`${scenario.baseUrl}/w/tickets/tickets`); + await page.getByRole("button", { name: "Add Ticket", exact: true }).click(); + await page.getByLabel("Title", { exact: true }).fill("Repository-free investigation"); + await page.getByLabel("Body", { exact: true }).fill( + "Compare options and return without conclusion; review is unnecessary.", + ); + await page.getByRole("button", { name: "Create Ticket", exact: true }).click(); + await page.getByRole("heading", { name: "Repository-free investigation", exact: true }) + .waitFor(); + assert(await page.getByText("No repository targets.", { exact: true }).isVisible()); + // Generic remote launch: a natural request, not an inferred code/review pipeline. + await page.goto( + `${scenario.baseUrl}/w/tickets/workers/new?ticketId=T-4&initialInput=Return%20without%20conclusion.%20Review%20is%20unnecessary.`, + ); + await page.locator(".worker-launch-form").waitFor(); + await page.getByRole("combobox", { name: "Runtime", exact: true }).selectOption("remote"); + const launches: Record[] = []; + await page.route("**/api/w/tickets/workers", async (route) => { + launches.push(route.request().postDataJSON()); + await route.fulfill({ + status: 403, + json: { error: "permission_denied", message: "Permission denied", diagnostics: [] }, + }); + }); + await page.getByRole("button", { name: "Start Worker", exact: true }).click(); + await page.getByText("permission_denied: Permission denied", { exact: true }).waitFor(); + assertEquals(launches.length, 1); + assertEquals(launches[0].workdir_attachments, []); + assertEquals(launches[0].profile, null); + assertEquals(launches[0].initial_submit, [{ + kind: "text", + content: "Return without conclusion. Review is unnecessary.", + }]); + assertEquals((launches[0].ticket_assignment as { ticket_id: string }).ticket_id, "T-4"); + await page.unroute("**/api/w/tickets/workers"); + for ( + const [status, message] of [[409, "Update conflict."], [403, "Permission denied."], [ + 503, + "Outcome unknown or request rejected.", + ]] as const + ) { + await page.goto( + `${scenario.baseUrl}/w/tickets/tickets/T-1-compare-options-without-deciding`, + ); + await page.locator(".ticket-detail-header").waitFor(); + await page.route( + "**/tickets/*/state", + (route) => route.fulfill({ status, json: { error: "failure", message } }), + ); + await page.getByRole("combobox", { name: "State", exact: true }).selectOption("done"); + await page.getByLabel("Reason", { exact: true }).first().fill( + "Keep this draft on rejection", + ); + await page.getByRole("button", { name: "Complete Ticket" }).click(); + await page.getByRole("alert").getByText(message, { exact: false }).waitFor(); + assertEquals( + await page.getByLabel("Reason", { exact: true }).first().inputValue(), + "Keep this draft on rejection", + ); + assert(await page.locator('.ticket-detail-kicker [data-status="planning"]').isVisible()); + assert(await page.getByRole("button", { name: "Refresh Ticket status" }).isEnabled()); + await page.unroute("**/tickets/*/state"); + } + } finally { + await context.close(); + } + const requests = await (await fetch(`${scenario.baseUrl}/fixture-state`)).json(); + assert( + requests.some((r: { body: { body?: string } }) => + r.body.body?.includes("Remaining: user choice") + ), + ); + assert(!requests.some((r: { path: string }) => /merge|review|workers/.test(r.path))); + } finally { + await browser.close(); + await stopOwnedProcesses(processes); + } +}); diff --git a/tools/web-ux/deno.json b/tools/web-ux/deno.json index 1ca411068..e3e278be5 100644 --- a/tools/web-ux/deno.json +++ b/tools/web-ux/deno.json @@ -11,7 +11,7 @@ "web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts", "check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts", "test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests", - "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts" + "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/ticket_shell_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts" }, "fmt": { "lineWidth": 100, diff --git a/tools/web-ux/scenarios/tickets-dark.json b/tools/web-ux/scenarios/tickets-dark.json new file mode 100644 index 000000000..74b0e2ffe --- /dev/null +++ b/tools/web-ux/scenarios/tickets-dark.json @@ -0,0 +1,202 @@ +{ + "schemaVersion": 1, + "id": "tickets-dark", + "title": "Flexible Ticket progress and optional resources", + "baseUrl": "http://127.0.0.1:15289", + "colorScheme": "dark", + "reducedMotion": "reduce", + "personas": [ + { + "id": "member", + "label": "Workspace member, not settings owner", + "auth": { + "kind": "anonymous" + } + } + ], + "viewports": [ + { + "label": "desktop", + "width": 1440, + "height": 900 + }, + { + "label": "narrow", + "width": 768, + "height": 900 + }, + { + "label": "mobile", + "width": 390, + "height": 844 + } + ], + "routes": [ + { + "id": "options", + "label": "Return options without conclusion", + "path": "/w/tickets/tickets/T-1", + "goal": "Read intent, record results and progress without a repository", + "dataState": "No repository or MR", + "ready": { + "kind": "selector", + "selector": ".ticket-detail-header" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Ticket detail", + "fullPage": true + }, + { + "id": "resolution", + "label": "Resolution and MR state", + "interaction": [ + { + "action": "click", + "selector": ".ticket-close-card > summary" + }, + { + "action": "click", + "selector": ".ticket-close-card textarea" + } + ], + "fullPage": true + } + ] + }, + { + "id": "read-only", + "label": "Investigation without review", + "path": "/w/tickets/tickets/T-2", + "goal": "Use read-only resources without requiring write or MR", + "dataState": "Read-only target", + "ready": { + "kind": "selector", + "selector": ".ticket-detail-header" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Ticket detail", + "fullPage": true + }, + { + "id": "resolution", + "label": "Resolution and MR state", + "interaction": [ + { + "action": "click", + "selector": ".ticket-close-card > summary" + }, + { + "action": "click", + "selector": ".ticket-close-card textarea" + } + ], + "fullPage": true + } + ] + }, + { + "id": "review", + "label": "Independent review before merge", + "path": "/w/tickets/tickets/T-3", + "goal": "Separate progress from actual MR review and integration", + "dataState": "Open MR, unavailable source observation", + "ready": { + "kind": "selector", + "selector": ".ticket-detail-header" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Ticket detail", + "fullPage": true + }, + { + "id": "resolution", + "label": "Resolution and MR state", + "interaction": [ + { + "action": "click", + "selector": ".ticket-close-card > summary" + }, + { + "action": "click", + "selector": ".ticket-close-card textarea" + } + ], + "fullPage": true + } + ] + }, + { + "id": "launch", + "label": "Natural request with optional recipe and resources", + "path": "/w/tickets/workers/new?ticketId=T-1&ticketTitle=Compare%20options%20without%20deciding&initialInput=Return%20options%20without%20a%20conclusion.%20Review%20is%20unnecessary.", + "goal": "Choose the general Ticket worker, no mandatory repository or Flow", + "dataState": "Member with embedded and remote Runtimes, read-only external grant available but not claimed", + "ready": { + "kind": "selector", + "selector": ".worker-launch-form" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Ticket launch", + "fullPage": true + } + ] + }, + { + "id": "list", + "label": "Member creates a repository-free Ticket", + "path": "/w/tickets/tickets", + "goal": "Create work from intent with optional resources, not a fixed delivery pipeline", + "dataState": "Three lightweight progress lanes, member normal operations", + "ready": { + "kind": "selector", + "selector": ".ticket-kanban" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Progress lanes", + "fullPage": true + }, + { + "id": "create", + "label": "Optional resources", + "interaction": [ + { + "action": "click", + "selector": "button:has-text(\"Add Ticket\")" + } + ], + "ready": { + "kind": "selector", + "selector": ".ticket-create-form" + }, + "fullPage": true + } + ] + } + ], + "processes": [ + { + "id": "ticket-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "browser-tests/ticket_fixture_server.ts", + "15289", + "../../web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15289/health" + } + ] +} diff --git a/tools/web-ux/scenarios/tickets.json b/tools/web-ux/scenarios/tickets.json new file mode 100644 index 000000000..3b94ce688 --- /dev/null +++ b/tools/web-ux/scenarios/tickets.json @@ -0,0 +1,202 @@ +{ + "schemaVersion": 1, + "id": "tickets", + "title": "Flexible Ticket progress and optional resources", + "baseUrl": "http://127.0.0.1:15289", + "colorScheme": "light", + "reducedMotion": "reduce", + "personas": [ + { + "id": "member", + "label": "Workspace member, not settings owner", + "auth": { + "kind": "anonymous" + } + } + ], + "viewports": [ + { + "label": "desktop", + "width": 1440, + "height": 900 + }, + { + "label": "narrow", + "width": 768, + "height": 900 + }, + { + "label": "mobile", + "width": 390, + "height": 844 + } + ], + "routes": [ + { + "id": "options", + "label": "Return options without conclusion", + "path": "/w/tickets/tickets/T-1", + "goal": "Read intent, record results and progress without a repository", + "dataState": "No repository or MR", + "ready": { + "kind": "selector", + "selector": ".ticket-detail-header" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Ticket detail", + "fullPage": true + }, + { + "id": "resolution", + "label": "Resolution and MR state", + "interaction": [ + { + "action": "click", + "selector": ".ticket-close-card > summary" + }, + { + "action": "click", + "selector": ".ticket-close-card textarea" + } + ], + "fullPage": true + } + ] + }, + { + "id": "read-only", + "label": "Investigation without review", + "path": "/w/tickets/tickets/T-2", + "goal": "Use read-only resources without requiring write or MR", + "dataState": "Read-only target", + "ready": { + "kind": "selector", + "selector": ".ticket-detail-header" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Ticket detail", + "fullPage": true + }, + { + "id": "resolution", + "label": "Resolution and MR state", + "interaction": [ + { + "action": "click", + "selector": ".ticket-close-card > summary" + }, + { + "action": "click", + "selector": ".ticket-close-card textarea" + } + ], + "fullPage": true + } + ] + }, + { + "id": "review", + "label": "Independent review before merge", + "path": "/w/tickets/tickets/T-3", + "goal": "Separate progress from actual MR review and integration", + "dataState": "Open MR, unavailable source observation", + "ready": { + "kind": "selector", + "selector": ".ticket-detail-header" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Ticket detail", + "fullPage": true + }, + { + "id": "resolution", + "label": "Resolution and MR state", + "interaction": [ + { + "action": "click", + "selector": ".ticket-close-card > summary" + }, + { + "action": "click", + "selector": ".ticket-close-card textarea" + } + ], + "fullPage": true + } + ] + }, + { + "id": "launch", + "label": "Natural request with optional recipe and resources", + "path": "/w/tickets/workers/new?ticketId=T-1&ticketTitle=Compare%20options%20without%20deciding&initialInput=Return%20options%20without%20a%20conclusion.%20Review%20is%20unnecessary.", + "goal": "Choose the general Ticket worker, no mandatory repository or Flow", + "dataState": "Member with embedded and remote Runtimes, read-only external grant available but not claimed", + "ready": { + "kind": "selector", + "selector": ".worker-launch-form" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Ticket launch", + "fullPage": true + } + ] + }, + { + "id": "list", + "label": "Member creates a repository-free Ticket", + "path": "/w/tickets/tickets", + "goal": "Create work from intent with optional resources, not a fixed delivery pipeline", + "dataState": "Three lightweight progress lanes, member normal operations", + "ready": { + "kind": "selector", + "selector": ".ticket-kanban" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Progress lanes", + "fullPage": true + }, + { + "id": "create", + "label": "Optional resources", + "interaction": [ + { + "action": "click", + "selector": "button:has-text(\"Add Ticket\")" + } + ], + "ready": { + "kind": "selector", + "selector": ".ticket-create-form" + }, + "fullPage": true + } + ] + } + ], + "processes": [ + { + "id": "ticket-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "browser-tests/ticket_fixture_server.ts", + "15289", + "../../web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15289/health" + } + ] +} diff --git a/tools/web-ux/src/processes.ts b/tools/web-ux/src/processes.ts index 27082f152..43c0f35d0 100644 --- a/tools/web-ux/src/processes.ts +++ b/tools/web-ux/src/processes.ts @@ -14,7 +14,7 @@ export type RunningProcess = { }; async function appendOutput( - stream: ReadableStream, + stream: ReadableStream, destination: string, secrets: string[], ): Promise { diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 733276e24..ac4ca6e82 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@8.3.2 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts", "test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts", "test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts", "build": "deno run -A npm:vite@8.3.2 build", diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 3aab46a30..cad3cea74 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:5f767ab90e6507d2e9486cd2efe74f62d613912ffa830ad617a088b2848f20da) +// Input contract: 0.1.0 (sha256:6dd3e9e7ffe5e653697addc29675545b5fb28ea856b14bafc580355f0453ec12) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index f08303328..ec4dbf713 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:5f767ab90e6507d2e9486cd2efe74f62d613912ffa830ad617a088b2848f20da) +// Input contract: 0.1.0 (sha256:6dd3e9e7ffe5e653697addc29675545b5fb28ea856b14bafc580355f0453ec12) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 485d1cbbf..f1ee3a758 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:5f767ab90e6507d2e9486cd2efe74f62d613912ffa830ad617a088b2848f20da) +// Input contract: 0.1.0 (sha256:6dd3e9e7ffe5e653697addc29675545b5fb28ea856b14bafc580355f0453ec12) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 79e959295..b62c197f9 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:5f767ab90e6507d2e9486cd2efe74f62d613912ffa830ad617a088b2848f20da) +// Input contract: 0.1.0 (sha256:6dd3e9e7ffe5e653697addc29675545b5fb28ea856b14bafc580355f0453ec12) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index fcceed331..aa682fc69 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-workdir-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0) +// Input contract: 0.1.0 (sha256:6dd3e9e7ffe5e653697addc29675545b5fb28ea856b14bafc580355f0453ec12) // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/workdir-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 7e8af935c..2983c1e4f 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:5f767ab90e6507d2e9486cd2efe74f62d613912ffa830ad617a088b2848f20da) +// Input contract: 0.1.0 (sha256:6dd3e9e7ffe5e653697addc29675545b5fb28ea856b14bafc580355f0453ec12) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check @@ -63,7 +63,7 @@ export type WorkerLaunchOptionsResponse = { default_profile?: string | null; dia export type WorkerLaunchProfileCandidate = { description: string; feature_connections: WorkerLaunchFeatureConnectionOptions; id: string; label: string; }; -export type WorkerLaunchRuntimeOption = { built_in: boolean; diagnostics: Array; display_name: string; runtime_id: string; status: string; worker_creation_available: boolean; working_directory_required: boolean; }; +export type WorkerLaunchRuntimeOption = { built_in: boolean; diagnostics: Array; display_name: string; runtime_id: string; status: string; supports_workdir_attachments: boolean; worker_creation_available: boolean; }; export type WorkerLaunchWorkerSummary = { diagnostics?: Array; display_name: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned: boolean; profile?: string | null; retention_state: string; runtime_id: string; singleton_key?: string | null; state: string; tags: Array; workdir_attachments?: Array; worker_id: string; workspace: WorkerWorkspaceSummary; }; diff --git a/web/workspace/src/lib/workspace/api/workers.test.ts b/web/workspace/src/lib/workspace/api/workers.test.ts index b70336003..f5f508475 100644 --- a/web/workspace/src/lib/workspace/api/workers.test.ts +++ b/web/workspace/src/lib/workspace/api/workers.test.ts @@ -248,7 +248,7 @@ Deno.test("Worker launch options parser accepts the generated wire shape", () => display_name: "Runtime A", built_in: false, worker_creation_available: true, - working_directory_required: true, + supports_workdir_attachments: true, status: "connected", diagnostics: [], }], diff --git a/web/workspace/src/lib/workspace/api/workers.ts b/web/workspace/src/lib/workspace/api/workers.ts index 856cadd7c..90c879a28 100644 --- a/web/workspace/src/lib/workspace/api/workers.ts +++ b/web/workspace/src/lib/workspace/api/workers.ts @@ -130,7 +130,7 @@ function runtimeOption( "display_name", "built_in", "worker_creation_available", - "working_directory_required", + "supports_workdir_attachments", "status", "diagnostics", ], @@ -144,9 +144,9 @@ function runtimeOption( item.worker_creation_available, `${label}.worker_creation_available`, ), - working_directory_required: boolean( - item.working_directory_required, - `${label}.working_directory_required`, + supports_workdir_attachments: boolean( + item.supports_workdir_attachments, + `${label}.supports_workdir_attachments`, ), status: string(item.status, `${label}.status`), diagnostics: array(item.diagnostics, `${label}.diagnostics`, diagnostic), diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 5b936cd03..33e81fa78 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -434,7 +434,7 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as ticketDetailPage.includes("`${path}/queue`") && !ticketDetailPage.includes("/merge-request/merge") && ticketDetailPage.includes("mergeRequest.selector_from") && - ticketDetailPage.includes("mergeRequest.review_status") && + ticketDetailPage.includes("summarySourceReviewStatus(mergeRequest)") && ticketDetailPage.includes("mergeRequestPagePath") && ticketDetailPage.includes('mutate("close", "/close"') && ticketDetailPage.includes("mutateAssignment") && diff --git a/web/workspace/src/lib/workspace/sidebar/worker-launch.test-fixtures.ts b/web/workspace/src/lib/workspace/sidebar/worker-launch.test-fixtures.ts index d2e8fa0f6..e86c7414e 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-launch.test-fixtures.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-launch.test-fixtures.ts @@ -1,4 +1,4 @@ -import type { WorkerLaunchOptionsResponse } from "./types.ts"; +import type { WorkerLaunchOptionsResponse } from "../../generated/worker-launch-api.ts"; export function emptyLaunchOptions( workspaceId: string, @@ -12,7 +12,7 @@ export function emptyLaunchOptions( status: "active", worker_creation_available: true, built_in: true, - working_directory_required: false, + supports_workdir_attachments: false, diagnostics: [], }, { @@ -21,7 +21,7 @@ export function emptyLaunchOptions( status: "active", worker_creation_available: true, built_in: false, - working_directory_required: true, + supports_workdir_attachments: true, diagnostics: [], }, ], diff --git a/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts b/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts index 501d66155..64f25500f 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts @@ -10,18 +10,37 @@ declare const Deno: { }; Deno.test("logical config attachments are never offered as process working directories", () => { - const config = { working_directory_id: "config-1", display_name: "Workspace config", - source: { kind: "workspace_config" as const, access: "read_write" as const, content_path: "/workspace-config", purpose: "Configuration" }, - materializer_kind: "logical_workspace_config" as const, status: "active" as const, cleanliness: "clean" }; - const form = defaultWorkerLaunchForm({ ...options, working_directories: [config] }, emptyForm({ - workdir_attachments: [{ alias: "config", working_directory_id: "config-1", relative_cwd: "" }], - })); - if (form.workdir_attachments.some((attachment) => attachment.working_directory_id === "config-1")) { + const config = { + working_directory_id: "config-1", + display_name: "Workspace config", + source: { + kind: "workspace_config" as const, + access: "read_write" as const, + content_path: "/workspace-config", + purpose: "Configuration", + }, + materializer_kind: "logical_workspace_config" as const, + status: "active" as const, + cleanliness: "clean", + }; + const form = defaultWorkerLaunchForm( + { ...options, working_directories: [config] }, + emptyForm({ + workdir_attachments: [{ + alias: "config", + working_directory_id: "config-1", + relative_cwd: "", + }], + }), + ); + if ( + form.workdir_attachments.some((attachment) => attachment.working_directory_id === "config-1") + ) { throw new Error("logical config was converted to a process cwd attachment"); } }); -Deno.test("defaultWorkerLaunchForm uses the Backend-published defaults and initial Workdir", () => { +Deno.test("defaultWorkerLaunchForm selects a Runtime without claiming a profile or Workdir", () => { const form = defaultWorkerLaunchForm( options, emptyForm({ initial_text: "hello" }), @@ -29,14 +48,10 @@ Deno.test("defaultWorkerLaunchForm uses the Backend-published defaults and initi assertEquals(form.runtime_id, "remote"); assertEquals(form.display_name, "Worker"); - assertEquals(form.profile, "builtin:coder"); + assertEquals(form.profile, ""); assertEquals(form.initial_text, "hello"); - assertEquals(form.workdir_attachments, [{ - alias: "workdir", - working_directory_id: "wd-1-repo", - relative_cwd: "", - }]); - assertEquals(form.working_directory_repository_key, "repo"); + assertEquals(form.workdir_attachments, []); + assertEquals(form.working_directory_repository_key, ""); assertEquals(form.working_directory_selector, "HEAD"); }); @@ -71,7 +86,7 @@ Deno.test("defaultWorkerLaunchForm preserves an available read-only External Wor }]); }); -Deno.test("defaultWorkerLaunchForm preserves an available Ticket role profile", () => { +Deno.test("defaultWorkerLaunchForm does not turn a generic profile choice into trusted review", () => { const reviewerOptions = { ...options, profiles: [ @@ -95,10 +110,10 @@ Deno.test("defaultWorkerLaunchForm preserves an available Ticket role profile", }), ); - assertEquals(form.profile, "builtin:reviewer"); + assertEquals(form.profile, ""); }); -Deno.test("defaultWorkerLaunchForm skips occupied Workdirs and leaves an editable attachment", () => { +Deno.test("defaultWorkerLaunchForm does not claim occupied Workdirs", () => { const form = defaultWorkerLaunchForm( { ...options, @@ -115,11 +130,7 @@ Deno.test("defaultWorkerLaunchForm skips occupied Workdirs and leaves an editabl emptyForm({ initial_text: "hello" }), ); - assertEquals(form.workdir_attachments, [{ - alias: "workdir", - working_directory_id: "", - relative_cwd: "", - }]); + assertEquals(form.workdir_attachments, []); }); Deno.test("defaultWorkerLaunchForm preserves a Ticket repository target", () => { @@ -149,11 +160,7 @@ Deno.test("defaultWorkerLaunchForm preserves a Ticket repository target", () => }), ); - assertEquals(form.workdir_attachments, [{ - alias: "workdir", - working_directory_id: "ticket-workdir", - relative_cwd: "", - }]); + assertEquals(form.workdir_attachments, []); assertEquals(form.working_directory_repository_key, "ticket-repo"); assertEquals(form.working_directory_selector, "work/ticket"); }); @@ -308,8 +315,7 @@ Deno.test("buildCreateWorkspaceWorkerRequest emits an empty attachment list for }); function emptyForm( - overrides: Partial[0]> = - {}, + overrides: Partial[0]> = {}, ): Parameters[0] { return { runtime_id: "", @@ -361,7 +367,7 @@ const options: WorkerLaunchOptionsResponse = { status: "active", worker_creation_available: true, built_in: false, - working_directory_required: true, + supports_workdir_attachments: true, diagnostics: [], }, { @@ -370,7 +376,7 @@ const options: WorkerLaunchOptionsResponse = { status: "active", worker_creation_available: true, built_in: true, - working_directory_required: false, + supports_workdir_attachments: false, diagnostics: [], }, ], diff --git a/web/workspace/src/lib/workspace/sidebar/worker-launch.ts b/web/workspace/src/lib/workspace/sidebar/worker-launch.ts index 983321b57..b6555cac4 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-launch.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-launch.ts @@ -1,6 +1,9 @@ import type { BrowserWorkerWorkingDirectorySelection, CreateWorkspaceWorkerRequest, + CreateWorkspaceWorkerTicketAssignmentRequest, + RepositoryApiError, + WorkerLaunchProfileCandidate, } from "#lib/generated/worker-launch-api.ts"; import { parseCreateWorkspaceWorkerRequest } from "#lib/workspace/api/workers.ts"; @@ -14,6 +17,8 @@ export type WorkerLaunchAttachmentFormState = { export type WorkerLaunchFormState = { runtime_id: string; + flow?: string; + ticket_assignment?: CreateWorkspaceWorkerTicketAssignmentRequest | null; display_name: string; profile: string; subjektiv_subject_id: string; @@ -35,84 +40,33 @@ export function defaultWorkerLaunchForm( ) ?? options?.runtimes.find((runtime) => runtime.worker_creation_available) ?? options?.runtimes[0]; - const preferredProfile = options?.profiles.find((candidate) => - candidate.id === options.default_profile - ); - const availableWorkingDirectories = - options?.working_directories.filter((directory) => - directory.status === "active" && - directory.source.kind !== "workspace_config" && - (directory.source.kind === "external_grant" || - directory.cleanliness === "clean") && - directory.occupied_by == null - ) ?? []; - const selectedRuntime = current.runtime_id - ? options?.runtimes.find((runtime) => - runtime.runtime_id === current.runtime_id - ) - : preferredRuntime; - const workdirlessRuntime = - selectedRuntime?.working_directory_required === false; - const preferredWorkingDirectory = workdirlessRuntime - ? undefined - : availableWorkingDirectories.find((directory) => - Boolean(current.working_directory_repository_key) && - directory.source.kind === "repository" && - directory.source.repository_key === - current.working_directory_repository_key && - (!current.working_directory_selector || - (directory.current_selector ?? directory.creation_selector) === - current.working_directory_selector) - ) ?? availableWorkingDirectories.find((directory) => - Boolean(current.working_directory_repository_key) && - directory.source.kind === "repository" && - directory.source.repository_key === - current.working_directory_repository_key - ) ?? (current.working_directory_repository_key - ? undefined - : availableWorkingDirectories[0]); - const preferredRepository = - options?.repositories.find((repository) => - repository.repository_key === current.working_directory_repository_key - ) ?? - options?.repositories[0]; + const runtime = + options?.runtimes.find((candidate) => candidate.runtime_id === current.runtime_id) ?? + preferredRuntime; const availableWorkdirIds = new Set( - availableWorkingDirectories.map((directory) => - directory.working_directory_id - ), + (options?.working_directories ?? []).filter((directory) => + directory.status === "active" && directory.source.kind !== "workspace_config" && + (directory.source.kind === "external_grant" || directory.cleanliness === "clean") && + directory.occupied_by == null + ).map((directory) => directory.working_directory_id), ); - const currentAttachments = workdirlessRuntime - ? [] - : current.workdir_attachments.filter((attachment) => - availableWorkdirIds.has(attachment.working_directory_id) - ); - const workdirAttachments = currentAttachments.length > 0 - ? currentAttachments - : preferredWorkingDirectory - ? [{ - alias: "workdir", - working_directory_id: preferredWorkingDirectory.working_directory_id, - relative_cwd: "", - }] - : selectedRuntime?.working_directory_required === true - ? [{ alias: "workdir", working_directory_id: "", relative_cwd: "" }] - : []; - + // Defaults never claim resource attachments or infer an execution recipe from a Ticket. return { + ...current, runtime_id: current.runtime_id || preferredRuntime?.runtime_id || "", display_name: current.display_name || "Worker", profile: - options?.profiles.some((candidate) => candidate.id === current.profile) + genericLaunchProfiles(options?.profiles ?? []).some((candidate) => + candidate.id === current.profile + ) ? current.profile - : preferredProfile?.id || "", - subjektiv_subject_id: current.subjektiv_subject_id, - initial_text: current.initial_text, - workdir_attachments: workdirAttachments, - working_directory_repository_key: - current.working_directory_repository_key || - preferredRepository?.repository_key || "", - working_directory_selector: current.working_directory_selector || - preferredRepository?.default_selector || "HEAD", + : "", + workdir_attachments: runtime?.supports_workdir_attachments === false + ? [] + : current.workdir_attachments.filter((attachment) => + availableWorkdirIds.has(attachment.working_directory_id) + ), + working_directory_selector: current.working_directory_selector || "HEAD", }; } @@ -172,15 +126,19 @@ function validatedAttachments( export function buildCreateWorkspaceWorkerRequest( form: WorkerLaunchFormState, ): CreateWorkspaceWorkerRequest { + if (["builtin:reviewer", "reviewer"].includes(form.profile.trim())) { + throw new Error("Trusted Reviewer launch requires a bound review request."); + } const initialMessage = form.initial_text.trim(); return parseCreateWorkspaceWorkerRequest({ runtime_id: form.runtime_id.trim(), display_name: form.display_name.trim(), profile: form.profile.trim() || null, - ticket_assignment: null, - initial_submit: initialMessage - ? [{ kind: "text", content: form.initial_text }] - : [], + ticket_assignment: form.ticket_assignment ?? null, + initial_submit: [ + ...(initialMessage ? [{ kind: "text", content: form.initial_text }] : []), + ...(form.flow?.trim() ? [{ kind: "flow", selector: form.flow.trim() }] : []), + ], workdir_attachments: validatedAttachments(form.workdir_attachments), feature_connections: form.subjektiv_subject_id.trim() ? { subjektiv: { subject_id: form.subjektiv_subject_id.trim() } } @@ -188,3 +146,21 @@ export function buildCreateWorkspaceWorkerRequest( control_operation_id: null, }); } + +// A trusted Reviewer is launched through the bound review path, not a Profile selection. +export function genericLaunchProfiles( + profiles: WorkerLaunchProfileCandidate[], +): WorkerLaunchProfileCandidate[] { + return profiles.filter((candidate) => + candidate.id !== "builtin:reviewer" && candidate.id !== "reviewer" + ); +} + +export function workerLaunchOutcomeUnknown(error: RepositoryApiError): boolean { + return [ + error.error, + error.message, + ...(error.diagnostics ?? []).flatMap((item) => [item.code, item.message]), + ] + .some((value) => /outcome[_\s-]?unknown|OutcomeUnknown/i.test(value)); +} diff --git a/web/workspace/src/lib/workspace/styles/tickets.css b/web/workspace/src/lib/workspace/styles/tickets.css index 2ceeb0954..ac8821c80 100644 --- a/web/workspace/src/lib/workspace/styles/tickets.css +++ b/web/workspace/src/lib/workspace/styles/tickets.css @@ -73,13 +73,22 @@ justify-content: space-between; gap: var(--space-4); } + .ticket-panel-header { + flex-wrap: wrap; + } + .ticket-panel-header > div:first-child { + flex: 1 1 12rem; + } .ticket-panel-controls { display: flex; + flex-wrap: wrap; align-items: center; + max-width: 100%; gap: var(--space-3); } .orchestrator-status { display: flex; + flex-wrap: wrap; align-items: center; gap: 0.65rem; border: 1px solid var(--line); @@ -480,17 +489,11 @@ .ticket-detail-grid { grid-template-columns: minmax(0, 1fr); } - .ticket-control-rail { - grid-row: 1; - } } @media (max-width: 64rem) { .ticket-detail-grid { grid-template-columns: 1fr; } - .ticket-control-rail { - grid-row: 1; - } } } diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts index 8bd8ef4fe..63dd10a71 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts @@ -84,75 +84,45 @@ Deno.test("ticket lane visibility advances in bounded pages of 30", () => { assertEquals(nextTicketLaneVisibleCount(95, 95), 95); }); -Deno.test("ticket worker launch uses the common Worker route and bounded Ticket context", () => { - const ticket = { - id: "00001KYRRDVH9", - title: "Ticket panel API", - targets: [ - { - repository_key: "docs", - ref_selector: "main", - access: "read_only", - }, - { - repository_key: "main repo", - ref_selector: "work/ticket", - access: "read_write", - }, - ], - } as TicketDetail; - - assertEquals( - ticketWorkerMessage(ticket.id, "coder"), - "Work on Ticket 00001KYRRDVH9 as its coder.", - ); - - const href = ticketWorkerLaunchHref("workspace one", ticket, "reviewer"); - const url = new URL(href, "https://example.test"); - assertEquals(url.pathname, "/w/workspace%20one/workers/new"); - assertEquals(url.searchParams.get("ticketId"), ticket.id); - assertEquals(url.searchParams.get("ticketRole"), "reviewer"); - assertEquals(url.searchParams.get("repositoryKey"), "main repo"); - assertEquals(url.searchParams.get("refSelector"), "work/ticket"); - assertEquals( - url.searchParams.get("initialInput"), - "Work on Ticket 00001KYRRDVH9 as its reviewer.", - ); +Deno.test("generic Ticket launch carries natural text without role or resource claims", () => { + const requests = [ + "Review is unnecessary; complete the requested work.", + "Review the change, then merge it if approved.", + "Investigate and return without a conclusion.", + ]; + const targetSets: TicketDetail["targets"][] = [ + [], + [{ repository_key: "docs", ref_selector: "main", access: "read_only" }], + [{ repository_key: "repo", ref_selector: "main", access: "read_write" }], + ]; + assertEquals(ticketWorkerMessage("T-720"), "Work on Ticket T-720."); + for (const targets of targetSets) { + const ticket = { id: "T-720", title: "Flexible launch", targets }; + for (const request of requests) { + const url = new URL( + ticketWorkerLaunchHref("workspace one", ticket, request), + "https://example.test", + ); + assertEquals(url.pathname, "/w/workspace%20one/workers/new"); + assertEquals(url.searchParams.get("ticketId"), ticket.id); + assertEquals(url.searchParams.get("ticketTitle"), ticket.title); + assertEquals(url.searchParams.get("initialInput"), request); + for ( + const field of [ + "ticketRole", + "profile", + "repositoryKey", + "refSelector", + "flow", + ] + ) { + assertEquals(url.searchParams.has(field), false); + } + } + } }); -Deno.test("ticket worker launch never selects a read_only or ambiguous write target", () => { - const readOnly = { - id: "ticket-read-only", - title: "Read only", - targets: [{ - repository_key: "docs", - ref_selector: "main", - access: "read_only", - }], - } as TicketDetail; - const readOnlyUrl = new URL( - ticketWorkerLaunchHref("workspace", readOnly, "coder"), - "https://example.test", - ); - assertEquals(readOnlyUrl.searchParams.has("repositoryKey"), false); - assertEquals(readOnlyUrl.searchParams.has("refSelector"), false); - - const ambiguous = { - ...readOnly, - targets: [ - { repository_key: "main", ref_selector: "develop", access: "read_write" }, - { repository_key: "docs", ref_selector: "main", access: "read_write" }, - ], - } as TicketDetail; - const ambiguousUrl = new URL( - ticketWorkerLaunchHref("workspace", ambiguous, "coder"), - "https://example.test", - ); - assertEquals(ambiguousUrl.searchParams.has("repositoryKey"), false); - assertEquals(ambiguousUrl.searchParams.has("refSelector"), false); -}); - -Deno.test("ticket detail uses server-derived role assignment actions", async () => { +Deno.test("ticket detail retains backend queue eligibility and generic Worker assignment actions", async () => { const source = await Deno.readTextFile( new URL( "../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte", @@ -162,11 +132,6 @@ Deno.test("ticket detail uses server-derived role assignment actions", async () assertEquals(source.includes("ticket.action_eligibility.can_queue"), true); assertEquals(source.includes("ticket.relations.blockers.length > 0"), true); - assertEquals( - source.includes("ticket.action_eligibility.queue_tickets"), - true, - ); - assertEquals(source.includes("This operation queues:"), true); assertEquals(source.includes("outcome.queued_tickets.join"), true); assertEquals( source.includes("resolve the listed blockers before Queue"), @@ -176,7 +141,14 @@ Deno.test("ticket detail uses server-derived role assignment actions", async () source.includes("ticket.action_eligibility.can_assign_orchestrator"), true, ); - assertEquals(source.includes("/assignments/${role}"), true); + assertEquals( + source.includes('mutateAssignment("start-manual", "worker"'), + true, + ); + assertEquals( + source.includes('mutateAssignment("start-manual", "reviewer"'), + false, + ); assertEquals(source.includes('kind: "workspace_agent"'), true); assertEquals(source.includes('kind: "worker"'), true); assertEquals(source.includes("ticket.assignee"), false); @@ -188,10 +160,6 @@ Deno.test("ticket detail uses server-derived role assignment actions", async () source.includes("(repository.diagnostics ?? []).length > 0"), false, ); - assertEquals( - source.includes("unique registered repository targets"), - true, - ); }); Deno.test("Ticket authoring creates the target collection without singular authority", async () => { diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts index b563e50b2..fd246e20e 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts @@ -14,7 +14,6 @@ export const TICKET_STATES = [ ] as const; export type TicketState = (typeof TICKET_STATES)[number]; -export type TicketWorkerRole = "coder" | "reviewer"; export type WorkspaceOrchestratorStatus = BrowserWorkspaceOrchestratorResponse; @@ -98,36 +97,20 @@ export function ticketLanes(tickets: TicketCardSummary[]): TicketLane[] { })); } -export function ticketWorkerMessage( - ticketId: string, - role: TicketWorkerRole, -): string { - return `Work on Ticket ${ticketId} as its ${role}.`; +export function ticketWorkerMessage(ticketId: string): string { + return `Work on Ticket ${ticketId}.`; } export function ticketWorkerLaunchHref( workspaceId: string, - ticket: Pick, - role: TicketWorkerRole, + ticket: Pick, + request = ticketWorkerMessage(ticket.id), ): string { - const implementationTargets = ticket.targets.filter((target) => - target.access === "read_write" - ); - const implementationTarget = implementationTargets.length === 1 - ? implementationTargets[0] - : null; const params = new URLSearchParams({ ticketId: ticket.id, ticketTitle: ticket.title, - ticketRole: role, - initialInput: ticketWorkerMessage(ticket.id, role), + initialInput: request, }); - if (implementationTarget) { - params.set("repositoryKey", implementationTarget.repository_key); - if (implementationTarget.ref_selector) { - params.set("refSelector", implementationTarget.ref_selector); - } - } return `/w/${ encodeURIComponent(workspaceId) }/workers/new?${params.toString()}`; diff --git a/web/workspace/src/lib/workspace/workers/worker-launch.test.ts b/web/workspace/src/lib/workspace/workers/worker-launch.test.ts new file mode 100644 index 000000000..ffa75bbf2 --- /dev/null +++ b/web/workspace/src/lib/workspace/workers/worker-launch.test.ts @@ -0,0 +1,186 @@ +import { + buildCreateWorkspaceWorkerRequest, + defaultWorkerLaunchForm, + genericLaunchProfiles, + workerLaunchOutcomeUnknown, +} from "../sidebar/worker-launch.ts"; +import type { WorkerLaunchFormState } from "../sidebar/worker-launch.ts"; +import { emptyLaunchOptions } from "#lib/workspace/sidebar/worker-launch.test-fixtures.ts"; +import { load } from "../../../routes/w/[workspaceId]/workers/new/+page.ts"; + +declare const Deno: { test(name: string, fn: () => void): void }; +const form: WorkerLaunchFormState = { + runtime_id: "embedded", + display_name: "Worker", + profile: "", + subjektiv_subject_id: "", + initial_text: "", + workdir_attachments: [], + working_directory_repository_key: "", + working_directory_selector: "HEAD", +}; +function equal(actual: unknown, expected: unknown) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ); + } +} + +Deno.test("generic Ticket request keeps conclusion and review instructions as natural text with no mandatory Flow or profile", () => { + for ( + const text of [ + "Review is unnecessary.", + "Review, then merge if approved.", + "Return without a conclusion.", + ] + ) { + const request = buildCreateWorkspaceWorkerRequest({ + ...form, + initial_text: text, + ticket_assignment: { ticket_id: "T-720", operation_id: "launch-720" }, + }); + equal(request.ticket_assignment, { + ticket_id: "T-720", + operation_id: "launch-720", + }); + equal(request.profile, null); + equal(request.workdir_attachments, []); + equal(request.initial_submit, [{ kind: "text", content: text }]); + } +}); + +Deno.test("explicit Flow and resource selections remain separate from the Ticket request", () => { + const request = buildCreateWorkspaceWorkerRequest({ + ...form, + runtime_id: "remote", + profile: "project:analysis", + initial_text: " Review is unnecessary.\nReturn without conclusion. ", + flow: " project:investigate ", + subjektiv_subject_id: "subject-1", + ticket_assignment: { ticket_id: "T-720", operation_id: "launch-720" }, + workdir_attachments: [{ + alias: "sessions", + working_directory_id: "read-only-grant", + relative_cwd: "logs", + }], + }); + equal(request.initial_submit, [{ + kind: "text", + content: " Review is unnecessary.\nReturn without conclusion. ", + }, { kind: "flow", selector: "project:investigate" }]); + equal(request.workdir_attachments, [{ + alias: "sessions", + working_directory_id: "read-only-grant", + relative_cwd: "logs", + }]); + equal(request.feature_connections, { + subjektiv: { subject_id: "subject-1" }, + }); +}); + +Deno.test("launch defaults never claim repository or Workdir resources", () => { + const options = emptyLaunchOptions("workspace"); + options.repositories = [{ repository_key: "repo", default_selector: "main" }]; + options.working_directories = [{ + working_directory_id: "wd-1", + source: { kind: "repository", repository_key: "repo" }, + materializer_kind: "runtime_git_clone", + status: "active", + cleanliness: "clean", + }]; + const result = defaultWorkerLaunchForm(options, { + ...form, + runtime_id: "remote", + }); + equal(result.profile, ""); + equal(result.workdir_attachments, []); + equal(result.working_directory_repository_key, ""); +}); + +Deno.test("launch defaults preserve explicit attachments only on a supporting Runtime", () => { + const options = emptyLaunchOptions("workspace"); + options.working_directories = [{ + working_directory_id: "read-only-grant", + source: { + kind: "external_grant", + grant_id: "grant-1", + grant_permissions: { read: true, write: false, command: false }, + }, + materializer_kind: "client_hosted_external", + status: "active", + cleanliness: "unknown", + }]; + const attachments = [{ + alias: "sessions", + working_directory_id: "read-only-grant", + relative_cwd: "logs", + }]; + for (const runtime of options.runtimes) { + const result = defaultWorkerLaunchForm(options, { + ...form, + runtime_id: runtime.runtime_id, + workdir_attachments: attachments, + }); + equal( + result.workdir_attachments, + runtime.supports_workdir_attachments ? attachments : [], + ); + } +}); + +Deno.test("generic launch cannot select a trusted Reviewer profile or infer it from URL roles", () => { + const options = emptyLaunchOptions("workspace"); + options.profiles.push({ + ...options.profiles[0], + id: "builtin:reviewer", + label: "Reviewer", + }); + equal(genericLaunchProfiles(options.profiles).map((item) => item.id), [ + "builtin:companion", + ]); + equal( + defaultWorkerLaunchForm(options, { ...form, profile: "builtin:reviewer" }) + .profile, + "", + ); + const data = load({ + params: { workspaceId: "workspace" }, + url: new URL( + "https://example.test/workers/new?ticketId=T-720&ticketRole=reviewer&profile=builtin:reviewer&repositoryKey=repo", + ), + }); + equal(data.ticketContext, { + ticketId: "T-720", + ticketTitle: "T-720", + initialInput: "Work on Ticket T-720.", + }); + let rejected = false; + try { + buildCreateWorkspaceWorkerRequest({ ...form, profile: "builtin:reviewer" }); + } catch { + rejected = true; + } + equal(rejected, true); +}); + +Deno.test("OutcomeUnknown is distinguished from a definite permission rejection", () => { + for (const code of ["OutcomeUnknown", "worker_spawn_outcome_unknown"]) { + equal( + workerLaunchOutcomeUnknown({ + error: "runtime_operation_failed", + message: "Uncertain launch", + diagnostics: [{ code, message: "Reconcile", severity: "error" }], + }), + true, + ); + } + equal( + workerLaunchOutcomeUnknown({ + error: "permission_denied", + message: "Read only", + diagnostics: [], + }), + false, + ); +}); diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte index 67e7766bd..c815b6f71 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte @@ -215,11 +215,7 @@
-

Delivery

Tickets

-

- Plan, route, review, and close work without leaving the workspace. -

{#if !createTargetsSavable} -

Every target row must select a different repository. The exact one read-write target and selector requirements are enforced when the Ticket is marked ready.

+

Each optional resource row must select a different registered repository. Empty or read-only resources are allowed.

{/if}
{#if errorMessage} - + {/if} {#if queueMessage} @@ -432,7 +453,7 @@
-
@@ -515,8 +536,9 @@ {#if event.author}

{event.author}

{/if} {#if event.from || event.to}

{event.from ?? "—"} → {event.to ?? "—"}

{/if} - {#if event.reason}

{event.reason}

{/if} + {#if event.reason}{/if} {#if event.body}{/if} + {#each event.references as reference}

{reference}

{/each}
{:else} @@ -527,6 +549,29 @@