diff --git a/Cargo.lock b/Cargo.lock index a7cb09e1..6dffc64e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5850,6 +5850,7 @@ dependencies = [ "chrono", "futures", "manifest", + "memory", "project-record", "protocol", "reqwest", diff --git a/crates/manifest/src/scope.rs b/crates/manifest/src/scope.rs index 5e01d56a..f1f70089 100644 --- a/crates/manifest/src/scope.rs +++ b/crates/manifest/src/scope.rs @@ -169,6 +169,13 @@ pub enum ScopeError { } impl Scope { + pub fn empty() -> Self { + Self { + allow: Vec::new(), + deny: Vec::new(), + } + } + /// Build a [`Scope`] from a declarative [`ScopeConfig`]. /// /// Every `target` in `config` must already be absolute — per-layer diff --git a/crates/memory/src/backend.rs b/crates/memory/src/backend.rs new file mode 100644 index 00000000..fcbf8b68 --- /dev/null +++ b/crates/memory/src/backend.rs @@ -0,0 +1,469 @@ +//! Workspace-backend operations for Memory tools. +//! +//! These types are the typed HTTP boundary used by runtime workers that have +//! Workspace authority but no direct local filesystem authority. The local +//! executor intentionally lives in the memory crate so the workspace backend and +//! future non-HTTP hosts share validation and path semantics. + +use std::fs; +use std::io; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::extract::{ExtractedPayload, write_staging}; +use crate::schema::SourceRef; +use crate::tool::MemoryToolKind; +use crate::workspace::WorkspaceLayout; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "operation", rename_all = "snake_case")] +pub enum MemoryBackendOperation { + Query(MemoryQueryOperation), + Read(MemoryReadOperation), + Write(MemoryWriteOperation), + Edit(MemoryEditOperation), + Delete(MemoryDeleteOperation), + StageExtracted(MemoryStageExtractedOperation), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum MemoryBackendHttpResponse { + Ok { + result: MemoryBackendOperationResult, + }, + Error { + message: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum MemoryBackendOperationResult { + ToolOutput(MemoryToolOutput), + StagingWritten(MemoryStagingWriteOutput), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryToolOutput { + pub summary: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryQueryOperation { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub query: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryReadOperation { + pub kind: MemoryToolKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slug: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub offset: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryWriteOperation { + pub kind: MemoryToolKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slug: Option, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryEditOperation { + pub kind: MemoryToolKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slug: Option, + pub old_string: String, + pub new_string: String, + #[serde(default)] + pub replace_all: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryDeleteOperation { + pub kind: MemoryToolKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slug: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryStageExtractedOperation { + pub source: SourceRef, + pub payload: ExtractedPayload, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryStagingWriteOutput { + pub staging_count: usize, + pub staging_ids: Vec, +} + +pub fn execute_memory_backend_operation( + layout: &WorkspaceLayout, + operation: MemoryBackendOperation, +) -> io::Result { + match operation { + MemoryBackendOperation::Query(operation) => { + execute_query(layout, operation).map(MemoryBackendOperationResult::ToolOutput) + } + MemoryBackendOperation::Read(operation) => { + execute_read(layout, operation).map(MemoryBackendOperationResult::ToolOutput) + } + MemoryBackendOperation::Write(operation) => { + execute_write(layout, operation).map(MemoryBackendOperationResult::ToolOutput) + } + MemoryBackendOperation::Edit(operation) => { + execute_edit(layout, operation).map(MemoryBackendOperationResult::ToolOutput) + } + MemoryBackendOperation::Delete(operation) => { + execute_delete(layout, operation).map(MemoryBackendOperationResult::ToolOutput) + } + MemoryBackendOperation::StageExtracted(operation) => { + let written = write_staging(layout, operation.source, operation.payload)?; + Ok(MemoryBackendOperationResult::StagingWritten( + MemoryStagingWriteOutput { + staging_count: written.len(), + staging_ids: written.iter().map(|item| item.id.to_string()).collect(), + }, + )) + } + } +} + +fn execute_query( + layout: &WorkspaceLayout, + operation: MemoryQueryOperation, +) -> io::Result { + let mut entries = Vec::new(); + for kind in [ + MemoryToolKind::Summary, + MemoryToolKind::Decision, + MemoryToolKind::Request, + ] { + entries.extend(query_kind(layout, kind, operation.query.as_deref())?); + } + + if entries.is_empty() { + let suffix = operation + .query + .as_deref() + .map(|query| format!(" matching {query:?}")) + .unwrap_or_default(); + return Ok(MemoryToolOutput { + summary: format!("No memory records found{suffix}"), + content: None, + }); + } + + let body = entries.join("\n\n"); + Ok(tool_output_from_string(body)) +} + +fn query_kind( + layout: &WorkspaceLayout, + kind: MemoryToolKind, + query: Option<&str>, +) -> io::Result> { + let mut entries = Vec::new(); + match kind { + MemoryToolKind::Summary => { + let path = memory_path(layout, kind, None)?; + if !path.exists() { + return Ok(entries); + } + let content = fs::read_to_string(&path)?; + if matches_query(&content, query) { + entries.push(format!("kind=summary\n{}", excerpt(&content, query))); + } + } + MemoryToolKind::Decision | MemoryToolKind::Request => { + let dir = record_dir(layout, kind); + if !dir.exists() { + return Ok(entries); + } + let mut files: Vec = fs::read_dir(&dir)? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("md")) + .collect(); + files.sort(); + for path in files { + let content = fs::read_to_string(&path)?; + if matches_query(&content, query) { + let slug = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or(""); + entries.push(format!( + "kind={} slug={}\n{}", + kind.as_str(), + slug, + excerpt(&content, query) + )); + } + } + } + } + Ok(entries) +} + +fn execute_read( + layout: &WorkspaceLayout, + operation: MemoryReadOperation, +) -> io::Result { + let path = memory_path(layout, operation.kind, operation.slug.as_deref())?; + let content = fs::read_to_string(&path)?; + let offset = operation.offset.unwrap_or(0); + let limit = operation.limit.unwrap_or(2000); + let lines: Vec<&str> = content.lines().collect(); + let selected = lines + .iter() + .enumerate() + .skip(offset) + .take(limit) + .map(|(index, line)| format!("{:>6}\t{}", index + 1, line)) + .collect::>() + .join("\n"); + let total = lines.len(); + Ok(MemoryToolOutput { + summary: format!( + "Read {} lines from {}{}", + selected.lines().count(), + operation.kind.as_str(), + operation + .slug + .as_deref() + .map(|slug| format!("/{slug}")) + .unwrap_or_default() + ), + content: Some(format!( + "total_lines={total} offset={offset} limit={limit}\n{selected}" + )), + }) +} + +fn execute_write( + layout: &WorkspaceLayout, + operation: MemoryWriteOperation, +) -> io::Result { + let path = memory_path(layout, operation.kind, operation.slug.as_deref())?; + validate_slug_rules(operation.kind, operation.slug.as_deref())?; + validate_memory_content( + operation.kind, + operation.slug.as_deref(), + &operation.content, + )?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&path, operation.content)?; + Ok(MemoryToolOutput { + summary: format!("Wrote {} record", operation.kind.as_str()), + content: Some(path.display().to_string()), + }) +} + +fn execute_edit( + layout: &WorkspaceLayout, + operation: MemoryEditOperation, +) -> io::Result { + validate_slug_rules(operation.kind, operation.slug.as_deref())?; + if operation.old_string == operation.new_string { + return Err(invalid_input("old_string and new_string must differ")); + } + let path = memory_path(layout, operation.kind, operation.slug.as_deref())?; + let original = fs::read_to_string(&path)?; + let count = original.matches(&operation.old_string).count(); + if count == 0 { + return Err(invalid_input("old_string was not found")); + } + if count > 1 && !operation.replace_all { + return Err(invalid_input( + "old_string matched more than once; set replace_all=true to replace every occurrence", + )); + } + let updated = if operation.replace_all { + original.replace(&operation.old_string, &operation.new_string) + } else { + original.replacen(&operation.old_string, &operation.new_string, 1) + }; + validate_memory_content(operation.kind, operation.slug.as_deref(), &updated)?; + fs::write(&path, updated)?; + Ok(MemoryToolOutput { + summary: format!( + "Edited {} occurrence(s)", + if operation.replace_all { count } else { 1 } + ), + content: Some(path.display().to_string()), + }) +} + +fn execute_delete( + layout: &WorkspaceLayout, + operation: MemoryDeleteOperation, +) -> io::Result { + validate_slug_rules(operation.kind, operation.slug.as_deref())?; + let path = memory_path(layout, operation.kind, operation.slug.as_deref())?; + fs::remove_file(&path)?; + Ok(MemoryToolOutput { + summary: format!("Deleted {} record", operation.kind.as_str()), + content: Some(path.display().to_string()), + }) +} + +fn memory_path( + layout: &WorkspaceLayout, + kind: MemoryToolKind, + slug: Option<&str>, +) -> io::Result { + match kind { + MemoryToolKind::Summary => { + if slug.is_some() { + return Err(invalid_input("summary records do not accept slug")); + } + Ok(layout.memory_dir().join("summary.md")) + } + MemoryToolKind::Decision => { + Ok(record_dir(layout, kind).join(format!("{}.md", required_slug(slug)?))) + } + MemoryToolKind::Request => { + Ok(record_dir(layout, kind).join(format!("{}.md", required_slug(slug)?))) + } + } +} + +fn record_dir(layout: &WorkspaceLayout, kind: MemoryToolKind) -> PathBuf { + match kind { + MemoryToolKind::Summary => layout.memory_dir(), + MemoryToolKind::Decision => layout.memory_dir().join("decisions"), + MemoryToolKind::Request => layout.memory_dir().join("requests"), + } +} + +fn required_slug(slug: Option<&str>) -> io::Result<&str> { + slug.filter(|value| !value.trim().is_empty()) + .ok_or_else(|| invalid_input("slug is required for this memory kind")) +} + +fn validate_slug_rules(kind: MemoryToolKind, slug: Option<&str>) -> io::Result<()> { + match kind { + MemoryToolKind::Summary => { + if slug.is_some() { + return Err(invalid_input("summary records do not accept slug")); + } + } + MemoryToolKind::Decision | MemoryToolKind::Request => { + validate_slug(required_slug(slug)?)?; + } + } + Ok(()) +} + +fn validate_slug(slug: &str) -> io::Result<()> { + if slug.is_empty() + || slug.contains('/') + || slug.contains('\\') + || slug == "." + || slug == ".." + || slug.starts_with('.') + || !slug + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') + { + return Err(invalid_input( + "slug must use lowercase ASCII letters, digits, and '-' only", + )); + } + Ok(()) +} + +fn validate_memory_content( + kind: MemoryToolKind, + slug: Option<&str>, + content: &str, +) -> io::Result<()> { + if content.trim().is_empty() { + return Err(invalid_input("content must not be empty")); + } + match kind { + MemoryToolKind::Summary => { + if !content.trim_start().starts_with("# Memory summary") { + return Err(invalid_input( + "summary content must start with '# Memory summary'", + )); + } + } + MemoryToolKind::Decision | MemoryToolKind::Request => { + validate_slug_rules(kind, slug)?; + if !content.trim_start().starts_with("---") { + return Err(invalid_input( + "memory record content must start with YAML frontmatter", + )); + } + } + } + Ok(()) +} + +fn matches_query(content: &str, query: Option<&str>) -> bool { + match query.map(str::trim).filter(|query| !query.is_empty()) { + Some(query) => content.to_lowercase().contains(&query.to_lowercase()), + None => true, + } +} + +fn excerpt(content: &str, query: Option<&str>) -> String { + let lines: Vec<&str> = content.lines().collect(); + let Some(query) = query.map(str::trim).filter(|query| !query.is_empty()) else { + return lines + .iter() + .take(20) + .copied() + .collect::>() + .join("\n"); + }; + let query_lower = query.to_lowercase(); + let hit = lines + .iter() + .position(|line| line.to_lowercase().contains(&query_lower)) + .unwrap_or(0); + let start = hit.saturating_sub(2); + let end = (hit + 3).min(lines.len()); + lines[start..end].join("\n") +} + +fn tool_output_from_string(value: String) -> MemoryToolOutput { + const SUMMARY_THRESHOLD: usize = 200; + if value.len() <= SUMMARY_THRESHOLD { + MemoryToolOutput { + summary: value, + content: None, + } + } else { + let lines = value.lines().count(); + let first_line: String = value + .lines() + .next() + .unwrap_or("") + .chars() + .take(80) + .collect(); + MemoryToolOutput { + summary: format!("{lines} lines | {first_line}…"), + content: Some(value), + } + } +} + +fn invalid_input(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, message.into()) +} diff --git a/crates/memory/src/lib.rs b/crates/memory/src/lib.rs index e729980f..77447380 100644 --- a/crates/memory/src/lib.rs +++ b/crates/memory/src/lib.rs @@ -6,6 +6,7 @@ //! Worker is responsible for denying it at the Scope level when memory is enabled. pub mod audit; +pub mod backend; pub mod consolidate; pub mod error; pub mod extract; diff --git a/crates/memory/src/tool/mod.rs b/crates/memory/src/tool/mod.rs index 8ba45c36..a9767547 100644 --- a/crates/memory/src/tool/mod.rs +++ b/crates/memory/src/tool/mod.rs @@ -28,7 +28,9 @@ pub use write::write_tool; /// Kinds the memory tools accept as input. `Workflow` is intentionally /// excluded — workflows are sub-Engine context, not agent-editable. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, Deserialize, schemars::JsonSchema, +)] #[serde(rename_all = "lowercase")] pub enum MemoryToolKind { Summary, diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index b1d94c74..c9df0b0d 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -361,6 +361,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { ) -> Result { let worker_name = Self::runtime_worker_name(&request); let profile = self.runtime_profile(&request); + let has_local_filesystem = request.working_directory.is_some(); let worker_root = request .working_directory .as_ref() @@ -385,17 +386,24 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { let archive = self .resolve_profile_source_archive(&request.request.profile_source) .await?; - let (mut manifest, loader) = { + let (manifest, loader) = { let manifest = archive .resolve_profile(selector, &worker_root, &worker_name) .map_err(|err| format!("failed to resolve profile source archive: {err}"))?; - worker::entrypoint::resolve_runtime_profile_manifest_from_manifest( - manifest, - &worker_root, - &worker_name, - )? + if has_local_filesystem { + worker::entrypoint::resolve_runtime_profile_manifest_from_manifest( + manifest, + &worker_root, + &worker_name, + )? + } else { + worker::entrypoint::resolve_runtime_profile_manifest_from_manifest_without_filesystem( + manifest, + &worker_root, + &worker_name, + )? + } }; - manifest.worker.name = worker_name; let store_dir = self.store_dir()?; let session_store = FsStore::new(&store_dir).map_err(|err| { diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 181a7041..b671fc68 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -726,34 +726,46 @@ where } { + let workspace_client = worker.workspace_client().clone(); let worker = worker.engine_mut(); - // Memory tools require both explicit feature exposure and memory storage - // configuration. This keeps resident-memory config separate from the - // model-visible Memory* tool surface. + // Memory tools require explicit feature exposure. Storage access may be + // provided by local filesystem authority or the path-free Workspace HTTP API. if feature_config.memory.enabled { - let mem = memory_config.as_ref().ok_or_else(|| { - std::io::Error::new( + if let Some(workspace_root) = local_workspace_root.as_ref() { + let mem = memory_config.as_ref().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "[feature.memory].enabled = true requires a [memory] configuration section", + ) + })?; + let layout = memory::WorkspaceLayout::resolve(mem, workspace_root); + let query_cfg = memory::tool::QueryConfig::from(mem); + worker.register_tool(memory::tool::read_tool_with_usage( + layout.clone(), + session_id_for_usage, + )); + worker.register_tool(memory::tool::write_tool(layout.clone())); + worker.register_tool(memory::tool::edit_tool(layout.clone())); + worker.register_tool(memory::tool::delete_tool(layout.clone())); + worker.register_tool(memory::tool::memory_query_tool(layout, query_cfg)); + } else if let WorkspaceClient::Http { + workspace_id, + base_url, + } = workspace_client + { + for definition in crate::feature::builtin::memory::workspace_http_memory_tools( + workspace_id, + base_url, + ) { + worker.register_tool(definition); + } + } else { + return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, - "[feature.memory].enabled = true requires a [memory] configuration section", - ) - })?; - let workspace_root = local_workspace_root.as_ref().ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "memory tools require local Worker filesystem authority", - ) - })?; - let layout = memory::WorkspaceLayout::resolve(mem, workspace_root); - let query_cfg = memory::tool::QueryConfig::from(mem); - worker.register_tool(memory::tool::read_tool_with_usage( - layout.clone(), - session_id_for_usage, - )); - worker.register_tool(memory::tool::write_tool(layout.clone())); - worker.register_tool(memory::tool::edit_tool(layout.clone())); - worker.register_tool(memory::tool::delete_tool(layout.clone())); - worker.register_tool(memory::tool::memory_query_tool(layout, query_cfg)); + "memory tools require Workspace HTTP API or local Worker filesystem authority", + )); + } } // Worker-orchestration tools (SpawnWorker + the four comm tools) share diff --git a/crates/worker/src/entrypoint.rs b/crates/worker/src/entrypoint.rs index 46563da1..762c31b8 100644 --- a/crates/worker/src/entrypoint.rs +++ b/crates/worker/src/entrypoint.rs @@ -273,6 +273,20 @@ pub fn resolve_runtime_profile_manifest_from_manifest( Ok((manifest, PromptLoader::builtins_only())) } +pub fn resolve_runtime_profile_manifest_from_manifest_without_filesystem( + mut manifest: WorkerManifest, + workspace_root: &Path, + worker_name: &str, +) -> Result<(WorkerManifest, PromptLoader), String> { + if manifest.worker.name.is_empty() { + manifest.worker.name = worker_name.to_string(); + } + manifest.scope = ScopeConfig::default(); + manifest.delegation_scope = ScopeConfig::default(); + apply_plugin_resolution_plan(&mut manifest, workspace_root); + Ok((manifest, PromptLoader::builtins_only())) +} + fn load_single_manifest( path: &Path, explicit_worker_name: Option<&str>, diff --git a/crates/worker/src/feature/builtin.rs b/crates/worker/src/feature/builtin.rs index bef8b017..c82c7c3d 100644 --- a/crates/worker/src/feature/builtin.rs +++ b/crates/worker/src/feature/builtin.rs @@ -4,6 +4,7 @@ //! same descriptor-approved registry path used by feature modules. They are not //! an external plugin-loading surface. +pub mod memory; pub mod session_explore; pub mod task; pub mod ticket; diff --git a/crates/worker/src/feature/builtin/memory.rs b/crates/worker/src/feature/builtin/memory.rs new file mode 100644 index 00000000..86b62b43 --- /dev/null +++ b/crates/worker/src/feature/builtin/memory.rs @@ -0,0 +1,261 @@ +//! Workspace-HTTP backed Memory tools. +//! +//! Runtime workers may have Workspace authority without direct local filesystem +//! authority. In that case model-visible Memory tools must go through the +//! workspace backend instead of resolving `.yoi/memory` from a Worker workdir. + +use std::sync::Arc; + +use async_trait::async_trait; +use llm_engine::tool::{ + Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, +}; +use memory::backend::{ + MemoryBackendHttpResponse, MemoryBackendOperation, MemoryBackendOperationResult, + MemoryDeleteOperation, MemoryEditOperation, MemoryQueryOperation, MemoryReadOperation, + MemoryToolOutput, MemoryWriteOperation, +}; +use serde::de::DeserializeOwned; +use serde_json::json; + +#[derive(Clone, Debug)] +pub struct WorkspaceHttpMemoryBackend { + workspace_id: String, + base_url: String, +} + +impl WorkspaceHttpMemoryBackend { + pub fn new(workspace_id: impl Into, base_url: impl Into) -> Self { + Self { + workspace_id: workspace_id.into(), + base_url: base_url.into(), + } + } + + fn execute(&self, operation: MemoryBackendOperation) -> Result { + let url = format!( + "{}/api/w/{}/memory/backend", + self.base_url.trim_end_matches('/'), + self.workspace_id + ); + let response = reqwest::blocking::Client::new() + .post(url) + .json(&operation) + .send() + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + let status = response.status(); + let body = response + .text() + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + if !status.is_success() { + return Err(ToolError::ExecutionFailed(format!( + "workspace memory backend returned HTTP {status}: {body}" + ))); + } + let response: MemoryBackendHttpResponse = serde_json::from_str(&body).map_err(|error| { + ToolError::ExecutionFailed(format!("decode memory backend response: {error}")) + })?; + match response { + MemoryBackendHttpResponse::Ok { + result: MemoryBackendOperationResult::ToolOutput(output), + } => Ok(tool_output(output)), + MemoryBackendHttpResponse::Ok { result } => Err(ToolError::ExecutionFailed(format!( + "unexpected memory backend result for model-visible tool: {result:?}" + ))), + MemoryBackendHttpResponse::Error { message } => { + Err(ToolError::ExecutionFailed(message)) + } + } + } +} + +pub fn workspace_http_memory_tools( + workspace_id: impl Into, + base_url: impl Into, +) -> Vec { + let backend = WorkspaceHttpMemoryBackend::new(workspace_id, base_url); + vec![ + memory_tool( + "MemoryRead", + READ_DESCRIPTION, + read_schema(), + backend.clone(), + |input| { + Ok(MemoryBackendOperation::Read(parse_input::< + MemoryReadOperation, + >(input)?)) + }, + ), + memory_tool( + "MemoryWrite", + WRITE_DESCRIPTION, + write_schema(), + backend.clone(), + |input| { + Ok(MemoryBackendOperation::Write(parse_input::< + MemoryWriteOperation, + >(input)?)) + }, + ), + memory_tool( + "MemoryEdit", + EDIT_DESCRIPTION, + edit_schema(), + backend.clone(), + |input| { + Ok(MemoryBackendOperation::Edit(parse_input::< + MemoryEditOperation, + >(input)?)) + }, + ), + memory_tool( + "MemoryDelete", + DELETE_DESCRIPTION, + delete_schema(), + backend.clone(), + |input| { + Ok(MemoryBackendOperation::Delete(parse_input::< + MemoryDeleteOperation, + >(input)?)) + }, + ), + memory_tool( + "MemoryQuery", + QUERY_DESCRIPTION, + query_schema(), + backend, + |input| { + Ok(MemoryBackendOperation::Query(parse_input::< + MemoryQueryOperation, + >(input)?)) + }, + ), + ] +} + +type OperationBuilder = fn(&str) -> Result; + +fn memory_tool( + name: &'static str, + description: &'static str, + schema: serde_json::Value, + backend: WorkspaceHttpMemoryBackend, + build: OperationBuilder, +) -> ToolDefinition { + Arc::new(move || { + ( + ToolMeta::new(name) + .description(description) + .input_schema(schema.clone()), + Arc::new(WorkspaceHttpMemoryTool { + backend: backend.clone(), + build, + }) as Arc, + ) + }) +} + +#[derive(Clone)] +struct WorkspaceHttpMemoryTool { + backend: WorkspaceHttpMemoryBackend, + build: OperationBuilder, +} + +#[async_trait] +impl Tool for WorkspaceHttpMemoryTool { + async fn execute( + &self, + input_json: &str, + _ctx: ToolExecutionContext, + ) -> Result { + let operation = (self.build)(input_json)?; + self.backend.execute(operation) + } +} + +fn parse_input(input: &str) -> Result { + serde_json::from_str(input).map_err(|error| ToolError::InvalidArgument(error.to_string())) +} + +fn tool_output(output: MemoryToolOutput) -> ToolOutput { + ToolOutput { + summary: output.summary, + content: output.content, + } +} + +const READ_DESCRIPTION: &str = "Read a durable memory record through Workspace authority."; +const WRITE_DESCRIPTION: &str = + "Create or overwrite a durable memory record through Workspace authority."; +const EDIT_DESCRIPTION: &str = + "Replace text in a durable memory record through Workspace authority."; +const DELETE_DESCRIPTION: &str = "Delete a durable memory record through Workspace authority."; +const QUERY_DESCRIPTION: &str = "Query durable memory records through Workspace authority."; + +fn kind_schema() -> serde_json::Value { + json!({"type":"string","enum":["summary","decision","request"]}) +} + +fn read_schema() -> serde_json::Value { + json!({ + "type":"object", + "additionalProperties": false, + "required":["kind"], + "properties":{ + "kind": kind_schema(), + "slug":{"type":["string","null"]}, + "offset":{"type":["integer","null"],"minimum":0}, + "limit":{"type":["integer","null"],"minimum":0} + } + }) +} + +fn write_schema() -> serde_json::Value { + json!({ + "type":"object", + "additionalProperties": false, + "required":["kind","content"], + "properties":{ + "kind": kind_schema(), + "slug":{"type":["string","null"]}, + "content":{"type":"string"} + } + }) +} + +fn edit_schema() -> serde_json::Value { + json!({ + "type":"object", + "additionalProperties": false, + "required":["kind","old_string","new_string"], + "properties":{ + "kind": kind_schema(), + "slug":{"type":["string","null"]}, + "old_string":{"type":"string"}, + "new_string":{"type":"string"}, + "replace_all":{"type":"boolean","default":false} + } + }) +} + +fn delete_schema() -> serde_json::Value { + json!({ + "type":"object", + "additionalProperties": false, + "required":["kind"], + "properties":{ + "kind": kind_schema(), + "slug":{"type":["string","null"]} + } + }) +} + +fn query_schema() -> serde_json::Value { + json!({ + "type":"object", + "additionalProperties": false, + "properties":{ + "query":{"type":["string","null"]} + } + }) +} diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 63564de1..ed166628 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -4971,7 +4971,11 @@ fn prepare_worker_common_with_context( let layout = memory::WorkspaceLayout::resolve(mem, &local.root); scope_config.deny.extend(memory::deny_write_rules(&layout)); } - let scope = Scope::from_config(&scope_config).map_err(WorkerError::Scope)?; + let scope = if scope_config.allow.is_empty() && filesystem_authority.as_local().is_none() { + Scope::empty() + } else { + Scope::from_config(&scope_config).map_err(WorkerError::Scope)? + }; prepare_worker_common_from_scope( manifest, loader, diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index 31a4083a..42bee351 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -21,6 +21,7 @@ serde_yaml.workspace = true sha2.workspace = true thiserror.workspace = true ticket.workspace = true +memory.workspace = true tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync"] } tokio-tungstenite.workspace = true worker.workspace = true diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 41f53c03..bc1164b5 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -1449,64 +1449,51 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { fn create_working_directory( &self, - request: WorkingDirectoryRequest, + _request: WorkingDirectoryRequest, ) -> RuntimeWorkingDirectoryResult { - match self.runtime.create_working_directory(request) { - Ok(working_directory) => RuntimeWorkingDirectoryResult { - state: WorkerOperationState::Accepted, - working_directory: Some(working_directory), - diagnostics: Vec::new(), - }, - Err(err) => RuntimeWorkingDirectoryResult { - state: WorkerOperationState::Rejected, - working_directory: None, - diagnostics: vec![embedded_runtime_diagnostic(&err)], - }, + RuntimeWorkingDirectoryResult { + state: WorkerOperationState::Rejected, + working_directory: None, + diagnostics: vec![embedded_workdir_unsupported_diagnostic()], } } fn list_working_directories(&self) -> RuntimeList { - match self.runtime.list_working_directories() { - Ok(items) => RuntimeList::new(items, Vec::new()), - Err(err) => RuntimeList::new(Vec::new(), vec![embedded_runtime_diagnostic(&err)]), - } + RuntimeList::new(Vec::new(), Vec::new()) } - fn working_directory(&self, working_directory_id: &str) -> RuntimeWorkingDirectoryResult { - match self.runtime.working_directory(working_directory_id) { - Ok(working_directory) => RuntimeWorkingDirectoryResult { - state: WorkerOperationState::Accepted, - working_directory: Some(working_directory), - diagnostics: Vec::new(), - }, - Err(err) => RuntimeWorkingDirectoryResult { - state: WorkerOperationState::Rejected, - working_directory: None, - diagnostics: vec![embedded_runtime_diagnostic(&err)], - }, + fn working_directory(&self, _working_directory_id: &str) -> RuntimeWorkingDirectoryResult { + RuntimeWorkingDirectoryResult { + state: WorkerOperationState::Rejected, + working_directory: None, + diagnostics: vec![embedded_workdir_unsupported_diagnostic()], } } fn cleanup_working_directory( &self, - working_directory_id: &str, + _working_directory_id: &str, ) -> RuntimeWorkingDirectoryResult { - match self.runtime.cleanup_working_directory(working_directory_id) { - Ok(working_directory) => RuntimeWorkingDirectoryResult { - state: WorkerOperationState::Accepted, - working_directory: Some(working_directory), - diagnostics: Vec::new(), - }, - Err(err) => RuntimeWorkingDirectoryResult { - state: WorkerOperationState::Rejected, - working_directory: None, - diagnostics: vec![embedded_runtime_diagnostic(&err)], - }, + RuntimeWorkingDirectoryResult { + state: WorkerOperationState::Rejected, + working_directory: None, + diagnostics: vec![embedded_workdir_unsupported_diagnostic()], } } fn spawn_worker(&self, request: WorkerSpawnRequest) -> WorkerSpawnResult { let mut diagnostics = Vec::new(); + if request.resolved_working_directory_request.is_some() + || request.resolved_working_directory.is_some() + { + diagnostics.push(embedded_workdir_unsupported_diagnostic()); + return WorkerSpawnResult { + state: WorkerOperationState::Rejected, + worker: None, + acceptance_evidence: Vec::new(), + diagnostics, + }; + } if matches!( request.acceptance, WorkerSpawnAcceptanceRequirement::SocketReady @@ -2647,12 +2634,15 @@ fn embedded_spawn_execution_failure_diagnostic( worker_runtime::execution::WorkerExecutionOutcome::Unsupported => "unsupported", worker_runtime::execution::WorkerExecutionOutcome::Errored => "errored", }; + let detail = result + .message + .as_deref() + .map(|message| format!(": {message}")) + .unwrap_or_default(); Some(diagnostic( format!("embedded_worker_execution_spawn_{status}"), severity, - format!( - "Embedded Worker execution spawn was {status} during setup; check runtime configuration" - ), + format!("Embedded Worker execution spawn was {status} during setup{detail}"), )) } @@ -3006,6 +2996,14 @@ fn remote_lifecycle_rejected( } } +fn embedded_workdir_unsupported_diagnostic() -> RuntimeDiagnostic { + diagnostic( + "embedded_worker_workdir_unsupported", + DiagnosticSeverity::Error, + "Embedded Runtime is no-workdir only; choose a non-embedded Runtime for workspace-file Workers".to_string(), + ) +} + fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnostic { match error { EmbeddedRuntimeError::RuntimeStopped => diagnostic( diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 565d57a1..92511061 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -12,6 +12,9 @@ use axum::routing::{delete, get, post, put}; use axum::{Json, Router}; use chrono::{SecondsFormat, Utc}; use futures::StreamExt; +use memory::backend::{ + MemoryBackendHttpResponse, MemoryBackendOperation, execute_memory_backend_operation, +}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use ticket::{ @@ -21,7 +24,6 @@ use ticket::{ use tokio::net::TcpListener; use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest}; use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; -use worker_runtime::working_directory::LocalGitWorktreeMaterializer; use crate::companion::{ CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse, @@ -193,10 +195,7 @@ impl WorkspaceApi { crate::Error::Store(format!( "failed to initialize embedded Worker backend: {err}" )) - })? - .with_working_directory_materializer(LocalGitWorktreeMaterializer::new( - config.embedded_runtime_store_root.clone(), - )); + })?; Self::new_with_execution_backend_and_broker( config, store, @@ -330,6 +329,10 @@ pub fn build_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/tickets/backend", post(scoped_ticket_backend_operation), ) + .route( + "/api/w/{workspace_id}/memory/backend", + post(scoped_memory_backend_operation), + ) .route("/api/tickets/{id}", get(get_ticket)) .route("/api/w/{workspace_id}/skills", get(scoped_list_skills)) .route("/api/w/{workspace_id}/skills/lint", get(scoped_lint_skills)) @@ -1275,6 +1278,23 @@ async fn scoped_ticket_backend_operation( Ok(Json(response)) } +async fn scoped_memory_backend_operation( + State(api): State, + AxumPath(path): AxumPath, + Json(operation): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let memory_config = manifest::MemoryConfig::default(); + let layout = memory::WorkspaceLayout::resolve(&memory_config, &api.config.workspace_root); + let response = match execute_memory_backend_operation(&layout, operation) { + Ok(result) => MemoryBackendHttpResponse::Ok { result }, + Err(error) => MemoryBackendHttpResponse::Error { + message: sanitize_backend_error(&error.to_string()), + }, + }; + Ok(Json(response)) +} + async fn scoped_list_skills( State(api): State, AxumPath(path): AxumPath, @@ -1501,10 +1521,21 @@ async fn scoped_list_working_directories( async fn scoped_create_working_directory( State(api): State, AxumPath(path): AxumPath, - Json(request): Json, + Json(_request): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - create_working_directory_for_runtime(api, request) + Err(ApiError::with_diagnostics( + Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "backend_workdir_create_unsupported".to_string(), + message: "Working directory creation must be scoped to a concrete Runtime".to_string(), + }, + vec![RuntimeDiagnostic { + code: "backend_workdir_create_unsupported".to_string(), + severity: DiagnosticSeverity::Error, + message: "Use runtime-scoped Worker creation or a runtime-scoped working-directory API; the backend does not own workdir lifecycle.".to_string(), + }], + )) } async fn scoped_working_directory_detail( @@ -3128,6 +3159,27 @@ struct RuntimeConfigBundleAvailabilityQuery { digest: String, } +fn reject_workdir_for_embedded_runtime( + runtime_id: &str, + has_workdir: bool, +) -> std::result::Result<(), ApiError> { + if runtime_id != EMBEDDED_WORKER_RUNTIME_ID || !has_workdir { + return Ok(()); + } + Err(ApiError::with_diagnostics( + Error::RuntimeOperationFailed { + runtime_id: runtime_id.to_string(), + code: "embedded_worker_workdir_unsupported".to_string(), + message: "The embedded Runtime does not accept working directories".to_string(), + }, + vec![RuntimeDiagnostic { + code: "embedded_worker_workdir_unsupported".to_string(), + severity: DiagnosticSeverity::Error, + message: "Choose a non-embedded Runtime for workspace-file Workers; embedded Workers are no-workdir Workspace-API workers.".to_string(), + }], + )) +} + fn reject_no_workdir_for_non_embedded_runtime( runtime_id: &str, ) -> std::result::Result<(), ApiError> { @@ -3155,6 +3207,10 @@ async fn create_runtime_worker( AxumPath(runtime_id): AxumPath, Json(mut request): Json, ) -> ApiResult> { + reject_workdir_for_embedded_runtime( + &runtime_id, + request.working_directory_request.is_some() || request.resolved_working_directory.is_some(), + )?; if request.working_directory_request.is_none() && request.resolved_working_directory.is_none() { reject_no_workdir_for_non_embedded_runtime(&runtime_id)?; } @@ -6031,89 +6087,6 @@ mod tests { .unwrap(); } - fn create_observed_workdir(api: &WorkspaceApi) -> String { - let Json(response) = create_working_directory_for_runtime( - api.clone(), - BrowserWorkingDirectoryCreateRequest { - runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), - repository_id: TEST_REPOSITORY_ID.to_string(), - selector: Some("HEAD".to_string()), - }, - ) - .unwrap_or_else(|err| panic!("create observed workdir: {}", err.error)); - response.item.working_directory_id - } - - #[tokio::test] - async fn observed_workdir_reports_verified_cleanliness_for_cleanup() { - let workspace = tempfile::tempdir().unwrap(); - init_clean_git_workspace(workspace.path()); - let api = test_api(workspace.path()).await; - let workdir_id = create_observed_workdir(&api); - sync_runtime_workdir_observations(&api, EMBEDDED_WORKER_RUNTIME_ID) - .unwrap_or_else(|err| panic!("sync runtime workdir observations: {}", err.error)); - - let stored = api - .store - .get_workdir_registry(&api.config.workspace_id, workdir_id.as_str()) - .unwrap() - .expect("workdir registry row"); - assert_eq!(stored.materialization_status, "present"); - assert_eq!(stored.cleanliness, "clean"); - - let plan = build_runtime_cleanup_plan(&api, EMBEDDED_WORKER_RUNTIME_ID) - .unwrap_or_else(|err| panic!("cleanup plan: {}", err.error)); - let candidate = plan - .workdirs - .iter() - .find(|candidate| candidate.workdir_id == workdir_id) - .expect("cleanup candidate"); - assert_eq!(candidate.cleanliness, CleanupWorkdirCleanliness::Clean); - assert_eq!(candidate.action, CleanupTargetKind::WorkdirCleanCleanup); - assert!(candidate.reason.contains("clean")); - - let target_id = candidate.target_id.clone(); - let request = ExecuteRuntimeCleanupRequest { - expected_plan_revision: plan.revision, - expected_plan_digest: plan.digest, - worker_target_ids: Vec::new(), - workdir_target_ids: vec![target_id], - confirm_dirty_discard_target_ids: Vec::new(), - }; - let response = execute_runtime_cleanup(&api, EMBEDDED_WORKER_RUNTIME_ID, request) - .unwrap_or_else(|err| panic!("cleanup execution: {}", err.error)); - assert_eq!( - response.results[0].action, - CleanupTargetKind::WorkdirCleanCleanup - ); - assert_eq!(response.results[0].status, "deleted"); - } - - #[tokio::test] - async fn stale_clean_registry_row_is_downgraded_by_real_observation_before_cleanup() { - let workspace = tempfile::tempdir().unwrap(); - init_clean_git_workspace(workspace.path()); - let api = test_api(workspace.path()).await; - let workdir_id = create_observed_workdir(&api); - let mut stale_clean = api - .store - .get_workdir_registry(&api.config.workspace_id, workdir_id.as_str()) - .unwrap() - .expect("workdir registry row"); - stale_clean.cleanliness = "clean".to_string(); - api.store.upsert_workdir_registry(&stale_clean).unwrap(); - - let plan = build_runtime_cleanup_plan(&api, EMBEDDED_WORKER_RUNTIME_ID) - .unwrap_or_else(|err| panic!("cleanup plan: {}", err.error)); - let candidate = plan - .workdirs - .iter() - .find(|candidate| candidate.workdir_id == workdir_id) - .expect("cleanup candidate"); - assert_eq!(candidate.cleanliness, CleanupWorkdirCleanliness::Clean); - assert_eq!(candidate.action, CleanupTargetKind::WorkdirCleanCleanup); - } - #[tokio::test] async fn synthetic_verified_clean_workdir_can_still_use_clean_cleanup_path() { let workspace = tempfile::tempdir().unwrap(); @@ -6478,71 +6451,33 @@ mod tests { } #[tokio::test] - async fn browser_working_directory_create_list_detail_and_cleanup_are_path_safe() { + async fn browser_working_directory_create_is_rejected_as_backend_lifecycle() { let dir = tempfile::tempdir().unwrap(); init_clean_git_workspace(dir.path()); let app = test_app(dir.path()).await; let workspace_path = format!("/api/w/{TEST_WORKSPACE_ID}/working-directories"); - let created = post_json( - app.clone(), - &workspace_path, - serde_json::json!({ - "repository_id": TEST_REPOSITORY_ID, - "selector": "HEAD", - }), - ) - .await; - let working_directory_id = created["item"]["working_directory_id"] - .as_str() - .unwrap() - .to_string(); - assert_eq!(created["item"]["repository_id"], TEST_REPOSITORY_ID); - assert_eq!(created["item"]["requested_selector"], "HEAD"); - assert_eq!(created["item"]["status"], "active"); - let projected = serde_json::to_string(&created).unwrap(); - assert!(!projected.contains(dir.path().to_string_lossy().as_ref())); - - let list = get_json(app.clone(), &workspace_path).await; - assert_eq!(list["items"].as_array().unwrap().len(), 1); - assert_eq!( - list["items"][0]["working_directory_id"], - working_directory_id - ); - - let detail_path = format!("{workspace_path}/{working_directory_id}"); - let detail = get_json(app.clone(), &detail_path).await; - assert_eq!(detail["item"]["working_directory_id"], working_directory_id); - - let cleanup_plan_path = format!( - "/api/w/{TEST_WORKSPACE_ID}/runtimes/{EMBEDDED_WORKER_RUNTIME_ID}/cleanup-plan" - ); - let plan = get_json(app.clone(), &cleanup_plan_path).await; - let target_id = plan["workdirs"] - .as_array() - .unwrap() - .iter() - .find(|candidate| candidate["workdir_id"] == working_directory_id) - .and_then(|candidate| candidate["target_id"].as_str()) - .unwrap() - .to_string(); - let removed = request_json( + let response = request_json( app, "POST", - &format!( - "/api/w/{TEST_WORKSPACE_ID}/runtimes/{EMBEDDED_WORKER_RUNTIME_ID}/cleanup-executions" - ), + &workspace_path, Some(serde_json::json!({ - "expected_plan_revision": plan["revision"], - "expected_plan_digest": plan["digest"], - "worker_target_ids": [], - "workdir_target_ids": [target_id], - "confirm_dirty_discard_target_ids": [target_id] + "repository_id": TEST_REPOSITORY_ID, + "selector": "HEAD", })), - StatusCode::OK, + StatusCode::BAD_REQUEST, ) .await; - assert_eq!(removed["results"][0]["status"], "deleted"); + assert!( + response["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|diagnostic| diagnostic["code"] == "backend_workdir_create_unsupported"), + "expected backend lifecycle diagnostic, got {response}" + ); + let projected = serde_json::to_string(&response).unwrap(); + assert!(!projected.contains(dir.path().to_string_lossy().as_ref())); } #[tokio::test] @@ -6550,24 +6485,14 @@ mod tests { let dir = tempfile::tempdir().unwrap(); init_clean_git_workspace(dir.path()); let app = test_app(dir.path()).await; - let working_directories_path = format!("/api/w/{TEST_WORKSPACE_ID}/working-directories"); - let created = post_json( - app.clone(), - &working_directories_path, - serde_json::json!({ - "repository_id": TEST_REPOSITORY_ID, - "selector": "HEAD", - }), - ) - .await; - let working_directory_id = created["item"]["working_directory_id"].as_str().unwrap(); + let working_directory_id = "test-workdir"; let response = request_json( app, "POST", &format!("/api/w/{TEST_WORKSPACE_ID}/workers"), Some(serde_json::json!({ - "runtime_id": EMBEDDED_WORKER_RUNTIME_ID, + "runtime_id": "remote-runtime", "display_name": "Coding Worker", "profile": "builtin:coder", "initial_text": "", @@ -6987,7 +6912,7 @@ mod tests { } #[tokio::test] - async fn runtime_worker_spawn_accepts_safe_repository_selector() { + async fn runtime_worker_spawn_rejects_embedded_workdir_request() { let dir = tempfile::tempdir().unwrap(); let app = test_app(dir.path()).await; let response = request_json( @@ -7009,9 +6934,17 @@ mod tests { "selector": "HEAD" } })), - StatusCode::OK, + StatusCode::BAD_REQUEST, ) .await; + assert!( + response["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|diagnostic| diagnostic["code"] == "embedded_worker_workdir_unsupported"), + "expected embedded workdir diagnostic, got {response}" + ); let projected = serde_json::to_string(&response).unwrap(); assert!(!projected.contains(dir.path().to_string_lossy().as_ref())); } diff --git a/package.nix b/package.nix index f7048c03..a06e73e5 100644 --- a/package.nix +++ b/package.nix @@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec { filter = sourceFilter; }; - cargoHash = "sha256-Xlaq8/cyMGk3m4z6BNLtSgoRnWqVVnGaLyzgBlr70rk="; + cargoHash = "sha256-MUEySmRu5Te8+GCwlt3qk7jxcumYIMvnuvj64o2XuDw="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint,