Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
107 changes: 107 additions & 0 deletions src-tauri/src/acp/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
11 changes: 10 additions & 1 deletion src-tauri/src/acp/file_system_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,9 +320,18 @@ fn agent_data_roots(agent_type: AgentType, runtime_env: &BTreeMap<String, String
/// Measured on 2.4.1 with a 3,000,000-byte file: given the whole file, a
/// `Read` handed the model its first 958 lines, 99,918 characters ending in
/// Kimi's own "Next Read" hint, and one with `n_lines=10` the ten lines.
///
/// DeepSeek Harness (`dsh-acp` 0.9.0) is the third: its `FsClient` ships the
/// `read` tool's `offset`/`limit` as its OWN page arguments and asks
/// `fs/read_text_file` for the whole file, then pages the answer itself — so
/// the `line`/`limit` hint never reaches it either, and every read of a file
/// past 2 MiB fails with `Invalid params` the model cannot act on. Measured
/// twice in 2026-11 (a 2.65 MiB single-line bundle), where the child then sat
/// in `Prompting` until it was cancelled by hand. It carries no approval mode
/// and no retry, so a read that errors is a read it never recovers from.
fn read_response_limit(agent_type: AgentType) -> 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,
Expand Down
182 changes: 182 additions & 0 deletions src-tauri/src/acp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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())
);
}
}
24 changes: 23 additions & 1 deletion src-tauri/src/commands/conversations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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).
Expand Down
4 changes: 3 additions & 1 deletion src/components/message/activity/approval-chip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -4097,7 +4097,8 @@
"allowAlways": "مسموح دائمًا",
"rejected": "رُفض",
"viaChatChannel": "عبر قناة دردشة",
"byPolicy": "بحسب سياسة أذونات التشغيل"
"byPolicy": "بحسب سياسة أذونات التشغيل",
"byAutoApprove": "تلقائيًا بواسطة CODEG_AUTO_APPROVE"
}
},
"messageNav": {
Expand Down
Loading