feat(acp): ✨ Add ACP server support#214
Conversation
AI Code Review SummaryPR: #214 (feat(acp): ✨ Add ACP server support) Overall AssessmentDetected 11 actionable findings, prioritize CRITICAL/HIGH before merge. Major Findings by Severity
Actionable Suggestions
Potential Risks
Test Suggestions
File-Level Coverage Notes
Inline Downgraded Items (processed but not inline)
Coverage Status
Uncovered list:
No-patch covered list:
Runtime/Budget
|
| } | ||
| } | ||
|
|
||
| async fn request_permission( |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| }} | ||
| > | ||
| {isPromoting ? <Loader2Icon className="size-3.5 animate-spin" /> : <Wand2Icon className="size-3.5" />} | ||
| {isPromoting ? <Loader2Icon className="size-3.5 animate-spin" /> : <ListStart className="size-3.5" />} |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| agent_handlers::serve_connection(state, agent_client_protocol_tokio::Stdio::new()).await | ||
| } | ||
|
|
||
| pub fn spawn_http_server_if_configured(state: AcpServerState) { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| Ok(()) | ||
| } | ||
|
|
||
| pub fn run_acp_stdio() -> anyhow::Result<()> { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| match task.stage { | ||
| TaskStage::Pending => PlanEntryStatus::Pending, | ||
| TaskStage::InProgress => PlanEntryStatus::InProgress, | ||
| TaskStage::Completed | TaskStage::Failed => PlanEntryStatus::Completed, |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| .await | ||
| } | ||
|
|
||
| fn is_loopback(ip: IpAddr) -> bool { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| @@ -9,7 +9,6 @@ import { | |||
| Loader2Icon, | |||
| PencilIcon, | |||
| Trash2Icon, | |||
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| @@ -1,3 +1,4 @@ | |||
| pub mod acp; | |||
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| Ok(()) | ||
| } | ||
|
|
||
| pub fn run_acp_stdio() -> anyhow::Result<()> { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| }} | ||
| > | ||
| {isPromoting ? <Loader2Icon className="size-3.5 animate-spin" /> : <Wand2Icon className="size-3.5" />} | ||
| {isPromoting ? <Loader2Icon className="size-3.5 animate-spin" /> : <ListStart className="size-3.5" />} |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| status: RunStatus, | ||
| error_message: Option<&str>, | ||
| ) -> Result<(), AppError> { | ||
| persist_final_run_state(pool, run_id, thread_id, status, error_message).await?; |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| // Broadcast a global event so the sidebar can pick up the new title even | ||
| // when no per-run stream subscription exists (e.g. inactive threads). | ||
| let _ = app_handle.emit( | ||
| emit_app_event( |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| let acp_state = crate::acp::AcpServerState::from_app_state(&state); | ||
| app.manage(state); | ||
| app.manage(desktop_runtime); | ||
| crate::acp::spawn_http_server(acp_state); |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| return Ok(PromptResponse::new(stop_reason)); | ||
| } | ||
| } | ||
| Err(broadcast::error::RecvError::Lagged(dropped_events)) => { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| pub index_manager: Arc<IndexManager>, | ||
| pub git_manager: Arc<GitManager>, | ||
| pub extensions_manager: Arc<ExtensionsManager>, | ||
| pub app_events: AppEventEmitterRef, |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| .message_id(None), | ||
| ), | ||
| ))?; | ||
| return Ok(false); |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| @@ -1,9 +1,11 @@ | |||
| use std::sync::Arc; | |||
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| .on_receive_request( | ||
| { | ||
| async move |_request: AuthenticateRequest, responder, _cx| { | ||
| responder.respond(AuthenticateResponse::new()) |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| )) | ||
| } | ||
|
|
||
| async fn handle_new_session( |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| const REJECT_ONCE_OPTION_ID: &str = "reject_once"; | ||
| const PERMISSION_TIMEOUT: Duration = Duration::from_secs(60); | ||
|
|
||
| pub async fn request_permission_and_resolve( |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| Ok(()) | ||
| } | ||
|
|
||
| pub fn run_acp_stdio() -> anyhow::Result<()> { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| request: ListSessionsRequest, | ||
| ) -> Result<ListSessionsResponse, agent_client_protocol::Error> { | ||
| let mut infos = Vec::new(); | ||
| let workspaces = state.workspace_manager.list().await.map_err(to_acp_error)?; |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| Some(thread.title), | ||
| Some(thread.last_active_at), | ||
| ); | ||
| state.sessions.insert(record.clone()).await; |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| message_id, | ||
| reasoning, | ||
| .. | ||
| } if !reasoning.trim().is_empty() => { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| crate::core::settings_manager::apply_bundled_catalog_if_newer(app.handle()); | ||
| tracing::info!(elapsed_ms = t0.elapsed().as_millis(), "⏱ [startup] apply_bundled_catalog_if_newer"); | ||
|
|
||
| let acp_state = crate::acp::AcpServerState::from_app_state(&state); |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } | ||
|
|
||
| # ACP server | ||
| agent-client-protocol = { version = "0.11.1", features = ["unstable"] } |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| None => None, | ||
| }; | ||
|
|
||
| for workspace in workspaces { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| agent_run_manager: Arc::clone(&state.agent_run_manager), | ||
| tool_gateway: Arc::clone(&state.tool_gateway), | ||
| terminal_manager: Arc::clone(&state.terminal_manager), | ||
| index_manager: Arc::new(IndexManager::new()), |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| assert!(!is_loopback("0.0.0.0".parse().unwrap())); | ||
| assert!(!is_loopback("192.168.1.10".parse().unwrap())); | ||
| // Be conservative: IPv4-mapped IPv6 is not accepted as an ACP bind target. | ||
| assert!(!is_loopback("::ffff:127.0.0.1".parse().unwrap())); |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
- Add is_cli_installed, install_cli_in_path, uninstall_cli_from_path Tauri commands that create/remove a symlink at /usr/local/bin/tiycode pointing to the current app binary (macOS: osascript privilege escalation, Linux: pkexec) - Add CLI registration row in General Settings Preferences section with i18n support (en/zh-CN), describing ACP server usage - Refactor ACP transport: support --http <addr> alongside --stdio, expose run_http() as public API, simplify address parsing
…tion Reorganize the settings UI by moving the CLI installation row out of the Preferences section into a new "ACP Server" section with updated labels and descriptions that better describe both stdio and HTTP transport options. - Add acpServerTitle, acpServerCliLabel, acpServerCliDesc translation keys for English and Chinese locales - Move CLI install/uninstall row from Preferences to new ACP Server section for clearer logical grouping
Resolve active agent profile and construct RuntimeModelPlan on the backend so ACP (headless) sessions inherit the user's model configuration without relying on the frontend to supply it. - Resolve active profile ID when creating new ACP sessions - Build model plan from profile at prompt time as fallback - Add agent_session_model_plan module to core
Position action buttons absolutely in the top-right corner instead of as a flex sibling, so the message content area uses the full card width without reserving space for non-overlapping buttons.
| #[cfg(target_os = "macos")] | ||
| fn create_symlink_privileged(target: &Path, link: &Path) -> Result<(), String> { | ||
| let script = format!( | ||
| "do shell script \"ln -sf '{}' '{}'\" with administrator privileges", |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| }} | ||
| > | ||
| {isPromoting ? <Loader2Icon className="size-3.5 animate-spin" /> : <Wand2Icon className="size-3.5" />} | ||
| {isPromoting ? <Loader2Icon className="size-3.5 animate-spin" /> : <ListStart className="size-3.5" />} |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| /// Install the CLI by creating a symlink at `/usr/local/bin/tiycode`. | ||
| /// On macOS, uses `osascript` to prompt for admin privileges when needed. | ||
| #[tauri::command] | ||
| pub fn install_cli_in_path() -> Result<String, String> { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| // ── Settings: General Panel ──────────────────────────────── | ||
| "settings.general.newProfile": "New Profile", | ||
| "settings.general.preferences": "Preferences", | ||
| "settings.general.cliLabel": "CLI in PATH", |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| }} | ||
| > | ||
| {isPromoting ? <Loader2Icon className="size-3.5 animate-spin" /> : <Wand2Icon className="size-3.5" />} | ||
| {isPromoting ? <Loader2Icon className="size-3.5 animate-spin" /> : <ListStart className="size-3.5" />} |
There was a problem hiding this comment.
[CRITICAL] Missing import for ListStart icon
The ListStart icon component from lucide-react is used but not imported, which will cause a TypeScript compilation error and possibly a runtime error.
Suggestion: Add 'ListStart' to the lucide-react import statement (e.g., import { ..., ListStart } from 'lucide-react';).
Risk: TypeScript compile error, application will likely fail to build or run.
Confidence: 0.95
| other => panic!("unexpected updates: {other:?}"), | ||
| } | ||
| } | ||
| } |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| Ok(Message::Close(_)) => None, | ||
| // Axum handles websocket ping/pong control frames; ACP JSON-RPC | ||
| // payloads are carried only by text/binary data frames. | ||
| Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => None, |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
|
|
||
| // Fall back to privileged removal. | ||
| remove_cli_link_privileged(&link_path)?; | ||
| Ok("CLI uninstalled successfully.".into()) |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| } | ||
| }, []); | ||
|
|
||
| const handleToggleCli = async () => { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| setCliLoading(true); | ||
| try { | ||
| if (cliInstalled) { | ||
| await invoke<string>("uninstall_cli_from_path"); |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| let link_path = PathBuf::from(CLI_SYMLINK_PATH); | ||
|
|
||
| // If already correctly installed, return early. | ||
| if link_path.exists() || link_path.symlink_metadata().is_ok() { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| request: CloseSessionRequest, | ||
| ) -> Result<CloseSessionResponse, agent_client_protocol::Error> { | ||
| let record = ensure_session_loaded(&state, request.session_id.clone(), None).await?; | ||
| let _ = state.agent_run_manager.cancel_run(&record.thread_id).await; |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| // ── Settings: General Panel ──────────────────────────────── | ||
| "settings.general.newProfile": "New Profile", | ||
| "settings.general.preferences": "Preferences", | ||
| "settings.general.cliLabel": "CLI in PATH", |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| await invoke<string>("install_cli_in_path"); | ||
| setCliInstalled(true); | ||
| } | ||
| } catch { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| } | ||
|
|
||
| // Try direct removal first. | ||
| if std::fs::remove_file(&link_path).is_ok() { |
There was a problem hiding this comment.
[CRITICAL] CLI uninstall may delete arbitrary files instead of only the symlink
The CLI uninstall functions do not verify that the target path is a symlink before deleting it, which could lead to accidental removal of unrelated files.
Suggestion: Use std::fs::symlink_metadata to confirm that the target is a symlink before attempting deletion. If it is not a symlink, refuse to uninstall and return an appropriate error message.
Risk: Blind file deletion may destroy important files, especially if the user or another tool has placed a binary at /usr/local/bin/tiycode. This is a local denial-of-service/data loss risk.
Confidence: 0.95
| } | ||
|
|
||
| #[cfg(target_os = "macos")] | ||
| fn create_symlink_privileged(target: &Path, link: &Path) -> Result<(), String> { |
There was a problem hiding this comment.
[CRITICAL] Command injection in macOS privileged symlink creation via osascript
Attackers who can influence the binary path (e.g., via symlink or executable location) can inject shell commands when the macOS CLI installer invokes osascript with administrator privileges.
Suggestion: Instead of embedding attacker-controlled paths directly in a shell string, construct the do shell script using a fixed, trusted format and pass paths through quoted form of AppleScript directive or use macOS security-scoped APIs. Avoid invoking a shell at all; use a direct privilege-escalation mechanism that accepts argument vectors (e.g., Security.framework).
Risk: Arbitrary command execution as root; complete system compromise during CLI installation.
Confidence: 0.95
|
|
||
| #[cfg(all(target_os = "linux", not(target_os = "macos")))] | ||
| fn create_symlink_privileged(target: &Path, link: &Path) -> Result<(), String> { | ||
| let output = Command::new("pkexec") |
There was a problem hiding this comment.
[CRITICAL] Command injection in Linux privileged symlink creation via pkexec
Paths derived from environment or binary location are passed to pkexec ln and pkexec rm as arguments, which can still lead to unintended behavior if special characters or relative path tricks are used.
Suggestion: Validate that both paths are absolute, contain only safe characters, and match expected patterns before passing them to pkexec. Consider using Path::canonicalize() before constructing the command to resolve symlinks and relative segments.
Risk: Privilege escalation and arbitrary file operations if binary path is attacker-controlled.
Confidence: 0.95
| /// Returns `Err` if: | ||
| /// - The profile does not exist | ||
| /// - The profile's primary provider/model is missing or disabled | ||
| pub async fn build_model_plan_from_profile( |
There was a problem hiding this comment.
[HIGH] Missing test for profile with disabled primary model in build_model_plan_from_profile
The critical error path for disabled primary models in ACP model plan construction has no test coverage. If the model plan silently fails, ACP sessions will not start.
Suggestion: Add integration tests that create a profile with a disabled primary provider/model and assert that build_model_plan_from_profile returns the expected AppError with the correct error code.
Risk: ACP users could receive cryptic 'Model plan missing' errors or silent crashes if the primary model is disabled, because the error is not validated in tests.
Confidence: 0.95
| } | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- |
There was a problem hiding this comment.
[MEDIUM] Missing test for Windows CLI path manipulation helpers
The new Windows PATH manipulation functions (path_contains_dir and remove_dir_from_path) have no unit tests, making it hard to verify correctness across edge cases like trailing semicolons, duplicate entries, and case-insensitive directory comparison.
Suggestion: Add unit tests for path_contains_dir and remove_dir_from_path with various semicolon-separated PATH strings, including empty strings, trailing semicolons, and directory entries with different casing (since Windows paths are case-insensitive).
Risk: A bug in remove_dir_from_path could corrupt the user's PATH environment variable, breaking other applications that rely on it.
Confidence: 0.90
| path_value | ||
| .split(';') | ||
| .filter(|s| !s.is_empty()) | ||
| .any(|entry| Path::new(entry) == dir) |
There was a problem hiding this comment.
[MEDIUM] Windows PATH directory comparison is case-sensitive
The PATH directory equality check is case-sensitive on Windows, causing potential misdetection of already-installed binaries.
Suggestion: Convert both paths to a canonical form (e.g., lowercase) or use fs::canonicalize and compare canonical paths, or use a case-insensitive comparison such as eq_ignore_ascii_case on the path components.
Risk: Duplicate PATH entries may be added or removal may miss existing entries, leading to a broken CLI experience on Windows.
Confidence: 0.90
| /// 1. `active_profile_id` from settings KV table | ||
| /// 2. The first profile with `is_default = true` (list_all sorts by is_default DESC) | ||
| /// 3. The first profile in the list (alphabetical fallback) | ||
| pub async fn resolve_active_profile_id(pool: &SqlitePool) -> Result<Option<String>, AppError> { |
There was a problem hiding this comment.
[MEDIUM] Missing test for resolve_active_profile_id fallback chains
The new resolve_active_profile_id function has multiple fallback paths that are not validated in tests. The empty-settings and empty-profile-list paths could silently return None without any observable error.
Suggestion: Add integration tests in src-tauri/tests/ that seed the settings_repo with and without active_profile_id, and with empty or non-empty profile lists, and assert the correct profile ID is resolved or None is returned.
Risk: No debug assertion or logging when all fallbacks fail; could lead to silent failures in ACP mode where no profile is found and the model plan cannot be built.
Confidence: 0.90
| ) | ||
| .await?; | ||
|
|
||
| // Resolve lightweight with fallback chain: explicit → auxiliary → primary |
There was a problem hiding this comment.
[MEDIUM] Missing test for lightweight role fallback chain
The lightweight role fallback logic lacks test coverage for the fallback chain, including the case where all three roles are undefined.
Suggestion: Add integration tests for profiles with only primary set, only auxiliary set, and no lightweight role, verifying the correct fallback role is used.
Risk: If the fallback chain has a logic bug, the lightweight model could be silently set to an incorrect role, affecting compaction and title generation behavior in ACP mode.
Confidence: 0.85
| #[derive(Debug, Default)] | ||
| pub struct NoopAppEventEmitter; | ||
|
|
||
| impl AppEventEmitter for NoopAppEventEmitter { |
There was a problem hiding this comment.
[MEDIUM] Missing test for NoopAppEventEmitter silent discard
The NoopAppEventEmitter is crucial for ACP headless mode, but there is no test confirming that the system works without Tauri event delivery. This is a regression risk.
Suggestion: Add a test that initializes the agent run manager with a NoopAppEventEmitter and runs a basic session to completion, verifying that no panics or hangs occur due to missing event delivery.
Risk: If a future refactor assumes events are always delivered, ACP mode could crash or hang because the NoopAppEventEmitter silently discards everything.
Confidence: 0.85
Summary
Test Plan
🤖 Generated with TiyCode