diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2eee213b55..66dd64bdcb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,6 +22,13 @@ crate-type = ["rlib"] [features] default = ["tauri-runtime", "browser-child"] +# Tauri's release switch: with it, `tauri` embeds `frontendDist` and the app +# loads its bundled assets; without it, `DEP_TAURI_DEV=true` and the app asks +# `devUrl` (http://localhost:3000) for a dev server that a plain +# `cargo build --release` never starts — an `ERR_CONNECTION_REFUSED` window. +# `pnpm tauri build` turns this on; a hand-run cargo build must name it. +custom-protocol = ["tauri/custom-protocol"] + tauri-runtime = [ "dep:tauri", "dep:tauri-plugin-opener", diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index e0717356fd..2a85fa9a44 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -3556,6 +3556,113 @@ async fn admit_permission( reply: PermissionReply, card: QueuedPermission, ) { + // Auto-approve: answer straight away instead of raising a card, for + // connections a codeg-driven launch marked with `CODEG_AUTO_APPROVE=1`. + // That env var is inherited by every ACP child codeg spawns, so a + // delegated sub-agent can never park on a card nobody is looking at. + // + // The motivating case is `deepseek-acp` 0.9.0: its ACP bridge has no + // approval MODE at all (`set_mode` / `availableModes` appear nowhere in + // the package), and the only options it ever offers are allow-once and + // reject-once (hard-coded in its `approval/request` handler). A write + // tool call therefore parked forever with its parent delegation waiting + // on it — twice for over 8 hours in 2026-11. + // + // Deliberately NOT gated on an agent type: the flag is the whole policy. + // `allow_once` only — never `allow_always`, which would write a standing + // rule into the user's own agent settings (see + // [`PermissionOptionInfo::allow_once_in`]). A request offering no such + // option falls through to the card exactly as before. + // + // A `cli_run` connection is NEVER auto-approved. That tag marks a headless + // `codeg-cli` run — its root connection, or a delegation child that + // inherited it at spawn — and its `--permissions` policy is authoritative + // there: `deny`, `reads`, `fail` and `wait` all mean something that + // answering `allow_once` here would silently overrule, along with the + // run's own permission reporting. Those connections keep the card/event + // path, which is what applies their policy. + if let Some(option_id) = crate::acp::auto_approve_option( + crate::acp::auto_approve_enabled(), + state.read().await.cli_run.is_some(), + &card.options, + ) { + let scope = permission_log_scope(state).await; + tracing::info!( + "[ACP] permission {} auto-approved {scope} (option={option_id}, CODEG_AUTO_APPROVE)", + card.request_id + ); + let tool_call_id = crate::acp::session_state::extract_tool_call_id(&card.tool_call); + let kind = PermissionOptionInfo::allow_once_in(&card.options) + .map(|o| o.kind.clone()) + .unwrap_or_default(); + let name = PermissionOptionInfo::allow_once_in(&card.options) + .map(|o| o.name.clone()) + .unwrap_or_default(); + // The agent may have withdrawn the request while the `state` read + // above was waiting (or before it ever got here). Answering + // `allow_once` then would grant a call the agent already cancelled, + // and the `ToolApproval` record would claim a decision nobody made. + // Answer the withdrawal instead, and record nothing — exactly what + // `watch_permission_withdrawal` does for a card. + if reply.cancellation().is_cancelled() { + tracing::info!( + "[ACP] permission {} withdrawn before auto-approval {scope}", + card.request_id + ); + match reply { + PermissionReply::Acp(responder) => { + let _ = responder.respond(RequestPermissionResponse::new( + RequestPermissionOutcome::Cancelled, + )); + } + PermissionReply::CodexElicitation { responder, .. } => { + let _ = responder.respond( + serde_json::to_value( + crate::acp::question::elicitation_cancel_response(), + ) + .unwrap_or_default(), + ); + } + } + return; + } + // Answer the way a user's click does — `respond_selected` is the + // same responder tail a card runs — but without parking the reply + // in the queue first: nothing will ever promote or withdraw it. + match reply { + PermissionReply::Acp(responder) => { + let outcome = RequestPermissionOutcome::Selected( + SelectedPermissionOutcome::new(option_id), + ); + let _ = responder.respond(RequestPermissionResponse::new(outcome)); + } + PermissionReply::CodexElicitation { + responder, + approval, + } => { + let response = + crate::acp::question::build_elicitation_approval_response(&approval, &option_id); + let _ = responder.respond(serde_json::to_value(response).unwrap_or_default()); + } + } + if !tool_call_id.is_empty() { + emit_with_state( + state, + emitter, + AcpEvent::ToolApproval { + approval: ToolApprovalInfo { + tool_call_id, + option_kind: kind, + option_name: name, + origin: crate::acp::AUTO_APPROVE_ORIGIN.to_string(), + decided_at: chrono::Utc::now(), + }, + }, + ) + .await; + } + return; + } let cancellation = reply.cancellation(); let (settled_tx, settled_rx) = oneshot::channel(); let request_id = card.request_id.clone(); diff --git a/src-tauri/src/acp/file_system_runtime.rs b/src-tauri/src/acp/file_system_runtime.rs index 95f2f5d87e..40a3b023af 100644 --- a/src-tauri/src/acp/file_system_runtime.rs +++ b/src-tauri/src/acp/file_system_runtime.rs @@ -320,9 +320,18 @@ fn agent_data_roots(agent_type: AgentType, runtime_env: &BTreeMap usize { match agent_type { - AgentType::Antigravity | AgentType::KimiCode => { + AgentType::Antigravity | AgentType::KimiCode | AgentType::DeepSeek => { usize::try_from(FS_MAX_FILE_SIZE_BYTES).unwrap_or(usize::MAX) } _ => FS_MAX_READ_RESPONSE_BYTES, diff --git a/src-tauri/src/acp/mod.rs b/src-tauri/src/acp/mod.rs index 89de586e1e..4aebc4af68 100644 --- a/src-tauri/src/acp/mod.rs +++ b/src-tauri/src/acp/mod.rs @@ -103,3 +103,185 @@ pub fn continued_session_ids(agent_type: crate::models::AgentType, session_id: & } crate::acp_transcript::continuation_ancestors(registry::registry_id_for(agent_type), session_id) } + +/// `CODEG_AUTO_APPROVE=1` turns codeg into the approver for every ACP child it +/// launches: a `session/request_permission` is answered with its `allow_once` +/// option instead of being raised as a card (#447). +/// +/// Read from the process env, which every ACP child inherits, so the flag is +/// inherited rather than plumbed per-connection. Off unless the variable is +/// exactly `1`, `true` or `yes` — an unset or empty value keeps the ordinary +/// card flow. +/// +/// This exists because some adapters cannot express an approval policy at all: +/// `deepseek-acp` 0.9.0 has no `set_mode`, no `availableModes`, and a +/// hard-coded allow-once / reject-once pair in its `approval/request` handler, +/// so a write tool call parked forever and its parent delegation waited on it. +pub fn auto_approve_enabled() -> bool { + parse_auto_approve(std::env::var(crate::acp::AUTO_APPROVE_ENV).ok().as_deref()) +} + +/// The flag's whole reading of its value, split out so it is testable without +/// touching the process env (which is shared by every test in the binary). +/// +/// `None` (unset) and any unrecognized string keep the card flow. Accepting +/// `1`/`true`/`yes` in any case is deliberate: the var is set by hand, in a +/// shell or an OS environment dialog, and a `TRUE` that silently did nothing +/// would be worse than the extra leniency. +fn parse_auto_approve(raw: Option<&str>) -> bool { + matches!( + raw.unwrap_or_default().trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" + ) +} + +/// The env var [`auto_approve_enabled`] reads. +pub const AUTO_APPROVE_ENV: &str = "CODEG_AUTO_APPROVE"; + +/// The [`crate::acp::types::ToolApprovalInfo::origin`] an auto-approved +/// permission records. +pub const AUTO_APPROVE_ORIGIN: &str = "auto_approve"; + +/// The whole "should this card be auto-approved?" decision, split out so its +/// every branch is unit-testable without a live ACP connection. +/// +/// `None` means "leave it on the card path"; `Some(id)` means "answer with this +/// option". Three gates, in the order the caller checks them: +/// +/// 1. the flag must be on; +/// 2. the connection must NOT carry a `cli_run` tag — that tag marks a +/// headless `codeg-cli` run (or a delegation child that inherited it) whose +/// `--permissions` policy is authoritative, and answering here would +/// overrule `deny` / `reads` / `fail` / `wait`; +/// 3. the request must actually offer an `allow_once` option. Rejection +/// deliberately comes first in some adapters (claude-agent-acp 0.91.0 +/// leads with it for `defaultToNo` calls), so this is a kind lookup, never +/// a positional one. `allow_always` alone is NOT enough: picking it would +/// write a standing rule into the user's own agent settings. +pub fn auto_approve_option( + enabled: bool, + cli_run_tagged: bool, + options: &[crate::acp::types::PermissionOptionInfo], +) -> Option { + if !enabled || cli_run_tagged { + return None; + } + crate::acp::types::PermissionOptionInfo::allow_once_in(options) + .map(|o| o.option_id.clone()) +} + +#[cfg(test)] +mod auto_approve_flag_tests { + use super::{auto_approve_option, parse_auto_approve, AUTO_APPROVE_ORIGIN}; + use crate::acp::types::PermissionOptionInfo; + + fn opt(id: &str, kind: &str) -> PermissionOptionInfo { + PermissionOptionInfo { + option_id: id.to_string(), + name: id.to_string(), + kind: kind.to_string(), + meta: None, + } + } + + /// The enabled spellings. Case and surrounding whitespace are irrelevant + /// because the value is typed by hand into a shell or an OS env dialog. + #[test] + fn recognises_the_enabled_spellings() { + for raw in ["1", "true", "TRUE", "yes", " Yes ", "\ttrue\n"] { + assert!(parse_auto_approve(Some(raw)), "{raw:?} must enable it"); + } + } + + /// Everything else keeps the card flow. Unset is the important one: a + /// machine that never set the variable must behave exactly as before, and + /// an empty value is how a shell writes "set but blank". + #[test] + fn everything_else_leaves_the_card_flow_alone() { + for raw in [None, Some(""), Some(" "), Some("0"), Some("false"), Some("no"), Some("on")] { + assert!( + !parse_auto_approve(raw), + "{raw:?} must NOT enable auto-approval" + ); + } + } + + /// The origin string is a wire value the frontend's ApprovalChip matches on + /// (`src/components/message/activity/approval-chip.tsx`). Changing it here + /// without changing that file would silently render an unattended approval + /// as a person's click. + #[test] + fn origin_is_the_string_the_frontend_expects() { + assert_eq!(AUTO_APPROVE_ORIGIN, "auto_approve"); + } + + /// Regression for the review's "Preserve CLI permission policies" finding: + /// a `cli_run` connection (a headless `codeg-cli` run, or a delegation + /// child that inherited its tag) must keep the card path so its + /// `--permissions` policy stays authoritative. + #[test] + fn cli_run_tagged_connection_is_never_auto_approved() { + let opts = vec![opt("allow", "allow_once"), opt("deny", "reject_once")]; + assert_eq!( + auto_approve_option(true, true, &opts), + None, + "the run's permission ceiling must win over CODEG_AUTO_APPROVE" + ); + } + + /// The flag off is the default state of every machine that never set it. + #[test] + fn disabled_flag_keeps_the_card_path() { + let opts = vec![opt("allow", "allow_once")]; + assert_eq!(auto_approve_option(false, false, &opts), None); + } + + /// claude-agent-acp 0.91.0 leads with the rejection for `defaultToNo` + /// calls. Selection is by kind, never by position. + #[test] + fn chooses_allow_once_even_when_rejection_comes_first() { + let opts = vec![ + opt("reject-1", "reject_once"), + opt("allow-1", "allow_once"), + ]; + assert_eq!( + auto_approve_option(true, false, &opts), + Some("allow-1".to_string()) + ); + } + + /// `allow_always` alone must NOT be auto-picked: it would write a standing + /// rule into the user's own agent settings. Fall through to the card. + #[test] + fn only_allow_always_available_falls_through_to_the_card() { + let opts = vec![ + opt("always", "allow_always"), + opt("reject", "reject_once"), + ]; + assert_eq!(auto_approve_option(true, false, &opts), None); + } + + /// An empty option list, and a list with no allow-kind at all, are both + /// ordinary card cases — no panic, no approval invented. + #[test] + fn no_usable_option_falls_through_to_the_card() { + assert_eq!(auto_approve_option(true, false, &[]), None); + assert_eq!( + auto_approve_option(true, false, &[opt("reject", "reject_once")]), + None + ); + } + + /// The happy path, once, to pin the returned id rather than just Some(_). + #[test] + fn enabled_non_cli_request_with_allow_once_is_answered() { + let opts = vec![ + opt("allow-1", "allow_once"), + opt("reject-1", "reject_once"), + ]; + assert_eq!( + auto_approve_option(true, false, &opts), + Some("allow-1".to_string()) + ); + } +} diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index a21907626b..b728e3524e 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -1567,7 +1567,24 @@ pub async fn get_folder_conversation_core( let agent_env = agent_setting_service::load_agent_env(conn, at).await; tokio::task::spawn_blocking(move || -> Result<_, AppCommandError> { let parser = build_agent_parser(at, &agent_env); - match parser.get_conversation(&eid) { + let parse_started = std::time::Instant::now(); + let parsed = parser.get_conversation(&eid); + // Duration of a full transcript parse is the quantity the turn + // window cannot bound: `apply_turn_window` slices AFTER this + // returns, so a 80 MB session is read and turned into messages + // whatever the window asks for. Logged for every detail load + // because the only way to know whether the window needs to move + // into the parsers is to see this number on real sessions. + let parse_ms = parse_started.elapsed().as_millis() as u64; + tracing::info!( + agent = %at, + external_id = %eid, + parse_ms, + turns = parsed.as_ref().map(|d| d.turns.len()).unwrap_or(0), + slow = parse_ms > crate::commands::conversations::SLOW_PARSE_MS, + "[conversations] transcript parsed" + ); + match parsed { Ok(d) => { // Claude `/clear` (and similar on-disk id changes) make // the parser resolve a different uuid than we asked for. @@ -2002,6 +2019,11 @@ fn apply_in_flight_message_id( Some(pending.message_id.clone()) } +/// A full transcript parse slower than this is logged as `slow`. Parsing runs +/// before the turn window slices anything, so this is the number that decides +/// whether windows have to move into the parsers themselves. +pub const SLOW_PARSE_MS: u64 = 500; + /// Resolve the raw `tailTurns` / `fromIndex` request fields into a window /// selector. `None` when neither is present (legacy full response); an error /// when both are (the two coordinate systems are mutually exclusive). diff --git a/src/components/message/activity/approval-chip.tsx b/src/components/message/activity/approval-chip.tsx index 88db10f0eb..674974d932 100644 --- a/src/components/message/activity/approval-chip.tsx +++ b/src/components/message/activity/approval-chip.tsx @@ -52,7 +52,9 @@ export function ApprovalChip({ approval }: { approval: ToolApproval }) { ? t("viaChatChannel") : approval.origin === "cli_policy" ? t("byPolicy") - : null + : approval.origin === "auto_approve" + ? t("byAutoApprove") + : null const title = [ approval.optionName, who, diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 092624ad9c..571cb5c5ab 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -4097,7 +4097,8 @@ "allowAlways": "مسموح دائمًا", "rejected": "رُفض", "viaChatChannel": "عبر قناة دردشة", - "byPolicy": "بحسب سياسة أذونات التشغيل" + "byPolicy": "بحسب سياسة أذونات التشغيل", + "byAutoApprove": "تلقائيًا بواسطة CODEG_AUTO_APPROVE" } }, "messageNav": { diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 717bf1ec45..bfb325b97f 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -4097,7 +4097,8 @@ "allowAlways": "immer erlaubt", "rejected": "abgelehnt", "viaChatChannel": "über einen Chat-Kanal", - "byPolicy": "durch die Berechtigungsrichtlinie des Laufs" + "byPolicy": "durch die Berechtigungsrichtlinie des Laufs", + "byAutoApprove": "automatisch durch CODEG_AUTO_APPROVE" } }, "messageNav": { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 95c2505440..db9e036ef4 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4097,7 +4097,8 @@ "allowAlways": "always allowed", "rejected": "declined", "viaChatChannel": "via a chat channel", - "byPolicy": "by the run's permission policy" + "byPolicy": "by the run's permission policy", + "byAutoApprove": "automatically, by CODEG_AUTO_APPROVE" } }, "messageNav": { diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 8410c0165b..c9da907c5c 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -4097,7 +4097,8 @@ "allowAlways": "siempre permitido", "rejected": "rechazado", "viaChatChannel": "desde un canal de chat", - "byPolicy": "por la política de permisos de la ejecución" + "byPolicy": "por la política de permisos de la ejecución", + "byAutoApprove": "automáticamente, por CODEG_AUTO_APPROVE" } }, "messageNav": { diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 1e03d8719c..fe1682106e 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -4097,7 +4097,8 @@ "allowAlways": "toujours autorisé", "rejected": "refusé", "viaChatChannel": "depuis un canal de discussion", - "byPolicy": "par la politique d'autorisation de l'exécution" + "byPolicy": "par la politique d'autorisation de l'exécution", + "byAutoApprove": "automatiquement, par CODEG_AUTO_APPROVE" } }, "messageNav": { diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index c4695f5207..6560bd423d 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -4097,7 +4097,8 @@ "allowAlways": "常に許可", "rejected": "拒否", "viaChatChannel": "チャットチャネル経由", - "byPolicy": "実行の権限ポリシーによる" + "byPolicy": "実行の権限ポリシーによる", + "byAutoApprove": "CODEG_AUTO_APPROVE による自動承認" } }, "messageNav": { diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 412f820533..57fb23b21e 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -4097,7 +4097,8 @@ "allowAlways": "항상 허용", "rejected": "거부됨", "viaChatChannel": "채팅 채널에서", - "byPolicy": "실행 권한 정책에 따름" + "byPolicy": "실행 권한 정책에 따름", + "byAutoApprove": "CODEG_AUTO_APPROVE에 의한 자동 승인" } }, "messageNav": { diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 503cba0504..6c0258f30b 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -4097,7 +4097,8 @@ "allowAlways": "sempre permitido", "rejected": "recusado", "viaChatChannel": "por um canal de chat", - "byPolicy": "pela política de permissões da execução" + "byPolicy": "pela política de permissões da execução", + "byAutoApprove": "automaticamente, por CODEG_AUTO_APPROVE" } }, "messageNav": { diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index b3bfccb921..84c9c27640 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -4097,7 +4097,8 @@ "allowAlways": "总是允许", "rejected": "已拒绝", "viaChatChannel": "来自聊天频道", - "byPolicy": "由运行的权限策略决定" + "byPolicy": "由运行的权限策略决定", + "byAutoApprove": "由 CODEG_AUTO_APPROVE 自动批准" } }, "messageNav": { diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 5bc94c985c..dac919a63e 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -4097,7 +4097,8 @@ "allowAlways": "一律允許", "rejected": "已拒絕", "viaChatChannel": "來自聊天頻道", - "byPolicy": "由執行的權限原則決定" + "byPolicy": "由執行的權限原則決定", + "byAutoApprove": "由 CODEG_AUTO_APPROVE 自動核准" } }, "messageNav": {