From 9273aadeacc15a2f6c9a6f317255fb257a5d9ef7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 21:22:44 +0800 Subject: [PATCH 1/3] refactor(workspace): pluggable backend abstraction + 16 optimizations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a workspace capability abstraction so built-in tools (read, write, edit, patch, ls, bash, grep, glob, git, git_stash, git_worktree) route through trait-based providers instead of hard-coded local filesystem calls. The default LocalWorkspaceBackend preserves existing behavior; future remote/browser/DFS/container backends can plug in via WorkspaceServicesBuilder without changing tool schemas. Key changes: - Split workspace.rs into workspace/{mod.rs, local.rs}: trait definitions + LocalWorkspaceBackend in separate files (CLAUDE.md file-size rule). - New traits: WorkspaceFileSystem, WorkspaceCommandRunner, WorkspaceSearch, WorkspaceGit (slim core) + optional WorkspaceGitStashProvider and WorkspaceGitWorktreeProvider, CommandOutputObserver. - WorkspaceServices: assembled via WorkspaceServicesBuilder; auto-downgrades capabilities when matching providers are missing; carries an optional per-operation timeout enforced by run_with_timeout helper. - Capability gating: register_builtins(registry, &capabilities) only registers tools whose required capability is provided — bash, grep, glob, git can be hidden from the model when the backend lacks them. - Local backend now implements WorkspaceCommandRunner (no more bash-specific local fallback in the tool). - CommandRequest carries Arc instead of leaking ToolEventSender into the abstraction. - ChildRunContext propagates workspace_services to child runs. - New Session direct-tool APIs: write_file, ls, edit_file, patch_file. - WorkspaceFileSystemExt policy: future trait extensions go to a separate extension trait (additive only). - Removed dead WorkspaceCapabilities flags (watch, atomic_write), unreachable bash fallback branch, set_workspace_services mutation hazard, redundant ToolExecutor private constructors. - Deprecated ToolContext::resolve_path / resolve_path_for_write under non-local backends. Tests: - 17 new unit + integration tests in workspace + tools modules. - All 1553 lib tests + integration tests pass. - cargo clippy --all-targets --all-features -D warnings clean. --- core/src/agent_api.rs | 39 + core/src/agent_api/capabilities.rs | 17 +- core/src/agent_api/direct_tools.rs | 34 + core/src/agent_api/session_options.rs | 14 + core/src/agent_api/session_runtime.rs | 4 +- core/src/agent_api/tests.rs | 155 ++++ core/src/child_run.rs | 2 + core/src/lib.rs | 14 + core/src/llm/structured_tests.rs | 4 +- core/src/tools/builtin/bash.rs | 80 +- core/src/tools/builtin/batch.rs | 10 +- core/src/tools/builtin/edit.rs | 42 +- core/src/tools/builtin/git.rs | 321 ++++--- core/src/tools/builtin/glob_tool.rs | 62 +- core/src/tools/builtin/grep.rs | 153 +--- core/src/tools/builtin/ls.rs | 75 +- core/src/tools/builtin/mod.rs | 44 +- core/src/tools/builtin/patch.rs | 38 +- core/src/tools/builtin/read.rs | 15 +- core/src/tools/builtin/write.rs | 60 +- core/src/tools/mod.rs | 374 ++++++-- core/src/tools/process.rs | 12 +- core/src/tools/program_tool.rs | 8 +- core/src/tools/registry.rs | 23 +- core/src/tools/task.rs | 21 +- core/src/tools/types.rs | 46 +- core/src/workspace/local.rs | 757 ++++++++++++++++ core/src/workspace/mod.rs | 854 ++++++++++++++++++ core/tests/test_ahp_idle_with_llm.rs | 4 +- core/tests/test_web_search_headless.rs | 12 +- core/tests/test_workspace_backend.rs | 676 ++++++++++++++ sdk/node/README.md | 22 + sdk/node/examples/basic/test_api_alignment.ts | 12 +- .../basic/test_real_config_env_sdk.mjs | 19 +- sdk/node/run_sdk_integration_tests.sh | 9 +- sdk/node/test-helpers.mjs | 1 + sdk/node/test.mjs | 23 +- sdk/python/README.md | 8 + sdk/python/tests/real_config_env_sdk.py | 23 +- 39 files changed, 3535 insertions(+), 552 deletions(-) create mode 100644 core/src/workspace/local.rs create mode 100644 core/src/workspace/mod.rs create mode 100644 core/tests/test_workspace_backend.rs diff --git a/core/src/agent_api.rs b/core/src/agent_api.rs index c7c317ad..76b5ba9b 100644 --- a/core/src/agent_api.rs +++ b/core/src/agent_api.rs @@ -175,6 +175,13 @@ pub struct SessionOptions { /// of `std::process::Command`. The host application constructs and owns /// the implementation (e.g., an A3S Box–backed handle). pub sandbox_handle: Option>, + /// Optional host-provided workspace backend. + /// + /// When set, built-in tools such as `read`, `write`, `ls`, and `bash` + /// execute against these workspace capabilities instead of assuming the + /// server-local filesystem. This is the primary extension point for DFS, + /// browser, container, and remote workspace deployments. + pub workspace_services: Option>, /// Enable auto-compaction when context usage exceeds threshold. pub auto_compact: bool, /// Context usage percentage threshold for auto-compaction (0.0 - 1.0). @@ -691,6 +698,38 @@ impl AgentSession { DirectToolRuntime::from_session(self).read_file(path).await } + /// Write a file in the workspace. + pub async fn write_file(&self, path: &str, content: &str) -> Result { + DirectToolRuntime::from_session(self) + .write_file(path, content) + .await + } + + /// List a directory in the workspace. + pub async fn ls(&self, path: Option<&str>) -> Result { + DirectToolRuntime::from_session(self).ls(path).await + } + + /// Edit a file by replacing text in the workspace. + pub async fn edit_file( + &self, + path: &str, + old_string: &str, + new_string: &str, + replace_all: bool, + ) -> Result { + DirectToolRuntime::from_session(self) + .edit_file(path, old_string, new_string, replace_all) + .await + } + + /// Apply a unified diff patch to a workspace file. + pub async fn patch_file(&self, path: &str, diff: &str) -> Result { + DirectToolRuntime::from_session(self) + .patch_file(path, diff) + .await + } + /// Execute a bash command in the workspace. /// /// When a sandbox handle is configured via diff --git a/core/src/agent_api/capabilities.rs b/core/src/agent_api/capabilities.rs index 858e9a1d..610029d0 100644 --- a/core/src/agent_api/capabilities.rs +++ b/core/src/agent_api/capabilities.rs @@ -39,10 +39,18 @@ pub(super) struct SessionCapabilities { pub(super) fn build_session_capabilities(input: SessionCapabilityInput<'_>) -> SessionCapabilities { let artifact_limits = input.opts.artifact_store_limits.unwrap_or_default(); - let tool_executor = Arc::new(ToolExecutor::new_with_artifact_limits( - input.workspace.display().to_string(), - artifact_limits, - )); + let workspace_services = input + .opts + .workspace_services + .clone() + .unwrap_or_else(|| crate::workspace::WorkspaceServices::local(input.workspace)); + let tool_executor = Arc::new( + ToolExecutor::new_with_workspace_services_and_artifact_limits( + input.workspace.display().to_string(), + workspace_services, + artifact_limits, + ), + ); let trace_sink = crate::trace::InMemoryTraceSink::default(); tool_executor.set_trace_sink(Arc::new(trace_sink.clone())); @@ -151,6 +159,7 @@ fn register_task_capability( max_execution_time_ms: opts.max_execution_time_ms, circuit_breaker_threshold: opts.circuit_breaker_threshold, confirmation_manager: opts.confirmation_manager.clone(), + workspace_services: opts.workspace_services.clone(), }; let registry = Arc::new(registry); diff --git a/core/src/agent_api/direct_tools.rs b/core/src/agent_api/direct_tools.rs index 1ab12302..8c173158 100644 --- a/core/src/agent_api/direct_tools.rs +++ b/core/src/agent_api/direct_tools.rs @@ -45,6 +45,40 @@ impl DirectToolRuntime { Ok(result.output) } + pub(super) async fn write_file(&self, path: &str, content: &str) -> Result { + let args = serde_json::json!({ "file_path": path, "content": content }); + self.call("write", args).await + } + + pub(super) async fn ls(&self, path: Option<&str>) -> Result { + let args = match path { + Some(path) => serde_json::json!({ "path": path }), + None => serde_json::json!({}), + }; + self.call("ls", args).await + } + + pub(super) async fn edit_file( + &self, + path: &str, + old_string: &str, + new_string: &str, + replace_all: bool, + ) -> Result { + let args = serde_json::json!({ + "file_path": path, + "old_string": old_string, + "new_string": new_string, + "replace_all": replace_all, + }); + self.call("edit", args).await + } + + pub(super) async fn patch_file(&self, path: &str, diff: &str) -> Result { + let args = serde_json::json!({ "file_path": path, "diff": diff }); + self.call("patch", args).await + } + pub(super) async fn bash(&self, command: &str) -> Result { let args = serde_json::json!({ "command": command }); let result = self diff --git a/core/src/agent_api/session_options.rs b/core/src/agent_api/session_options.rs index 4dde58e4..832a10de 100644 --- a/core/src/agent_api/session_options.rs +++ b/core/src/agent_api/session_options.rs @@ -43,6 +43,7 @@ impl std::fmt::Debug for SessionOptions { .field("tool_timeout_ms", &self.tool_timeout_ms) .field("circuit_breaker_threshold", &self.circuit_breaker_threshold) .field("sandbox_handle", &self.sandbox_handle.is_some()) + .field("workspace_services", &self.workspace_services.is_some()) .field("auto_compact", &self.auto_compact) .field("auto_compact_threshold", &self.auto_compact_threshold) .field("continuation_enabled", &self.continuation_enabled) @@ -335,6 +336,19 @@ impl SessionOptions { self } + /// Provide a workspace backend for this session. + /// + /// Built-in tools keep their stable names and schemas, while their backing + /// implementation can target a DFS, browser workspace, remote runner, or + /// any other host-provided backend. + pub fn with_workspace_backend( + mut self, + services: Arc, + ) -> Self { + self.workspace_services = Some(services); + self + } + /// Enable auto-compaction when context usage exceeds threshold. /// /// When enabled, the agent loop automatically prunes large tool outputs diff --git a/core/src/agent_api/session_runtime.rs b/core/src/agent_api/session_runtime.rs index 87c67c07..26298703 100644 --- a/core/src/agent_api/session_runtime.rs +++ b/core/src/agent_api/session_runtime.rs @@ -105,12 +105,12 @@ fn build_command_queue( fn build_tool_context( code_config: &CodeConfig, - workspace: &Path, + _workspace: &Path, opts: &SessionOptions, tool_executor: Arc, agent_event_tx: broadcast::Sender, ) -> ToolContext { - let mut tool_context = ToolContext::new(workspace.to_path_buf()); + let mut tool_context = tool_executor.registry().context(); if let Some(ref search_config) = code_config.search { tool_context = tool_context.with_search_config(search_config.clone()); } diff --git a/core/src/agent_api/tests.rs b/core/src/agent_api/tests.rs index ce91e9e3..da1d86c3 100644 --- a/core/src/agent_api/tests.rs +++ b/core/src/agent_api/tests.rs @@ -53,6 +53,98 @@ struct CapturingContextProvider { session_ids: std::sync::Mutex>>, } +#[derive(Default)] +struct TestWorkspaceFs { + files: std::sync::RwLock>, +} + +impl TestWorkspaceFs { + fn insert(&self, path: &str, content: &str) { + self.files + .write() + .unwrap() + .insert(path.to_string(), content.to_string()); + } + + fn read_raw(&self, path: &str) -> Option { + self.files.read().unwrap().get(path).cloned() + } +} + +#[async_trait::async_trait] +impl crate::workspace::WorkspaceFileSystem for TestWorkspaceFs { + async fn read_text(&self, path: &crate::workspace::WorkspacePath) -> anyhow::Result { + self.files + .read() + .unwrap() + .get(path.as_str()) + .cloned() + .ok_or_else(|| anyhow::anyhow!("missing test workspace file: {}", path.as_str())) + } + + async fn write_text( + &self, + path: &crate::workspace::WorkspacePath, + content: &str, + ) -> anyhow::Result { + self.insert(path.as_str(), content); + Ok(crate::workspace::WorkspaceWriteOutcome { + bytes: content.len(), + lines: content.lines().count(), + }) + } + + async fn list_dir( + &self, + path: &crate::workspace::WorkspacePath, + ) -> anyhow::Result> { + let prefix = if path.is_root() { + String::new() + } else { + format!("{}/", path.as_str()) + }; + let files = self.files.read().unwrap(); + let mut entries = Vec::new(); + + for (file_path, content) in files.iter() { + if !file_path.starts_with(&prefix) { + continue; + } + let remaining = &file_path[prefix.len()..]; + if remaining.is_empty() || remaining.contains('/') { + continue; + } + entries.push(crate::workspace::WorkspaceDirEntry { + name: remaining.to_string(), + kind: crate::workspace::WorkspaceFileType::File, + size: content.len() as u64, + }); + } + + Ok(entries) + } +} + +#[derive(Default)] +struct TestWorkspaceRunner { + commands: std::sync::RwLock>, +} + +#[async_trait::async_trait] +impl crate::workspace::WorkspaceCommandRunner for TestWorkspaceRunner { + async fn exec( + &self, + request: crate::workspace::CommandRequest, + ) -> anyhow::Result { + self.commands.write().unwrap().push(request.command.clone()); + Ok(crate::workspace::CommandOutput { + output: format!("session runner: {}\n", request.command), + exit_code: 0, + timed_out: false, + }) + } +} + #[async_trait::async_trait] impl crate::context::ContextProvider for CapturingContextProvider { fn name(&self) -> &str { @@ -250,6 +342,69 @@ async fn test_session_default() { assert!(debug.contains("AgentSession")); } +#[tokio::test] +async fn test_session_uses_workspace_backend_for_direct_tools() { + let fs = Arc::new(TestWorkspaceFs::default()); + fs.insert("app.txt", "hello from backend\n"); + let fs_backend: Arc = fs.clone(); + let runner = Arc::new(TestWorkspaceRunner::default()); + let runner_backend: Arc = runner.clone(); + let services = crate::workspace::WorkspaceServices::builder( + crate::workspace::WorkspaceRef::new("session-workspace", "session://workspace"), + fs_backend, + ) + .command_runner(runner_backend) + .build(); + + let agent = Agent::from_config(test_config()).await.unwrap(); + let session = agent + .session( + "/server/local-placeholder", + Some(SessionOptions::new().with_workspace_backend(services)), + ) + .unwrap(); + + let tool_names = session.tool_names(); + assert!(tool_names.contains(&"read".to_string())); + assert!(tool_names.contains(&"write".to_string())); + assert!(tool_names.contains(&"ls".to_string())); + assert!(tool_names.contains(&"bash".to_string())); + assert!(!tool_names.contains(&"grep".to_string())); + assert!(!tool_names.contains(&"glob".to_string())); + assert!(!tool_names.contains(&"git".to_string())); + + let read = session.read_file("app.txt").await.unwrap(); + assert!(read.contains("hello from backend")); + + let write = session + .write_file("created.txt", "one\ntwo\n") + .await + .unwrap(); + assert_eq!(write.exit_code, 0, "{}", write.output); + assert_eq!(fs.read_raw("created.txt").as_deref(), Some("one\ntwo\n")); + + let listing = session.ls(None).await.unwrap(); + assert_eq!(listing.exit_code, 0, "{}", listing.output); + assert!(listing.output.contains("created.txt")); + + let edit = session + .edit_file("created.txt", "one", "uno", false) + .await + .unwrap(); + assert_eq!(edit.exit_code, 0, "{}", edit.output); + assert_eq!(fs.read_raw("created.txt").as_deref(), Some("uno\ntwo\n")); + + let patch = session + .patch_file("created.txt", "@@ -1,2 +1,2 @@\n uno\n-two\n+dos") + .await + .unwrap(); + assert_eq!(patch.exit_code, 0, "{}", patch.output); + assert_eq!(fs.read_raw("created.txt").as_deref(), Some("uno\ndos\n")); + + let bash = session.bash("pwd").await.unwrap(); + assert_eq!(bash, "session runner: pwd\n"); +} + #[tokio::test] async fn test_session_routes_agents_md_through_context_provider() { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/core/src/child_run.rs b/core/src/child_run.rs index 21bc3521..c5b45838 100644 --- a/core/src/child_run.rs +++ b/core/src/child_run.rs @@ -15,6 +15,7 @@ //! | max_execution_time_ms | Yes | Prevents runaway child runs | //! | circuit_breaker_threshold | Yes | LLM failure handling should be consistent | //! | confirmation_manager | Depends | Governed by ConfirmationInheritance | +//! | workspace_services | Yes | Child tools must operate on the same workspace | //! | memory | No | Child has isolated context | //! | queue_config | No | Child runs are synchronous within parent | //! | planning_mode | No | Child tasks are pre-planned by parent | @@ -39,6 +40,7 @@ pub struct ChildRunContext { pub max_execution_time_ms: Option, pub circuit_breaker_threshold: Option, pub confirmation_manager: Option>, + pub workspace_services: Option>, } impl ChildRunContext { diff --git a/core/src/lib.rs b/core/src/lib.rs index a774497f..ab9852a2 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -113,6 +113,7 @@ pub(crate) mod tool_confirmation; pub mod tools; pub mod trace; pub mod verification; +pub mod workspace; // Re-export key types at crate root for ergonomic usage pub use agent::{AgentEvent, AgentResult}; @@ -133,3 +134,16 @@ pub use subagent::{ AgentDefinition, AgentRegistry, CattleAgentKind, CattleAgentSpec, ConfirmationInheritance, WorkerAgentKind, WorkerAgentSpec, }; +pub use workspace::{ + CommandOutput, CommandOutputObserver, CommandRequest, LocalWorkspaceBackend, + VirtualPathResolver, WorkspaceCapabilities, WorkspaceCommandRunner, WorkspaceDirEntry, + WorkspaceFileSystem, WorkspaceFileType, WorkspaceGit, WorkspaceGitBranch, + WorkspaceGitCheckoutOutput, WorkspaceGitCheckoutRequest, WorkspaceGitCommit, + WorkspaceGitCreateBranchRequest, WorkspaceGitCreateWorktreeRequest, WorkspaceGitDiffRequest, + WorkspaceGitRemote, WorkspaceGitRemoveWorktreeRequest, WorkspaceGitStash, + WorkspaceGitStashProvider, WorkspaceGitStashRequest, WorkspaceGitStatus, WorkspaceGitWorktree, + WorkspaceGitWorktreeMutation, WorkspaceGitWorktreeProvider, WorkspaceGlobRequest, + WorkspaceGlobResult, WorkspaceGrepRequest, WorkspaceGrepResult, WorkspacePath, + WorkspacePathResolver, WorkspaceRef, WorkspaceSearch, WorkspaceServices, + WorkspaceServicesBuilder, WorkspaceWriteOutcome, +}; diff --git a/core/src/llm/structured_tests.rs b/core/src/llm/structured_tests.rs index 3b480de4..3ba3cc01 100644 --- a/core/src/llm/structured_tests.rs +++ b/core/src/llm/structured_tests.rs @@ -1176,7 +1176,7 @@ async fn test_integration_generate_blocking_prompt_mode() { let result = result.unwrap(); assert_eq!(result.object["sentiment"], "positive"); let confidence = result.object["confidence"].as_f64().unwrap(); - assert!(confidence >= 0.0 && confidence <= 1.0); + assert!((0.0..=1.0).contains(&confidence)); eprintln!( "Integration test passed: sentiment={}, confidence={}, repairs={}", result.object["sentiment"], confidence, result.repair_rounds @@ -1243,7 +1243,7 @@ async fn test_integration_generate_streaming_tool_mode() { for lang in languages { assert!(lang["name"].is_string()); let year = lang["year"].as_i64().unwrap(); - assert!(year >= 1950 && year <= 2030); + assert!((1950..=2030).contains(&year)); } let partial_count = partials.lock().unwrap().len(); diff --git a/core/src/tools/builtin/bash.rs b/core/src/tools/builtin/bash.rs index ab64c6ae..f6eec6d8 100644 --- a/core/src/tools/builtin/bash.rs +++ b/core/src/tools/builtin/bash.rs @@ -1,17 +1,37 @@ //! Bash tool - Execute shell commands -use crate::tools::process::read_process_output; -use crate::tools::types::{Tool, ToolContext, ToolOutput}; +use crate::tools::types::{Tool, ToolContext, ToolEventSender, ToolOutput, ToolStreamEvent}; +use crate::workspace::{CommandOutputObserver, CommandRequest}; use anyhow::Result; use async_trait::async_trait; #[cfg(windows)] use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use std::collections::HashMap; use std::process::Stdio; +use std::sync::Arc; use tokio::process::Command; /// Default timeout in seconds (2 minutes) -const DEFAULT_TIMEOUT_MS: u64 = 120_000; +pub(crate) const DEFAULT_TIMEOUT_MS: u64 = 120_000; + +/// Adapter that forwards `CommandOutputObserver` deltas to a tool event channel. +/// +/// Keeps `workspace::CommandRequest` free of `ToolEventSender`. Constructed in +/// the bash tool when a session has installed an event channel; backend +/// implementations only see `&dyn CommandOutputObserver`. +struct ToolEventObserver { + tx: ToolEventSender, +} + +#[async_trait] +impl CommandOutputObserver for ToolEventObserver { + async fn on_output_delta(&self, delta: &str) { + self.tx + .send(ToolStreamEvent::OutputDelta(delta.to_string())) + .await + .ok(); + } +} #[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x0800_0000; @@ -562,7 +582,7 @@ fn parse_simple_windows_http_command(command: &str) -> Option Option { +pub(crate) async fn maybe_execute_simple_windows_http_command(command: &str) -> Option { let parsed = parse_simple_windows_http_command(command)?; let method = reqwest::Method::from_bytes(parsed.method.as_bytes()).ok()?; let client = reqwest::Client::new(); @@ -964,7 +984,7 @@ pub struct BashTool; /// - Unix: `bash -c ` /// - Windows: `powershell.exe -Command ` with hidden console window, /// falling back to `cmd.exe /C ` if PowerShell cannot be started. -fn spawn_shell( +pub(crate) fn spawn_shell( command: &str, workspace: &std::path::Path, command_env: Option<&HashMap>, @@ -1095,43 +1115,41 @@ impl Tool for BashTool { }); } - // Local execution path (default when no sandbox is configured). + // Capability gating guarantees that `bash` is only registered when the + // workspace backend provides a command runner, so this unwrap is sound. + let runner = ctx + .workspace_services + .command_runner() + .expect("bash registered without workspace command runner"); let timeout_ms = args .get("timeout") .and_then(|v| v.as_u64()) .unwrap_or(DEFAULT_TIMEOUT_MS); + let output_observer = ctx + .event_tx + .clone() + .map(|tx| Arc::new(ToolEventObserver { tx }) as Arc); + let result = runner + .exec(CommandRequest { + command: command.to_string(), + timeout_ms, + output_observer, + env: ctx.command_env.clone(), + }) + .await + .map_err(|e| anyhow::anyhow!("Workspace bash execution failed: {}", e))?; - #[cfg(windows)] - if let Some(output) = maybe_execute_simple_windows_http_command(command).await { - return Ok(output); - } - - let timeout_secs = timeout_ms / 1000; - - let mut child = spawn_shell( - command, - std::path::Path::new(&ctx.workspace), - ctx.command_env.as_deref(), - ) - .map_err(|e| anyhow::anyhow!("Failed to spawn shell: {}", e))?; - - let (output, timed_out) = - read_process_output(&mut child, timeout_secs, ctx.event_tx.as_ref()).await; - - if timed_out { + if result.timed_out { return Ok(ToolOutput::error(format!( "{}\n\n[Command timed out after {}ms]", - output, timeout_ms + result.output, timeout_ms ))); } - let status = child.wait().await?; - let exit_code = status.code().unwrap_or(-1); - Ok(ToolOutput { - content: output, - success: exit_code == 0, - metadata: Some(serde_json::json!({ "exit_code": exit_code })), + content: result.output, + success: result.exit_code == 0, + metadata: Some(serde_json::json!({ "exit_code": result.exit_code })), images: vec![], }) } diff --git a/core/src/tools/builtin/batch.rs b/core/src/tools/builtin/batch.rs index 98f3a22e..42aa8463 100644 --- a/core/src/tools/builtin/batch.rs +++ b/core/src/tools/builtin/batch.rs @@ -231,15 +231,7 @@ mod tests { } fn make_ctx() -> ToolContext { - ToolContext { - workspace: PathBuf::from("/tmp"), - session_id: None, - event_tx: None, - agent_event_tx: None, - search_config: None, - sandbox: None, - command_env: None, - } + ToolContext::new(PathBuf::from("/tmp")) } #[test] diff --git a/core/src/tools/builtin/edit.rs b/core/src/tools/builtin/edit.rs index 8587d217..7b2f1a37 100644 --- a/core/src/tools/builtin/edit.rs +++ b/core/src/tools/builtin/edit.rs @@ -76,18 +76,27 @@ impl Tool for EditTool { .and_then(|v| v.as_bool()) .unwrap_or(false); - let resolved = match ctx.resolve_path(file_path) { - Ok(p) => p, + let workspace_path = match ctx.resolve_workspace_path(file_path) { + Ok(path) => path, Err(e) => return Ok(ToolOutput::error(format!("Failed to resolve path: {}", e))), }; - - let content = match tokio::fs::read_to_string(&resolved).await { + let display_path = ctx.workspace_services.display_path(&workspace_path); + + let fs = ctx.workspace_services.fs(); + let path_for_read = workspace_path.clone(); + let fs_for_read = fs.clone(); + let content = match ctx + .workspace_services + .run_with_timeout("read_text", async move { + fs_for_read.read_text(&path_for_read).await + }) + .await + { Ok(c) => c, Err(e) => { return Ok(ToolOutput::error(format!( "Failed to read file {}: {}", - resolved.display(), - e + display_path, e ))) } }; @@ -97,7 +106,7 @@ impl Tool for EditTool { if count == 0 { return Ok(ToolOutput::error(format!( "old_string not found in {}", - resolved.display() + display_path ))); } @@ -105,7 +114,7 @@ impl Tool for EditTool { return Ok(ToolOutput::error(format!( "old_string found {} times in {}. Use replace_all=true to replace all occurrences, or provide a more specific string.", count, - resolved.display() + display_path ))); } @@ -115,8 +124,16 @@ impl Tool for EditTool { content.replacen(old_string, new_string, 1) }; - match tokio::fs::write(&resolved, &new_content).await { - Ok(()) => { + let path_for_write = workspace_path.clone(); + let content_for_write = new_content.clone(); + match ctx + .workspace_services + .run_with_timeout("write_text", async move { + fs.write_text(&path_for_write, &content_for_write).await + }) + .await + { + Ok(_) => { // Attach diff metadata so frontend can show Monaco diff let mut metadata = serde_json::Map::new(); metadata.insert("file_path".to_string(), serde_json::json!(file_path)); @@ -126,14 +143,13 @@ impl Tool for EditTool { Ok(ToolOutput::success(format!( "Replaced {} occurrence(s) in {}", if replace_all { count } else { 1 }, - resolved.display() + display_path )) .with_metadata(serde_json::Value::Object(metadata))) } Err(e) => Ok(ToolOutput::error(format!( "Failed to write file {}: {}", - resolved.display(), - e + display_path, e ))), } } diff --git a/core/src/tools/builtin/git.rs b/core/src/tools/builtin/git.rs index 80ab12c8..b28b354c 100644 --- a/core/src/tools/builtin/git.rs +++ b/core/src/tools/builtin/git.rs @@ -1,13 +1,17 @@ -//! Git tool — Uses system git with auto-installation support +//! Git tool backed by workspace Git services. //! -//! Provides Git operations using the external git command. -//! If git is not installed, attempts to install it automatically. +//! The tool keeps the model-facing contract stable while the concrete Git +//! implementation can be local, remote, browser-backed, or DFS-backed. -use crate::git; use crate::tools::types::{Tool, ToolContext, ToolOutput}; +use crate::workspace::{ + WorkspaceGit, WorkspaceGitCheckoutRequest, WorkspaceGitCreateBranchRequest, + WorkspaceGitCreateWorktreeRequest, WorkspaceGitDiffRequest, WorkspaceGitRemote, + WorkspaceGitRemoveWorktreeRequest, WorkspaceGitStashProvider, WorkspaceGitStashRequest, + WorkspaceGitWorktreeProvider, +}; use anyhow::Result; use async_trait::async_trait; -use std::path::Path; pub struct GitTool; @@ -18,7 +22,7 @@ impl Tool for GitTool { } fn description(&self) -> &str { - "Execute Git operations using the system git command. Supports: status, log, branch, checkout, diff, stash, remote, and worktree management. Auto-installs git if not available." + "Execute Git operations for the current workspace. Supports: status, log, branch, checkout, diff, stash, remote, and worktree management." } fn parameters(&self) -> serde_json::Value { @@ -33,7 +37,11 @@ impl Tool for GitTool { ], "description": "Required. Git command to execute." }, - // Branch/worktree specific + "subcommand": { + "type": "string", + "enum": ["list", "create", "remove"], + "description": "Worktree subcommand. Defaults to list." + }, "name": { "type": "string", "description": "Branch name for branch/checkout/worktree operations." @@ -42,7 +50,6 @@ impl Tool for GitTool { "type": "string", "description": "Path for worktree operations." }, - // Checkout specific "ref": { "type": "string", "description": "Reference (branch, tag, commit) for checkout." @@ -51,17 +58,14 @@ impl Tool for GitTool { "type": "boolean", "description": "Force checkout/create even if it loses changes." }, - // Diff specific "target": { "type": "string", "description": "Target ref for diff (e.g., HEAD~1, main). If omitted, diffs working tree." }, - // Log specific "max_count": { "type": "integer", "description": "Maximum number of log entries to show (default 10)." }, - // Stash specific "message": { "type": "string", "description": "Message for stash." @@ -70,12 +74,10 @@ impl Tool for GitTool { "type": "boolean", "description": "Include untracked files in stash (default false)." }, - // Remote specific "remote_name": { "type": "string", - "description": "Remote name (default 'origin')." + "description": "Remote name (reserved for provider-specific filtering)." }, - // Worktree specific "new_branch": { "type": "boolean", "description": "Create a new branch for worktree (default true)." @@ -97,7 +99,7 @@ impl Tool for GitTool { {"command": "stash"}, {"command": "stash", "message": "WIP: work in progress"}, {"command": "remote"}, - {"command": "worktree", "command": "list"} + {"command": "worktree", "subcommand": "list"} ] }) } @@ -108,23 +110,50 @@ impl Tool for GitTool { None => return Ok(ToolOutput::error("command parameter is required")), }; - // Verify workspace is a git repo - if !git::is_git_repo(&ctx.workspace) { - return Ok(ToolOutput::error(format!( - "Not a git repository: {}", - ctx.workspace.display() - ))); + let Some(git) = ctx.workspace_services.git() else { + return Ok(ToolOutput::error( + "Git is not available for this workspace backend", + )); + }; + + match git.is_repository().await { + Ok(true) => {} + Ok(false) => { + return Ok(ToolOutput::error(format!( + "Not a git repository: {}", + ctx.workspace_services.workspace_ref().display_root + ))) + } + Err(e) => { + return Ok(ToolOutput::error(format!( + "Failed to inspect git repository: {e}" + ))) + } } match command { - "status" => self.status(ctx).await, - "log" => self.log(args, ctx).await, - "branch" => self.branch(args, ctx).await, - "checkout" => self.checkout(args, ctx).await, - "diff" => self.diff(args, ctx).await, - "stash" => self.stash(args, ctx).await, - "remote" => self.remote(args, ctx).await, - "worktree" => self.worktree(args, ctx).await, + "status" => self.status(ctx, git.as_ref()).await, + "log" => self.log(args, git.as_ref()).await, + "branch" => self.branch(args, git.as_ref()).await, + "checkout" => self.checkout(args, git.as_ref()).await, + "diff" => self.diff(args, git.as_ref()).await, + "stash" => { + let Some(stash) = ctx.workspace_services.git_stash() else { + return Ok(ToolOutput::error( + "Stash operations are not supported by this workspace backend", + )); + }; + self.stash(args, stash.as_ref()).await + } + "remote" => self.remote(git.as_ref()).await, + "worktree" => { + let Some(worktree) = ctx.workspace_services.git_worktree() else { + return Ok(ToolOutput::error( + "Worktree operations are not supported by this workspace backend", + )); + }; + self.worktree(args, worktree.as_ref()).await + } _ => Ok(ToolOutput::error(format!( "Unknown command: {command}. Use: status, log, branch, checkout, diff, stash, remote, worktree" ))), @@ -134,8 +163,8 @@ impl Tool for GitTool { impl GitTool { /// Show repository status. - async fn status(&self, ctx: &ToolContext) -> Result { - match git::get_status(&ctx.workspace) { + async fn status(&self, ctx: &ToolContext, git: &dyn WorkspaceGit) -> Result { + match git.status().await { Ok(status) => { let status_str = if status.is_dirty { format!("{} uncommitted change(s)", status.dirty_count) @@ -149,7 +178,7 @@ impl GitTool { Commit: {}\n\ Status: {}\n\ Worktree: {}", - ctx.workspace.display(), + ctx.workspace_services.workspace_ref().display_root, status.branch, status.commit, status_str, @@ -171,10 +200,10 @@ impl GitTool { } /// Show commit log. - async fn log(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result { + async fn log(&self, args: &serde_json::Value, git: &dyn WorkspaceGit) -> Result { let max_count = args.get("max_count").and_then(|v| v.as_u64()).unwrap_or(10) as usize; - match git::get_log(&ctx.workspace, max_count) { + match git.log(max_count).await { Ok(commits) => { if commits.is_empty() { return Ok(ToolOutput::success("No commits found.")); @@ -182,13 +211,13 @@ impl GitTool { let entries: Vec = commits .iter() - .map(|c| { + .map(|commit| { format!( "{} - {} ({})\n {}", - &c.id[..7], - c.author, - c.date, - c.message + short_commit_id(&commit.id), + commit.author, + commit.date, + commit.message ) }) .collect(); @@ -205,13 +234,19 @@ impl GitTool { } /// Branch operations: list or create. - async fn branch(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result { + async fn branch(&self, args: &serde_json::Value, git: &dyn WorkspaceGit) -> Result { let name = args.get("name").and_then(|v| v.as_str()); if let Some(branch_name) = name { let base = args.get("base").and_then(|v| v.as_str()).unwrap_or("HEAD"); - match git::create_branch(&ctx.workspace, branch_name, base) { + match git + .create_branch(WorkspaceGitCreateBranchRequest { + name: branch_name.to_string(), + base: base.to_string(), + }) + .await + { Ok(_) => Ok(ToolOutput::success(format!( "Created branch: {} (based on {})", branch_name, base @@ -220,8 +255,7 @@ impl GitTool { Err(e) => Ok(ToolOutput::error(format!("Failed to create branch: {e}"))), } } else { - // List branches - match git::list_branches(&ctx.workspace) { + match git.list_branches().await { Ok(branches) => { if branches.is_empty() { return Ok(ToolOutput::success("No branches found.")); @@ -229,9 +263,9 @@ impl GitTool { let entries: Vec = branches .iter() - .map(|b| { - let prefix = if b.is_current { "* " } else { " " }; - format!("{}{}", prefix, b.name) + .map(|branch| { + let prefix = if branch.is_current { "* " } else { " " }; + format!("{}{}", prefix, branch.name) }) .collect(); @@ -246,47 +280,46 @@ impl GitTool { } /// Checkout a branch or commit. - async fn checkout(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result { + async fn checkout( + &self, + args: &serde_json::Value, + git: &dyn WorkspaceGit, + ) -> Result { let refspec = match args.get("ref").and_then(|v| v.as_str()) { Some(r) => r, None => return Ok(ToolOutput::error("ref parameter is required for checkout")), }; let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false); - let args_strs: Vec<&str> = if force { - vec!["checkout", "--force", refspec] - } else { - vec!["checkout", refspec] - }; - let output = tokio::process::Command::new("git") - .args(["-C", &ctx.workspace.display().to_string()]) - .args(&args_strs) - .output() - .await?; - - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - Ok(ToolOutput::success(format!( + match git + .checkout(WorkspaceGitCheckoutRequest { + refspec: refspec.to_string(), + force, + }) + .await + { + Ok(output) => Ok(ToolOutput::success(format!( "Checked out: {}{}", refspec, - if stdout.trim().is_empty() { - "".to_string() + if output.stdout.trim().is_empty() { + String::new() } else { - format!("\n{}", stdout) + format!("\n{}", output.stdout) } - ))) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - Ok(ToolOutput::error(format!("Failed to checkout: {}", stderr))) + ))), + Err(e) => Ok(ToolOutput::error(format!("Failed to checkout: {e}"))), } } /// Show diff. - async fn diff(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result { - let target = args.get("target").and_then(|v| v.as_str()); + async fn diff(&self, args: &serde_json::Value, git: &dyn WorkspaceGit) -> Result { + let target = args + .get("target") + .and_then(|v| v.as_str()) + .map(ToString::to_string); - match git::get_diff(&ctx.workspace, target) { + match git.diff(WorkspaceGitDiffRequest { target }).await { Ok(diff) => { if diff.trim().is_empty() { return Ok(ToolOutput::success("No changes.".to_string())); @@ -298,22 +331,33 @@ impl GitTool { } /// Stash operations. - async fn stash(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result { - let message = args.get("message").and_then(|v| v.as_str()); + async fn stash( + &self, + args: &serde_json::Value, + stash: &dyn WorkspaceGitStashProvider, + ) -> Result { + let message = args + .get("message") + .and_then(|v| v.as_str()) + .map(ToString::to_string); let include_untracked = args .get("include_untracked") .and_then(|v| v.as_bool()) .unwrap_or(false); if message.is_some() || include_untracked { - // Create stash - match git::stash(&ctx.workspace, message, include_untracked) { + match stash + .stash(WorkspaceGitStashRequest { + message, + include_untracked, + }) + .await + { Ok(_) => Ok(ToolOutput::success("Created stash".to_string())), Err(e) => Ok(ToolOutput::error(format!("Failed to stash: {e}"))), } } else { - // List stashes - match git::list_stashes(&ctx.workspace) { + match stash.list_stashes().await { Ok(stashes) => { if stashes.is_empty() { return Ok(ToolOutput::success("No stashes found.".to_string())); @@ -321,7 +365,7 @@ impl GitTool { let entries: Vec = stashes .iter() - .map(|s| format!("{}: {}", s.index, s.message)) + .map(|stash| format!("{}: {}", stash.index, stash.message)) .collect(); Ok( @@ -335,33 +379,37 @@ impl GitTool { } /// Show remote information. - async fn remote(&self, _args: &serde_json::Value, ctx: &ToolContext) -> Result { - let output = tokio::process::Command::new("git") - .args(["-C", &ctx.workspace.display().to_string()]) - .args(["remote", "-v"]) - .output() - .await?; - - let stdout = String::from_utf8_lossy(&output.stdout); - - if output.status.success() && !stdout.trim().is_empty() { - Ok(ToolOutput::success(format!("Remotes:\n{}", stdout))) - } else { - Ok(ToolOutput::success("No remotes configured.".to_string())) + async fn remote(&self, git: &dyn WorkspaceGit) -> Result { + match git.list_remotes().await { + Ok(remotes) if remotes.is_empty() => { + Ok(ToolOutput::success("No remotes configured.".to_string())) + } + Ok(remotes) => { + let entries: Vec = remotes.iter().map(format_remote).collect(); + Ok(ToolOutput::success(format!( + "Remotes:\n{}", + entries.join("\n") + ))) + } + Err(e) => Ok(ToolOutput::error(format!("Failed to list remotes: {e}"))), } } /// Worktree operations. - async fn worktree(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result { + async fn worktree( + &self, + args: &serde_json::Value, + worktree: &dyn WorkspaceGitWorktreeProvider, + ) -> Result { let subcommand = args .get("subcommand") .and_then(|v| v.as_str()) .unwrap_or("list"); match subcommand { - "list" => self.list_worktrees(ctx).await, - "create" => self.create_worktree(args, ctx).await, - "remove" => self.remove_worktree(args, ctx).await, + "list" => self.list_worktrees(worktree).await, + "create" => self.create_worktree(args, worktree).await, + "remove" => self.remove_worktree(args, worktree).await, _ => Ok(ToolOutput::error(format!( "Unknown worktree subcommand: {subcommand}. Use: list, create, remove" ))), @@ -369,8 +417,11 @@ impl GitTool { } /// List all worktrees. - async fn list_worktrees(&self, ctx: &ToolContext) -> Result { - match git::list_worktrees(&ctx.workspace) { + async fn list_worktrees( + &self, + worktree: &dyn WorkspaceGitWorktreeProvider, + ) -> Result { + match worktree.list_worktrees().await { Ok(worktrees) => { if worktrees.is_empty() { return Ok(ToolOutput::success("No worktrees found.")); @@ -378,15 +429,15 @@ impl GitTool { let entries: Vec = worktrees .iter() - .map(|wt| { - let suffix = if wt.is_bare { + .map(|worktree| { + let suffix = if worktree.is_bare { " (bare)".to_string() - } else if wt.is_detached { + } else if worktree.is_detached { " (detached)".to_string() } else { - format!(" [{}]", wt.branch) + format!(" [{}]", worktree.branch) }; - format!(" {}{}", wt.path, suffix) + format!(" {}{}", worktree.path, suffix) }) .collect(); @@ -405,7 +456,7 @@ impl GitTool { async fn create_worktree( &self, args: &serde_json::Value, - ctx: &ToolContext, + worktree: &dyn WorkspaceGitWorktreeProvider, ) -> Result { let branch = match args .get("name") @@ -424,28 +475,25 @@ impl GitTool { .get("new_branch") .and_then(|v| v.as_bool()) .unwrap_or(true); - - let path = if let Some(p) = args.get("path").and_then(|v| v.as_str()) { - ctx.workspace.join(p) - } else { - let repo_name = ctx - .workspace - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "repo".to_string()); - ctx.workspace - .parent() - .unwrap_or(&ctx.workspace) - .join(format!("{repo_name}-{branch}")) - }; - - match git::create_worktree(&ctx.workspace, branch, &path, new_branch) { - Ok(_) => Ok(ToolOutput::success(format!( + let path = args + .get("path") + .and_then(|v| v.as_str()) + .map(ToString::to_string); + + match worktree + .create_worktree(WorkspaceGitCreateWorktreeRequest { + branch: branch.to_string(), + path, + new_branch, + }) + .await + { + Ok(result) => Ok(ToolOutput::success(format!( "Created worktree at: {}\nBranch: {branch}", - path.display() + result.path )) .with_metadata(serde_json::json!({ - "path": path.display().to_string(), + "path": result.path, "branch": branch, }))), Err(e) => Ok(ToolOutput::error(format!("Failed to create worktree: {e}"))), @@ -456,7 +504,7 @@ impl GitTool { async fn remove_worktree( &self, args: &serde_json::Value, - ctx: &ToolContext, + worktree: &dyn WorkspaceGitWorktreeProvider, ) -> Result { let path = match args.get("path").and_then(|v| v.as_str()) { Some(p) => p, @@ -468,18 +516,35 @@ impl GitTool { }; let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false); - let path = Path::new(path); - match git::remove_worktree(&ctx.workspace, path, force) { - Ok(_) => Ok(ToolOutput::success(format!( + match worktree + .remove_worktree(WorkspaceGitRemoveWorktreeRequest { + path: path.to_string(), + force, + }) + .await + { + Ok(result) => Ok(ToolOutput::success(format!( "Removed worktree at: {}", - path.display() + result.path ))), Err(e) => Ok(ToolOutput::error(format!("Failed to remove worktree: {e}"))), } } } +fn short_commit_id(id: &str) -> &str { + id.get(..7).unwrap_or(id) +} + +fn format_remote(remote: &WorkspaceGitRemote) -> String { + if remote.direction.is_empty() { + format!("{}\t{}", remote.name, remote.url) + } else { + format!("{}\t{} ({})", remote.name, remote.url, remote.direction) + } +} + #[cfg(test)] mod tests { use super::*; @@ -487,7 +552,7 @@ mod tests { #[tokio::test] async fn test_git_not_installed() { - // This test just checks that the tool handles non-git repos properly + // This test checks that the local provider handles non-git repos properly. let tool = GitTool; let dir = tempfile::tempdir().unwrap(); let ctx = ToolContext::new(dir.path().to_path_buf()); diff --git a/core/src/tools/builtin/glob_tool.rs b/core/src/tools/builtin/glob_tool.rs index 6a41e1ca..b8e797bb 100644 --- a/core/src/tools/builtin/glob_tool.rs +++ b/core/src/tools/builtin/glob_tool.rs @@ -1,9 +1,9 @@ //! Glob tool - Find files matching a glob pattern use crate::tools::types::{Tool, ToolContext, ToolOutput}; +use crate::workspace::WorkspaceGlobRequest; use anyhow::Result; use async_trait::async_trait; -use std::path::PathBuf; pub struct GlobTool; @@ -50,24 +50,28 @@ impl Tool for GlobTool { None => return Ok(ToolOutput::error("pattern parameter is required")), }; - let base_dir = match args.get("path").and_then(|v| v.as_str()) { - Some(p) => { - if std::path::Path::new(p).is_absolute() { - PathBuf::from(p) - } else { - ctx.workspace.join(p) - } - } - None => ctx.workspace.clone(), + let path_str = args.get("path").and_then(|v| v.as_str()).unwrap_or("."); + let base = match ctx.resolve_workspace_path(path_str) { + Ok(path) => path, + Err(e) => return Ok(ToolOutput::error(format!("Failed to resolve path: {}", e))), }; - // Build the full glob pattern - let full_pattern = base_dir.join(pattern); - // Normalize to forward slashes — glob crate requires '/' on all platforms - let full_pattern_str = full_pattern.to_string_lossy().replace('\\', "/"); + let Some(search) = ctx.workspace_services.search() else { + return Ok(ToolOutput::error( + "glob is not available: this workspace backend did not provide search", + )); + }; - let entries = match glob::glob(&full_pattern_str) { - Ok(paths) => paths, + let request = WorkspaceGlobRequest { + base, + pattern: pattern.to_string(), + }; + let result = match ctx + .workspace_services + .run_with_timeout("glob", async move { search.glob(request).await }) + .await + { + Ok(result) => result, Err(e) => { return Ok(ToolOutput::error(format!( "Invalid glob pattern '{}': {}", @@ -75,26 +79,11 @@ impl Tool for GlobTool { ))) } }; - - let mut matches: Vec = Vec::new(); - for entry in entries { - match entry { - Ok(path) => { - // Show path relative to workspace if possible, always use forward slashes - let display = path - .strip_prefix(&ctx.workspace) - .unwrap_or(&path) - .to_string_lossy() - .replace('\\', "/"); - matches.push(display); - } - Err(e) => { - tracing::warn!("Glob entry error: {}", e); - } - } - } - - matches.sort(); + let matches: Vec = result + .matches + .into_iter() + .map(|path| path.as_str().to_string()) + .collect(); if matches.is_empty() { Ok(ToolOutput::success(format!( @@ -113,6 +102,7 @@ impl Tool for GlobTool { #[cfg(test)] mod tests { use super::*; + use std::path::PathBuf; #[tokio::test] async fn test_glob_find_files() { diff --git a/core/src/tools/builtin/grep.rs b/core/src/tools/builtin/grep.rs index a285151f..e88c6b99 100644 --- a/core/src/tools/builtin/grep.rs +++ b/core/src/tools/builtin/grep.rs @@ -2,11 +2,10 @@ use crate::tools::types::{Tool, ToolContext, ToolOutput}; use crate::tools::MAX_OUTPUT_SIZE; +use crate::workspace::WorkspaceGrepRequest; use anyhow::Result; use async_trait::async_trait; -use ignore::WalkBuilder; use regex::Regex; -use std::path::PathBuf; pub struct GrepTool; @@ -75,126 +74,59 @@ impl Tool for GrepTool { pattern_str.to_string() }; - let regex = match Regex::new(®ex_pattern) { - Ok(r) => r, - Err(e) => { - return Ok(ToolOutput::error(format!( - "Invalid regex pattern '{}': {}", - pattern_str, e - ))) - } - }; + if let Err(e) = Regex::new(®ex_pattern) { + return Ok(ToolOutput::error(format!( + "Invalid regex pattern '{}': {}", + pattern_str, e + ))); + } - let search_path = match args.get("path").and_then(|v| v.as_str()) { - Some(p) => { - if std::path::Path::new(p).is_absolute() { - PathBuf::from(p) - } else { - ctx.workspace.join(p) - } - } - None => ctx.workspace.clone(), + let path_str = args.get("path").and_then(|v| v.as_str()).unwrap_or("."); + let base = match ctx.resolve_workspace_path(path_str) { + Ok(path) => path, + Err(e) => return Ok(ToolOutput::error(format!("Failed to resolve path: {}", e))), }; let glob_filter = args.get("glob").and_then(|v| v.as_str()); let context_lines = args.get("context").and_then(|v| v.as_u64()).unwrap_or(0) as usize; - // Use ignore crate to respect .gitignore - let mut builder = WalkBuilder::new(&search_path); - builder.hidden(false).git_ignore(true).git_global(true); - - if let Some(glob_pat) = glob_filter { - let mut types = ignore::types::TypesBuilder::new(); - types.add("custom", glob_pat).ok(); - types.select("custom"); - if let Ok(built) = types.build() { - builder.types(built); - } - } - - let mut output = String::new(); - let mut match_count = 0; - let mut file_count = 0; - let mut total_size = 0; - - for entry in builder.build().flatten() { - if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) { - continue; - } - - let file_path = entry.path(); - let content = match std::fs::read_to_string(file_path) { - Ok(c) => c, - Err(_) => continue, // Skip binary/unreadable files - }; - - let lines: Vec<&str> = content.lines().collect(); - let mut file_matches = Vec::new(); - - for (line_idx, line) in lines.iter().enumerate() { - if regex.is_match(line) { - file_matches.push(line_idx); - } - } - - if file_matches.is_empty() { - continue; - } - - file_count += 1; - // Normalize paths for consistent strip_prefix (avoids UNC prefix mismatch on Windows) - let file_display = file_path.to_string_lossy().replace('\\', "/"); - let workspace_display = ctx.workspace.to_string_lossy().replace('\\', "/"); - let rel_path = if let Some(stripped) = file_display.strip_prefix(&workspace_display) { - stripped.trim_start_matches('/').to_string() - } else { - file_path - .strip_prefix(&ctx.workspace) - .unwrap_or(file_path) - .to_string_lossy() - .to_string() - }; - - for &match_idx in &file_matches { - if total_size > MAX_OUTPUT_SIZE { - output.push_str("\n... (output truncated)\n"); - return Ok(ToolOutput::success(format!( - "{}Found {} matches in {} files (output truncated)", - output, match_count, file_count - ))); - } - - match_count += 1; - - let start = match_idx.saturating_sub(context_lines); - let end = (match_idx + context_lines + 1).min(lines.len()); - - for (i, line) in lines[start..end].iter().enumerate() { - let abs_i = start + i; - let prefix = if abs_i == match_idx { ">" } else { " " }; - let line_str = format!("{}{}:{}: {}\n", prefix, rel_path, abs_i + 1, line); - total_size += line_str.len(); - output.push_str(&line_str); - } - - if context_lines > 0 { - output.push_str("--\n"); - total_size += 3; - } - } - } + let Some(search) = ctx.workspace_services.search() else { + return Ok(ToolOutput::error( + "grep is not available: this workspace backend did not provide search", + )); + }; + let request = WorkspaceGrepRequest { + base, + pattern: pattern_str.to_string(), + glob: glob_filter.map(str::to_string), + context_lines, + case_insensitive, + max_output_size: MAX_OUTPUT_SIZE, + }; + let result = match ctx + .workspace_services + .run_with_timeout("grep", async move { search.grep(request).await }) + .await + { + Ok(result) => result, + Err(e) => return Ok(ToolOutput::error(format!("Grep search failed: {}", e))), + }; - if match_count == 0 { + if result.match_count == 0 { Ok(ToolOutput::success(format!( "No matches found for pattern: {}", pattern_str ))) + } else if result.truncated { + Ok(ToolOutput::success(format!( + "{}\n... (output truncated)\nFound {} matches in {} files (output truncated)", + result.output, result.match_count, result.file_count + ))) } else { - output.push_str(&format!( - "\n{} match(es) in {} file(s)", - match_count, file_count - )); - Ok(ToolOutput::success(output)) + Ok(ToolOutput::success(format!( + "{}\n{} match(es) in {} file(s)", + result.output, result.match_count, result.file_count + ))) } } } @@ -202,6 +134,7 @@ impl Tool for GrepTool { #[cfg(test)] mod tests { use super::*; + use std::path::PathBuf; #[tokio::test] async fn test_grep_find_pattern() { diff --git a/core/src/tools/builtin/ls.rs b/core/src/tools/builtin/ls.rs index af4fbbdc..3926af76 100644 --- a/core/src/tools/builtin/ls.rs +++ b/core/src/tools/builtin/ls.rs @@ -3,7 +3,6 @@ use crate::tools::types::{Tool, ToolContext, ToolOutput}; use anyhow::Result; use async_trait::async_trait; -use std::path::PathBuf; pub struct LsTool; @@ -40,77 +39,49 @@ impl Tool for LsTool { async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result { let path_str = args.get("path").and_then(|v| v.as_str()).unwrap_or("."); - let target = if std::path::Path::new(path_str).is_absolute() { - PathBuf::from(path_str) - } else { - ctx.workspace.join(path_str) + let workspace_path = match ctx.resolve_workspace_path(path_str) { + Ok(p) => p, + Err(e) => return Ok(ToolOutput::error(format!("Failed to resolve path: {}", e))), }; - if !target.exists() { - return Ok(ToolOutput::error(format!( - "Directory not found: {}", - target.display() - ))); - } - - if !target.is_dir() { - return Ok(ToolOutput::error(format!( - "Not a directory: {}", - target.display() - ))); - } - - let mut entries = Vec::new(); - let mut dir = match tokio::fs::read_dir(&target).await { - Ok(d) => d, + let fs = ctx.workspace_services.fs(); + let path_for_list = workspace_path.clone(); + let mut entries = match ctx + .workspace_services + .run_with_timeout("list_dir", async move { fs.list_dir(&path_for_list).await }) + .await + { + Ok(entries) => entries, Err(e) => { return Ok(ToolOutput::error(format!( "Failed to read directory {}: {}", - target.display(), + ctx.workspace_services.display_path(&workspace_path), e ))) } }; - while let Ok(Some(entry)) = dir.next_entry().await { - let name = entry.file_name().to_string_lossy().to_string(); - let file_type = entry.file_type().await; - let metadata = entry.metadata().await; - - let (kind, size) = match (&file_type, &metadata) { - (Ok(ft), Ok(m)) => { - let kind = if ft.is_dir() { - "dir" - } else if ft.is_symlink() { - "link" - } else { - "file" - }; - (kind, m.len()) - } - _ => ("unknown", 0), - }; - - entries.push((name, kind, size)); - } - entries.sort_by(|a, b| { // Directories first, then alphabetical - let dir_order = (a.1 != "dir").cmp(&(b.1 != "dir")); - dir_order.then(a.0.to_lowercase().cmp(&b.0.to_lowercase())) + let dir_order = (a.kind.as_tool_kind() != "dir").cmp(&(b.kind.as_tool_kind() != "dir")); + dir_order.then(a.name.to_lowercase().cmp(&b.name.to_lowercase())) }); - let mut output = format!("Directory: {}\n\n", target.display()); + let mut output = format!( + "Directory: {}\n\n", + ctx.workspace_services.display_path(&workspace_path) + ); if entries.is_empty() { output.push_str("(empty directory)\n"); } else { - for (name, kind, size) in &entries { - let size_str = format_size(*size); - let suffix = if *kind == "dir" { "/" } else { "" }; + for entry in &entries { + let kind = entry.kind.as_tool_kind(); + let size_str = format_size(entry.size); + let suffix = if kind == "dir" { "/" } else { "" }; output.push_str(&format!( "{:<6} {:>8} {}{}\n", - kind, size_str, name, suffix + kind, size_str, entry.name, suffix )); } output.push_str(&format!("\n{} entries\n", entries.len())); diff --git a/core/src/tools/builtin/mod.rs b/core/src/tools/builtin/mod.rs index 361a69c2..106b4ad8 100644 --- a/core/src/tools/builtin/mod.rs +++ b/core/src/tools/builtin/mod.rs @@ -3,7 +3,7 @@ //! These replace the previous `a3s-tools` binary backend with direct Rust //! implementations that execute in-process. Each tool implements the `Tool` trait. -mod bash; +pub(crate) mod bash; pub mod batch; mod edit; mod generate_object; @@ -20,22 +20,42 @@ mod write; use super::registry::ToolRegistry; use std::sync::Arc; -/// Register all baseline built-in tools with the registry. +/// Register all baseline built-in tools with the registry, gated by +/// workspace capabilities. +/// +/// Tools whose required capability is missing are not registered, so the model +/// never sees a tool the backend cannot service. `web_fetch` and `web_search` +/// have no workspace capability and are always registered. /// /// Note: `batch` is NOT registered here — it requires an `Arc` /// and must be registered after the registry is wrapped in an Arc. -pub fn register_builtins(registry: &ToolRegistry) { - registry.register_builtin(Arc::new(read::ReadTool)); - registry.register_builtin(Arc::new(write::WriteTool)); - registry.register_builtin(Arc::new(edit::EditTool)); - registry.register_builtin(Arc::new(patch::PatchTool)); - registry.register_builtin(Arc::new(bash::BashTool)); - registry.register_builtin(Arc::new(grep::GrepTool)); - registry.register_builtin(Arc::new(glob_tool::GlobTool)); - registry.register_builtin(Arc::new(ls::LsTool)); +pub fn register_builtins( + registry: &ToolRegistry, + capabilities: &crate::workspace::WorkspaceCapabilities, +) { + if capabilities.read { + registry.register_builtin(Arc::new(read::ReadTool)); + registry.register_builtin(Arc::new(ls::LsTool)); + } + if capabilities.write { + registry.register_builtin(Arc::new(write::WriteTool)); + } + if capabilities.read && capabilities.write { + registry.register_builtin(Arc::new(edit::EditTool)); + registry.register_builtin(Arc::new(patch::PatchTool)); + } + if capabilities.exec { + registry.register_builtin(Arc::new(bash::BashTool)); + } + if capabilities.search { + registry.register_builtin(Arc::new(grep::GrepTool)); + registry.register_builtin(Arc::new(glob_tool::GlobTool)); + } + if capabilities.git { + registry.register_builtin(Arc::new(git::GitTool)); + } registry.register_builtin(Arc::new(web_fetch::WebFetchTool)); registry.register_builtin(Arc::new(web_search::WebSearchTool::new())); - registry.register_builtin(Arc::new(git::GitTool)); } /// Register the batch tool. Must be called after the registry is wrapped in Arc. diff --git a/core/src/tools/builtin/patch.rs b/core/src/tools/builtin/patch.rs index b8c0194d..843cc64e 100644 --- a/core/src/tools/builtin/patch.rs +++ b/core/src/tools/builtin/patch.rs @@ -235,18 +235,27 @@ impl Tool for PatchTool { None => return Ok(ToolOutput::error("diff parameter is required")), }; - let resolved = match ctx.resolve_path(file_path) { - Ok(p) => p, + let workspace_path = match ctx.resolve_workspace_path(file_path) { + Ok(path) => path, Err(e) => return Ok(ToolOutput::error(format!("Failed to resolve path: {}", e))), }; - - let content = match tokio::fs::read_to_string(&resolved).await { + let display_path = ctx.workspace_services.display_path(&workspace_path); + + let fs = ctx.workspace_services.fs(); + let path_for_read = workspace_path.clone(); + let fs_for_read = fs.clone(); + let content = match ctx + .workspace_services + .run_with_timeout("read_text", async move { + fs_for_read.read_text(&path_for_read).await + }) + .await + { Ok(c) => c, Err(e) => { return Ok(ToolOutput::error(format!( "Failed to read file {}: {}", - resolved.display(), - e + display_path, e ))) } }; @@ -268,16 +277,23 @@ impl Tool for PatchTool { new_content }; - match tokio::fs::write(&resolved, &final_content).await { - Ok(()) => Ok(ToolOutput::success(format!( + let path_for_write = workspace_path.clone(); + let content_for_write = final_content.clone(); + match ctx + .workspace_services + .run_with_timeout("write_text", async move { + fs.write_text(&path_for_write, &content_for_write).await + }) + .await + { + Ok(_) => Ok(ToolOutput::success(format!( "Applied {} hunk(s) to {}", hunks.len(), - resolved.display() + display_path ))), Err(e) => Ok(ToolOutput::error(format!( "Failed to write patched file {}: {}", - resolved.display(), - e + display_path, e ))), } } diff --git a/core/src/tools/builtin/read.rs b/core/src/tools/builtin/read.rs index 90455ef6..53abc780 100644 --- a/core/src/tools/builtin/read.rs +++ b/core/src/tools/builtin/read.rs @@ -64,17 +64,26 @@ impl Tool for ReadTool { .map(|v| v as usize) .unwrap_or(MAX_READ_LINES); - let resolved = match ctx.resolve_path(file_path) { + let workspace_path = match ctx.resolve_workspace_path(file_path) { Ok(p) => p, Err(e) => return Ok(ToolOutput::error(format!("Failed to resolve path: {}", e))), }; - let content = match tokio::fs::read_to_string(&resolved).await { + let fs = ctx.workspace_services.fs(); + let path_for_read = workspace_path.clone(); + let content = match ctx + .workspace_services + .run_with_timeout( + "read_text", + async move { fs.read_text(&path_for_read).await }, + ) + .await + { Ok(c) => c, Err(e) => { return Ok(ToolOutput::error(format!( "Failed to read file {}: {}", - resolved.display(), + ctx.workspace_services.display_path(&workspace_path), e ))) } diff --git a/core/src/tools/builtin/write.rs b/core/src/tools/builtin/write.rs index 8d09dba5..0b618f59 100644 --- a/core/src/tools/builtin/write.rs +++ b/core/src/tools/builtin/write.rs @@ -51,41 +51,33 @@ impl Tool for WriteTool { None => return Ok(ToolOutput::error("content parameter is required")), }; - // Build the target path, creating parent dirs if needed - let target = if std::path::Path::new(file_path).is_absolute() { - std::path::PathBuf::from(file_path) - } else { - ctx.workspace.join(file_path) - }; - - // Create parent directories first (before resolve_path_for_write which checks parent exists) - if let Some(parent) = target.parent() { - if let Err(e) = tokio::fs::create_dir_all(parent).await { - return Ok(ToolOutput::error(format!( - "Failed to create parent directories for {}: {}", - target.display(), - e - ))); - } - } - - let resolved = match ctx.resolve_path_for_write(file_path) { + let workspace_path = match ctx.resolve_workspace_path(file_path) { Ok(p) => p, Err(e) => return Ok(ToolOutput::error(format!("Failed to resolve path: {}", e))), }; // Read existing content for diff metadata (if file exists) - let before_content = if resolved.exists() { - tokio::fs::read_to_string(&resolved).await.ok() - } else { - None - }; - - match tokio::fs::write(&resolved, content).await { - Ok(()) => { - let lines = content.lines().count(); - let bytes = content.len(); - + let fs = ctx.workspace_services.fs(); + let path_for_before = workspace_path.clone(); + let fs_for_before = fs.clone(); + let before_content = ctx + .workspace_services + .run_with_timeout("read_text", async move { + fs_for_before.read_text(&path_for_before).await + }) + .await + .ok(); + + let path_for_write = workspace_path.clone(); + let content_for_write = content.to_string(); + match ctx + .workspace_services + .run_with_timeout("write_text", async move { + fs.write_text(&path_for_write, &content_for_write).await + }) + .await + { + Ok(outcome) => { // Attach diff metadata let mut metadata = serde_json::Map::new(); metadata.insert("file_path".to_string(), serde_json::json!(file_path)); @@ -96,15 +88,15 @@ impl Tool for WriteTool { Ok(ToolOutput::success(format!( "Wrote {} bytes ({} lines) to {}", - bytes, - lines, - resolved.display() + outcome.bytes, + outcome.lines, + ctx.workspace_services.display_path(&workspace_path) )) .with_metadata(serde_json::Value::Object(metadata))) } Err(e) => Ok(ToolOutput::error(format!( "Failed to write file {}: {}", - resolved.display(), + ctx.workspace_services.display_path(&workspace_path), e ))), } diff --git a/core/src/tools/mod.rs b/core/src/tools/mod.rs index 529ac370..55c73e91 100644 --- a/core/src/tools/mod.rs +++ b/core/src/tools/mod.rs @@ -10,8 +10,8 @@ //! ``` mod artifacts; -mod builtin; -mod process; +pub(crate) mod builtin; +pub(crate) mod process; mod program_tool; mod registry; mod selector; @@ -209,45 +209,71 @@ pub struct ToolExecutor { registry: Arc, file_history: Arc, command_env: Option>>, + workspace_services: Arc, } impl ToolExecutor { pub fn new(workspace: String) -> Self { - Self::new_with_options(workspace, None, ArtifactStoreLimits::default()) - } - - pub fn new_with_command_env(workspace: String, command_env: HashMap) -> Self { - Self::new_with_options(workspace, Some(command_env), ArtifactStoreLimits::default()) + let workspace_services = + crate::workspace::WorkspaceServices::local(PathBuf::from(&workspace)); + Self::build( + workspace, + None, + ArtifactStoreLimits::default(), + workspace_services, + ) } pub fn new_with_artifact_limits( workspace: String, artifact_limits: ArtifactStoreLimits, ) -> Self { - Self::new_with_options(workspace, None, artifact_limits) + let workspace_services = + crate::workspace::WorkspaceServices::local(PathBuf::from(&workspace)); + Self::build(workspace, None, artifact_limits, workspace_services) } - pub fn new_with_command_env_and_artifact_limits( + pub fn new_with_workspace_services( workspace: String, - command_env: HashMap, + workspace_services: Arc, + ) -> Self { + Self::build( + workspace, + None, + ArtifactStoreLimits::default(), + workspace_services, + ) + } + + pub fn new_with_workspace_services_and_artifact_limits( + workspace: String, + workspace_services: Arc, artifact_limits: ArtifactStoreLimits, ) -> Self { - Self::new_with_options(workspace, Some(command_env), artifact_limits) + Self::build(workspace, None, artifact_limits, workspace_services) } - fn new_with_options( + fn build( workspace: String, command_env: Option>, artifact_limits: ArtifactStoreLimits, + workspace_services: Arc, ) -> Self { let workspace_path = PathBuf::from(&workspace); - let registry = Arc::new(ToolRegistry::with_artifact_limits( + let command_env = command_env.map(Arc::new); + let registry = Arc::new(ToolRegistry::with_artifact_limits_and_workspace_services( workspace_path.clone(), artifact_limits, + Arc::clone(&workspace_services), )); + if let Some(env) = command_env.clone() { + registry.set_command_env(env); + } - // Register native Rust built-in tools - builtin::register_builtins(®istry); + // Register native Rust built-in tools — only those whose required + // workspace capability is available, so the model never sees a tool + // the backend cannot service. + builtin::register_builtins(®istry, &workspace_services.capabilities()); // Batch tool requires Arc, registered separately builtin::register_batch(®istry); builtin::register_program(®istry); @@ -256,7 +282,8 @@ impl ToolExecutor { workspace: workspace_path, registry, file_history: Arc::new(FileHistory::new(500)), - command_env: command_env.map(Arc::new), + command_env, + workspace_services, } } @@ -273,51 +300,14 @@ impl ToolExecutor { if let Some(field) = path_field { if let Some(path_str) = args.get(field).and_then(|v| v.as_str()) { - let target = if std::path::Path::new(path_str).is_absolute() { - std::path::PathBuf::from(path_str) - } else { - ctx.workspace.join(path_str) - }; - - // Canonicalize workspace first — fail closed if it can't be resolved - let canonical_workspace = ctx.workspace.canonicalize().map_err(|e| { + ctx.resolve_workspace_path(path_str).map_err(|e| { anyhow::anyhow!( - "Workspace boundary check failed: cannot canonicalize workspace '{}': {}", - ctx.workspace.display(), + "Workspace boundary check failed for tool '{}' path '{}': {}", + name, + path_str, e ) })?; - - // Try to canonicalize target; fall back to parent directory for new files - let canonical_target = target.canonicalize().or_else(|_| { - target - .parent() - .and_then(|p| p.canonicalize().ok()) - .ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::NotFound, "parent not found") - }) - }); - - match canonical_target { - Ok(canonical) => { - if !canonical.starts_with(&canonical_workspace) { - anyhow::bail!( - "Workspace boundary violation: tool '{}' path '{}' escapes workspace '{}'", - name, - path_str, - ctx.workspace.display() - ); - } - } - Err(_) => { - // Fail closed: if we can't resolve the target path, deny the operation - anyhow::bail!( - "Workspace boundary check failed: cannot resolve path '{}' for tool '{}'", - path_str, - name - ); - } - } } } @@ -375,16 +365,32 @@ impl ToolExecutor { } fn capture_snapshot(&self, name: &str, args: &serde_json::Value) { + let Some(local_root) = self.workspace_services.local_root() else { + return; + }; + if let Some(file_path) = file_history::extract_file_path(name, args) { - let resolved = self.workspace.join(&file_path); - let path_to_read = if resolved.exists() { - resolved - } else if std::path::Path::new(&file_path).exists() { - std::path::PathBuf::from(&file_path) + let workspace_path = match self.workspace_services.normalize_path(&file_path) { + Ok(path) => path, + Err(e) => { + tracing::warn!( + "Skipping file snapshot for invalid path {}: {}", + file_path, + e + ); + return; + } + }; + let path_to_read = if workspace_path.is_root() { + local_root.to_path_buf() } else { + local_root.join(workspace_path.as_str()) + }; + + if !path_to_read.exists() { self.file_history.save_snapshot(&file_path, "", name); return; - }; + } match std::fs::read_to_string(&path_to_read) { Ok(content) => { @@ -404,9 +410,14 @@ impl ToolExecutor { } pub async fn execute(&self, name: &str, args: &serde_json::Value) -> Result { + let ctx = self.registry.context(); + if let Err(e) = Self::check_workspace_boundary(name, args, &ctx) { + return Ok(ToolResult::error(name, e.to_string())); + } + tracing::info!("Executing tool: {} with args: {}", name, args); self.capture_snapshot(name, args); - let mut result = self.registry.execute(name, args).await; + let mut result = self.registry.execute_with_context(name, args, &ctx).await; if let Ok(ref mut r) = result { self.attach_diff_metadata(name, args, r); } @@ -458,7 +469,13 @@ impl ToolExecutor { #[cfg(test)] mod tests { use super::*; + use crate::workspace::{ + CommandOutput, CommandRequest, WorkspaceCommandRunner, WorkspaceDirEntry, + WorkspaceFileSystem, WorkspaceFileType, WorkspacePath, WorkspaceRef, WorkspaceServices, + WorkspaceWriteOutcome, + }; use async_trait::async_trait; + use std::sync::RwLock; struct LargeArtifactTool; @@ -532,6 +549,89 @@ mod tests { } } + #[derive(Default)] + struct MemoryWorkspaceFs { + files: RwLock>, + } + + impl MemoryWorkspaceFs { + fn insert(&self, path: &str, content: &str) { + self.files + .write() + .unwrap() + .insert(path.to_string(), content.to_string()); + } + + fn get(&self, path: &str) -> Option { + self.files.read().unwrap().get(path).cloned() + } + } + + #[async_trait] + impl WorkspaceFileSystem for MemoryWorkspaceFs { + async fn read_text(&self, path: &WorkspacePath) -> Result { + self.files + .read() + .unwrap() + .get(path.as_str()) + .cloned() + .ok_or_else(|| anyhow::anyhow!("missing file: {}", path.as_str())) + } + + async fn write_text( + &self, + path: &WorkspacePath, + content: &str, + ) -> Result { + self.insert(path.as_str(), content); + Ok(WorkspaceWriteOutcome { + bytes: content.len(), + lines: content.lines().count(), + }) + } + + async fn list_dir(&self, path: &WorkspacePath) -> Result> { + let prefix = if path.is_root() { + String::new() + } else { + format!("{}/", path.as_str()) + }; + let files = self.files.read().unwrap(); + let mut entries = Vec::new(); + for name in files.keys() { + if !name.starts_with(&prefix) { + continue; + } + let remaining = &name[prefix.len()..]; + if remaining.is_empty() || remaining.contains('/') { + continue; + } + entries.push(WorkspaceDirEntry { + name: remaining.to_string(), + kind: WorkspaceFileType::File, + size: files + .get(name) + .map(|content| content.len() as u64) + .unwrap_or(0), + }); + } + Ok(entries) + } + } + + struct MockCommandRunner; + + #[async_trait] + impl WorkspaceCommandRunner for MockCommandRunner { + async fn exec(&self, request: CommandRequest) -> Result { + Ok(CommandOutput { + output: format!("remote: {}\n", request.command), + exit_code: 0, + timed_out: false, + }) + } + } + #[tokio::test] async fn test_tool_executor_creation() { let executor = ToolExecutor::new("/tmp".to_string()); @@ -568,6 +668,142 @@ mod tests { assert!(definitions.iter().any(|t| t.name == "batch")); } + #[tokio::test] + async fn test_builtin_file_tools_use_workspace_services() { + let fs = Arc::new(MemoryWorkspaceFs::default()); + fs.insert("remote.txt", "first\nsecond\n"); + let services = WorkspaceServices::builder( + WorkspaceRef::new("browser-workspace", "browser://workspace"), + fs.clone(), + ) + .build(); + let executor = ToolExecutor::new_with_workspace_services_and_artifact_limits( + "/server/local-placeholder".to_string(), + services, + ArtifactStoreLimits::default(), + ); + let definitions = executor.definitions(); + assert!(definitions.iter().any(|tool| tool.name == "read")); + assert!(definitions.iter().any(|tool| tool.name == "write")); + assert!(definitions.iter().any(|tool| tool.name == "ls")); + assert!(!definitions.iter().any(|tool| tool.name == "bash")); + assert!(!definitions.iter().any(|tool| tool.name == "grep")); + assert!(definitions.iter().any(|tool| tool.name == "edit")); + assert!(definitions.iter().any(|tool| tool.name == "patch")); + + let read = executor + .execute("read", &serde_json::json!({"file_path": "remote.txt"})) + .await + .unwrap(); + assert_eq!(read.exit_code, 0); + assert!(read.output.contains("first")); + + let write = executor + .execute( + "write", + &serde_json::json!({"file_path": "created.txt", "content": "remote write\n"}), + ) + .await + .unwrap(); + assert_eq!(write.exit_code, 0); + assert_eq!(fs.get("created.txt").unwrap(), "remote write\n"); + + let ls = executor + .execute("ls", &serde_json::json!({})) + .await + .unwrap(); + assert_eq!(ls.exit_code, 0); + assert!(ls.output.contains("created.txt")); + assert!(ls.output.contains("remote.txt")); + } + + #[tokio::test] + async fn test_bash_uses_workspace_command_runner() { + let fs = Arc::new(MemoryWorkspaceFs::default()); + let fs_backend: Arc = fs; + let services = WorkspaceServices::builder( + WorkspaceRef::new("remote-workspace", "remote://workspace"), + fs_backend, + ) + .command_runner(Arc::new(MockCommandRunner)) + .build(); + let executor = ToolExecutor::new_with_workspace_services_and_artifact_limits( + "/server/local-placeholder".to_string(), + services, + ArtifactStoreLimits::default(), + ); + assert!(executor + .definitions() + .iter() + .any(|tool| tool.name == "bash")); + + let result = executor + .execute("bash", &serde_json::json!({"command": "pwd"})) + .await + .unwrap(); + + assert_eq!(result.exit_code, 0); + assert_eq!(result.output, "remote: pwd\n"); + } + + #[tokio::test] + async fn test_command_env_is_available_on_default_context() { + let temp = tempfile::tempdir().unwrap(); + let mut env = HashMap::new(); + env.insert( + "A3S_COMMAND_ENV_TEST".to_string(), + "registry-env".to_string(), + ); + + let executor = ToolExecutor::new(temp.path().to_string_lossy().to_string()); + executor.registry().set_command_env(Arc::new(env)); + let context = executor.registry().context(); + assert_eq!( + context + .command_env + .as_ref() + .and_then(|env| env.get("A3S_COMMAND_ENV_TEST")) + .map(String::as_str), + Some("registry-env") + ); + + #[cfg(windows)] + let command = "Write-Output $env:A3S_COMMAND_ENV_TEST"; + #[cfg(not(windows))] + let command = "printf '%s' \"$A3S_COMMAND_ENV_TEST\""; + + let result = executor + .execute("bash", &serde_json::json!({ "command": command })) + .await + .unwrap(); + + assert_eq!(result.exit_code, 0, "{}", result.output); + assert!(result.output.contains("registry-env")); + } + + #[tokio::test] + async fn test_execute_applies_workspace_boundary_for_default_context() { + let workspace = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + std::fs::write(outside.path().join("secret.txt"), "secret").unwrap(); + + let executor = ToolExecutor::new(workspace.path().to_string_lossy().to_string()); + let result = executor + .execute( + "grep", + &serde_json::json!({ + "pattern": "secret", + "path": outside.path().to_string_lossy() + }), + ) + .await + .unwrap(); + + assert_eq!(result.exit_code, 1); + assert!(result.output.contains("Workspace boundary")); + assert!(result.output.contains("escapes workspace")); + } + #[test] fn test_tool_result_success() { let result = ToolResult::success("test_tool", "output text".to_string()); @@ -813,15 +1049,7 @@ mod tests { std::fs::write(&file, "original\n").unwrap(); let executor = ToolExecutor::new(canonical_dir.to_str().unwrap().to_string()); - let ctx = ToolContext { - workspace: canonical_dir.clone(), - session_id: None, - event_tx: None, - agent_event_tx: None, - search_config: None, - sandbox: None, - command_env: None, - }; + let ctx = ToolContext::new(canonical_dir.clone()); let args = serde_json::json!({ "file_path": "ctx.txt", "content": "updated\n" diff --git a/core/src/tools/process.rs b/core/src/tools/process.rs index 35424cde..86bfbf87 100644 --- a/core/src/tools/process.rs +++ b/core/src/tools/process.rs @@ -1,7 +1,7 @@ //! Process output reading utility -use super::types::{ToolEventSender, ToolStreamEvent}; use super::MAX_OUTPUT_SIZE; +use crate::workspace::CommandOutputObserver; use tokio::io::AsyncBufReadExt; use tokio::io::BufReader; use tokio::process::Child; @@ -9,7 +9,7 @@ use tokio::process::Child; pub(crate) async fn read_process_output( child: &mut Child, timeout_secs: u64, - event_tx: Option<&ToolEventSender>, + observer: Option<&dyn CommandOutputObserver>, ) -> (String, bool) { let stdout = match child.stdout.take() { Some(s) => s, @@ -47,10 +47,10 @@ pub(crate) async fn read_process_output( output.push('\n'); total_size += line.len() + 1; } - if let Some(tx) = event_tx { + if let Some(obs) = observer { let mut delta = line; delta.push('\n'); - tx.send(ToolStreamEvent::OutputDelta(delta)).await.ok(); + obs.on_output_delta(&delta).await; } } Ok(None) => stdout_done = true, @@ -65,10 +65,10 @@ pub(crate) async fn read_process_output( output.push('\n'); total_size += line.len() + 1; } - if let Some(tx) = event_tx { + if let Some(obs) = observer { let mut delta = line; delta.push('\n'); - tx.send(ToolStreamEvent::OutputDelta(delta)).await.ok(); + obs.on_output_delta(&delta).await; } } Ok(None) => stderr_done = true, diff --git a/core/src/tools/program_tool.rs b/core/src/tools/program_tool.rs index fa7c6ee8..eeeeac7a 100644 --- a/core/src/tools/program_tool.rs +++ b/core/src/tools/program_tool.rs @@ -189,10 +189,12 @@ async fn load_script_source( return Err("program script path must point to a .js or .mjs file".to_string()); } - let resolved = ctx - .resolve_path(path) + let workspace_path = ctx + .resolve_workspace_path(path) .map_err(|err| format!("failed to resolve script path: {err}"))?; - tokio::fs::read_to_string(&resolved) + ctx.workspace_services + .fs() + .read_text(&workspace_path) .await .map_err(|err| format!("failed to read script path '{}': {err}", path)) } diff --git a/core/src/tools/registry.rs b/core/src/tools/registry.rs index d664268a..b865f08a 100644 --- a/core/src/tools/registry.rs +++ b/core/src/tools/registry.rs @@ -34,10 +34,24 @@ impl ToolRegistry { /// Create a new tool registry with custom artifact retention limits. pub fn with_artifact_limits(workspace: PathBuf, artifact_limits: ArtifactStoreLimits) -> Self { + Self::with_artifact_limits_and_workspace_services( + workspace.clone(), + artifact_limits, + crate::workspace::WorkspaceServices::local(workspace), + ) + } + + /// Create a new tool registry with custom artifact limits and workspace backend. + pub fn with_artifact_limits_and_workspace_services( + workspace: PathBuf, + artifact_limits: ArtifactStoreLimits, + workspace_services: Arc, + ) -> Self { + let context = ToolContext::new(workspace).with_workspace_services(workspace_services); Self { tools: RwLock::new(HashMap::new()), builtins: RwLock::new(std::collections::HashSet::new()), - context: RwLock::new(ToolContext::new(workspace)), + context: RwLock::new(context), artifact_store: ArtifactStore::with_limits(artifact_limits), trace_sink: RwLock::new(Arc::new(InMemoryTraceSink::default())), } @@ -169,6 +183,13 @@ impl ToolRegistry { *ctx = ctx.clone().with_sandbox(sandbox); } + /// Set environment overrides used by subprocess-backed tools when executed + /// without an explicit context. + pub fn set_command_env(&self, env: Arc>) { + let mut ctx = self.context.write().unwrap(); + *ctx = ctx.clone().with_command_env(env); + } + /// Execute a tool by name using the registry's default context pub async fn execute(&self, name: &str, args: &serde_json::Value) -> Result { let ctx = self.context(); diff --git a/core/src/tools/task.rs b/core/src/tools/task.rs index 83bde495..099eb669 100644 --- a/core/src/tools/task.rs +++ b/core/src/tools/task.rs @@ -206,7 +206,19 @@ impl TaskExecutor { // Build a child ToolExecutor. Task tools are intentionally omitted // here to prevent unlimited delegation nesting. - let child_executor = crate::tools::ToolExecutor::new(self.workspace.clone()); + let child_executor = if let Some(ref parent_ctx) = self.parent_context { + if let Some(ref services) = parent_ctx.workspace_services { + crate::tools::ToolExecutor::new_with_workspace_services_and_artifact_limits( + self.workspace.clone(), + Arc::clone(services), + crate::tools::ArtifactStoreLimits::default(), + ) + } else { + crate::tools::ToolExecutor::new(self.workspace.clone()) + } + } else { + crate::tools::ToolExecutor::new(self.workspace.clone()) + }; // Register MCP tools so child agents can access MCP servers. if let Some(ref mcp) = self.mcp_manager { @@ -241,8 +253,13 @@ impl TaskExecutor { child_config.max_tool_rounds = max_steps; } - let tool_context = + let mut tool_context = ToolContext::new(PathBuf::from(&self.workspace)).with_session_id(session_id.clone()); + if let Some(ref parent_ctx) = self.parent_context { + if let Some(ref services) = parent_ctx.workspace_services { + tool_context = tool_context.with_workspace_services(Arc::clone(services)); + } + } let agent_loop = AgentLoop::new( Arc::clone(&self.llm_client), diff --git a/core/src/tools/types.rs b/core/src/tools/types.rs index ae69f4be..71857835 100644 --- a/core/src/tools/types.rs +++ b/core/src/tools/types.rs @@ -37,6 +37,8 @@ pub struct ToolContext { pub sandbox: Option>, /// Optional command environment overrides for subprocess-based tools. pub command_env: Option>>, + /// Host-provided workspace capabilities used by built-in tools. + pub workspace_services: Arc, } impl std::fmt::Debug for ToolContext { @@ -45,6 +47,7 @@ impl std::fmt::Debug for ToolContext { .field("workspace", &self.workspace) .field("session_id", &self.session_id) .field("sandbox", &self.sandbox.is_some()) + .field("workspace_services", &self.workspace_services) .finish() } } @@ -62,6 +65,7 @@ impl ToolContext { search_config: None, sandbox: None, command_env: None, + workspace_services: crate::workspace::WorkspaceServices::local(workspace), } } @@ -104,13 +108,50 @@ impl ToolContext { self } - /// Resolve path relative to workspace, ensuring it stays within sandbox + /// Set host-provided workspace capabilities for built-in tools. + pub fn with_workspace_services( + mut self, + services: Arc, + ) -> Self { + self.workspace_services = services; + self + } + + /// Normalize a user-supplied path through the configured workspace backend. + pub fn resolve_workspace_path(&self, path: &str) -> Result { + self.workspace_services.normalize_path(path) + } + + /// Resolve path relative to workspace, ensuring it stays within sandbox. + /// + /// Deprecated: returns a host-filesystem `PathBuf` that is meaningless for + /// virtual / DFS / browser workspace backends. New code should call + /// [`Self::resolve_workspace_path`] and route I/O through + /// `workspace_services.fs()` instead. + #[deprecated( + note = "Use resolve_workspace_path() and route I/O through workspace_services.fs() for non-local backends" + )] pub fn resolve_path(&self, path: &str) -> Result { + if self.workspace_services.local_root().is_none() { + anyhow::bail!( + "resolve_path is only valid for local workspaces; this session uses a non-local workspace backend, call resolve_workspace_path() instead" + ); + } a3s_common::tools::resolve_path(&self.workspace, path).map_err(|e| anyhow::anyhow!("{}", e)) } - /// Resolve path for writing (allows non-existent files) + /// Resolve path for writing (allows non-existent files). + /// + /// Deprecated: see [`Self::resolve_path`]. + #[deprecated( + note = "Use resolve_workspace_path() and route I/O through workspace_services.fs() for non-local backends" + )] pub fn resolve_path_for_write(&self, path: &str) -> Result { + if self.workspace_services.local_root().is_none() { + anyhow::bail!( + "resolve_path_for_write is only valid for local workspaces; this session uses a non-local workspace backend, call resolve_workspace_path() instead" + ); + } a3s_common::tools::resolve_path_for_write(&self.workspace, path) .map_err(|e| anyhow::anyhow!("{}", e)) } @@ -192,6 +233,7 @@ mod tests { use super::*; #[test] + #[allow(deprecated)] fn test_tool_context_resolve_path() { let temp_dir = tempfile::tempdir().unwrap(); let ctx = ToolContext::new(temp_dir.path().to_path_buf()); diff --git a/core/src/workspace/local.rs b/core/src/workspace/local.rs new file mode 100644 index 00000000..6f028aa5 --- /dev/null +++ b/core/src/workspace/local.rs @@ -0,0 +1,757 @@ +//! Local filesystem-backed workspace implementation. +//! +//! [`LocalWorkspaceBackend`] preserves the historical "agent runs on the host +//! filesystem" behavior. It implements every workspace capability trait so +//! local sessions get the full tool surface (read, write, edit, patch, ls, +//! bash, grep, glob, git, git_stash, git_worktree). + +use super::{ + default_path_input, has_windows_path_prefix, normalize_relative_path, + pathbuf_to_workspace_path, validate_relative_pattern, CommandOutput, CommandRequest, + WorkspaceCommandRunner, WorkspaceDirEntry, WorkspaceFileSystem, WorkspaceFileType, + WorkspaceGit, WorkspaceGitBranch, WorkspaceGitCheckoutOutput, WorkspaceGitCheckoutRequest, + WorkspaceGitCommit, WorkspaceGitCreateBranchRequest, WorkspaceGitCreateWorktreeRequest, + WorkspaceGitDiffRequest, WorkspaceGitRemote, WorkspaceGitRemoveWorktreeRequest, + WorkspaceGitStash, WorkspaceGitStashProvider, WorkspaceGitStashRequest, WorkspaceGitStatus, + WorkspaceGitWorktree, WorkspaceGitWorktreeMutation, WorkspaceGitWorktreeProvider, + WorkspaceGlobRequest, WorkspaceGlobResult, WorkspaceGrepRequest, WorkspaceGrepResult, + WorkspacePath, WorkspacePathResolver, WorkspaceSearch, WorkspaceWriteOutcome, +}; +use anyhow::{anyhow, bail, Result}; +use async_trait::async_trait; +use std::path::{Component, Path, PathBuf}; + +/// Local filesystem-backed workspace implementation. +#[derive(Debug)] +pub struct LocalWorkspaceBackend { + pub(super) root: PathBuf, +} + +impl LocalWorkspaceBackend { + pub fn new(root: PathBuf) -> Self { + let canonical = root.canonicalize(); + let root = match canonical { + Ok(canonical) => canonical, + Err(e) => { + tracing::warn!( + "LocalWorkspaceBackend: failed to canonicalize root '{}' at construction: {} \ + (path resolution will fail-closed at first use)", + root.display(), + e + ); + root + } + }; + Self { root } + } + + fn local_path_for_read(&self, path: &WorkspacePath) -> Result { + a3s_common::tools::resolve_path(&self.root, path.as_str()).map_err(|e| anyhow!("{}", e)) + } + + fn local_path_for_write(&self, path: &WorkspacePath) -> Result { + let target = if path.is_root() { + self.root.clone() + } else { + self.root.join(path.as_str()) + }; + + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + anyhow!( + "Failed to create parent directories for {}: {}", + target.display(), + e + ) + })?; + } + + a3s_common::tools::resolve_path_for_write(&self.root, path.as_str()) + .map_err(|e| anyhow!("{}", e)) + } +} + +impl WorkspacePathResolver for LocalWorkspaceBackend { + fn normalize(&self, input: &str) -> Result { + normalize_local_path(&self.root, input) + } +} + +#[async_trait] +impl WorkspaceFileSystem for LocalWorkspaceBackend { + async fn read_text(&self, path: &WorkspacePath) -> Result { + let resolved = self.local_path_for_read(path)?; + tokio::fs::read_to_string(&resolved) + .await + .map_err(|e| anyhow!("Failed to read file {}: {}", resolved.display(), e)) + } + + async fn write_text( + &self, + path: &WorkspacePath, + content: &str, + ) -> Result { + let resolved = self.local_path_for_write(path)?; + tokio::fs::write(&resolved, content) + .await + .map_err(|e| anyhow!("Failed to write file {}: {}", resolved.display(), e))?; + + Ok(WorkspaceWriteOutcome { + bytes: content.len(), + lines: content.lines().count(), + }) + } + + async fn list_dir(&self, path: &WorkspacePath) -> Result> { + let target = self.local_path_for_read(path)?; + if !target.exists() { + bail!("Directory not found: {}", target.display()); + } + if !target.is_dir() { + bail!("Not a directory: {}", target.display()); + } + + let mut dir = tokio::fs::read_dir(&target) + .await + .map_err(|e| anyhow!("Failed to read directory {}: {}", target.display(), e))?; + let mut entries = Vec::new(); + + while let Some(entry) = dir.next_entry().await? { + let name = entry.file_name().to_string_lossy().to_string(); + let file_type = entry.file_type().await; + let metadata = entry.metadata().await; + let (kind, size) = match (&file_type, &metadata) { + (Ok(ft), Ok(m)) => { + let kind = if ft.is_dir() { + WorkspaceFileType::Directory + } else if ft.is_symlink() { + WorkspaceFileType::Symlink + } else { + WorkspaceFileType::File + }; + (kind, m.len()) + } + _ => (WorkspaceFileType::Unknown, 0), + }; + entries.push(WorkspaceDirEntry { name, kind, size }); + } + + Ok(entries) + } +} + +#[async_trait] +impl WorkspaceSearch for LocalWorkspaceBackend { + async fn glob(&self, request: WorkspaceGlobRequest) -> Result { + validate_relative_pattern(&request.pattern, "glob pattern")?; + let base = self.local_path_for_read(&request.base)?; + let full_pattern = base.join(&request.pattern); + let full_pattern = full_pattern.to_string_lossy().replace('\\', "/"); + + let entries = glob::glob(&full_pattern) + .map_err(|e| anyhow!("Invalid glob pattern '{}': {}", request.pattern, e))?; + + let mut matches = Vec::new(); + for entry in entries { + match entry { + Ok(path) => { + if let Ok(relative) = path.strip_prefix(&self.root) { + matches.push(pathbuf_to_workspace_path(relative)); + } + } + Err(e) => tracing::warn!("Glob entry error: {}", e), + } + } + + matches.sort_by(|a, b| a.as_str().cmp(b.as_str())); + Ok(WorkspaceGlobResult { matches }) + } + + async fn grep(&self, request: WorkspaceGrepRequest) -> Result { + if let Some(ref glob) = request.glob { + validate_relative_pattern(glob, "grep glob filter")?; + } + + let regex_pattern = if request.case_insensitive { + format!("(?i){}", request.pattern) + } else { + request.pattern.clone() + }; + let regex = regex::Regex::new(®ex_pattern) + .map_err(|e| anyhow!("Invalid regex pattern '{}': {}", request.pattern, e))?; + + let search_path = self.local_path_for_read(&request.base)?; + let mut builder = ignore::WalkBuilder::new(&search_path); + builder.hidden(false).git_ignore(true).git_global(true); + + if let Some(ref glob_pat) = request.glob { + let mut types = ignore::types::TypesBuilder::new(); + types.add("custom", glob_pat).ok(); + types.select("custom"); + if let Ok(built) = types.build() { + builder.types(built); + } + } + + let mut output = String::new(); + let mut match_count = 0; + let mut file_count = 0; + let mut total_size = 0; + + for entry in builder.build().flatten() { + if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) { + continue; + } + + let file_path = entry.path(); + let content = match std::fs::read_to_string(file_path) { + Ok(content) => content, + Err(_) => continue, + }; + + let lines: Vec<&str> = content.lines().collect(); + let mut file_matches = Vec::new(); + for (line_idx, line) in lines.iter().enumerate() { + if regex.is_match(line) { + file_matches.push(line_idx); + } + } + + if file_matches.is_empty() { + continue; + } + + file_count += 1; + let rel_path = file_path + .strip_prefix(&self.root) + .unwrap_or(file_path) + .to_string_lossy() + .replace('\\', "/"); + + for &match_idx in &file_matches { + if total_size > request.max_output_size { + return Ok(WorkspaceGrepResult { + output, + match_count, + file_count, + truncated: true, + }); + } + + match_count += 1; + + let start = match_idx.saturating_sub(request.context_lines); + let end = (match_idx + request.context_lines + 1).min(lines.len()); + + for (i, line) in lines[start..end].iter().enumerate() { + let abs_i = start + i; + let prefix = if abs_i == match_idx { ">" } else { " " }; + let line = format!("{}{}:{}: {}\n", prefix, rel_path, abs_i + 1, line); + total_size += line.len(); + output.push_str(&line); + } + + if request.context_lines > 0 { + output.push_str("--\n"); + total_size += 3; + } + } + } + + Ok(WorkspaceGrepResult { + output, + match_count, + file_count, + truncated: false, + }) + } +} + +#[async_trait] +impl WorkspaceGit for LocalWorkspaceBackend { + async fn is_repository(&self) -> Result { + self.run_blocking_git(|root| Ok(crate::git::is_git_repo(&root))) + .await + } + + async fn status(&self) -> Result { + self.run_blocking_git(|root| { + let status = crate::git::get_status(&root)?; + Ok(WorkspaceGitStatus { + branch: status.branch, + commit: status.commit, + is_worktree: status.is_worktree, + is_dirty: status.is_dirty, + dirty_count: status.dirty_count, + }) + }) + .await + } + + async fn log(&self, max_count: usize) -> Result> { + self.run_blocking_git(move |root| { + Ok(crate::git::get_log(&root, max_count)? + .into_iter() + .map(|commit| WorkspaceGitCommit { + id: commit.id, + message: commit.message, + author: commit.author, + date: commit.date, + }) + .collect()) + }) + .await + } + + async fn list_branches(&self) -> Result> { + self.run_blocking_git(|root| { + Ok(crate::git::list_branches(&root)? + .into_iter() + .map(|branch| WorkspaceGitBranch { + name: branch.name, + is_current: branch.is_current, + }) + .collect()) + }) + .await + } + + async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()> { + self.run_blocking_git(move |root| { + crate::git::create_branch(&root, &request.name, &request.base) + }) + .await + } + + async fn checkout( + &self, + request: WorkspaceGitCheckoutRequest, + ) -> Result { + let args = if request.force { + vec![ + "checkout".to_string(), + "--force".to_string(), + request.refspec, + ] + } else { + vec!["checkout".to_string(), request.refspec] + }; + let (success, stdout, stderr) = self.run_git_command(args).await?; + if !success { + bail!("{}", stderr.trim_end()); + } + Ok(WorkspaceGitCheckoutOutput { stdout }) + } + + async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result { + self.run_blocking_git(move |root| crate::git::get_diff(&root, request.target.as_deref())) + .await + } + + async fn list_remotes(&self) -> Result> { + let (success, stdout, stderr) = self + .run_git_command(vec!["remote".to_string(), "-v".to_string()]) + .await?; + if !success { + bail!("{}", stderr.trim_end()); + } + + Ok(stdout.lines().filter_map(parse_git_remote_line).collect()) + } +} + +#[async_trait] +impl WorkspaceGitStashProvider for LocalWorkspaceBackend { + async fn list_stashes(&self) -> Result> { + self.run_blocking_git(|root| { + Ok(crate::git::list_stashes(&root)? + .into_iter() + .map(|stash| WorkspaceGitStash { + index: stash.index, + message: stash.message, + }) + .collect()) + }) + .await + } + + async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()> { + self.run_blocking_git(move |root| { + crate::git::stash(&root, request.message.as_deref(), request.include_untracked) + }) + .await + } +} + +#[async_trait] +impl WorkspaceGitWorktreeProvider for LocalWorkspaceBackend { + async fn list_worktrees(&self) -> Result> { + self.run_blocking_git(|root| { + Ok(crate::git::list_worktrees(&root)? + .into_iter() + .map(|worktree| WorkspaceGitWorktree { + path: worktree.path, + branch: worktree.branch, + is_bare: worktree.is_bare, + is_detached: worktree.is_detached, + }) + .collect()) + }) + .await + } + + async fn create_worktree( + &self, + request: WorkspaceGitCreateWorktreeRequest, + ) -> Result { + let branch = request.branch; + let path = request + .path + .map(|path| { + let path = PathBuf::from(path); + if path.is_absolute() { + path + } else { + self.root.join(path) + } + }) + .unwrap_or_else(|| default_local_worktree_path(&self.root, &branch)); + let display_path = path.display().to_string(); + let new_branch = request.new_branch; + let branch_for_git = branch.clone(); + + self.run_blocking_git(move |root| { + crate::git::create_worktree(&root, &branch_for_git, &path, new_branch) + }) + .await?; + + Ok(WorkspaceGitWorktreeMutation { + path: display_path, + branch: Some(branch), + }) + } + + async fn remove_worktree( + &self, + request: WorkspaceGitRemoveWorktreeRequest, + ) -> Result { + let path = PathBuf::from(request.path); + let display_path = path.display().to_string(); + let force = request.force; + + self.run_blocking_git(move |root| crate::git::remove_worktree(&root, &path, force)) + .await?; + + Ok(WorkspaceGitWorktreeMutation { + path: display_path, + branch: None, + }) + } +} + +#[async_trait] +impl WorkspaceCommandRunner for LocalWorkspaceBackend { + async fn exec(&self, request: CommandRequest) -> Result { + #[cfg(windows)] + if let Some(output) = + crate::tools::builtin::bash::maybe_execute_simple_windows_http_command(&request.command) + .await + { + let exit_code = output + .metadata + .as_ref() + .and_then(|m| m.get("exit_code")) + .and_then(|v| v.as_i64()) + .map(|v| v as i32) + .unwrap_or(if output.success { 0 } else { -1 }); + return Ok(CommandOutput { + output: output.content, + exit_code, + timed_out: false, + }); + } + + let timeout_secs = request.timeout_ms / 1000; + let mut child = crate::tools::builtin::bash::spawn_shell( + &request.command, + &self.root, + request.env.as_deref(), + ) + .map_err(|e| anyhow!("Failed to spawn shell: {}", e))?; + + let (output, timed_out) = crate::tools::process::read_process_output( + &mut child, + timeout_secs, + request.output_observer.as_deref(), + ) + .await; + + if timed_out { + return Ok(CommandOutput { + output, + exit_code: -1, + timed_out: true, + }); + } + + let status = child + .wait() + .await + .map_err(|e| anyhow!("Failed to wait for shell: {}", e))?; + let exit_code = status.code().unwrap_or(-1); + + Ok(CommandOutput { + output, + exit_code, + timed_out: false, + }) + } +} + +impl LocalWorkspaceBackend { + async fn run_blocking_git(&self, operation: F) -> Result + where + T: Send + 'static, + F: FnOnce(PathBuf) -> Result + Send + 'static, + { + let root = self.root.clone(); + tokio::task::spawn_blocking(move || operation(root)) + .await + .map_err(|e| anyhow!("Git worker failed: {}", e))? + } + + async fn run_git_command(&self, args: Vec) -> Result<(bool, String, String)> { + tokio::task::spawn_blocking(crate::git::ensure_git_installed) + .await + .map_err(|e| anyhow!("Git worker failed: {}", e))??; + + let output = tokio::process::Command::new("git") + .arg("-C") + .arg(self.root.as_os_str()) + .args(&args) + .output() + .await + .map_err(|e| anyhow!("Failed to execute git: {}", e))?; + + Ok(( + output.status.success(), + String::from_utf8_lossy(&output.stdout).to_string(), + String::from_utf8_lossy(&output.stderr).to_string(), + )) + } +} + +fn parse_git_remote_line(line: &str) -> Option { + let mut parts = line.split_whitespace(); + let name = parts.next()?; + let url = parts.next()?; + let direction = parts + .next() + .unwrap_or_default() + .trim_start_matches('(') + .trim_end_matches(')'); + + Some(WorkspaceGitRemote { + name: name.to_string(), + url: url.to_string(), + direction: direction.to_string(), + }) +} + +fn default_local_worktree_path(root: &Path, branch: &str) -> PathBuf { + let repo_name = root + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| "repo".to_string()); + root.parent() + .unwrap_or(root) + .join(format!("{repo_name}-{branch}")) +} + +pub(super) fn normalize_local_path(root: &Path, input: &str) -> Result { + let input = default_path_input(input); + let candidate = Path::new(input); + + if candidate.is_absolute() { + let root = normalize_absolute_path(root)?; + let target = normalize_absolute_path(candidate)?; + if !target.starts_with(&root) { + bail!( + "Workspace boundary violation: path '{}' escapes workspace '{}'", + input, + root.display() + ); + } + let relative = target + .strip_prefix(&root) + .map_err(|_| anyhow!("Failed to compute workspace-relative path"))?; + return Ok(pathbuf_to_workspace_path(relative)); + } + + if has_windows_path_prefix(input) { + bail!("Absolute paths are not supported by this workspace backend"); + } + + let normalized_input = input.replace('\\', "/"); + let path = Path::new(&normalized_input); + if path.is_absolute() { + bail!("Absolute paths are not supported by this workspace backend"); + } + + let relative = normalize_relative_path(path)?; + Ok(pathbuf_to_workspace_path(&relative)) +} + +fn normalize_absolute_path(path: &Path) -> Result { + let lexical = normalize_absolute_path_lexical(path)?; + if let Ok(canonical) = lexical.canonicalize() { + return Ok(canonical); + } + + let mut current = lexical.as_path(); + let mut suffix = Vec::new(); + while !current.exists() { + let Some(file_name) = current.file_name() else { + return Ok(lexical); + }; + suffix.push(file_name.to_os_string()); + let Some(parent) = current.parent() else { + return Ok(lexical); + }; + current = parent; + } + + let mut normalized = current.canonicalize().unwrap_or_else(|_| { + normalize_absolute_path_lexical(current).unwrap_or_else(|_| current.into()) + }); + for part in suffix.iter().rev() { + normalized.push(part); + } + Ok(normalized) +} + +fn normalize_absolute_path_lexical(path: &Path) -> Result { + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::Prefix(prefix) => out.push(prefix.as_os_str()), + Component::RootDir => out.push(Path::new(std::path::MAIN_SEPARATOR_STR)), + Component::CurDir => {} + Component::Normal(part) => out.push(part), + Component::ParentDir => { + if !out.pop() { + bail!("Invalid absolute path"); + } + } + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::super::WorkspaceServices; + use super::*; + + #[tokio::test] + async fn local_backend_reads_writes_and_lists() { + let temp = tempfile::tempdir().unwrap(); + let services = WorkspaceServices::local(temp.path()); + let path = services.normalize_path("dir/file.txt").unwrap(); + + let written = services + .fs() + .write_text(&path, "hello\nworld\n") + .await + .unwrap(); + assert_eq!(written.bytes, 12); + assert_eq!(written.lines, 2); + + let content = services.fs().read_text(&path).await.unwrap(); + assert_eq!(content, "hello\nworld\n"); + + let dir = services.normalize_path("dir").unwrap(); + let entries = services.fs().list_dir(&dir).await.unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].name, "file.txt"); + } + + #[tokio::test] + async fn local_backend_searches_glob_and_grep() { + let temp = tempfile::tempdir().unwrap(); + let services = WorkspaceServices::local(temp.path()); + services + .fs() + .write_text( + &services.normalize_path("src/main.rs").unwrap(), + "fn main() {\n println!(\"hello\");\n}\n", + ) + .await + .unwrap(); + services + .fs() + .write_text( + &services.normalize_path("README.md").unwrap(), + "hello from docs\n", + ) + .await + .unwrap(); + + let search = services.search().expect("local backend supports search"); + let glob = search + .glob(WorkspaceGlobRequest { + base: services.normalize_path("src").unwrap(), + pattern: "*.rs".to_string(), + }) + .await + .unwrap(); + assert_eq!(glob.matches[0].as_str(), "src/main.rs"); + + let grep = search + .grep(WorkspaceGrepRequest { + base: WorkspacePath::root(), + pattern: "hello".to_string(), + glob: Some("**/*.rs".to_string()), + context_lines: 0, + case_insensitive: false, + max_output_size: 1024, + }) + .await + .unwrap(); + assert_eq!(grep.match_count, 1); + assert_eq!(grep.file_count, 1); + assert!(grep.output.contains("src/main.rs:2")); + } + + #[test] + fn local_backend_rejects_absolute_paths_outside_workspace() { + let temp = tempfile::tempdir().unwrap(); + let services = WorkspaceServices::local(temp.path()); + let outside = temp.path().parent().unwrap().join("secret.txt"); + let err = services + .normalize_path(outside.to_str().unwrap()) + .expect_err("outside absolute path should be rejected"); + assert!(err.to_string().contains("escapes workspace")); + } + + #[test] + fn local_backend_rejects_backslash_parent_escape() { + let temp = tempfile::tempdir().unwrap(); + let services = WorkspaceServices::local(temp.path()); + let err = services + .normalize_path(r"..\secret.txt") + .expect_err("backslash parent traversal should be rejected"); + assert!(err.to_string().contains("escapes workspace")); + } + + #[test] + fn local_backend_allows_absolute_paths_inside_workspace() { + let temp = tempfile::tempdir().unwrap(); + let services = WorkspaceServices::local(temp.path()); + let absolute = temp.path().join("src/main.rs"); + let path = services + .normalize_path(absolute.to_str().unwrap()) + .expect("absolute path inside workspace should normalize"); + assert_eq!(path.as_str(), "src/main.rs"); + } +} diff --git a/core/src/workspace/mod.rs b/core/src/workspace/mod.rs new file mode 100644 index 00000000..86ccf301 --- /dev/null +++ b/core/src/workspace/mod.rs @@ -0,0 +1,854 @@ +//! Workspace capability abstractions. +//! +//! Built-in tools expose stable model-facing contracts (`read`, `write`, `ls`, +//! `bash`, ...). The concrete place where those operations happen is supplied +//! by a workspace capability backend. The default backend is the local +//! filesystem (see [`LocalWorkspaceBackend`]); hosts can provide remote, +//! browser, DFS, or container-backed implementations by assembling +//! [`WorkspaceServices`] through [`WorkspaceServicesBuilder`]. + +mod local; + +pub use local::LocalWorkspaceBackend; + +use anyhow::{anyhow, bail, Result}; +use async_trait::async_trait; +use std::collections::HashMap; +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; + +/// Identity and display metadata for a workspace. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceRef { + /// Stable workspace identifier used by host backends. + pub id: String, + /// Human-readable root shown in tool output. + pub display_root: String, +} + +impl WorkspaceRef { + pub fn new(id: impl Into, display_root: impl Into) -> Self { + Self { + id: id.into(), + display_root: display_root.into(), + } + } +} + +/// A normalized virtual path inside a workspace. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct WorkspacePath { + inner: String, +} + +impl WorkspacePath { + pub fn root() -> Self { + Self { + inner: ".".to_string(), + } + } + + pub fn from_normalized(path: impl Into) -> Self { + let path = path.into(); + let path = path.trim_matches('/'); + if path.is_empty() || path == "." { + Self::root() + } else { + Self { + inner: path.replace('\\', "/"), + } + } + } + + pub fn as_str(&self) -> &str { + &self.inner + } + + pub fn is_root(&self) -> bool { + self.inner == "." + } +} + +/// Workspace capability flags used to gate which built-in tools are registered. +/// +/// Each flag corresponds to a provider trait on [`WorkspaceServices`]; flags +/// without a backing provider are deliberately omitted so the surface stays +/// minimal until a real consumer appears. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WorkspaceCapabilities { + pub read: bool, + pub write: bool, + pub exec: bool, + pub search: bool, + pub git: bool, +} + +impl WorkspaceCapabilities { + pub fn local_default() -> Self { + Self { + read: true, + write: true, + exec: true, + search: true, + git: true, + } + } + + pub fn read_write() -> Self { + Self { + read: true, + write: true, + exec: false, + search: false, + git: false, + } + } +} + +impl Default for WorkspaceCapabilities { + fn default() -> Self { + Self::read_write() + } +} + +/// Directory entry kind. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkspaceFileType { + File, + Directory, + Symlink, + Unknown, +} + +impl WorkspaceFileType { + pub fn as_tool_kind(self) -> &'static str { + match self { + Self::File => "file", + Self::Directory => "dir", + Self::Symlink => "link", + Self::Unknown => "unknown", + } + } +} + +/// Directory entry returned by a workspace backend. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceDirEntry { + pub name: String, + pub kind: WorkspaceFileType, + pub size: u64, +} + +/// Result metadata for a write operation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceWriteOutcome { + pub bytes: usize, + pub lines: usize, +} + +/// Glob request for workspace-backed search. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceGlobRequest { + pub base: WorkspacePath, + pub pattern: String, +} + +/// Glob result returned by a workspace search provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceGlobResult { + pub matches: Vec, +} + +/// Grep request for workspace-backed search. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceGrepRequest { + pub base: WorkspacePath, + pub pattern: String, + pub glob: Option, + pub context_lines: usize, + pub case_insensitive: bool, + pub max_output_size: usize, +} + +/// Grep result returned by a workspace search provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceGrepResult { + pub output: String, + pub match_count: usize, + pub file_count: usize, + pub truncated: bool, +} + +/// Repository status returned by a workspace Git provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceGitStatus { + pub branch: String, + pub commit: String, + pub is_worktree: bool, + pub is_dirty: bool, + pub dirty_count: usize, +} + +/// Commit information returned by a workspace Git provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceGitCommit { + pub id: String, + pub message: String, + pub author: String, + pub date: String, +} + +/// Branch information returned by a workspace Git provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceGitBranch { + pub name: String, + pub is_current: bool, +} + +/// Branch creation request for a workspace Git provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceGitCreateBranchRequest { + pub name: String, + pub base: String, +} + +/// Checkout request for a workspace Git provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceGitCheckoutRequest { + pub refspec: String, + pub force: bool, +} + +/// Checkout output returned by a workspace Git provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceGitCheckoutOutput { + pub stdout: String, +} + +/// Diff request for a workspace Git provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceGitDiffRequest { + pub target: Option, +} + +/// Stash information returned by a workspace Git provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceGitStash { + pub index: usize, + pub message: String, +} + +/// Stash request for a workspace Git provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceGitStashRequest { + pub message: Option, + pub include_untracked: bool, +} + +/// Remote information returned by a workspace Git provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceGitRemote { + pub name: String, + pub url: String, + pub direction: String, +} + +/// Worktree information returned by a workspace Git provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceGitWorktree { + pub path: String, + pub branch: String, + pub is_bare: bool, + pub is_detached: bool, +} + +/// Worktree creation request for a workspace Git provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceGitCreateWorktreeRequest { + pub branch: String, + pub path: Option, + pub new_branch: bool, +} + +/// Worktree removal request for a workspace Git provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceGitRemoveWorktreeRequest { + pub path: String, + pub force: bool, +} + +/// Mutation result for workspace Git worktree operations. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceGitWorktreeMutation { + pub path: String, + pub branch: Option, +} + +/// Observer that receives streaming output deltas from a workspace command. +/// +/// Backend implementations call this on each chunk of stdout/stderr they +/// observe. Tool layers wire host event channels behind this trait, so the +/// workspace abstraction does not depend on any tool event type. +#[async_trait] +pub trait CommandOutputObserver: Send + Sync { + async fn on_output_delta(&self, delta: &str); +} + +/// Command execution request. +#[derive(Clone)] +pub struct CommandRequest { + pub command: String, + pub timeout_ms: u64, + pub output_observer: Option>, + pub env: Option>>, +} + +impl std::fmt::Debug for CommandRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CommandRequest") + .field("command", &self.command) + .field("timeout_ms", &self.timeout_ms) + .field("output_observer", &self.output_observer.is_some()) + .field("env", &self.env.as_ref().map(|env| env.len())) + .finish() + } +} + +/// Command execution output. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandOutput { + pub output: String, + pub exit_code: i32, + pub timed_out: bool, +} + +/// Normalizes and validates host-supplied paths before they reach a backend. +pub trait WorkspacePathResolver: Send + Sync { + fn normalize(&self, input: &str) -> Result; +} + +/// File operations available to built-in file tools. +/// +/// **Trait stability policy:** new methods added to this trait are a breaking +/// change for every external backend implementation. Until the workspace +/// extension story is stabilised, new methods will be added to a separate +/// `WorkspaceFileSystemExt` trait (with default implementations that fall back +/// to the core methods) rather than to this trait directly. Backend authors +/// can rely on this trait surface remaining additive only through extension +/// traits. +#[async_trait] +pub trait WorkspaceFileSystem: Send + Sync { + async fn read_text(&self, path: &WorkspacePath) -> Result; + async fn write_text( + &self, + path: &WorkspacePath, + content: &str, + ) -> Result; + async fn list_dir(&self, path: &WorkspacePath) -> Result>; +} + +/// Shell/command execution available to the `bash` tool. +#[async_trait] +pub trait WorkspaceCommandRunner: Send + Sync { + async fn exec(&self, request: CommandRequest) -> Result; +} + +/// Search operations available to `glob` and `grep`. +#[async_trait] +pub trait WorkspaceSearch: Send + Sync { + async fn glob(&self, request: WorkspaceGlobRequest) -> Result; + async fn grep(&self, request: WorkspaceGrepRequest) -> Result; +} + +/// Core Git operations supported by virtually every workspace Git backend. +/// +/// Optional features (stash, worktrees) live in separate traits so backends +/// like browser-side `isomorphic-git` can implement only what they support +/// instead of returning runtime "unsupported" errors. +#[async_trait] +pub trait WorkspaceGit: Send + Sync { + async fn is_repository(&self) -> Result; + async fn status(&self) -> Result; + async fn log(&self, max_count: usize) -> Result>; + async fn list_branches(&self) -> Result>; + async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()>; + async fn checkout( + &self, + request: WorkspaceGitCheckoutRequest, + ) -> Result; + async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result; + async fn list_remotes(&self) -> Result>; +} + +/// Optional Git stash operations. +/// +/// Browser-side libraries such as `isomorphic-git` do not implement stash; +/// backends that cannot stash simply do not implement this trait. +#[async_trait] +pub trait WorkspaceGitStashProvider: Send + Sync { + async fn list_stashes(&self) -> Result>; + async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()>; +} + +/// Optional Git worktree operations. +/// +/// Worktrees are a local-filesystem concept and are typically not supported +/// by remote or browser-backed git providers. +#[async_trait] +pub trait WorkspaceGitWorktreeProvider: Send + Sync { + async fn list_worktrees(&self) -> Result>; + async fn create_worktree( + &self, + request: WorkspaceGitCreateWorktreeRequest, + ) -> Result; + async fn remove_worktree( + &self, + request: WorkspaceGitRemoveWorktreeRequest, + ) -> Result; +} + +/// The host-provided workspace capability bundle used by tool execution. +pub struct WorkspaceServices { + workspace_ref: WorkspaceRef, + capabilities: WorkspaceCapabilities, + path_resolver: Arc, + file_system: Arc, + command_runner: Option>, + search: Option>, + git: Option>, + git_stash: Option>, + git_worktree: Option>, + /// Default timeout applied to non-bash workspace operations (file system, + /// search, git). Bash uses its own per-call timeout in [`CommandRequest`]. + /// `None` means no enforced timeout — appropriate for the local backend. + operation_timeout: Option, + local_root: Option, +} + +impl std::fmt::Debug for WorkspaceServices { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WorkspaceServices") + .field("workspace_ref", &self.workspace_ref) + .field("capabilities", &self.capabilities) + .field("command_runner", &self.command_runner.is_some()) + .field("search", &self.search.is_some()) + .field("git", &self.git.is_some()) + .field("git_stash", &self.git_stash.is_some()) + .field("git_worktree", &self.git_worktree.is_some()) + .field("local_root", &self.local_root) + .finish() + } +} + +impl WorkspaceServices { + pub(crate) fn new_with_git( + workspace_ref: WorkspaceRef, + mut capabilities: WorkspaceCapabilities, + path_resolver: Arc, + file_system: Arc, + command_runner: Option>, + search: Option>, + git: Option>, + ) -> Self { + if command_runner.is_none() { + capabilities.exec = false; + } + if search.is_none() { + capabilities.search = false; + } + if git.is_none() { + capabilities.git = false; + } + Self { + workspace_ref, + capabilities, + path_resolver, + file_system, + command_runner, + search, + git, + git_stash: None, + git_worktree: None, + operation_timeout: None, + local_root: None, + } + } + + pub fn builder( + workspace_ref: WorkspaceRef, + file_system: Arc, + ) -> WorkspaceServicesBuilder { + WorkspaceServicesBuilder::new(workspace_ref, file_system) + } + + pub fn local(root: impl Into) -> Arc { + let backend = Arc::new(LocalWorkspaceBackend::new(root.into())); + let workspace_ref = WorkspaceRef::new( + backend.root.display().to_string(), + backend.root.display().to_string(), + ); + let path_resolver: Arc = backend.clone(); + let file_system: Arc = backend.clone(); + let command_runner: Arc = backend.clone(); + let search: Arc = backend.clone(); + let git: Arc = backend.clone(); + let git_stash: Arc = backend.clone(); + let git_worktree: Arc = backend.clone(); + Arc::new(Self { + workspace_ref, + capabilities: WorkspaceCapabilities::local_default(), + path_resolver, + file_system, + command_runner: Some(command_runner), + search: Some(search), + git: Some(git), + git_stash: Some(git_stash), + git_worktree: Some(git_worktree), + operation_timeout: None, + local_root: Some(backend.root.clone()), + }) + } + + pub fn workspace_ref(&self) -> &WorkspaceRef { + &self.workspace_ref + } + + pub fn capabilities(&self) -> WorkspaceCapabilities { + self.capabilities + } + + pub fn normalize_path(&self, input: &str) -> Result { + self.path_resolver.normalize(input) + } + + pub fn fs(&self) -> Arc { + Arc::clone(&self.file_system) + } + + pub fn command_runner(&self) -> Option> { + self.command_runner.clone() + } + + pub fn search(&self) -> Option> { + self.search.clone() + } + + pub fn git(&self) -> Option> { + self.git.clone() + } + + pub fn git_stash(&self) -> Option> { + self.git_stash.clone() + } + + pub fn git_worktree(&self) -> Option> { + self.git_worktree.clone() + } + + /// Default timeout applied to non-bash workspace operations. + /// + /// `None` means no enforced timeout. Backends that may stall (remote, + /// browser, DFS) should set this so tools using [`Self::run_with_timeout`] + /// surface a timeout error instead of letting the agent loop hang. + pub fn operation_timeout(&self) -> Option { + self.operation_timeout + } + + /// Run a workspace future under the configured operation timeout. + /// + /// Tools that route through file system / search / git providers should + /// wrap their calls with this helper so non-local backends never stall + /// the agent loop indefinitely. + pub async fn run_with_timeout(&self, op: &'static str, fut: F) -> Result + where + F: std::future::Future>, + { + match self.operation_timeout { + Some(d) => tokio::time::timeout(d, fut) + .await + .map_err(|_| anyhow!("workspace operation '{}' timed out after {:?}", op, d))?, + None => fut.await, + } + } + + pub fn local_root(&self) -> Option<&Path> { + self.local_root.as_deref() + } + + pub fn display_path(&self, path: &WorkspacePath) -> String { + if path.is_root() { + return self.workspace_ref.display_root.clone(); + } + + let root = self.workspace_ref.display_root.trim_end_matches('/'); + if root.is_empty() { + path.as_str().to_string() + } else { + format!("{root}/{}", path.as_str()) + } + } +} + +/// Builder for assembling workspace services without constructor arity churn. +pub struct WorkspaceServicesBuilder { + workspace_ref: WorkspaceRef, + capabilities: WorkspaceCapabilities, + path_resolver: Arc, + file_system: Arc, + command_runner: Option>, + search: Option>, + git: Option>, + git_stash: Option>, + git_worktree: Option>, + operation_timeout: Option, +} + +impl WorkspaceServicesBuilder { + pub fn new(workspace_ref: WorkspaceRef, file_system: Arc) -> Self { + Self { + workspace_ref, + capabilities: WorkspaceCapabilities::read_write(), + path_resolver: Arc::new(VirtualPathResolver), + file_system, + command_runner: None, + search: None, + git: None, + git_stash: None, + git_worktree: None, + operation_timeout: None, + } + } + + pub fn capabilities(mut self, capabilities: WorkspaceCapabilities) -> Self { + self.capabilities = capabilities; + self + } + + pub fn command_runner(mut self, command_runner: Arc) -> Self { + self.capabilities.exec = true; + self.command_runner = Some(command_runner); + self + } + + pub fn search(mut self, search: Arc) -> Self { + self.capabilities.search = true; + self.search = Some(search); + self + } + + pub fn git(mut self, git: Arc) -> Self { + self.capabilities.git = true; + self.git = Some(git); + self + } + + pub fn git_stash(mut self, git_stash: Arc) -> Self { + self.git_stash = Some(git_stash); + self + } + + pub fn git_worktree(mut self, git_worktree: Arc) -> Self { + self.git_worktree = Some(git_worktree); + self + } + + /// Apply a default timeout to non-bash workspace operations (file system, + /// search, git). Backends that may stall — remote, browser, DFS — should + /// set this so tools surface a timeout error rather than hanging. + pub fn operation_timeout(mut self, timeout: std::time::Duration) -> Self { + self.operation_timeout = Some(timeout); + self + } + + pub fn build(self) -> Arc { + let mut services = WorkspaceServices::new_with_git( + self.workspace_ref, + self.capabilities, + self.path_resolver, + self.file_system, + self.command_runner, + self.search, + self.git, + ); + services.git_stash = self.git_stash; + services.git_worktree = self.git_worktree; + services.operation_timeout = self.operation_timeout; + Arc::new(services) + } +} + +/// Lexical resolver suitable for virtual/browser/DFS workspaces. +#[derive(Debug, Default)] +pub struct VirtualPathResolver; + +impl WorkspacePathResolver for VirtualPathResolver { + fn normalize(&self, input: &str) -> Result { + normalize_virtual_path(input) + } +} + +fn normalize_virtual_path(input: &str) -> Result { + let input = default_path_input(input); + if has_windows_path_prefix(input) { + bail!("Absolute paths are not supported by this workspace backend"); + } + + let normalized_input = input.replace('\\', "/"); + let path = Path::new(&normalized_input); + if path.is_absolute() { + bail!("Absolute paths are not supported by this workspace backend"); + } + + let relative = normalize_relative_path(path)?; + Ok(pathbuf_to_workspace_path(&relative)) +} + +fn default_path_input(input: &str) -> &str { + let trimmed = input.trim(); + if trimmed.is_empty() { + "." + } else { + trimmed + } +} + +fn has_windows_path_prefix(input: &str) -> bool { + let bytes = input.as_bytes(); + if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' { + return true; + } + + input.starts_with("\\\\") || input.starts_with("//") +} + +fn validate_relative_pattern(pattern: &str, label: &str) -> Result<()> { + let pattern = pattern.trim(); + if pattern.is_empty() { + bail!("{label} cannot be empty"); + } + if has_windows_path_prefix(pattern) || Path::new(pattern).is_absolute() { + bail!("{label} must be relative to the workspace"); + } + + let normalized = pattern.replace('\\', "/"); + if normalized.split('/').any(|component| component == "..") { + bail!("{label} must not contain parent directory traversal"); + } + + Ok(()) +} + +fn normalize_relative_path(path: &Path) -> Result { + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::Normal(part) => out.push(part), + Component::ParentDir => { + if !out.pop() { + bail!("Workspace boundary violation: path escapes workspace"); + } + } + Component::RootDir | Component::Prefix(_) => { + bail!("Absolute paths are not supported by this workspace backend"); + } + } + } + Ok(out) +} + +fn pathbuf_to_workspace_path(path: &Path) -> WorkspacePath { + let display = path.to_string_lossy().replace('\\', "/"); + WorkspacePath::from_normalized(display) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn virtual_resolver_normalizes_relative_paths() { + let resolver = VirtualPathResolver; + let path = resolver.normalize("./src/../README.md").unwrap(); + assert_eq!(path.as_str(), "README.md"); + } + + #[test] + fn virtual_resolver_normalizes_backslash_separators() { + let resolver = VirtualPathResolver; + let path = resolver.normalize(r"src\main.rs").unwrap(); + assert_eq!(path.as_str(), "src/main.rs"); + } + + #[test] + fn virtual_resolver_rejects_escape() { + let resolver = VirtualPathResolver; + let err = resolver.normalize("../secret.txt").unwrap_err(); + assert!(err.to_string().contains("escapes workspace")); + } + + #[test] + fn virtual_resolver_rejects_backslash_escape() { + let resolver = VirtualPathResolver; + let err = resolver.normalize(r"..\secret.txt").unwrap_err(); + assert!(err.to_string().contains("escapes workspace")); + } + + #[test] + fn virtual_resolver_rejects_absolute_paths() { + let resolver = VirtualPathResolver; + let err = resolver.normalize("/tmp/secret.txt").unwrap_err(); + assert!(err.to_string().contains("Absolute paths")); + } + + #[test] + fn virtual_resolver_rejects_windows_absolute_paths() { + let resolver = VirtualPathResolver; + + let drive_err = resolver.normalize(r"C:\Users\secret.txt").unwrap_err(); + assert!(drive_err.to_string().contains("Absolute paths")); + + let unc_err = resolver + .normalize(r"\\server\share\secret.txt") + .unwrap_err(); + assert!(unc_err.to_string().contains("Absolute paths")); + } + + #[test] + fn workspace_services_disable_exec_without_runner() { + struct EmptyFs; + + #[async_trait] + impl WorkspaceFileSystem for EmptyFs { + async fn read_text(&self, _path: &WorkspacePath) -> Result { + bail!("not implemented") + } + + async fn write_text( + &self, + _path: &WorkspacePath, + _content: &str, + ) -> Result { + bail!("not implemented") + } + + async fn list_dir(&self, _path: &WorkspacePath) -> Result> { + bail!("not implemented") + } + } + + let fs_backend: Arc = Arc::new(EmptyFs); + let services = WorkspaceServices::builder( + WorkspaceRef::new("virtual", "virtual://workspace"), + fs_backend, + ) + .capabilities(WorkspaceCapabilities { + exec: true, + ..WorkspaceCapabilities::read_write() + }) + .build(); + + assert!(!services.capabilities().exec); + assert!(services.command_runner().is_none()); + } +} diff --git a/core/tests/test_ahp_idle_with_llm.rs b/core/tests/test_ahp_idle_with_llm.rs index 6911d100..fb681915 100644 --- a/core/tests/test_ahp_idle_with_llm.rs +++ b/core/tests/test_ahp_idle_with_llm.rs @@ -518,7 +518,7 @@ fn test_minmax_llm_basic_completion() { let (api_key, base_url, model) = get_test_config(); - let client = OpenAiClient::new(api_key.into(), model).with_base_url(base_url); + let client = OpenAiClient::new(api_key, model).with_base_url(base_url); let messages = vec![Message::user( "Reply with exactly the word 'HELLO' in uppercase, nothing else.", @@ -539,7 +539,7 @@ fn test_minmax_llm_with_system_prompt() { let (api_key, base_url, model) = get_test_config(); - let client = OpenAiClient::new(api_key.into(), model).with_base_url(base_url); + let client = OpenAiClient::new(api_key, model).with_base_url(base_url); let system = "You are a security analyzer. When given a command, respond with only 'SAFE' or 'DANGEROUS'."; diff --git a/core/tests/test_web_search_headless.rs b/core/tests/test_web_search_headless.rs index 1abca651..5e8b294e 100644 --- a/core/tests/test_web_search_headless.rs +++ b/core/tests/test_web_search_headless.rs @@ -21,15 +21,9 @@ fn make_context(headless: Option) -> ToolContext { }) }); - ToolContext { - workspace: PathBuf::from("/tmp"), - session_id: Some("test-session".to_string()), - event_tx: None, - agent_event_tx: None, - search_config, - sandbox: None, - command_env: None, - } + let mut context = ToolContext::new(PathBuf::from("/tmp")).with_session_id("test-session"); + context.search_config = search_config; + context } #[tokio::test] diff --git a/core/tests/test_workspace_backend.rs b/core/tests/test_workspace_backend.rs new file mode 100644 index 00000000..0bc95e77 --- /dev/null +++ b/core/tests/test_workspace_backend.rs @@ -0,0 +1,676 @@ +use a3s_code_core::tools::{ArtifactStoreLimits, ToolExecutor}; +use a3s_code_core::{ + CommandOutput, CommandRequest, WorkspaceCommandRunner, WorkspaceDirEntry, WorkspaceFileSystem, + WorkspaceFileType, WorkspaceGit, WorkspaceGitBranch, WorkspaceGitCheckoutOutput, + WorkspaceGitCheckoutRequest, WorkspaceGitCommit, WorkspaceGitCreateBranchRequest, + WorkspaceGitCreateWorktreeRequest, WorkspaceGitDiffRequest, WorkspaceGitRemote, + WorkspaceGitRemoveWorktreeRequest, WorkspaceGitStash, WorkspaceGitStashProvider, + WorkspaceGitStashRequest, WorkspaceGitStatus, WorkspaceGitWorktree, + WorkspaceGitWorktreeMutation, WorkspaceGitWorktreeProvider, WorkspaceGlobRequest, + WorkspaceGlobResult, WorkspaceGrepRequest, WorkspaceGrepResult, WorkspacePath, WorkspaceRef, + WorkspaceSearch, WorkspaceServices, WorkspaceWriteOutcome, +}; +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use serde_json::json; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +#[derive(Default)] +struct MemoryWorkspace { + files: RwLock>, +} + +impl MemoryWorkspace { + fn insert(&self, path: &str, content: &str) { + self.files + .write() + .unwrap() + .insert(path.to_string(), content.to_string()); + } + + fn read_raw(&self, path: &str) -> Option { + self.files.read().unwrap().get(path).cloned() + } +} + +#[async_trait] +impl WorkspaceFileSystem for MemoryWorkspace { + async fn read_text(&self, path: &WorkspacePath) -> Result { + self.files + .read() + .unwrap() + .get(path.as_str()) + .cloned() + .ok_or_else(|| anyhow!("missing remote file: {}", path.as_str())) + } + + async fn write_text( + &self, + path: &WorkspacePath, + content: &str, + ) -> Result { + self.insert(path.as_str(), content); + Ok(WorkspaceWriteOutcome { + bytes: content.len(), + lines: content.lines().count(), + }) + } + + async fn list_dir(&self, path: &WorkspacePath) -> Result> { + let prefix = if path.is_root() { + String::new() + } else { + format!("{}/", path.as_str()) + }; + let files = self.files.read().unwrap(); + let mut entries = HashMap::::new(); + + for (file_path, content) in files.iter() { + if !file_path.starts_with(&prefix) { + continue; + } + + let remaining = &file_path[prefix.len()..]; + if remaining.is_empty() { + continue; + } + + let (name, kind, size) = match remaining.split_once('/') { + Some((dir, _)) => (dir.to_string(), WorkspaceFileType::Directory, 0), + None => ( + remaining.to_string(), + WorkspaceFileType::File, + content.len() as u64, + ), + }; + + entries + .entry(name.clone()) + .or_insert(WorkspaceDirEntry { name, kind, size }); + } + + Ok(entries.into_values().collect()) + } +} + +#[async_trait] +impl WorkspaceSearch for MemoryWorkspace { + async fn glob(&self, request: WorkspaceGlobRequest) -> Result { + let pattern = + glob::Pattern::new(&request.pattern).map_err(|e| anyhow!("invalid glob: {}", e))?; + let base_prefix = if request.base.is_root() { + String::new() + } else { + format!("{}/", request.base.as_str()) + }; + let files = self.files.read().unwrap(); + let mut matches = Vec::new(); + + for file_path in files.keys() { + if !file_path.starts_with(&base_prefix) { + continue; + } + let remaining = &file_path[base_prefix.len()..]; + if pattern.matches(remaining) { + matches.push(WorkspacePath::from_normalized(file_path.clone())); + } + } + + matches.sort_by(|a, b| a.as_str().cmp(b.as_str())); + Ok(WorkspaceGlobResult { matches }) + } + + async fn grep(&self, request: WorkspaceGrepRequest) -> Result { + let pattern = if request.case_insensitive { + format!("(?i){}", request.pattern) + } else { + request.pattern.clone() + }; + let regex = regex::Regex::new(&pattern)?; + let glob = request + .glob + .as_deref() + .map(glob::Pattern::new) + .transpose() + .map_err(|e| anyhow!("invalid glob: {}", e))?; + let base_prefix = if request.base.is_root() { + String::new() + } else { + format!("{}/", request.base.as_str()) + }; + let files = self.files.read().unwrap(); + let mut output = String::new(); + let mut match_count = 0; + let mut matched_files = 0; + + for (file_path, content) in files.iter() { + if !file_path.starts_with(&base_prefix) { + continue; + } + let remaining = &file_path[base_prefix.len()..]; + if glob.as_ref().is_some_and(|glob| !glob.matches(remaining)) { + continue; + } + + let mut file_matched = false; + for (idx, line) in content.lines().enumerate() { + if regex.is_match(line) { + file_matched = true; + match_count += 1; + output.push_str(&format!(">{}:{}: {}\n", file_path, idx + 1, line)); + } + } + if file_matched { + matched_files += 1; + } + } + + Ok(WorkspaceGrepResult { + output, + match_count, + file_count: matched_files, + truncated: false, + }) + } +} + +#[derive(Default)] +struct RecordingRunner { + calls: RwLock>, +} + +impl RecordingRunner { + fn calls(&self) -> Vec<(String, u64)> { + self.calls.read().unwrap().clone() + } +} + +#[async_trait] +impl WorkspaceCommandRunner for RecordingRunner { + async fn exec(&self, request: CommandRequest) -> Result { + self.calls + .write() + .unwrap() + .push((request.command.clone(), request.timeout_ms)); + Ok(CommandOutput { + output: format!("remote runner executed: {}\n", request.command), + exit_code: 0, + timed_out: false, + }) + } +} + +#[derive(Default)] +struct RecordingGit { + calls: RwLock>, +} + +impl RecordingGit { + fn calls(&self) -> Vec { + self.calls.read().unwrap().clone() + } + + fn record(&self, call: impl Into) { + self.calls.write().unwrap().push(call.into()); + } +} + +#[async_trait] +impl WorkspaceGit for RecordingGit { + async fn is_repository(&self) -> Result { + self.record("is_repository"); + Ok(true) + } + + async fn status(&self) -> Result { + self.record("status"); + Ok(WorkspaceGitStatus { + branch: "remote-main".to_string(), + commit: "abcdef1234567890 init".to_string(), + is_worktree: false, + is_dirty: true, + dirty_count: 2, + }) + } + + async fn log(&self, max_count: usize) -> Result> { + self.record(format!("log:{max_count}")); + Ok(vec![WorkspaceGitCommit { + id: "abcdef1234567890".to_string(), + message: "Initial commit".to_string(), + author: "A3S".to_string(), + date: "2026-05-18 10:00".to_string(), + }]) + } + + async fn list_branches(&self) -> Result> { + self.record("list_branches"); + Ok(vec![ + WorkspaceGitBranch { + name: "remote-main".to_string(), + is_current: true, + }, + WorkspaceGitBranch { + name: "feature-x".to_string(), + is_current: false, + }, + ]) + } + + async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()> { + self.record(format!("create_branch:{}:{}", request.name, request.base)); + Ok(()) + } + + async fn checkout( + &self, + request: WorkspaceGitCheckoutRequest, + ) -> Result { + self.record(format!("checkout:{}:{}", request.refspec, request.force)); + Ok(WorkspaceGitCheckoutOutput { + stdout: "Switched remotely\n".to_string(), + }) + } + + async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result { + self.record(format!( + "diff:{}", + request + .target + .unwrap_or_else(|| "".to_string()) + )); + Ok("diff --git a/src/main.rs b/src/main.rs\n".to_string()) + } + + async fn list_remotes(&self) -> Result> { + self.record("list_remotes"); + Ok(vec![ + WorkspaceGitRemote { + name: "origin".to_string(), + url: "ssh://example/repo.git".to_string(), + direction: "fetch".to_string(), + }, + WorkspaceGitRemote { + name: "origin".to_string(), + url: "ssh://example/repo.git".to_string(), + direction: "push".to_string(), + }, + ]) + } +} + +#[async_trait] +impl WorkspaceGitStashProvider for RecordingGit { + async fn list_stashes(&self) -> Result> { + self.record("list_stashes"); + Ok(vec![WorkspaceGitStash { + index: 0, + message: "WIP remote".to_string(), + }]) + } + + async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()> { + self.record(format!( + "stash:{}:{}", + request.message.unwrap_or_default(), + request.include_untracked + )); + Ok(()) + } +} + +#[async_trait] +impl WorkspaceGitWorktreeProvider for RecordingGit { + async fn list_worktrees(&self) -> Result> { + self.record("list_worktrees"); + Ok(vec![WorkspaceGitWorktree { + path: "dfs://workspace".to_string(), + branch: "remote-main".to_string(), + is_bare: false, + is_detached: false, + }]) + } + + async fn create_worktree( + &self, + request: WorkspaceGitCreateWorktreeRequest, + ) -> Result { + let path = request + .path + .unwrap_or_else(|| format!("dfs://workspace-{}", request.branch)); + self.record(format!( + "create_worktree:{}:{}:{}", + request.branch, path, request.new_branch + )); + Ok(WorkspaceGitWorktreeMutation { + path, + branch: Some(request.branch), + }) + } + + async fn remove_worktree( + &self, + request: WorkspaceGitRemoveWorktreeRequest, + ) -> Result { + self.record(format!( + "remove_worktree:{}:{}", + request.path, request.force + )); + Ok(WorkspaceGitWorktreeMutation { + path: request.path, + branch: None, + }) + } +} + +fn virtual_services(fs: Arc) -> Arc { + let fs_backend: Arc = fs; + WorkspaceServices::builder( + WorkspaceRef::new("browser-workspace", "browser://workspace"), + fs_backend, + ) + .build() +} + +fn virtual_services_with_runner( + fs: Arc, + runner: Arc, +) -> Arc { + let fs_backend: Arc = fs; + let runner_backend: Arc = runner; + WorkspaceServices::builder( + WorkspaceRef::new("dfs-workspace", "dfs://workspace"), + fs_backend, + ) + .command_runner(runner_backend) + .build() +} + +fn virtual_services_with_git( + fs: Arc, + git: Arc, +) -> Arc { + let fs_backend: Arc = fs; + let git_backend: Arc = git.clone(); + let stash_backend: Arc = git.clone(); + let worktree_backend: Arc = git; + WorkspaceServices::builder( + WorkspaceRef::new("git-workspace", "dfs://workspace"), + fs_backend, + ) + .git(git_backend) + .git_stash(stash_backend) + .git_worktree(worktree_backend) + .build() +} + +fn virtual_services_with_search(fs: Arc) -> Arc { + let fs_backend: Arc = fs.clone(); + let search_backend: Arc = fs; + WorkspaceServices::builder( + WorkspaceRef::new("search-workspace", "search://workspace"), + fs_backend, + ) + .search(search_backend) + .build() +} + +#[tokio::test] +async fn custom_workspace_file_tools_do_not_touch_local_filesystem() { + let local_placeholder = tempfile::tempdir().expect("local placeholder"); + let fs = Arc::new(MemoryWorkspace::default()); + fs.insert("src/main.rs", "fn main() {}\n"); + + let executor = ToolExecutor::new_with_workspace_services( + local_placeholder.path().to_string_lossy().to_string(), + virtual_services(Arc::clone(&fs)), + ); + + let definitions = executor.definitions(); + assert!(definitions.iter().any(|tool| tool.name == "read")); + assert!(definitions.iter().any(|tool| tool.name == "write")); + assert!(definitions.iter().any(|tool| tool.name == "ls")); + assert!(definitions.iter().any(|tool| tool.name == "edit")); + assert!(definitions.iter().any(|tool| tool.name == "patch")); + assert!(!definitions.iter().any(|tool| tool.name == "bash")); + assert!(!definitions.iter().any(|tool| tool.name == "grep")); + assert!(!definitions.iter().any(|tool| tool.name == "glob")); + assert!(!definitions.iter().any(|tool| tool.name == "git")); + + let read = executor + .execute("read", &json!({ "file_path": "src/main.rs" })) + .await + .expect("read tool"); + assert_eq!(read.exit_code, 0, "{}", read.output); + assert!(read.output.contains("fn main")); + + let write = executor + .execute( + "write", + &json!({ "file_path": "src/generated.rs", "content": "pub const VALUE: u8 = 7;\n" }), + ) + .await + .expect("write tool"); + assert_eq!(write.exit_code, 0, "{}", write.output); + assert_eq!( + fs.read_raw("src/generated.rs").as_deref(), + Some("pub const VALUE: u8 = 7;\n") + ); + assert!(!local_placeholder.path().join("src/generated.rs").exists()); + + let edit = executor + .execute( + "edit", + &json!({ + "file_path": "src/generated.rs", + "old_string": "VALUE: u8 = 7", + "new_string": "VALUE: u8 = 8" + }), + ) + .await + .expect("edit tool"); + assert_eq!(edit.exit_code, 0, "{}", edit.output); + assert_eq!( + fs.read_raw("src/generated.rs").as_deref(), + Some("pub const VALUE: u8 = 8;\n") + ); + + let patch = executor + .execute( + "patch", + &json!({ + "file_path": "src/generated.rs", + "diff": "@@ -1,1 +1,1 @@\n-pub const VALUE: u8 = 8;\n+pub const VALUE: u8 = 9;" + }), + ) + .await + .expect("patch tool"); + assert_eq!(patch.exit_code, 0, "{}", patch.output); + assert_eq!( + fs.read_raw("src/generated.rs").as_deref(), + Some("pub const VALUE: u8 = 9;\n") + ); + + let listing = executor + .execute("ls", &json!({ "path": "src" })) + .await + .expect("ls tool"); + assert_eq!(listing.exit_code, 0, "{}", listing.output); + assert!(listing.output.contains("main.rs")); + assert!(listing.output.contains("generated.rs")); +} + +#[tokio::test] +async fn custom_workspace_path_resolver_blocks_escape_before_backend_access() { + let fs = Arc::new(MemoryWorkspace::default()); + fs.insert("safe.txt", "ok\n"); + let executor = ToolExecutor::new_with_workspace_services_and_artifact_limits( + "/server/local-placeholder".to_string(), + virtual_services(fs), + ArtifactStoreLimits::default(), + ); + + let read = executor + .execute("read", &json!({ "file_path": "../secret.txt" })) + .await + .expect("read tool"); + assert_eq!(read.exit_code, 1); + assert!(read.output.contains("escapes workspace")); + + let write = executor + .execute( + "write", + &json!({ "file_path": "/tmp/secret.txt", "content": "nope" }), + ) + .await + .expect("write tool"); + assert_eq!(write.exit_code, 1); + assert!(write.output.contains("Absolute paths")); +} + +#[tokio::test] +async fn custom_workspace_runner_drives_bash_tool() { + let fs = Arc::new(MemoryWorkspace::default()); + let runner = Arc::new(RecordingRunner::default()); + let executor = ToolExecutor::new_with_workspace_services_and_artifact_limits( + "/server/local-placeholder".to_string(), + virtual_services_with_runner(fs, Arc::clone(&runner)), + ArtifactStoreLimits::default(), + ); + + let definitions = executor.definitions(); + assert!(definitions.iter().any(|tool| tool.name == "bash")); + + let result = executor + .execute("bash", &json!({ "command": "pwd && ls", "timeout": 1234 })) + .await + .expect("bash tool"); + assert_eq!(result.exit_code, 0, "{}", result.output); + assert_eq!(result.output, "remote runner executed: pwd && ls\n"); + assert_eq!(runner.calls(), vec![("pwd && ls".to_string(), 1234)]); +} + +#[tokio::test] +async fn custom_workspace_search_provider_drives_grep_and_glob_tools() { + let fs = Arc::new(MemoryWorkspace::default()); + fs.insert("src/main.rs", "fn main() {\n println!(\"Hello\");\n}\n"); + fs.insert("src/lib.rs", "pub fn helper() {}\n"); + fs.insert("README.md", "hello from docs\n"); + let executor = ToolExecutor::new_with_workspace_services_and_artifact_limits( + "/server/local-placeholder".to_string(), + virtual_services_with_search(Arc::clone(&fs)), + ArtifactStoreLimits::default(), + ); + + let definitions = executor.definitions(); + assert!(definitions.iter().any(|tool| tool.name == "grep")); + assert!(definitions.iter().any(|tool| tool.name == "glob")); + assert!(!definitions.iter().any(|tool| tool.name == "git")); + + let glob = executor + .execute("glob", &json!({ "pattern": "*.rs", "path": "src" })) + .await + .expect("glob tool"); + assert_eq!(glob.exit_code, 0, "{}", glob.output); + assert!(glob.output.contains("src/main.rs")); + assert!(glob.output.contains("src/lib.rs")); + assert!(!glob.output.contains("README.md")); + + let grep = executor + .execute( + "grep", + &json!({ "pattern": "hello", "path": ".", "glob": "**/*.rs", "-i": true }), + ) + .await + .expect("grep tool"); + assert_eq!(grep.exit_code, 0, "{}", grep.output); + assert!(grep.output.contains("src/main.rs:2")); + assert!(grep.output.contains("1 match(es) in 1 file(s)")); + assert!(!grep.output.contains("README.md")); +} + +#[tokio::test] +async fn custom_workspace_git_provider_drives_git_tool() { + let local_placeholder = tempfile::tempdir().expect("local placeholder"); + let fs = Arc::new(MemoryWorkspace::default()); + let git = Arc::new(RecordingGit::default()); + let executor = ToolExecutor::new_with_workspace_services_and_artifact_limits( + local_placeholder.path().to_string_lossy().to_string(), + virtual_services_with_git(fs, Arc::clone(&git)), + ArtifactStoreLimits::default(), + ); + + let definitions = executor.definitions(); + assert!(definitions.iter().any(|tool| tool.name == "git")); + assert!(!definitions.iter().any(|tool| tool.name == "bash")); + + let status = executor + .execute("git", &json!({ "command": "status" })) + .await + .expect("git status"); + assert_eq!(status.exit_code, 0, "{}", status.output); + assert!(status.output.contains("Workspace: dfs://workspace")); + assert!(status.output.contains("Branch: remote-main")); + assert!(status.output.contains("2 uncommitted change(s)")); + + let branch = executor + .execute( + "git", + &json!({ "command": "branch", "name": "feature-y", "base": "remote-main" }), + ) + .await + .expect("git branch"); + assert_eq!(branch.exit_code, 0, "{}", branch.output); + assert!(branch.output.contains("Created branch: feature-y")); + + let diff = executor + .execute("git", &json!({ "command": "diff", "target": "HEAD~1" })) + .await + .expect("git diff"); + assert_eq!(diff.exit_code, 0, "{}", diff.output); + assert!(diff.output.contains("diff --git")); + + let remote = executor + .execute("git", &json!({ "command": "remote" })) + .await + .expect("git remote"); + assert_eq!(remote.exit_code, 0, "{}", remote.output); + assert!(remote + .output + .contains("origin\tssh://example/repo.git (fetch)")); + + let worktree = executor + .execute( + "git", + &json!({ + "command": "worktree", + "subcommand": "create", + "name": "feature-y", + "path": "branches/feature-y", + "new_branch": false + }), + ) + .await + .expect("git worktree create"); + assert_eq!(worktree.exit_code, 0, "{}", worktree.output); + assert!(worktree + .output + .contains("Created worktree at: branches/feature-y")); + + assert_eq!( + git.calls(), + vec![ + "is_repository", + "status", + "is_repository", + "create_branch:feature-y:remote-main", + "is_repository", + "diff:HEAD~1", + "is_repository", + "list_remotes", + "is_repository", + "create_worktree:feature-y:branches/feature-y:false", + ] + ); +} diff --git a/sdk/node/README.md b/sdk/node/README.md index 39dda2ef..3c31bdba 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -52,6 +52,28 @@ Omit `allowedTools` to allow every registered session tool except `program`. Scripts can also be loaded from workspace-relative `.js` or `.mjs` files with `{ path: 'scripts/ptc/search.js' }`. +## Workspace Backends And Direct Files + +The default workspace backend is the local filesystem rooted at the session +workspace. SDK callers can pass the explicit typed backend now, using the same +option surface that remote, browser, DFS, and container-backed workspaces will +use: + +```js +const { Agent, LocalWorkspaceBackend } = require('@a3s-lab/code') + +const agent = await Agent.create('agent.acl') +const session = agent.session('/repo', { + workspaceBackend: new LocalWorkspaceBackend('/repo'), +}) + +await session.writeFile('notes.txt', 'one\ntwo\n') +await session.readFile('notes.txt') +await session.ls() +await session.editFile('notes.txt', 'one', 'uno') +await session.patchFile('notes.txt', '@@ -1,2 +1,2 @@\n uno\n-two\n+dos') +``` + ## Planning Events Planning is automatic by default. Prefer the explicit tri-state diff --git a/sdk/node/examples/basic/test_api_alignment.ts b/sdk/node/examples/basic/test_api_alignment.ts index e5199b53..8db90c92 100644 --- a/sdk/node/examples/basic/test_api_alignment.ts +++ b/sdk/node/examples/basic/test_api_alignment.ts @@ -21,7 +21,7 @@ import { fileURLToPath } from 'url'; const require = createRequire(import.meta.url); const a3sCode = require('@a3s-lab/code') as typeof import('@a3s-lab/code'); -const { Agent } = a3sCode; +const { Agent, LocalWorkspaceBackend } = a3sCode; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -69,12 +69,14 @@ const opts: SessionOptions = { continuationEnabled: undefined, maxContinuationTurns: undefined, planningMode: undefined, + workspaceBackend: new LocalWorkspaceBackend(process.cwd()), }; check('temperature field accepted', true); check('thinkingBudget field accepted', true); check('continuationEnabled field accepted', true); check('maxContinuationTurns field accepted', true); check('planningMode field accepted', true); +check('workspaceBackend field accepted', true); const opts2: SessionOptions = { temperature: 0.5, @@ -104,9 +106,17 @@ type ToolDefinitionsMethod = SessionApi['toolDefinitions']; const taskName: keyof Pick = 'task'; const tasksName: keyof Pick = 'tasks'; const toolDefinitionsName: keyof Pick = 'toolDefinitions'; +const writeFileName: keyof Pick = 'writeFile'; +const lsName: keyof Pick = 'ls'; +const editFileName: keyof Pick = 'editFile'; +const patchFileName: keyof Pick = 'patchFile'; check('task method type accepted', taskName === 'task'); check('tasks method type accepted', tasksName === 'tasks'); check('toolDefinitions method type accepted', toolDefinitionsName === 'toolDefinitions'); +check('writeFile method type accepted', writeFileName === 'writeFile'); +check('ls method type accepted', lsName === 'ls'); +check('editFile method type accepted', editFileName === 'editFile'); +check('patchFile method type accepted', patchFileName === 'patchFile'); const sessionForAgentOpts: SessionOptions = { role: 'Custom reviewer', diff --git a/sdk/node/examples/basic/test_real_config_env_sdk.mjs b/sdk/node/examples/basic/test_real_config_env_sdk.mjs index 183c20df..0e03a214 100644 --- a/sdk/node/examples/basic/test_real_config_env_sdk.mjs +++ b/sdk/node/examples/basic/test_real_config_env_sdk.mjs @@ -8,12 +8,12 @@ import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; -import { mkdtempSync } from 'node:fs'; +import { mkdtempSync, readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; const require = createRequire(import.meta.url); -const { Agent } = require('@a3s-lab/code'); +const { Agent, LocalWorkspaceBackend } = require('@a3s-lab/code'); const timeoutMs = Number(process.env.A3S_CODE_SDK_REAL_TIMEOUT_MS || '180000'); const runFullAgentSmoke = process.env.A3S_CODE_SDK_REAL_AGENT_SMOKE !== '0'; const runChildAgentSmoke = process.env.A3S_CODE_SDK_REAL_CHILD_AGENT_SMOKE === '1'; @@ -54,6 +54,7 @@ const session = agent.session(workspace, { permissionPolicy: { defaultDecision: 'allow' }, maxParseRetries: 1, circuitBreakerThreshold: 1, + workspaceBackend: new LocalWorkspaceBackend(workspace), }); const toolNames = await step('toolNames', () => session.toolNames()); @@ -68,6 +69,20 @@ assert.ok( 'program schema should be visible through toolDefinitions()', ); +const writeResult = await step('writeFile', () => session.writeFile('notes.txt', 'one\ntwo\n')); +assert.equal(writeResult.exitCode, 0, writeResult.output); +assert.match(await step('readFile', () => session.readFile('notes.txt')), /one/); +const lsResult = await step('ls', () => session.ls()); +assert.equal(lsResult.exitCode, 0, lsResult.output); +assert.match(lsResult.output, /notes\.txt/); +const editResult = await step('editFile', () => session.editFile('notes.txt', 'one', 'uno')); +assert.equal(editResult.exitCode, 0, editResult.output); +const patchResult = await step('patchFile', () => + session.patchFile('notes.txt', '@@ -1,2 +1,2 @@\n uno\n-two\n+dos'), +); +assert.equal(patchResult.exitCode, 0, patchResult.output); +assert.equal(readFileSync(join(workspace, 'notes.txt'), 'utf8'), 'uno\ndos\n'); + const programResult = await step('program', () => session.program({ source: ` export default async function run(ctx, inputs) { diff --git a/sdk/node/run_sdk_integration_tests.sh b/sdk/node/run_sdk_integration_tests.sh index 5e613ee3..227e8cde 100755 --- a/sdk/node/run_sdk_integration_tests.sh +++ b/sdk/node/run_sdk_integration_tests.sh @@ -14,7 +14,7 @@ if [[ ! -f "$CONFIG_FILE" ]]; then fi echo "===================================================================" -echo "SDK Integration Test: ConfirmationInheritance" +echo "SDK Integration Tests" echo "===================================================================" echo "Config: $CONFIG_FILE" echo "" @@ -40,7 +40,11 @@ if [[ ! -f "index.js" ]]; then exit 1 fi +npm test +npm run test:helpers +npx tsc --noEmit -p examples/tsconfig.json node test_confirmation_inheritance.mjs +node examples/basic/test_real_config_env_sdk.mjs echo "" # Test 2: Python SDK @@ -56,7 +60,7 @@ if [[ -f ".venv/bin/python3" ]]; then echo "Using Python from venv: $PYTHON_BIN" fi -if ! $PYTHON_BIN -c "import a3s_code" 2>/dev/null; then +if ! $PYTHON_BIN -c "from a3s_code import Agent, LocalWorkspaceBackend" 2>/dev/null; then echo "WARNING: Python SDK not built. Building with maturin develop..." if ! command -v maturin &> /dev/null; then echo "ERROR: maturin not found. Install with: pip install maturin" @@ -66,6 +70,7 @@ if ! $PYTHON_BIN -c "import a3s_code" 2>/dev/null; then fi $PYTHON_BIN test_confirmation_inheritance.py +$PYTHON_BIN tests/real_config_env_sdk.py echo "" echo "===================================================================" diff --git a/sdk/node/test-helpers.mjs b/sdk/node/test-helpers.mjs index ed02a132..1f0879c0 100644 --- a/sdk/node/test-helpers.mjs +++ b/sdk/node/test-helpers.mjs @@ -3,6 +3,7 @@ import mod from './index.js' assert.equal(typeof mod.builtinSkills, 'function') assert.equal(typeof mod.formatVerificationSummary, 'function') +assert.equal(typeof mod.LocalWorkspaceBackend, 'function') const skills = mod.builtinSkills() assert.equal(Array.isArray(skills), true) diff --git a/sdk/node/test.mjs b/sdk/node/test.mjs index 87bb768c..e7acb093 100644 --- a/sdk/node/test.mjs +++ b/sdk/node/test.mjs @@ -8,6 +8,7 @@ const requiredExports = [ 'Agent', 'Session', 'EventStream', + 'LocalWorkspaceBackend', 'builtinSkills', ] @@ -35,7 +36,27 @@ providers "anthropic" { `.trim() const agent = await mod.Agent.create(inlineConfig) -const session = agent.session(workspace, { permissionPolicy: { defaultDecision: 'allow' } }) +const session = agent.session(workspace, { + permissionPolicy: { defaultDecision: 'allow' }, + workspaceBackend: new mod.LocalWorkspaceBackend(workspace), +}) + +const write = await session.writeFile('notes.txt', 'one\ntwo\n') +assert.equal(write.exitCode, 0, write.output) + +const read = await session.readFile('notes.txt') +assert.equal(read.includes('one'), true, 'readFile should read from workspace backend') + +const listing = await session.ls() +assert.equal(listing.exitCode, 0, listing.output) +assert.equal(listing.output.includes('notes.txt'), true, 'ls should list workspace files') + +const edit = await session.editFile('notes.txt', 'one', 'uno') +assert.equal(edit.exitCode, 0, edit.output) + +const patch = await session.patchFile('notes.txt', '@@ -1,2 +1,2 @@\n uno\n-two\n+dos') +assert.equal(patch.exitCode, 0, patch.output) +assert.equal(fs.readFileSync(path.join(workspace, 'notes.txt'), 'utf8'), 'uno\ndos\n') const commands = session.listCommands() assert.equal(Array.isArray(commands), true, 'listCommands() should return an array') diff --git a/sdk/python/README.md b/sdk/python/README.md index 9a8426b5..60bf576e 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -60,6 +60,7 @@ from a3s_code import ( FileMemoryStore, FileSessionStore, HttpTransport, + LocalWorkspaceBackend, ) agent = Agent.create("agent.acl") @@ -96,7 +97,14 @@ if runs: session.cancel_run(runs[-1]["id"]) # Direct tools (bypass LLM) +opts = SessionOptions() +opts.workspace_backend = LocalWorkspaceBackend("/my-project") +session = agent.session("/my-project", opts) +session.write_file("notes.txt", "one\ntwo\n") session.read_file("src/main.py") +session.ls() +session.edit_file("notes.txt", "one", "uno") +session.patch_file("notes.txt", "@@ -1,2 +1,2 @@\n uno\n-two\n+dos") session.bash("pytest") session.glob("**/*.py") session.grep("TODO") diff --git a/sdk/python/tests/real_config_env_sdk.py b/sdk/python/tests/real_config_env_sdk.py index 613f612e..05842e52 100644 --- a/sdk/python/tests/real_config_env_sdk.py +++ b/sdk/python/tests/real_config_env_sdk.py @@ -11,7 +11,7 @@ import tempfile import time -from a3s_code import Agent, PermissionPolicy, SessionOptions +from a3s_code import Agent, LocalWorkspaceBackend, PermissionPolicy, SessionOptions RUN_FULL_AGENT_SMOKE = os.environ.get("A3S_CODE_SDK_REAL_AGENT_SMOKE") != "0" @@ -47,6 +47,7 @@ def step(name, fn): workspace = os.environ.get("A3S_CODE_SDK_REAL_WORKSPACE") or tempfile.mkdtemp( prefix="a3s-code-python-sdk-real-" ) +opts.workspace_backend = LocalWorkspaceBackend(workspace) print(f"[python-sdk-real] workspace={workspace}", flush=True) session = agent.session(workspace, opts) @@ -59,6 +60,26 @@ def step(name, fn): assert isinstance(tool_definitions, list) assert any(tool.get("name") == "program" for tool in tool_definitions) +write_result = step( + "write_file", + lambda: session.write_file("notes.txt", "one\ntwo\n"), +) +assert write_result.exit_code == 0, write_result.output +assert "one" in step("read_file", lambda: session.read_file("notes.txt")) +ls_result = step("ls", lambda: session.ls()) +assert ls_result.exit_code == 0, ls_result.output +assert "notes.txt" in ls_result.output +edit_result = step( + "edit_file", + lambda: session.edit_file("notes.txt", "one", "uno"), +) +assert edit_result.exit_code == 0, edit_result.output +patch_result = step( + "patch_file", + lambda: session.patch_file("notes.txt", "@@ -1,2 +1,2 @@\n uno\n-two\n+dos"), +) +assert patch_result.exit_code == 0, patch_result.output + program_result = step( "program", lambda: session.program( From 4fb66f0a6d4c2b56e7535b1d78988cf7fe180cb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 21:39:37 +0800 Subject: [PATCH 2/3] feat(workspace): S3-compatible workspace backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds S3WorkspaceBackend behind the new `s3` Cargo feature. The backend implements WorkspaceFileSystem against any S3-compatible endpoint (AWS S3, MinIO, RustFS, Cloudflare R2, Backblaze B2, ...) via the AWS Rust SDK. It deliberately does NOT implement WorkspaceCommandRunner, WorkspaceSearch, or any git provider trait — capability gating then prevents bash/grep/glob/git from being registered when the backend is in use, so the model never sees tools the backend cannot service. Includes: - core/src/workspace/s3.rs: S3WorkspaceBackend + S3BackendConfig builder (endpoint / region / session_token / force_path_style / request_timeout), with_client() injection for tests. - WorkspaceServices::s3(config) and WorkspaceServices::from_s3_backend() factories with a 60s default per-operation timeout. - 11 unit tests covering key/prefix derivation, list prefix handling, capability matrix, and config builder. - core/tests/test_s3_backend.rs: env-gated end-to-end integration test that exercises the full session executor (read/write/edit/patch/ls, asserting bash/git/grep/glob are not registered) against a real S3-compatible endpoint; auto-cleans the per-run UUID prefix. - Node and Python SDKs expose S3WorkspaceBackend alongside LocalWorkspaceBackend via the same workspaceBackend / workspace_backend option surface; both SDK Rust crates depend on a3s-code-core with the s3 feature enabled. - README documents the new backend with cross-language usage examples. The new feature is fully optional: building a3s-code-core without `--features s3` keeps the dependency tree slim and excludes the AWS SDK. --- Cargo.lock | 547 +++++++++++++++++++++++++++++++++- README.md | 102 +++++++ core/Cargo.toml | 15 + core/src/lib.rs | 2 + core/src/workspace/mod.rs | 4 + core/src/workspace/s3.rs | 547 ++++++++++++++++++++++++++++++++++ core/tests/test_s3_backend.rs | 237 +++++++++++++++ sdk/node/Cargo.toml | 2 +- sdk/node/index.d.ts | 101 +++++++ sdk/node/index.js | 4 +- sdk/node/src/lib.rs | 299 ++++++++++++++++++- sdk/python/Cargo.toml | 2 +- sdk/python/src/lib.rs | 404 +++++++++++++++++++++---- 13 files changed, 2199 insertions(+), 67 deletions(-) create mode 100644 core/src/workspace/s3.rs create mode 100644 core/tests/test_s3_backend.rs diff --git a/Cargo.lock b/Cargo.lock index d76e92ee..150b03af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -48,6 +48,10 @@ dependencies = [ "anyhow", "async-stream", "async-trait", + "aws-credential-types", + "aws-sdk-s3", + "aws-smithy-runtime-api", + "aws-smithy-types", "base64 0.21.7", "bytes", "cfb", @@ -531,6 +535,312 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-credential-types" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-runtime" +version = "1.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dcd93c82209ac7413532388067dce79be5a8780c1786e5fae3df22e4dee2864" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-s3" +version = "1.132.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5575840a3a6b11f6011463ebe359320dfe5b67babb5e9b06fed6ddf809a9ab40" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "http-body 1.0.1", + "lru", + "percent-encoding", + "regex-lite", + "sha2 0.11.0", + "tracing", + "url", +] + +[[package]] +name = "aws-sigv4" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68dc0b907359b120170613b5c09ccc61304eac3998ff6274b97d93ee6490115a" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "percent-encoding", + "sha2 0.11.0", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-checksums" +version = "0.64.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10efbbcec1e044b81600e2fc562a391951d291152d95b482d5b7e7132299d762" +dependencies = [ + "aws-smithy-http", + "aws-smithy-types", + "bytes", + "crc-fast", + "hex", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "md-5 0.11.0", + "pin-project-lite", + "sha1 0.11.0", + "sha2 0.11.0", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf09d74e5e32f76b8762da505a3cd59303e367a664ca67295387baa8c1d7548" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.13", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-rustls 0.24.2", + "pin-project-lite", + "rustls 0.21.12", + "rustls-native-certs", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9648b0bb82a2eedd844052c6ad2a1a822d1f8e3adee5fbf668366717e428856a" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0504b1ab12debb5959e5165ee5fe97dd387e7aa7ea6a477bfd7635dfe769a4f5" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71a13df6ada0aafbf21a73bdfcdf9324cfa9df77d96b8446045be3cde61b42e" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "aws-smithy-types" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bbcaa9304ea40902d3d5f42a0428d1bd895a2b0f6999436fb279ffddc58ac" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "axum" version = "0.7.9" @@ -598,6 +908,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -619,6 +939,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "blocking" version = "1.6.2" @@ -684,6 +1013,16 @@ dependencies = [ "serde", ] +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "cc" version = "1.2.58" @@ -839,6 +1178,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + [[package]] name = "colorchoice" version = "1.0.5" @@ -872,6 +1217,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation" version = "0.9.4" @@ -907,6 +1258,42 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc-fast" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd92aca2c6001b1bf5ba0ff84ee74ec8501b52bbef0cac80bf25a6c1d87a83d" +dependencies = [ + "crc", + "digest 0.10.7", + "rustversion", + "spin", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -960,6 +1347,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cssparser" version = "0.34.0" @@ -983,6 +1379,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -1040,8 +1445,20 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "const-oid", + "crypto-common 0.2.1", + "ctutils", ] [[package]] @@ -1546,6 +1963,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "home" version = "0.5.12" @@ -1677,6 +2103,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "0.14.32" @@ -1732,6 +2167,7 @@ dependencies = [ "futures-util", "http 0.2.12", "hyper 0.14.32", + "log", "rustls 0.21.12", "tokio", "tokio-rustls 0.24.1", @@ -2097,7 +2533,7 @@ dependencies = [ "indexmap 2.13.0", "itoa", "log", - "md-5", + "md-5 0.10.6", "nom", "rangemap", "rayon", @@ -2105,6 +2541,15 @@ dependencies = [ "weezl", ] +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -2198,7 +2643,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", ] [[package]] @@ -2271,6 +2726,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2427,6 +2891,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "parking" version = "2.2.1" @@ -2942,6 +3412,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.10" @@ -3097,6 +3573,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -3414,8 +3899,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -3425,8 +3921,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -3438,7 +3945,7 @@ dependencies = [ "async-trait", "bytes", "hex", - "sha2", + "sha2 0.10.9", "tokio", ] @@ -3523,6 +4030,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4153,7 +4666,7 @@ dependencies = [ "httparse", "log", "rand 0.8.5", - "sha1", + "sha1 0.10.6", "thiserror 1.0.69", "utf-8", ] @@ -4173,7 +4686,7 @@ dependencies = [ "rand 0.8.5", "rustls 0.23.37", "rustls-pki-types", - "sha1", + "sha1 0.10.6", "thiserror 1.0.69", "utf-8", ] @@ -4298,6 +4811,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "walkdir" version = "2.5.0" @@ -4968,6 +5487,12 @@ dependencies = [ "markup5ever 0.36.1", ] +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "yoke" version = "0.8.2" diff --git a/README.md b/README.md index 474a1a7d..a9ae6250 100644 --- a/README.md +++ b/README.md @@ -455,6 +455,108 @@ Intent-gated tools: This follows the same direction as modern agent harnesses: remove routine tool clutter from the model's context and expose capabilities only when the task asks for them. +Workspace backends are capability providers behind the stable built-in tool +contracts. By default, `read`, `write`, `edit`, `patch`, `ls`, `grep`, `glob`, +`bash`, and `git` operate on the local workspace. Embedded hosts can supply +`WorkspaceServices` through `SessionOptions::with_workspace_backend(...)` so +those same tools target a DFS, browser workspace, remote container, or other +host-managed environment. + +```rust +use a3s_code_core::{Agent, SessionOptions, WorkspaceServices}; + +# async fn run() -> anyhow::Result<()> { +let agent = Agent::new("agent.acl").await?; +let workspace = WorkspaceServices::local("/repo"); +let session = agent.session( + "/repo", + Some(SessionOptions::new().with_workspace_backend(workspace)), +)?; +# Ok(()) +# } +``` + +For non-local backends, A3S Code exposes tools according to declared workspace +capabilities. `bash` is exposed only when a command runner is available, +`grep`/`glob` only when a search provider is available, and `git` only when a +workspace Git provider is available. Browser hosts can pair a virtual file +system with a browser Git implementation, while cloud hosts can route the same +tool contract through DFS or RPC-backed providers. + +#### S3-compatible storage backend + +When the `s3` Cargo feature is enabled, `S3WorkspaceBackend` lets built-in +file tools (`read`, `write`, `edit`, `patch`, `ls`) target any S3-compatible +endpoint — AWS S3, MinIO, RustFS, Cloudflare R2, Backblaze B2, and so on. +`bash`, `git`, `grep`, and `glob` are intentionally not registered because +object storage cannot service them. + +```toml +# Cargo.toml +[dependencies] +a3s-code-core = { version = "2.6", features = ["s3"] } +``` + +```rust +use a3s_code_core::{Agent, S3BackendConfig, SessionOptions, WorkspaceServices}; + +# async fn run() -> anyhow::Result<()> { +let agent = Agent::new("agent.acl").await?; +let config = S3BackendConfig::new( + "workspace", // bucket + "users/u1/sessions/s1", // workspace prefix inside the bucket + "AKIA...", // access key id + "...", // secret access key +) +.endpoint("https://minio.local:9000") // omit for AWS S3 +.region("us-east-1") +.force_path_style(true); // true for MinIO/RustFS, false for AWS +let session = agent.session( + "s3://workspace/users/u1/sessions/s1", + Some(SessionOptions::new().with_workspace_backend(WorkspaceServices::s3(config))), +)?; +# Ok(()) +# } +``` + +The Node and Python SDKs expose the same backend: + +```js +// Node +import { Agent, S3WorkspaceBackend } from '@a3s-lab/code'; +const session = agent.session(workspaceUri, { + workspaceBackend: new S3WorkspaceBackend({ + endpoint: 'https://minio.local:9000', + region: 'us-east-1', + accessKeyId: 'AKIA...', + secretAccessKey: '...', + bucket: 'workspace', + prefix: 'users/u1/sessions/s1', + forcePathStyle: true, + }), +}); +``` + +```python +# Python +from a3s_code import Agent, S3WorkspaceBackend, SessionOptions +opts = SessionOptions() +opts.workspace_backend = S3WorkspaceBackend( + bucket="workspace", + prefix="users/u1/sessions/s1", + access_key_id="AKIA...", + secret_access_key="...", + endpoint="https://minio.local:9000", + region="us-east-1", + force_path_style=True, +) +session = agent.session(workspace_uri, opts) +``` + +S3 has no atomic read-modify-write, so concurrent writers to the same key may +overwrite each other. Partition workspaces per session/user via the `prefix` +field when running multi-tenant. + ### 4. Programmatic Tool Calling High-frequency tool chains should move out of the LLM loop. diff --git a/core/Cargo.toml b/core/Cargo.toml index 63a2cda9..f6f49a6d 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -104,6 +104,12 @@ uuid = { version = "1.6", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } rquickjs = { version = "0.11.0", features = ["futures"] } +# S3-compatible workspace backend (optional, gated by `s3` feature) +aws-sdk-s3 = { version = "1", default-features = false, features = ["rt-tokio", "rustls"], optional = true } +aws-credential-types = { version = "1", optional = true } +aws-smithy-types = { version = "1", optional = true } +aws-smithy-runtime-api = { version = "1", optional = true } + [target.'cfg(unix)'.dependencies] a3s-ahp = { version = "2.4", path = "../../ahp", optional = true, features = ["http", "websocket", "unix-socket"] } @@ -122,6 +128,15 @@ telemetry = [ ] # Enable AHP (Agent Harness Protocol) integration for external supervision ahp = ["dep:a3s-ahp"] +# Enable the S3-compatible workspace backend (`S3WorkspaceBackend`). +# Brings in the AWS Rust SDK and is safe to point at any S3-compatible +# endpoint (AWS S3, MinIO, RustFS, Cloudflare R2, ...). +s3 = [ + "dep:aws-sdk-s3", + "dep:aws-credential-types", + "dep:aws-smithy-types", + "dep:aws-smithy-runtime-api", +] [dev-dependencies] # AHP for integration tests diff --git a/core/src/lib.rs b/core/src/lib.rs index ab9852a2..676b9a49 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -147,3 +147,5 @@ pub use workspace::{ WorkspacePathResolver, WorkspaceRef, WorkspaceSearch, WorkspaceServices, WorkspaceServicesBuilder, WorkspaceWriteOutcome, }; +#[cfg(feature = "s3")] +pub use workspace::{S3BackendConfig, S3WorkspaceBackend}; diff --git a/core/src/workspace/mod.rs b/core/src/workspace/mod.rs index 86ccf301..716049ff 100644 --- a/core/src/workspace/mod.rs +++ b/core/src/workspace/mod.rs @@ -8,8 +8,12 @@ //! [`WorkspaceServices`] through [`WorkspaceServicesBuilder`]. mod local; +#[cfg(feature = "s3")] +mod s3; pub use local::LocalWorkspaceBackend; +#[cfg(feature = "s3")] +pub use s3::{S3BackendConfig, S3WorkspaceBackend}; use anyhow::{anyhow, bail, Result}; use async_trait::async_trait; diff --git a/core/src/workspace/s3.rs b/core/src/workspace/s3.rs new file mode 100644 index 00000000..7de70413 --- /dev/null +++ b/core/src/workspace/s3.rs @@ -0,0 +1,547 @@ +//! S3-compatible object-storage workspace backend. +//! +//! [`S3WorkspaceBackend`] implements [`WorkspaceFileSystem`] against any +//! S3-compatible endpoint (AWS S3, MinIO, Cloudflare R2, Backblaze B2, ...) +//! using the AWS Rust SDK. The backend deliberately does **not** implement +//! [`WorkspaceCommandRunner`], [`WorkspaceSearch`], or any of the git +//! provider traits — object storage cannot natively service those operations, +//! and capability gating prevents the corresponding tools (`bash`, `grep`, +//! `glob`, `git`) from being registered when the backend is in use. +//! +//! Path semantics are lexical (no host filesystem involved), inherited from +//! [`super::VirtualPathResolver`]: paths are relative, parent-directory +//! traversal is rejected, and absolute or Windows-style paths are refused. +//! +//! # Concurrency caveats +//! +//! S3 does not provide atomic rename or read-modify-write. Tools like `edit` +//! and `patch` perform a `read_text` then `write_text` — concurrent writers +//! to the same key will overwrite each other (last-writer-wins). Callers +//! that need stronger guarantees should partition workspaces per session. +//! +//! Available only when the `s3` feature is enabled. + +use super::{ + WorkspaceDirEntry, WorkspaceFileSystem, WorkspaceFileType, WorkspacePath, WorkspaceWriteOutcome, +}; +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use aws_credential_types::Credentials; +use aws_sdk_s3::config::{BehaviorVersion, Region}; +use aws_sdk_s3::error::SdkError; +use aws_sdk_s3::operation::list_objects_v2::ListObjectsV2Error; +use aws_sdk_s3::primitives::ByteStream; +use aws_sdk_s3::Client; +use std::sync::Arc; +use std::time::Duration; + +const DEFAULT_REGION: &str = "us-east-1"; + +/// Configuration for an [`S3WorkspaceBackend`]. +/// +/// `endpoint` is optional: omit it to use the AWS default. Set it to point at +/// MinIO, RustFS, R2, or any other S3-compatible service. +/// +/// `prefix` is the logical workspace root inside the bucket — every workspace +/// path becomes `/` when sent to S3. An empty prefix means the +/// bucket root itself acts as the workspace. +#[derive(Debug, Clone)] +pub struct S3BackendConfig { + pub endpoint: Option, + pub region: Option, + pub access_key_id: String, + pub secret_access_key: String, + pub session_token: Option, + pub bucket: String, + pub prefix: String, + /// `true` for MinIO / RustFS / most non-AWS endpoints, `false` for AWS S3. + pub force_path_style: bool, + /// Per-operation request timeout. Defaults to 30 seconds. Independent of + /// the workspace-level `operation_timeout` set on [`super::WorkspaceServices`]; + /// whichever fires first wins. + pub request_timeout: Option, +} + +impl S3BackendConfig { + pub fn new( + bucket: impl Into, + prefix: impl Into, + access_key_id: impl Into, + secret_access_key: impl Into, + ) -> Self { + Self { + endpoint: None, + region: None, + access_key_id: access_key_id.into(), + secret_access_key: secret_access_key.into(), + session_token: None, + bucket: bucket.into(), + prefix: prefix.into(), + force_path_style: false, + request_timeout: None, + } + } + + pub fn endpoint(mut self, endpoint: impl Into) -> Self { + self.endpoint = Some(endpoint.into()); + self + } + + pub fn region(mut self, region: impl Into) -> Self { + self.region = Some(region.into()); + self + } + + pub fn session_token(mut self, token: impl Into) -> Self { + self.session_token = Some(token.into()); + self + } + + pub fn force_path_style(mut self, enabled: bool) -> Self { + self.force_path_style = enabled; + self + } + + pub fn request_timeout(mut self, timeout: Duration) -> Self { + self.request_timeout = Some(timeout); + self + } +} + +/// S3-compatible workspace backend. +/// +/// Construct with [`Self::new`] for production, or [`Self::with_client`] for +/// tests that need to inject a pre-built [`aws_sdk_s3::Client`] (e.g. with a +/// mock HTTP layer). +#[derive(Debug, Clone)] +pub struct S3WorkspaceBackend { + client: Client, + bucket: String, + /// Normalised prefix without trailing slash. Empty string means + /// "bucket root is the workspace". + prefix: String, +} + +impl S3WorkspaceBackend { + /// Build a backend from declarative configuration. + pub fn new(config: S3BackendConfig) -> Self { + let credentials = Credentials::new( + config.access_key_id, + config.secret_access_key, + config.session_token, + None, + "a3s-code-static", + ); + + let mut builder = aws_sdk_s3::Config::builder() + .behavior_version(BehaviorVersion::latest()) + .region(Region::new( + config.region.unwrap_or_else(|| DEFAULT_REGION.to_string()), + )) + .credentials_provider(credentials) + .force_path_style(config.force_path_style); + + if let Some(endpoint) = config.endpoint { + builder = builder.endpoint_url(endpoint); + } + + let client = Client::from_conf(builder.build()); + Self::with_client(client, config.bucket, config.prefix) + } + + /// Build a backend from a pre-configured S3 client. Intended for tests + /// and advanced use cases (custom retries, signer overrides, http_client + /// injection, etc.). + pub fn with_client( + client: Client, + bucket: impl Into, + prefix: impl Into, + ) -> Self { + Self { + client, + bucket: bucket.into(), + prefix: normalize_prefix(&prefix.into()), + } + } + + /// The bucket this backend is bound to. + pub fn bucket(&self) -> &str { + &self.bucket + } + + /// The workspace prefix inside the bucket (no leading or trailing slash). + pub fn prefix(&self) -> &str { + &self.prefix + } + + /// Underlying AWS SDK client — exposed for advanced workflows that need + /// to perform out-of-band operations (e.g. presigned URLs, ACL changes). + pub fn client(&self) -> &Client { + &self.client + } + + fn key_for(&self, path: &WorkspacePath) -> String { + if path.is_root() { + self.prefix.clone() + } else if self.prefix.is_empty() { + path.as_str().to_string() + } else { + format!("{}/{}", self.prefix, path.as_str()) + } + } + + fn list_prefix_for(&self, path: &WorkspacePath) -> String { + if path.is_root() { + if self.prefix.is_empty() { + String::new() + } else { + format!("{}/", self.prefix) + } + } else if self.prefix.is_empty() { + format!("{}/", path.as_str()) + } else { + format!("{}/{}/", self.prefix, path.as_str()) + } + } +} + +#[async_trait] +impl WorkspaceFileSystem for S3WorkspaceBackend { + async fn read_text(&self, path: &WorkspacePath) -> Result { + let key = self.key_for(path); + let resp = self + .client + .get_object() + .bucket(&self.bucket) + .key(&key) + .send() + .await + .map_err(|e| classify_get_error(&self.bucket, &key, e))?; + + let bytes = resp + .body + .collect() + .await + .map_err(|e| { + anyhow!( + "Failed to read S3 object body s3://{}/{}: {}", + self.bucket, + key, + e + ) + })? + .into_bytes(); + + String::from_utf8(bytes.to_vec()).map_err(|e| { + anyhow!( + "S3 object s3://{}/{} is not valid UTF-8: {}", + self.bucket, + key, + e + ) + }) + } + + async fn write_text( + &self, + path: &WorkspacePath, + content: &str, + ) -> Result { + let key = self.key_for(path); + let body = ByteStream::from(content.as_bytes().to_vec()); + + self.client + .put_object() + .bucket(&self.bucket) + .key(&key) + .body(body) + .content_type("text/plain; charset=utf-8") + .send() + .await + .map_err(|e| { + anyhow!( + "Failed to write S3 object s3://{}/{}: {}", + self.bucket, + key, + e + ) + })?; + + Ok(WorkspaceWriteOutcome { + bytes: content.len(), + lines: content.lines().count(), + }) + } + + async fn list_dir(&self, path: &WorkspacePath) -> Result> { + let prefix = self.list_prefix_for(path); + let mut entries: Vec = Vec::new(); + let mut continuation: Option = None; + + loop { + let mut req = self + .client + .list_objects_v2() + .bucket(&self.bucket) + .prefix(&prefix) + .delimiter("/"); + if let Some(token) = continuation.as_ref() { + req = req.continuation_token(token); + } + + let resp = req + .send() + .await + .map_err(|e| classify_list_error(&self.bucket, &prefix, e))?; + + // CommonPrefixes → directories + for cp in resp.common_prefixes() { + if let Some(p) = cp.prefix() { + // p looks like "/"; extract + if let Some(name) = strip_dir_name(p, &prefix) { + entries.push(WorkspaceDirEntry { + name, + kind: WorkspaceFileType::Directory, + size: 0, + }); + } + } + } + + // Contents → files + for obj in resp.contents() { + let Some(key) = obj.key() else { continue }; + // Skip the prefix marker itself (key == prefix exactly). + if key == prefix { + continue; + } + if let Some(name) = strip_file_name(key, &prefix) { + entries.push(WorkspaceDirEntry { + name, + kind: WorkspaceFileType::File, + size: obj.size().unwrap_or(0).max(0) as u64, + }); + } + } + + if resp.is_truncated().unwrap_or(false) { + continuation = resp.next_continuation_token().map(|s| s.to_string()); + if continuation.is_none() { + break; + } + } else { + break; + } + } + + Ok(entries) + } +} + +fn normalize_prefix(prefix: &str) -> String { + prefix + .trim_start_matches('/') + .trim_end_matches('/') + .to_string() +} + +fn strip_dir_name(common_prefix: &str, listing_prefix: &str) -> Option { + let remainder = common_prefix.strip_prefix(listing_prefix)?; + let trimmed = remainder.trim_end_matches('/'); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn strip_file_name(key: &str, listing_prefix: &str) -> Option { + let remainder = key.strip_prefix(listing_prefix)?; + if remainder.is_empty() || remainder.contains('/') { + None + } else { + Some(remainder.to_string()) + } +} + +fn classify_get_error(bucket: &str, key: &str, error: SdkError) -> anyhow::Error +where + E: std::error::Error + Send + Sync + 'static, +{ + let raw = error + .raw_response() + .map(|r| r.status().as_u16()) + .unwrap_or_default(); + if raw == 404 { + anyhow!("S3 object not found: s3://{}/{}", bucket, key) + } else { + anyhow!( + "Failed to read S3 object s3://{}/{}: {}", + bucket, + key, + error + ) + } +} + +fn classify_list_error( + bucket: &str, + prefix: &str, + error: SdkError, +) -> anyhow::Error { + anyhow!( + "Failed to list S3 prefix s3://{}/{}: {}", + bucket, + prefix, + error + ) +} + +impl super::WorkspaceServices { + /// Build a workspace whose files live in an S3-compatible bucket. + /// + /// The resulting [`WorkspaceServices`](super::WorkspaceServices) exposes + /// only read / write / list capabilities (`read`, `write`, `edit`, + /// `patch`, `ls`); `bash`, `git`, `grep`, and `glob` are intentionally + /// not registered because object storage cannot service them. A 60s + /// per-operation timeout is applied by default — override via + /// [`super::WorkspaceServicesBuilder::operation_timeout`] when building + /// manually. + pub fn s3(config: S3BackendConfig) -> Arc { + let backend = Arc::new(S3WorkspaceBackend::new(config)); + Self::from_s3_backend(backend) + } + + /// Build a workspace from a pre-constructed [`S3WorkspaceBackend`]. + /// + /// Useful when the caller has injected a custom AWS client (e.g. a mocked + /// HTTP layer, alternative credential provider, or a wrapper that adds + /// metrics / tracing). + pub fn from_s3_backend(backend: Arc) -> Arc { + let workspace_ref = super::WorkspaceRef::new( + format!("s3://{}/{}", backend.bucket(), backend.prefix()), + format!("s3://{}/{}", backend.bucket(), backend.prefix()), + ); + let fs: Arc = backend; + Self::builder(workspace_ref, fs) + .operation_timeout(Duration::from_secs(60)) + .build() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_for_root_uses_prefix_only() { + let backend = make_backend("ws/u1/s1"); + let key = backend.key_for(&WorkspacePath::root()); + assert_eq!(key, "ws/u1/s1"); + } + + #[test] + fn key_for_nested_path_joins_with_slash() { + let backend = make_backend("ws/u1/s1"); + let key = backend.key_for(&WorkspacePath::from_normalized("src/main.rs")); + assert_eq!(key, "ws/u1/s1/src/main.rs"); + } + + #[test] + fn key_for_empty_prefix_uses_path_only() { + let backend = make_backend(""); + assert_eq!( + backend.key_for(&WorkspacePath::from_normalized("notes.txt")), + "notes.txt" + ); + assert_eq!(backend.key_for(&WorkspacePath::root()), ""); + } + + #[test] + fn list_prefix_root_with_workspace_prefix() { + let backend = make_backend("ws/u1/s1"); + assert_eq!(backend.list_prefix_for(&WorkspacePath::root()), "ws/u1/s1/"); + } + + #[test] + fn list_prefix_root_with_empty_workspace_prefix() { + let backend = make_backend(""); + assert_eq!(backend.list_prefix_for(&WorkspacePath::root()), ""); + } + + #[test] + fn list_prefix_nested_path() { + let backend = make_backend("ws/u1/s1"); + let path = WorkspacePath::from_normalized("src"); + assert_eq!(backend.list_prefix_for(&path), "ws/u1/s1/src/"); + } + + #[test] + fn normalize_prefix_strips_slashes() { + assert_eq!(normalize_prefix("/foo/bar/"), "foo/bar"); + assert_eq!(normalize_prefix("foo"), "foo"); + assert_eq!(normalize_prefix(""), ""); + assert_eq!(normalize_prefix("/"), ""); + } + + #[test] + fn strip_dir_name_extracts_immediate_child() { + assert_eq!( + strip_dir_name("ws/u1/s1/src/", "ws/u1/s1/"), + Some("src".to_string()) + ); + assert_eq!(strip_dir_name("ws/u1/s1/", "ws/u1/s1/"), None); + assert_eq!(strip_dir_name("other/", "ws/u1/s1/"), None); + } + + #[test] + fn strip_file_name_rejects_nested_keys() { + assert_eq!( + strip_file_name("ws/u1/s1/notes.txt", "ws/u1/s1/"), + Some("notes.txt".to_string()) + ); + // Nested key — should be claimed by a deeper LIST instead. + assert_eq!(strip_file_name("ws/u1/s1/src/main.rs", "ws/u1/s1/"), None); + assert_eq!(strip_file_name("other/notes.txt", "ws/u1/s1/"), None); + } + + #[test] + fn config_builder_sets_fields() { + let cfg = S3BackendConfig::new("bucket", "prefix", "AK", "SK") + .endpoint("https://minio.local:9000") + .region("cn-east-1") + .session_token("TOKEN") + .force_path_style(true) + .request_timeout(Duration::from_secs(5)); + assert_eq!(cfg.bucket, "bucket"); + assert_eq!(cfg.prefix, "prefix"); + assert_eq!(cfg.endpoint.as_deref(), Some("https://minio.local:9000")); + assert_eq!(cfg.region.as_deref(), Some("cn-east-1")); + assert_eq!(cfg.session_token.as_deref(), Some("TOKEN")); + assert!(cfg.force_path_style); + assert_eq!(cfg.request_timeout, Some(Duration::from_secs(5))); + } + + #[test] + fn services_s3_factory_disables_exec_search_and_git() { + let cfg = S3BackendConfig::new("bucket", "ws", "AK", "SK"); + let services = super::super::WorkspaceServices::s3(cfg); + let caps = services.capabilities(); + assert!(caps.read); + assert!(caps.write); + assert!(!caps.exec); + assert!(!caps.search); + assert!(!caps.git); + assert!(services.command_runner().is_none()); + assert!(services.search().is_none()); + assert!(services.git().is_none()); + assert!(services.git_stash().is_none()); + assert!(services.git_worktree().is_none()); + assert_eq!(services.operation_timeout(), Some(Duration::from_secs(60))); + } + + fn make_backend(prefix: &str) -> S3WorkspaceBackend { + let cfg = S3BackendConfig::new("bucket", prefix, "AK", "SK"); + S3WorkspaceBackend::new(cfg) + } +} diff --git a/core/tests/test_s3_backend.rs b/core/tests/test_s3_backend.rs new file mode 100644 index 00000000..69fbfc6c --- /dev/null +++ b/core/tests/test_s3_backend.rs @@ -0,0 +1,237 @@ +//! End-to-end integration test for [`a3s_code_core::S3WorkspaceBackend`]. +//! +//! Gated on `A3S_S3_TEST_ENDPOINT` so it does not run in default CI. To +//! exercise it locally, point at a MinIO / RustFS / real S3 endpoint: +//! +//! ```sh +//! export A3S_S3_TEST_ENDPOINT=http://127.0.0.1:9000 +//! export A3S_S3_TEST_REGION=us-east-1 +//! export A3S_S3_TEST_ACCESS_KEY_ID=minioadmin +//! export A3S_S3_TEST_SECRET_ACCESS_KEY=minioadmin +//! export A3S_S3_TEST_BUCKET=a3s-code-tests +//! export A3S_S3_TEST_FORCE_PATH_STYLE=true # for MinIO/RustFS +//! cargo test -p a3s-code-core --features s3 --test test_s3_backend -- --ignored +//! ``` +//! +//! The test uses a per-run UUID prefix so it never collides with other +//! sessions and cleans up its own keys on success. + +#![cfg(feature = "s3")] + +use a3s_code_core::tools::{ArtifactStoreLimits, ToolExecutor}; +use a3s_code_core::{S3BackendConfig, S3WorkspaceBackend, WorkspaceServices}; +use serde_json::json; +use std::sync::Arc; +use std::time::Duration; +use uuid::Uuid; + +fn env_required(key: &str) -> Option { + std::env::var(key).ok().filter(|v| !v.is_empty()) +} + +fn live_config() -> Option { + let endpoint = env_required("A3S_S3_TEST_ENDPOINT")?; + let bucket = env_required("A3S_S3_TEST_BUCKET")?; + let access_key_id = env_required("A3S_S3_TEST_ACCESS_KEY_ID")?; + let secret_access_key = env_required("A3S_S3_TEST_SECRET_ACCESS_KEY")?; + let prefix = format!( + "{}/{}", + env_required("A3S_S3_TEST_PREFIX").unwrap_or_else(|| "a3s-code-tests".to_string()), + Uuid::new_v4() + ); + + let mut cfg = S3BackendConfig::new(bucket, prefix, access_key_id, secret_access_key) + .endpoint(endpoint) + .request_timeout(Duration::from_secs(10)); + + if let Some(region) = env_required("A3S_S3_TEST_REGION") { + cfg = cfg.region(region); + } else { + cfg = cfg.region("us-east-1"); + } + if let Some(force) = env_required("A3S_S3_TEST_FORCE_PATH_STYLE") { + cfg = cfg.force_path_style(force.parse().unwrap_or(true)); + } else { + cfg = cfg.force_path_style(true); + } + if let Some(token) = env_required("A3S_S3_TEST_SESSION_TOKEN") { + cfg = cfg.session_token(token); + } + Some(cfg) +} + +#[tokio::test] +#[ignore = "requires A3S_S3_TEST_ENDPOINT and friends"] +async fn s3_backend_roundtrips_via_session_executor() { + let Some(cfg) = live_config() else { + eprintln!("Skipping: A3S_S3_TEST_ENDPOINT not configured"); + return; + }; + let prefix_for_cleanup = cfg.prefix.clone(); + let bucket_for_cleanup = cfg.bucket.clone(); + + let backend = Arc::new(S3WorkspaceBackend::new(cfg)); + let services = WorkspaceServices::from_s3_backend(Arc::clone(&backend)); + + // Capability gating must hide bash/git/grep/glob. + let executor = ToolExecutor::new_with_workspace_services_and_artifact_limits( + format!("s3://{}/{}", backend.bucket(), backend.prefix()), + Arc::clone(&services), + ArtifactStoreLimits::default(), + ); + let definitions = executor.definitions(); + let names: Vec<&str> = definitions.iter().map(|t| t.name.as_str()).collect(); + assert!(names.contains(&"read"), "read must be registered"); + assert!(names.contains(&"write"), "write must be registered"); + assert!(names.contains(&"edit"), "edit must be registered"); + assert!(names.contains(&"patch"), "patch must be registered"); + assert!(names.contains(&"ls"), "ls must be registered"); + assert!( + !names.contains(&"bash"), + "bash must NOT be registered for S3 backend" + ); + assert!( + !names.contains(&"grep"), + "grep must NOT be registered for S3 backend" + ); + assert!( + !names.contains(&"glob"), + "glob must NOT be registered for S3 backend" + ); + assert!( + !names.contains(&"git"), + "git must NOT be registered for S3 backend" + ); + + // write + let write = executor + .execute( + "write", + &json!({ "file_path": "notes/hello.txt", "content": "one\ntwo\n" }), + ) + .await + .expect("write tool dispatched"); + assert_eq!(write.exit_code, 0, "{}", write.output); + + // read + let read = executor + .execute("read", &json!({ "file_path": "notes/hello.txt" })) + .await + .expect("read tool dispatched"); + assert_eq!(read.exit_code, 0, "{}", read.output); + assert!( + read.output.contains("one"), + "read should return persisted content: {}", + read.output + ); + + // ls — root should contain "notes" subdirectory + let ls_root = executor + .execute("ls", &json!({ "path": "." })) + .await + .expect("ls root"); + assert_eq!(ls_root.exit_code, 0, "{}", ls_root.output); + assert!( + ls_root.output.contains("notes"), + "ls / should surface the notes/ prefix: {}", + ls_root.output + ); + + // ls notes — should contain hello.txt + let ls_notes = executor + .execute("ls", &json!({ "path": "notes" })) + .await + .expect("ls notes"); + assert_eq!(ls_notes.exit_code, 0, "{}", ls_notes.output); + assert!( + ls_notes.output.contains("hello.txt"), + "ls notes/ should surface hello.txt: {}", + ls_notes.output + ); + + // edit — replace "one" with "uno" + let edit = executor + .execute( + "edit", + &json!({ + "file_path": "notes/hello.txt", + "old_string": "one", + "new_string": "uno" + }), + ) + .await + .expect("edit tool dispatched"); + assert_eq!(edit.exit_code, 0, "{}", edit.output); + + let read_after_edit = executor + .execute("read", &json!({ "file_path": "notes/hello.txt" })) + .await + .expect("read after edit"); + assert!( + read_after_edit.output.contains("uno"), + "edit should persist: {}", + read_after_edit.output + ); + + // patch — apply a unified diff + let patch = executor + .execute( + "patch", + &json!({ + "file_path": "notes/hello.txt", + "diff": "@@ -1,2 +1,2 @@\n uno\n-two\n+dos" + }), + ) + .await + .expect("patch tool dispatched"); + assert_eq!(patch.exit_code, 0, "{}", patch.output); + + let final_content = executor + .execute("read", &json!({ "file_path": "notes/hello.txt" })) + .await + .expect("read after patch") + .output; + assert!( + final_content.contains("uno") && final_content.contains("dos"), + "patch should produce uno/dos: {}", + final_content + ); + + // Cleanup keys under our test prefix. + cleanup_prefix(backend.client(), &bucket_for_cleanup, &prefix_for_cleanup).await; +} + +async fn cleanup_prefix(client: &aws_sdk_s3::Client, bucket: &str, prefix: &str) { + let prefix = if prefix.ends_with('/') { + prefix.to_string() + } else { + format!("{prefix}/") + }; + let mut continuation: Option = None; + loop { + let mut req = client.list_objects_v2().bucket(bucket).prefix(&prefix); + if let Some(ref token) = continuation { + req = req.continuation_token(token); + } + let resp = match req.send().await { + Ok(r) => r, + Err(e) => { + eprintln!("cleanup list_objects_v2 failed: {e}"); + return; + } + }; + for obj in resp.contents() { + if let Some(key) = obj.key() { + let _ = client.delete_object().bucket(bucket).key(key).send().await; + } + } + if resp.is_truncated().unwrap_or(false) { + continuation = resp.next_continuation_token().map(|s| s.to_string()); + if continuation.is_none() { + break; + } + } else { + break; + } + } +} diff --git a/sdk/node/Cargo.toml b/sdk/node/Cargo.toml index d3d6b8af..5e188263 100644 --- a/sdk/node/Cargo.toml +++ b/sdk/node/Cargo.toml @@ -11,7 +11,7 @@ description = "A3S Code Node.js bindings - Native addon via napi-rs" crate-type = ["cdylib"] [dependencies] -a3s-code-core = { version = "2.5.0", path = "../../core", features = ["ahp"] } +a3s-code-core = { version = "2.5.0", path = "../../core", features = ["ahp", "s3"] } napi = { version = "2", features = ["async", "napi6", "serde-json"] } napi-derive = "2" tokio = { version = "1.35", features = ["full"] } diff --git a/sdk/node/index.d.ts b/sdk/node/index.d.ts index 9310b778..5d8c1d89 100644 --- a/sdk/node/index.d.ts +++ b/sdk/node/index.d.ts @@ -177,6 +177,42 @@ export interface JsSessionStore { export interface JsSecurityProvider { kind: string } +export interface JsWorkspaceBackend { + kind: string + root?: string + s3?: JsS3BackendConfig +} +/** + * Configuration for an S3-compatible workspace backend. + * + * Use this with [`S3WorkspaceBackend`] to point a session's built-in file + * tools at any S3-compatible endpoint (AWS S3, MinIO, RustFS, R2, etc.). + * `endpoint` is optional — omit it to use the AWS default. `prefix` is + * the logical workspace root inside the bucket; every workspace path + * becomes `/` when sent to S3. + */ +export interface JsS3BackendConfig { + /** + * Optional S3 endpoint URL. Omit for AWS S3 (the SDK will compute it + * from `region`). Set to `https://...` for MinIO / RustFS / R2 / etc. + */ + endpoint?: string + /** AWS region. Defaults to `us-east-1` when omitted. */ + region?: string + /** Static access key. Use `sessionToken` together when STS-issued. */ + accessKeyId: string + secretAccessKey: string + sessionToken?: string + /** Bucket name. */ + bucket: string + /** + * Logical workspace prefix inside the bucket (without leading/trailing + * slashes). Use `""` to make the bucket root the workspace. + */ + prefix: string + /** `true` for MinIO / RustFS / most non-AWS endpoints; `false` for AWS S3. */ + forcePathStyle?: boolean +} /** * Union type for AHP transport configuration. * Accepts any of: StdioTransport, HttpTransport, WebSocketTransport, UnixSocketTransport. @@ -339,6 +375,17 @@ export interface SessionOptions { * ``` */ securityProvider?: JsSecurityProvider + /** + * Workspace backend used by built-in tools. + * + * Pass `new LocalWorkspaceBackend("/repo")` to explicitly use the local + * filesystem backend. This option is the SDK surface for future remote, + * browser, DFS, and container-backed workspace implementations. + * ```js + * agent.session('/repo', { workspaceBackend: new LocalWorkspaceBackend('/repo') }); + * ``` + */ + workspaceBackend?: JsWorkspaceBackend /** * Custom role/identity prepended before the core agentic prompt. * Example: "You are a senior Python developer specializing in FastAPI." @@ -698,6 +745,52 @@ export declare class DefaultSecurityProvider { kind: string constructor() } +/** + * Local filesystem workspace backend. + * + * This is the explicit typed form of the default local workspace behavior. + * It is useful when callers want to pass workspace backends through the same + * option surface that remote/browser backends will use. + * + * ```js + * agent.session('/repo', { workspaceBackend: new LocalWorkspaceBackend('/repo') }); + * ``` + */ +export declare class LocalWorkspaceBackend { + kind: string + root: string + /** Create a local filesystem workspace backend rooted at `root`. */ + constructor(root: string) +} +/** + * S3-compatible object-storage workspace backend. + * + * Points built-in file tools (`read`, `write`, `edit`, `patch`, `ls`) at an + * S3-compatible bucket. Works with AWS S3, MinIO, RustFS, Cloudflare R2, + * Backblaze B2, and other S3-API-compatible services. + * + * `bash`, `git`, `grep`, and `glob` are intentionally **not** registered + * when this backend is in use — object storage cannot service them. + * + * ```js + * const backend = new S3WorkspaceBackend({ + * endpoint: 'https://minio.local:9000', + * region: 'us-east-1', + * accessKeyId: 'AKIA...', + * secretAccessKey: '...', + * bucket: 'workspace', + * prefix: 'users/u1/sessions/s1', + * forcePathStyle: true, + * }); + * agent.session('s3://workspace/users/u1/sessions/s1', { workspaceBackend: backend }); + * ``` + */ +export declare class S3WorkspaceBackend { + kind: string + s3: JsS3BackendConfig + /** Create an S3-compatible workspace backend. */ + constructor(config: JsS3BackendConfig) +} /** * Stdio transport for AHP (Agent Harness Protocol). * @@ -919,6 +1012,14 @@ export declare class Session { program(options: ProgramScriptOptions): Promise /** Read a file from the workspace. */ readFile(path: string): Promise + /** Write a file in the workspace. */ + writeFile(path: string, content: string): Promise + /** List a directory in the workspace. */ + ls(path?: string | undefined | null): Promise + /** Edit a file by replacing text in the workspace. */ + editFile(path: string, oldString: string, newString: string, replaceAll?: boolean | undefined | null): Promise + /** Apply a unified diff patch to a workspace file. */ + patchFile(path: string, diff: string): Promise /** Execute a bash command in the workspace. */ bash(command: string): Promise /** Search for files matching a glob pattern. */ diff --git a/sdk/node/index.js b/sdk/node/index.js index a1fffe56..07446cb6 100644 --- a/sdk/node/index.js +++ b/sdk/node/index.js @@ -310,7 +310,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { formatVerificationSummary, EventStream, FileMemoryStore, FileSessionStore, MemorySessionStore, DefaultSecurityProvider, StdioTransport, HttpTransport, WebSocketTransport, UnixSocketTransport, Agent, Session, builtinSkills, BrowserBackend } = nativeBinding +const { formatVerificationSummary, EventStream, FileMemoryStore, FileSessionStore, MemorySessionStore, DefaultSecurityProvider, LocalWorkspaceBackend, S3WorkspaceBackend, StdioTransport, HttpTransport, WebSocketTransport, UnixSocketTransport, Agent, Session, builtinSkills, BrowserBackend } = nativeBinding module.exports.formatVerificationSummary = formatVerificationSummary module.exports.EventStream = EventStream @@ -318,6 +318,8 @@ module.exports.FileMemoryStore = FileMemoryStore module.exports.FileSessionStore = FileSessionStore module.exports.MemorySessionStore = MemorySessionStore module.exports.DefaultSecurityProvider = DefaultSecurityProvider +module.exports.LocalWorkspaceBackend = LocalWorkspaceBackend +module.exports.S3WorkspaceBackend = S3WorkspaceBackend module.exports.StdioTransport = StdioTransport module.exports.HttpTransport = HttpTransport module.exports.WebSocketTransport = WebSocketTransport diff --git a/sdk/node/src/lib.rs b/sdk/node/src/lib.rs index 1d7ea4ea..ef262ebc 100644 --- a/sdk/node/src/lib.rs +++ b/sdk/node/src/lib.rs @@ -1193,6 +1193,42 @@ pub struct JsSecurityProvider { pub kind: String, } +#[napi(object)] +#[derive(Clone, Default)] +pub struct JsWorkspaceBackend { + pub kind: String, + pub root: Option, + pub s3: Option, +} + +/// Configuration for an S3-compatible workspace backend. +/// +/// Use this with [`S3WorkspaceBackend`] to point a session's built-in file +/// tools at any S3-compatible endpoint (AWS S3, MinIO, RustFS, R2, etc.). +/// `endpoint` is optional — omit it to use the AWS default. `prefix` is +/// the logical workspace root inside the bucket; every workspace path +/// becomes `/` when sent to S3. +#[napi(object)] +#[derive(Clone, Default)] +pub struct JsS3BackendConfig { + /// Optional S3 endpoint URL. Omit for AWS S3 (the SDK will compute it + /// from `region`). Set to `https://...` for MinIO / RustFS / R2 / etc. + pub endpoint: Option, + /// AWS region. Defaults to `us-east-1` when omitted. + pub region: Option, + /// Static access key. Use `sessionToken` together when STS-issued. + pub access_key_id: String, + pub secret_access_key: String, + pub session_token: Option, + /// Bucket name. + pub bucket: String, + /// Logical workspace prefix inside the bucket (without leading/trailing + /// slashes). Use `""` to make the bucket root the workspace. + pub prefix: String, + /// `true` for MinIO / RustFS / most non-AWS endpoints; `false` for AWS S3. + pub force_path_style: Option, +} + /// File-backed long-term memory store. /// /// ```js @@ -1297,6 +1333,72 @@ impl Default for DefaultSecurityProvider { } } +/// Local filesystem workspace backend. +/// +/// This is the explicit typed form of the default local workspace behavior. +/// It is useful when callers want to pass workspace backends through the same +/// option surface that remote/browser backends will use. +/// +/// ```js +/// agent.session('/repo', { workspaceBackend: new LocalWorkspaceBackend('/repo') }); +/// ``` +#[napi] +pub struct LocalWorkspaceBackend { + pub kind: String, + pub root: String, +} + +#[napi] +impl LocalWorkspaceBackend { + /// Create a local filesystem workspace backend rooted at `root`. + #[napi(constructor)] + pub fn new(root: String) -> Self { + Self { + kind: "local".to_string(), + root, + } + } +} + +/// S3-compatible object-storage workspace backend. +/// +/// Points built-in file tools (`read`, `write`, `edit`, `patch`, `ls`) at an +/// S3-compatible bucket. Works with AWS S3, MinIO, RustFS, Cloudflare R2, +/// Backblaze B2, and other S3-API-compatible services. +/// +/// `bash`, `git`, `grep`, and `glob` are intentionally **not** registered +/// when this backend is in use — object storage cannot service them. +/// +/// ```js +/// const backend = new S3WorkspaceBackend({ +/// endpoint: 'https://minio.local:9000', +/// region: 'us-east-1', +/// accessKeyId: 'AKIA...', +/// secretAccessKey: '...', +/// bucket: 'workspace', +/// prefix: 'users/u1/sessions/s1', +/// forcePathStyle: true, +/// }); +/// agent.session('s3://workspace/users/u1/sessions/s1', { workspaceBackend: backend }); +/// ``` +#[napi] +pub struct S3WorkspaceBackend { + pub kind: String, + pub s3: JsS3BackendConfig, +} + +#[napi] +impl S3WorkspaceBackend { + /// Create an S3-compatible workspace backend. + #[napi(constructor)] + pub fn new(config: JsS3BackendConfig) -> Self { + Self { + kind: "s3".to_string(), + s3: config, + } + } +} + // ============================================================================ // AHP Transport Classes // ============================================================================ @@ -1616,6 +1718,15 @@ pub struct SessionOptions { /// agent.session('.', { securityProvider: new DefaultSecurityProvider() }); /// ``` pub security_provider: Option, + /// Workspace backend used by built-in tools. + /// + /// Pass `new LocalWorkspaceBackend("/repo")` to explicitly use the local + /// filesystem backend. This option is the SDK surface for future remote, + /// browser, DFS, and container-backed workspace implementations. + /// ```js + /// agent.session('/repo', { workspaceBackend: new LocalWorkspaceBackend('/repo') }); + /// ``` + pub workspace_backend: Option, /// Custom role/identity prepended before the core agentic prompt. /// Example: "You are a senior Python developer specializing in FastAPI." pub role: Option, @@ -1869,6 +1980,28 @@ fn parse_handler_mode(mode: &str) -> napi::Result { } } +fn s3_config_to_core(js: &JsS3BackendConfig) -> a3s_code_core::S3BackendConfig { + let mut cfg = a3s_code_core::S3BackendConfig::new( + js.bucket.clone(), + js.prefix.clone(), + js.access_key_id.clone(), + js.secret_access_key.clone(), + ); + if let Some(ref endpoint) = js.endpoint { + cfg = cfg.endpoint(endpoint.clone()); + } + if let Some(ref region) = js.region { + cfg = cfg.region(region.clone()); + } + if let Some(ref token) = js.session_token { + cfg = cfg.session_token(token.clone()); + } + if let Some(force) = js.force_path_style { + cfg = cfg.force_path_style(force); + } + cfg +} + /// Build RustSessionOptions from JS SessionOptions. fn js_session_options_to_rust(options: Option) -> napi::Result { let Some(o) = options else { @@ -1948,6 +2081,32 @@ fn js_session_options_to_rust(options: Option) -> napi::Result { + let root = backend.root.as_ref().ok_or_else(|| { + napi::Error::from_reason("LocalWorkspaceBackend requires a root path") + })?; + opts = opts + .with_workspace_backend(a3s_code_core::WorkspaceServices::local(root.clone())); + } + "s3" => { + let s3_config = backend.s3.as_ref().ok_or_else(|| { + napi::Error::from_reason( + "S3WorkspaceBackend requires the `s3` configuration field", + ) + })?; + let core_config = s3_config_to_core(s3_config); + opts = + opts.with_workspace_backend(a3s_code_core::WorkspaceServices::s3(core_config)); + } + other => { + return Err(napi::Error::from_reason(format!( + "Unsupported workspace backend kind '{other}'" + ))); + } + } + } // Build prompt slots if any slot is set if o.role.is_some() || o.guidelines.is_some() || o.response_style.is_some() || o.extra.is_some() { @@ -2260,9 +2419,7 @@ fn parse_confirmation_inheritance( } } -fn confirmation_inheritance_to_js( - ci: &a3s_code_core::subagent::ConfirmationInheritance, -) -> String { +fn confirmation_inheritance_to_js(ci: &a3s_code_core::subagent::ConfirmationInheritance) -> String { use a3s_code_core::subagent::ConfirmationInheritance; match ci { ConfirmationInheritance::AutoApprove => "auto_approve".to_string(), @@ -2771,6 +2928,69 @@ impl Session { .map_err(|e| napi::Error::from_reason(format!("{e}"))) } + /// Write a file in the workspace. + #[napi] + pub async fn write_file(&self, path: String, content: String) -> napi::Result { + let session = self.inner.clone(); + let result = get_runtime() + .spawn(async move { session.write_file(&path, &content).await }) + .await + .map_err(|e| napi::Error::from_reason(format!("Task join error: {e}")))? + .map_err(|e| napi::Error::from_reason(format!("{e}")))?; + Ok(tool_result_from_core(result)) + } + + /// List a directory in the workspace. + #[napi] + pub async fn ls(&self, path: Option) -> napi::Result { + let session = self.inner.clone(); + let result = get_runtime() + .spawn(async move { session.ls(path.as_deref()).await }) + .await + .map_err(|e| napi::Error::from_reason(format!("Task join error: {e}")))? + .map_err(|e| napi::Error::from_reason(format!("{e}")))?; + Ok(tool_result_from_core(result)) + } + + /// Edit a file by replacing text in the workspace. + #[napi] + pub async fn edit_file( + &self, + path: String, + old_string: String, + new_string: String, + replace_all: Option, + ) -> napi::Result { + let session = self.inner.clone(); + let result = get_runtime() + .spawn(async move { + session + .edit_file( + &path, + &old_string, + &new_string, + replace_all.unwrap_or(false), + ) + .await + }) + .await + .map_err(|e| napi::Error::from_reason(format!("Task join error: {e}")))? + .map_err(|e| napi::Error::from_reason(format!("{e}")))?; + Ok(tool_result_from_core(result)) + } + + /// Apply a unified diff patch to a workspace file. + #[napi] + pub async fn patch_file(&self, path: String, diff: String) -> napi::Result { + let session = self.inner.clone(); + let result = get_runtime() + .spawn(async move { session.patch_file(&path, &diff).await }) + .await + .map_err(|e| napi::Error::from_reason(format!("Task join error: {e}")))? + .map_err(|e| napi::Error::from_reason(format!("{e}")))?; + Ok(tool_result_from_core(result)) + } + /// Execute a bash command in the workspace. #[napi] pub async fn bash(&self, command: String) -> napi::Result { @@ -4410,6 +4630,79 @@ mod tests { ); } + #[test] + fn local_workspace_backend_maps_to_rust_session_options() { + let opts = js_session_options_to_rust(Some(SessionOptions { + workspace_backend: Some(JsWorkspaceBackend { + kind: "local".to_string(), + root: Some(".".to_string()), + s3: None, + }), + ..Default::default() + })) + .unwrap(); + + assert!(opts.workspace_services.is_some()); + } + + #[test] + fn workspace_backend_rejects_missing_local_root() { + let result = js_session_options_to_rust(Some(SessionOptions { + workspace_backend: Some(JsWorkspaceBackend { + kind: "local".to_string(), + root: None, + s3: None, + }), + ..Default::default() + })); + + assert!(result.is_err()); + } + + #[test] + fn s3_workspace_backend_maps_to_rust_session_options() { + let opts = js_session_options_to_rust(Some(SessionOptions { + workspace_backend: Some(JsWorkspaceBackend { + kind: "s3".to_string(), + root: None, + s3: Some(JsS3BackendConfig { + endpoint: Some("https://minio.local:9000".to_string()), + region: Some("us-east-1".to_string()), + access_key_id: "AKIA".to_string(), + secret_access_key: "secret".to_string(), + session_token: None, + bucket: "workspace".to_string(), + prefix: "users/u1/sessions/s1".to_string(), + force_path_style: Some(true), + }), + }), + ..Default::default() + })) + .unwrap(); + + let services = opts.workspace_services.expect("s3 backend builds services"); + let caps = services.capabilities(); + assert!(caps.read); + assert!(caps.write); + assert!(!caps.exec, "S3 must not expose bash"); + assert!(!caps.git, "S3 must not expose git"); + assert!(!caps.search, "S3 must not expose grep/glob"); + } + + #[test] + fn workspace_backend_rejects_missing_s3_config() { + let result = js_session_options_to_rust(Some(SessionOptions { + workspace_backend: Some(JsWorkspaceBackend { + kind: "s3".to_string(), + root: None, + s3: None, + }), + ..Default::default() + })); + + assert!(result.is_err()); + } + #[test] fn confirmation_policy_rejects_invalid_yolo_lane() { let result = js_session_options_to_rust(Some(SessionOptions { diff --git a/sdk/python/Cargo.toml b/sdk/python/Cargo.toml index 973e8390..acc1cdbc 100644 --- a/sdk/python/Cargo.toml +++ b/sdk/python/Cargo.toml @@ -12,7 +12,7 @@ name = "a3s_code" crate-type = ["cdylib"] [dependencies] -a3s-code-core = { version = "2.5.0", path = "../../core", features = ["ahp"] } +a3s-code-core = { version = "2.5.0", path = "../../core", features = ["ahp", "s3"] } pyo3 = "0.23" tokio = { version = "1.35", features = ["full"] } serde_json = "1.0" diff --git a/sdk/python/src/lib.rs b/sdk/python/src/lib.rs index aba6f6d6..54624bcd 100644 --- a/sdk/python/src/lib.rs +++ b/sdk/python/src/lib.rs @@ -23,14 +23,14 @@ use a3s_code_core::config::{ SearchConfig as RustSearchConfig, SearchEngineConfig as RustSearchEngineConfig, SearchHealthConfig as RustSearchHealthConfig, }; +use a3s_code_core::hitl::{ + ConfirmationPolicy as RustConfirmationPolicy, TimeoutAction as RustTimeoutAction, +}; use a3s_code_core::hooks::{ Hook as RustHook, HookConfig as RustHookConfig, HookEvent as RustHookEvent, HookEventType as RustHookEventType, HookHandler as RustHookHandler, HookMatcher as RustHookMatcher, HookResponse as RustHookResponse, }; -use a3s_code_core::hitl::{ - ConfirmationPolicy as RustConfirmationPolicy, TimeoutAction as RustTimeoutAction, -}; use a3s_code_core::llm::Message as RustMessage; use a3s_code_core::permissions::{ PermissionDecision as RustPermissionDecision, PermissionPolicy as RustPermissionPolicy, @@ -1221,8 +1221,7 @@ impl PySession { prompt: &Bound<'_, PyAny>, history: Option<&Bound<'_, PyList>>, ) -> PyResult { - let (prompt, rust_history, rust_attachments) = - py_session_input_to_parts(prompt, history)?; + let (prompt, rust_history, rust_attachments) = py_session_input_to_parts(prompt, history)?; let session = self.inner.clone(); let result = if rust_attachments.is_empty() { py.allow_threads(move || { @@ -1267,8 +1266,7 @@ impl PySession { prompt: &Bound<'_, PyAny>, history: Option<&Bound<'_, PyList>>, ) -> PyResult { - let (prompt, rust_history, rust_attachments) = - py_session_input_to_parts(prompt, history)?; + let (prompt, rust_history, rust_attachments) = py_session_input_to_parts(prompt, history)?; let session = self.inner.clone(); let (rx, _handle) = if rust_attachments.is_empty() { py.allow_threads(move || { @@ -1295,11 +1293,7 @@ impl PySession { /// /// Prefer this for new integrations when the call may need history, /// attachments, or future request options. - fn send_request( - &self, - py: Python<'_>, - request: &Bound<'_, PyDict>, - ) -> PyResult { + fn send_request(&self, py: Python<'_>, request: &Bound<'_, PyDict>) -> PyResult { let (prompt, rust_history, rust_attachments) = py_session_request_to_parts(request)?; let session = self.inner.clone(); @@ -1471,8 +1465,7 @@ impl PySession { /// Return active tool calls observed for the currently running operation. fn active_tools(&self, py: Python<'_>) -> PyResult { let session = self.inner.clone(); - let active_tools = - py.allow_threads(move || get_runtime().block_on(session.active_tools())); + let active_tools = py.allow_threads(move || get_runtime().block_on(session.active_tools())); let json = serde_json::to_string(&active_tools).map_err(|e| { PyRuntimeError::new_err(format!("Failed to serialize active tools: {e}")) })?; @@ -1510,11 +1503,7 @@ impl PySession { } /// Delegate a bounded task with the compact object-shaped API. - fn task( - &self, - py: Python<'_>, - options: &Bound<'_, PyDict>, - ) -> PyResult { + fn task(&self, py: Python<'_>, options: &Bound<'_, PyDict>) -> PyResult { let json_str = py_dict_to_json(options)?; let args: serde_json::Value = serde_json::from_str(&json_str) .map_err(|e| PyValueError::new_err(format!("Invalid task options: {e}")))?; @@ -1570,7 +1559,9 @@ impl PySession { let session = self.inner.clone(); let result = py .allow_threads(move || get_runtime().block_on(session.tool("parallel_task", args))) - .map_err(|e| PyRuntimeError::new_err(format!("Parallel task delegation failed: {e}")))?; + .map_err(|e| { + PyRuntimeError::new_err(format!("Parallel task delegation failed: {e}")) + })?; Ok(PyToolResult { name: result.name, @@ -1581,11 +1572,7 @@ impl PySession { } /// Execute several delegated child-agent tasks concurrently through ``parallel_task``. - fn parallel_task( - &self, - py: Python<'_>, - tasks: &Bound<'_, PyAny>, - ) -> PyResult { + fn parallel_task(&self, py: Python<'_>, tasks: &Bound<'_, PyAny>) -> PyResult { self.tasks(py, tasks) } @@ -1617,6 +1604,82 @@ impl PySession { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } + /// Write a file in the workspace. + fn write_file(&self, py: Python<'_>, path: String, content: String) -> PyResult { + let session = self.inner.clone(); + let result = py + .allow_threads(move || get_runtime().block_on(session.write_file(&path, &content))) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + + Ok(PyToolResult { + name: result.name, + output: result.output, + exit_code: result.exit_code, + metadata_json: result.metadata.as_ref().map(serde_json::Value::to_string), + }) + } + + /// List a directory in the workspace. + #[pyo3(signature = (path=None))] + fn ls(&self, py: Python<'_>, path: Option) -> PyResult { + let session = self.inner.clone(); + let result = py + .allow_threads(move || get_runtime().block_on(session.ls(path.as_deref()))) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + + Ok(PyToolResult { + name: result.name, + output: result.output, + exit_code: result.exit_code, + metadata_json: result.metadata.as_ref().map(serde_json::Value::to_string), + }) + } + + /// Edit a file by replacing text in the workspace. + #[pyo3(signature = (path, old_string, new_string, replace_all=false))] + fn edit_file( + &self, + py: Python<'_>, + path: String, + old_string: String, + new_string: String, + replace_all: bool, + ) -> PyResult { + let session = self.inner.clone(); + let result = py + .allow_threads(move || { + get_runtime().block_on(session.edit_file( + &path, + &old_string, + &new_string, + replace_all, + )) + }) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + + Ok(PyToolResult { + name: result.name, + output: result.output, + exit_code: result.exit_code, + metadata_json: result.metadata.as_ref().map(serde_json::Value::to_string), + }) + } + + /// Apply a unified diff patch to a workspace file. + fn patch_file(&self, py: Python<'_>, path: String, diff: String) -> PyResult { + let session = self.inner.clone(); + let result = py + .allow_threads(move || get_runtime().block_on(session.patch_file(&path, &diff))) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + + Ok(PyToolResult { + name: result.name, + output: result.output, + exit_code: result.exit_code, + metadata_json: result.metadata.as_ref().map(serde_json::Value::to_string), + }) + } + /// Execute a bash command in the workspace. fn bash(&self, py: Python<'_>, command: String) -> PyResult { let session = self.inner.clone(); @@ -1758,11 +1821,7 @@ impl PySession { /// Example: /// session.git_command({"command": "status"}) /// session.git_command({"command": "worktree", "subcommand": "list"}) - fn git_command( - &self, - py: Python<'_>, - args: &Bound<'_, PyDict>, - ) -> PyResult { + fn git_command(&self, py: Python<'_>, args: &Bound<'_, PyDict>) -> PyResult { let json_str = py_dict_to_json(args)?; let args: serde_json::Value = serde_json::from_str(&json_str) .map_err(|e| PyValueError::new_err(format!("Invalid git args: {e}")))?; @@ -2066,11 +2125,7 @@ impl PySession { /// "env": {"GITHUB_TOKEN": "..."}, /// "timeout_ms": 30000, /// }) - fn add_mcp_server_config( - &self, - py: Python<'_>, - config: &Bound<'_, PyDict>, - ) -> PyResult { + fn add_mcp_server_config(&self, py: Python<'_>, config: &Bound<'_, PyDict>) -> PyResult { let json_str = py_dict_to_json(config)?; let value: serde_json::Value = serde_json::from_str(&json_str) .map_err(|e| PyValueError::new_err(format!("Invalid MCP server config: {e}")))?; @@ -3019,6 +3074,144 @@ impl PyDefaultSecurityProvider { } } +/// Local filesystem workspace backend. +/// +/// This is the explicit typed form of the default local workspace behavior. +/// It is useful when callers want to pass workspace backends through the same +/// option surface that remote/browser backends will use. +/// +/// .. code-block:: python +/// +/// opts = SessionOptions() +/// opts.workspace_backend = LocalWorkspaceBackend('/repo') +/// session = agent.session('/repo', opts) +#[pyclass(name = "LocalWorkspaceBackend")] +#[derive(Clone)] +struct PyLocalWorkspaceBackend { + #[pyo3(get, set)] + root: String, +} + +#[pymethods] +impl PyLocalWorkspaceBackend { + #[new] + fn new(root: String) -> Self { + Self { root } + } + + fn __repr__(&self) -> String { + format!("LocalWorkspaceBackend(root={:?})", self.root) + } +} + +/// S3-compatible object-storage workspace backend. +/// +/// Points the built-in file tools (``read``, ``write``, ``edit``, ``patch``, +/// ``ls``) at any S3-compatible bucket (AWS S3, MinIO, RustFS, Cloudflare R2, +/// Backblaze B2, ...). ``bash``, ``git``, ``grep`` and ``glob`` are +/// intentionally **not** registered when this backend is used because +/// object storage cannot service them. +/// +/// .. code-block:: python +/// +/// opts = SessionOptions() +/// opts.workspace_backend = S3WorkspaceBackend( +/// bucket="workspace", +/// prefix="users/u1/sessions/s1", +/// access_key_id="AKIA...", +/// secret_access_key="...", +/// endpoint="https://minio.local:9000", +/// region="us-east-1", +/// force_path_style=True, +/// ) +/// session = agent.session("s3://workspace/users/u1/sessions/s1", opts) +#[pyclass(name = "S3WorkspaceBackend")] +#[derive(Clone)] +struct PyS3WorkspaceBackend { + #[pyo3(get, set)] + bucket: String, + #[pyo3(get, set)] + prefix: String, + #[pyo3(get, set)] + access_key_id: String, + #[pyo3(get, set)] + secret_access_key: String, + #[pyo3(get, set)] + endpoint: Option, + #[pyo3(get, set)] + region: Option, + #[pyo3(get, set)] + session_token: Option, + #[pyo3(get, set)] + force_path_style: bool, +} + +#[pymethods] +impl PyS3WorkspaceBackend { + #[new] + #[pyo3(signature = ( + bucket, + prefix, + access_key_id, + secret_access_key, + endpoint = None, + region = None, + session_token = None, + force_path_style = false, + ))] + #[allow(clippy::too_many_arguments)] + fn new( + bucket: String, + prefix: String, + access_key_id: String, + secret_access_key: String, + endpoint: Option, + region: Option, + session_token: Option, + force_path_style: bool, + ) -> Self { + Self { + bucket, + prefix, + access_key_id, + secret_access_key, + endpoint, + region, + session_token, + force_path_style, + } + } + + fn __repr__(&self) -> String { + format!( + "S3WorkspaceBackend(bucket={:?}, prefix={:?}, endpoint={:?}, region={:?}, force_path_style={})", + self.bucket, self.prefix, self.endpoint, self.region, self.force_path_style + ) + } +} + +impl PyS3WorkspaceBackend { + fn to_core(&self) -> a3s_code_core::S3BackendConfig { + let mut cfg = a3s_code_core::S3BackendConfig::new( + self.bucket.clone(), + self.prefix.clone(), + self.access_key_id.clone(), + self.secret_access_key.clone(), + ) + .force_path_style(self.force_path_style); + if let Some(ref endpoint) = self.endpoint { + cfg = cfg.endpoint(endpoint.clone()); + } + if let Some(ref region) = self.region { + cfg = cfg.region(region.clone()); + } + if let Some(ref token) = self.session_token { + cfg = cfg.session_token(token.clone()); + } + cfg + } +} + // ============================================================================ // AHP Transport Classes // ============================================================================ @@ -3476,9 +3669,7 @@ fn parse_py_confirmation_inheritance( } } -fn confirmation_inheritance_to_py( - ci: &a3s_code_core::subagent::ConfirmationInheritance, -) -> String { +fn confirmation_inheritance_to_py(ci: &a3s_code_core::subagent::ConfirmationInheritance) -> String { use a3s_code_core::subagent::ConfirmationInheritance; match ci { ConfirmationInheritance::AutoApprove => "auto_approve".to_string(), @@ -3524,6 +3715,8 @@ struct PySessionOptions { session_store: Option, /// Security provider. Set to ``DefaultSecurityProvider`` to enable taint tracking. security_provider: Option, + /// Workspace backend. Set to ``LocalWorkspaceBackend`` to use local filesystem tools explicitly. + workspace_backend: Option, /// Custom role/identity (e.g. "You are a Python expert") role: Option, /// Custom coding guidelines @@ -3617,6 +3810,9 @@ impl Clone for PySessionOptions { security_provider: pyo3::Python::with_gil(|py| { self.security_provider.as_ref().map(|o| o.clone_ref(py)) }), + workspace_backend: pyo3::Python::with_gil(|py| { + self.workspace_backend.as_ref().map(|o| o.clone_ref(py)) + }), role: self.role.clone(), guidelines: self.guidelines.clone(), response_style: self.response_style.clone(), @@ -3661,6 +3857,7 @@ impl PySessionOptions { memory_store: None, session_store: None, security_provider: None, + workspace_backend: None, role: None, guidelines: None, response_style: None, @@ -3855,6 +4052,23 @@ impl PySessionOptions { self.security_provider = value; } + /// Workspace backend used by built-in tools. + /// + /// Assign a ``LocalWorkspaceBackend`` instance: + /// + /// .. code-block:: python + /// + /// opts.workspace_backend = LocalWorkspaceBackend('/repo') + #[getter] + fn get_workspace_backend(&self, py: pyo3::Python<'_>) -> Option { + self.workspace_backend.as_ref().map(|o| o.clone_ref(py)) + } + + #[setter] + fn set_workspace_backend(&mut self, value: Option) { + self.workspace_backend = value; + } + /// Custom role/identity prepended before the core agentic prompt. /// Example: "You are a senior Python developer specializing in FastAPI." #[getter] @@ -4100,7 +4314,7 @@ impl PySessionOptions { fn __repr__(&self) -> String { format!( - "SessionOptions(model={:?}, builtin_skills={}, queue_config={}, auto_compact={}, memory_store={}, session_store={}, security_provider={}, inline_skills={})", + "SessionOptions(model={:?}, builtin_skills={}, queue_config={}, auto_compact={}, memory_store={}, session_store={}, security_provider={}, workspace_backend={}, inline_skills={})", self.model, self.builtin_skills, if self.queue_config.is_some() { "Some(...)" } else { "None" }, @@ -4108,6 +4322,7 @@ impl PySessionOptions { if self.memory_store.is_some() { "Some(...)" } else { "None" }, if self.session_store.is_some() { "Some(...)" } else { "None" }, if self.security_provider.is_some() { "Some(...)" } else { "None" }, + if self.workspace_backend.is_some() { "Some(...)" } else { "None" }, self.inline_skills.len(), ) } @@ -4254,9 +4469,7 @@ fn parse_handler_mode(mode: &str) -> PyResult { fn parse_planning_mode(mode: &str) -> PyResult { match mode.trim().to_ascii_lowercase().as_str() { "auto" => Ok(RustPlanningMode::Auto), - "enabled" | "enable" | "on" | "force" | "forced" | "true" => { - Ok(RustPlanningMode::Enabled) - } + "enabled" | "enable" | "on" | "force" | "forced" | "true" => Ok(RustPlanningMode::Enabled), "disabled" | "disable" | "off" | "false" => Ok(RustPlanningMode::Disabled), _ => Err(PyValueError::new_err(format!( "Invalid planning_mode '{}'. Expected 'auto', 'enabled', or 'disabled'", @@ -4302,7 +4515,9 @@ fn delegate_task_args( fn parallel_task_args(tasks: serde_json::Value) -> PyResult { if !tasks.is_array() { - return Err(PyValueError::new_err("tasks must be a list of dictionaries")); + return Err(PyValueError::new_err( + "tasks must be a list of dictionaries", + )); } Ok(serde_json::json!({ "tasks": tasks })) } @@ -4389,6 +4604,35 @@ fn build_rust_session_options(so: PySessionOptions) -> PyResult BackendKind { + if let Ok(local) = backend.extract::>(py) { + return BackendKind::Local(local.root.clone()); + } + if let Ok(s3) = backend.extract::>(py) { + return BackendKind::S3(s3.to_core()); + } + BackendKind::Unknown + }); + match resolved { + BackendKind::Local(root) => { + o = o.with_workspace_backend(a3s_code_core::WorkspaceServices::local(root)); + } + BackendKind::S3(cfg) => { + o = o.with_workspace_backend(a3s_code_core::WorkspaceServices::s3(cfg)); + } + BackendKind::Unknown => { + return Err(PyTypeError::new_err( + "workspace_backend must be a LocalWorkspaceBackend or S3WorkspaceBackend instance", + )); + } + } + } // Build prompt slots if any slot is set if so.role.is_some() || so.guidelines.is_some() @@ -4660,7 +4904,12 @@ fn normalize_mcp_server_config( .as_object_mut() .ok_or_else(|| PyValueError::new_err("MCP server config must be a dict"))?; - for key in ["timeout_ms", "timeoutMs", "tool_timeout_ms", "toolTimeoutMs"] { + for key in [ + "timeout_ms", + "timeoutMs", + "tool_timeout_ms", + "toolTimeoutMs", + ] { if let Some(timeout_ms) = obj.remove(key) { let timeout_ms = timeout_ms .as_u64() @@ -5181,6 +5430,8 @@ fn a3s_code_native(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; @@ -5263,6 +5514,59 @@ mod tests { assert!(matches!(opts.planning_mode, RustPlanningMode::Enabled)); } + #[test] + fn local_workspace_backend_maps_to_rust_session_options() { + pyo3::prepare_freethreaded_python(); + let opts = Python::with_gil(|py| { + let backend = Py::new( + py, + PyLocalWorkspaceBackend { + root: ".".to_string(), + }, + ) + .unwrap(); + let mut session_options = PySessionOptions::new(); + session_options.workspace_backend = Some(backend.into_any()); + build_rust_session_options(session_options) + }) + .unwrap(); + + assert!(opts.workspace_services.is_some()); + } + + #[test] + fn s3_workspace_backend_maps_to_rust_session_options() { + pyo3::prepare_freethreaded_python(); + let opts = Python::with_gil(|py| { + let backend = Py::new( + py, + PyS3WorkspaceBackend { + bucket: "workspace".to_string(), + prefix: "users/u1/sessions/s1".to_string(), + access_key_id: "AKIA".to_string(), + secret_access_key: "secret".to_string(), + endpoint: Some("https://minio.local:9000".to_string()), + region: Some("us-east-1".to_string()), + session_token: None, + force_path_style: true, + }, + ) + .unwrap(); + let mut session_options = PySessionOptions::new(); + session_options.workspace_backend = Some(backend.into_any()); + build_rust_session_options(session_options) + }) + .unwrap(); + + let services = opts.workspace_services.expect("s3 backend builds services"); + let caps = services.capabilities(); + assert!(caps.read); + assert!(caps.write); + assert!(!caps.exec); + assert!(!caps.git); + assert!(!caps.search); + } + #[test] fn delegate_task_args_use_core_task_schema() { let args = delegate_task_args( @@ -5305,8 +5609,11 @@ mod tests { "async function run(ctx, inputs) { return inputs; }", ) .unwrap(); - dict.set_item("inputs", serde_json::json!({ "needle": "auth" }).to_string()) - .unwrap(); + dict.set_item( + "inputs", + serde_json::json!({ "needle": "auth" }).to_string(), + ) + .unwrap(); dict.set_item("allowedTools", vec!["grep", "read"]).unwrap(); let args = normalize_program_script_options(&dict).unwrap(); @@ -5354,10 +5661,7 @@ mod tests { .unwrap(); match config.transport { - a3s_code_core::mcp::protocol::McpTransportConfig::StreamableHttp { - url, - headers, - } => { + a3s_code_core::mcp::protocol::McpTransportConfig::StreamableHttp { url, headers } => { assert_eq!(url, "https://example.com/mcp"); assert_eq!( headers.get("Authorization").map(String::as_str), From 620bc1ce55a8fe85ef7b1a33c32885dc9e2cce4f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 21:41:38 +0800 Subject: [PATCH 3/3] chore: bump all SDK versions to 2.6.0 + CHANGELOG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns the release surface for v2.6.0: - core/Cargo.toml, sdk/node/Cargo.toml, sdk/python/Cargo.toml: package version + a3s-code-core dependency version. - sdk/python/pyproject.toml: project version. - sdk/node/package.json: root version + all 6 optionalDependencies (@a3s-lab/code-{darwin-arm64,linux-{x64,arm64}-{gnu,musl},win32-x64-msvc}). - sdk/node/package-lock.json and sdk/node/examples/package-lock.json. - Cargo.lock: a3s-code-core / a3s-code-node / a3s-code-py entries. - CHANGELOG.md: v2.6.0 entry covering the workspace abstraction refactor and the new S3WorkspaceBackend feature. scripts/check_release_versions.sh 2.6.0 → pass. --- CHANGELOG.md | 73 +++++++++++++++++++++++++++++ Cargo.lock | 2 +- core/Cargo.toml | 2 +- sdk/node/Cargo.toml | 4 +- sdk/node/examples/package-lock.json | 14 +++--- sdk/node/package-lock.json | 16 +++---- sdk/node/package.json | 14 +++--- sdk/python/Cargo.toml | 4 +- sdk/python/pyproject.toml | 2 +- 9 files changed, 102 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e05afb1..5e23fc23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added `S3WorkspaceBackend` — an S3-compatible workspace backend that lets + built-in file tools (`read`, `write`, `edit`, `patch`, `ls`) operate + directly against any S3-compatible endpoint (AWS S3, MinIO, RustFS, R2, + Backblaze B2, ...). Gated behind the new `s3` Cargo feature. +- Added `S3BackendConfig` builder for configuring endpoint, region, static + or session-token credentials, force-path-style, request timeout, and + bucket prefix. +- Added `WorkspaceServices::s3()` factory and `WorkspaceServices::from_s3_backend()` + helper. The factory installs a 60s default per-operation timeout and + declines `bash`, `git`, `grep`, and `glob` capabilities — capability + gating automatically hides those tools from the model so it cannot + call operations the backend cannot service. +- Exposed `S3WorkspaceBackend` in the Node and Python SDKs alongside + `LocalWorkspaceBackend`. Configuration uses the same option surface + (`workspaceBackend` / `workspace_backend`). + +### Changed + +- Restructured `core/src/workspace.rs` into a `workspace/` module with + `workspace/mod.rs` (abstract traits + `WorkspaceServices`), + `workspace/local.rs` (`LocalWorkspaceBackend`), and `workspace/s3.rs` + (`S3WorkspaceBackend`). No behavioural change for existing callers. + +## [2.6.0] - 2026-05-18 + +### Added + +- Added `WorkspaceServices` capability abstraction (`core/src/workspace.rs`) + that lets the host supply file system, command runner, search, and Git + providers behind the stable built-in tool contract. The default + `LocalWorkspaceBackend` preserves existing local-filesystem behavior, while + DFS, browser, container, and remote backends can be assembled via + `WorkspaceServicesBuilder`. +- Added `SessionOptions::with_workspace_backend()` (alias + `with_workspace_services`) so callers can opt-in to non-local workspaces + without changing tool schemas. +- Added capability-driven tool gating: `bash`, `grep`, `glob`, and `git` are + only registered when the workspace backend declares the matching capability, + preventing models from invoking tools the backend cannot service. +- Added `Session::write_file`, `Session::ls`, `Session::edit_file`, and + `Session::patch_file` direct-tool APIs in core, Node, and Python SDKs, + alongside the existing `read_file` / `bash` / `glob` / `grep`. +- Added `LocalWorkspaceBackend` class to the Node and Python SDKs as the + explicit typed form of the default backend and the option surface for future + remote/browser/DFS workspaces. +- Added `workspace_services` to `ChildRunContext` so child runs inherit the + parent's workspace backend. +- Added 17 unit + integration tests covering virtual path resolution, capability + downgrade, contract-level tool routing for files / search / bash / git + through pluggable backends, and session-level direct-tool dispatch. + +### Changed + +- Refactored built-in tools `read`, `write`, `edit`, `patch`, `ls`, `bash`, + `grep`, `glob`, and `git` to route operations through `WorkspaceServices` + instead of hard-coded local filesystem calls. Local behavior is unchanged. +- Centralized workspace-boundary path checks in + `ToolContext::resolve_workspace_path`, removing duplicated canonicalization + logic from `ToolExecutor::execute`. + +### Fixed + +- Removed two `clippy::useless_conversion` warnings in + `core/tests/test_ahp_idle_with_llm.rs` so `cargo clippy --all-targets` is + clean. + +### Documentation + +- Updated `README.md`, Node SDK README, and Python SDK README with workspace + backend usage and the new direct-tool API surface. + ## [2.5.0] - 2026-05-12 ### Added diff --git a/Cargo.lock b/Cargo.lock index 150b03af..79b9929e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,7 +37,7 @@ dependencies = [ [[package]] name = "a3s-code-core" -version = "2.5.0" +version = "2.6.0" dependencies = [ "a3s-acl 0.2.0", "a3s-ahp", diff --git a/core/Cargo.toml b/core/Cargo.toml index f6f49a6d..34f1ca95 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "a3s-code-core" -version = "2.5.0" +version = "2.6.0" edition = "2021" authors = ["A3S Lab Team"] license = "MIT" diff --git a/sdk/node/Cargo.toml b/sdk/node/Cargo.toml index 5e188263..91cc0992 100644 --- a/sdk/node/Cargo.toml +++ b/sdk/node/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "a3s-code-node" -version = "2.5.0" +version = "2.6.0" edition = "2021" authors = ["A3S Lab Team"] license = "MIT" @@ -11,7 +11,7 @@ description = "A3S Code Node.js bindings - Native addon via napi-rs" crate-type = ["cdylib"] [dependencies] -a3s-code-core = { version = "2.5.0", path = "../../core", features = ["ahp", "s3"] } +a3s-code-core = { version = "2.6.0", path = "../../core", features = ["ahp", "s3"] } napi = { version = "2", features = ["async", "napi6", "serde-json"] } napi-derive = "2" tokio = { version = "1.35", features = ["full"] } diff --git a/sdk/node/examples/package-lock.json b/sdk/node/examples/package-lock.json index babf7e55..7b55c68b 100644 --- a/sdk/node/examples/package-lock.json +++ b/sdk/node/examples/package-lock.json @@ -18,7 +18,7 @@ }, "..": { "name": "@a3s-lab/code", - "version": "2.5.0", + "version": "2.6.0", "license": "MIT", "devDependencies": { "@napi-rs/cli": "^2", @@ -27,12 +27,12 @@ "typescript": "^5.9.3" }, "optionalDependencies": { - "@a3s-lab/code-darwin-arm64": "2.5.0", - "@a3s-lab/code-linux-arm64-gnu": "2.5.0", - "@a3s-lab/code-linux-arm64-musl": "2.5.0", - "@a3s-lab/code-linux-x64-gnu": "2.5.0", - "@a3s-lab/code-linux-x64-musl": "2.5.0", - "@a3s-lab/code-win32-x64-msvc": "2.5.0" + "@a3s-lab/code-darwin-arm64": "2.6.0", + "@a3s-lab/code-linux-arm64-gnu": "2.6.0", + "@a3s-lab/code-linux-arm64-musl": "2.6.0", + "@a3s-lab/code-linux-x64-gnu": "2.6.0", + "@a3s-lab/code-linux-x64-musl": "2.6.0", + "@a3s-lab/code-win32-x64-msvc": "2.6.0" } }, "node_modules/@a3s-lab/code": { diff --git a/sdk/node/package-lock.json b/sdk/node/package-lock.json index 6f46c489..cd1540ff 100644 --- a/sdk/node/package-lock.json +++ b/sdk/node/package-lock.json @@ -1,12 +1,12 @@ { "name": "@a3s-lab/code", - "version": "2.5.0", + "version": "2.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@a3s-lab/code", - "version": "2.5.0", + "version": "2.6.0", "license": "MIT", "devDependencies": { "@napi-rs/cli": "^2", @@ -15,12 +15,12 @@ "typescript": "^5.9.3" }, "optionalDependencies": { - "@a3s-lab/code-darwin-arm64": "2.5.0", - "@a3s-lab/code-linux-arm64-gnu": "2.5.0", - "@a3s-lab/code-linux-arm64-musl": "2.5.0", - "@a3s-lab/code-linux-x64-gnu": "2.5.0", - "@a3s-lab/code-linux-x64-musl": "2.5.0", - "@a3s-lab/code-win32-x64-msvc": "2.5.0" + "@a3s-lab/code-darwin-arm64": "2.6.0", + "@a3s-lab/code-linux-arm64-gnu": "2.6.0", + "@a3s-lab/code-linux-arm64-musl": "2.6.0", + "@a3s-lab/code-linux-x64-gnu": "2.6.0", + "@a3s-lab/code-linux-x64-musl": "2.6.0", + "@a3s-lab/code-win32-x64-msvc": "2.6.0" } }, "node_modules/@a3s-lab/code-darwin-arm64": { diff --git a/sdk/node/package.json b/sdk/node/package.json index d0238d28..e7381603 100644 --- a/sdk/node/package.json +++ b/sdk/node/package.json @@ -1,6 +1,6 @@ { "name": "@a3s-lab/code", - "version": "2.5.0", + "version": "2.6.0", "description": "A3S Code - Native Node.js bindings for the coding-agent runtime", "main": "index.js", "types": "index.d.ts", @@ -40,11 +40,11 @@ "test:helpers": "node test-helpers.mjs" }, "optionalDependencies": { - "@a3s-lab/code-darwin-arm64": "2.5.0", - "@a3s-lab/code-linux-x64-gnu": "2.5.0", - "@a3s-lab/code-linux-x64-musl": "2.5.0", - "@a3s-lab/code-linux-arm64-gnu": "2.5.0", - "@a3s-lab/code-linux-arm64-musl": "2.5.0", - "@a3s-lab/code-win32-x64-msvc": "2.5.0" + "@a3s-lab/code-darwin-arm64": "2.6.0", + "@a3s-lab/code-linux-x64-gnu": "2.6.0", + "@a3s-lab/code-linux-x64-musl": "2.6.0", + "@a3s-lab/code-linux-arm64-gnu": "2.6.0", + "@a3s-lab/code-linux-arm64-musl": "2.6.0", + "@a3s-lab/code-win32-x64-msvc": "2.6.0" } } diff --git a/sdk/python/Cargo.toml b/sdk/python/Cargo.toml index acc1cdbc..6bbe92bd 100644 --- a/sdk/python/Cargo.toml +++ b/sdk/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "a3s-code-py" -version = "2.5.0" +version = "2.6.0" edition = "2021" authors = ["A3S Lab Team"] license = "MIT" @@ -12,7 +12,7 @@ name = "a3s_code" crate-type = ["cdylib"] [dependencies] -a3s-code-core = { version = "2.5.0", path = "../../core", features = ["ahp", "s3"] } +a3s-code-core = { version = "2.6.0", path = "../../core", features = ["ahp", "s3"] } pyo3 = "0.23" tokio = { version = "1.35", features = ["full"] } serde_json = "1.0" diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 3718defe..498c9974 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "a3s-code" -version = "2.5.0" +version = "2.6.0" description = "A3S Code - Native Python bindings for the coding-agent runtime" readme = "README.md" license = {text = "MIT"}